diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /toolkit/components/formautofill/shared | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/formautofill/shared')
16 files changed, 11715 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..a849e889b2 --- /dev/null +++ b/toolkit/components/formautofill/shared/AddressComponent.sys.mjs @@ -0,0 +1,1120 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddressParser: "resource://gre/modules/shared/AddressParser.sys.mjs", + FormAutofillNameUtils: + "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs", + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", + PhoneNumber: "resource://autofill/phonenumberutils/PhoneNumber.sys.mjs", + PhoneNumberNormalizer: + "resource://autofill/phonenumberutils/PhoneNumberNormalizer.sys.mjs", +}); + +/** + * Class representing a collection of tokens extracted from a string. + */ +class Tokens { + #tokens = null; + + // By default we split passed string with whitespace. + constructor(value, sep = /\s+/) { + this.#tokens = value.split(sep); + } + + get tokens() { + return this.#tokens; + } + + /** + * Checks if all the tokens in the current object can be found in another + * token object. + * + * @param {Tokens} other The other Tokens instance to compare with. + * @param {Function} compare An optional custom comparison function. + * @returns {boolean} True if the current Token object is a subset of the + * other Token object, false otherwise. + */ + isSubset(other, compare = (a, b) => a == b) { + return this.tokens.every(tokenSelf => { + for (const tokenOther of other.tokens) { + if (compare(tokenSelf, tokenOther)) { + return true; + } + } + return false; + }); + } + + /** + * Checks if all the tokens in the current object can be found in another + * Token object's tokens (in order). + * For example, ["John", "Doe"] is a subset of ["John", "Michael", "Doe"] + * in order but not a subset of ["Doe", "Michael", "John"] in order. + * + * @param {Tokens} other The other Tokens instance to compare with. + * @param {Function} compare An optional custom comparison function. + * @returns {boolean} True if the current Token object is a subset of the + * other Token object, false otherwise. + */ + isSubsetInOrder(other, compare = (a, b) => a == b) { + if (this.tokens.length > other.tokens.length) { + return false; + } + + let idx = 0; + return this.tokens.every(tokenSelf => { + for (; idx < other.tokens.length; idx++) { + if (compare(tokenSelf, other.tokens[idx])) { + return true; + } + } + return false; + }); + } +} + +/** + * The AddressField class is a base class representing a single address field. + */ +class AddressField { + #userValue = null; + + #region = null; + + /** + * Create a representation of a single address field. + * + * @param {string} value + * The unnormalized value of an address field. + * + * @param {string} region + * The region of a single address field. Used to determine what collator should be + * for string comparisons of the address's field value. + */ + constructor(value, region) { + this.#userValue = value?.trim(); + this.#region = region; + } + + /** + * Get the unnormalized value of the address field. + * + * @returns {string} The unnormalized field value. + */ + get userValue() { + return this.#userValue; + } + + /** + * Get the collator used for string comparisons. + * + * @returns {Intl.Collator} The collator. + */ + get collator() { + return lazy.FormAutofillUtils.getSearchCollators(this.#region, { + ignorePunctuation: false, + }); + } + + get region() { + return this.#region; + } + + /** + * Compares two strings using the collator. + * + * @param {string} a The first string to compare. + * @param {string} b The second string to compare. + * @returns {number} A negative, zero, or positive value, depending on the comparison result. + */ + localeCompare(a, b) { + return lazy.FormAutofillUtils.strCompare(a, b, this.collator); + } + + /** + * Checks if the field value is empty. + * + * @returns {boolean} True if the field value is empty, false otherwise. + */ + isEmpty() { + return !this.#userValue; + } + + /** + * Normalizes the unnormalized field value using the provided options. + * + * @param {object} options - Options for normalization. + * @returns {string} The normalized field value. + */ + normalizeUserValue(options) { + return lazy.AddressParser.normalizeString(this.#userValue, options); + } + + /** + * Returns a string representation of the address field. + * Ex. "Country: US", "PostalCode: 55123", etc. + */ + toString() { + return `${this.constructor.name}: ${this.#userValue}\n`; + } + + /** + * Checks if the field value is valid. + * + * @returns {boolean} True if the field value is valid, false otherwise. + */ + isValid() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + /** + * Compares the current field value with another field value for equality. + */ + equals() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + /** + * Checks if the current field value contains another field value. + */ + contains() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } +} + +/** + * A street address. + * See autocomplete="street-address". + */ +class StreetAddress extends AddressField { + static ac = "street-address"; + + #structuredStreetAddress = null; + + constructor(value, region) { + super(value, region); + + this.#structuredStreetAddress = lazy.AddressParser.parseStreetAddress( + lazy.AddressParser.replaceControlCharacters(this.userValue, " ") + ); + } + + get structuredStreetAddress() { + return this.#structuredStreetAddress; + } + get street_number() { + return this.#structuredStreetAddress?.street_number; + } + get street_name() { + return this.#structuredStreetAddress?.street_name; + } + get floor_number() { + return this.#structuredStreetAddress?.floor_number; + } + get apartment_number() { + return this.#structuredStreetAddress?.apartment_number; + } + + isValid() { + return this.userValue ? !!/[\p{Letter}]/u.exec(this.userValue) : true; + } + + equals(other) { + if (this.structuredStreetAddress && other.structuredStreetAddress) { + return ( + this.street_number?.toLowerCase() == + other.street_number?.toLowerCase() && + this.street_name?.toLowerCase() == other.street_name?.toLowerCase() && + this.apartment_number?.toLowerCase() == + other.apartment_number?.toLowerCase() && + this.floor_number?.toLowerCase() == other.floor_number?.toLowerCase() + ); + } + + const options = { + ignore_case: true, + }; + + return ( + this.normalizeUserValue(options) == other.normalizeUserValue(options) + ); + } + + contains(other) { + let selfStreetName = this.userValue; + let otherStreetName = other.userValue; + + // Compare street number, apartment number and floor number if + // both addresses are parsed successfully. + if (this.structuredStreetAddress && other.structuredStreetAddress) { + if ( + (other.street_number && this.street_number != other.street_number) || + (other.apartment_number && + this.apartment_number != other.apartment_number) || + (other.floor_number && this.floor_number != other.floor_number) + ) { + return false; + } + + // Use parsed street name to compare + selfStreetName = this.street_name; + otherStreetName = other.street_name; + } + + // Check if one street name contains the other + const options = { + ignore_case: true, + replace_punctuation: " ", + }; + const selfTokens = new Tokens( + lazy.AddressParser.normalizeString(selfStreetName, options), + /[\s\n\r]+/ + ); + const otherTokens = new Tokens( + lazy.AddressParser.normalizeString(otherStreetName, options), + /[\s\n\r]+/ + ); + + return otherTokens.isSubsetInOrder(selfTokens, (a, b) => + this.localeCompare(a, b) + ); + } + + static fromRecord(record, region) { + return new StreetAddress(record[StreetAddress.ac], region); + } +} + +/** + * A postal code / zip code + * See autocomplete="postal-code" + */ +class PostalCode extends AddressField { + static ac = "postal-code"; + + constructor(value, region) { + super(value, region); + } + + isValid() { + const { postalCodePattern } = lazy.FormAutofillUtils.getFormFormat( + this.region + ); + const regexp = new RegExp(`^${postalCodePattern}$`); + return regexp.test(this.userValue); + } + + equals(other) { + const options = { + ignore_case: true, + remove_whitespace: true, + remove_punctuation: true, + }; + + return ( + this.normalizeUserValue(options) == other.normalizeUserValue(options) + ); + } + + contains(other) { + const options = { + ignore_case: true, + remove_whitespace: true, + remove_punctuation: true, + }; + + const self_normalized_value = this.normalizeUserValue(options); + const other_normalized_value = other.normalizeUserValue(options); + + return ( + self_normalized_value.endsWith(other_normalized_value) || + self_normalized_value.startsWith(other_normalized_value) + ); + } + + static fromRecord(record, region) { + return new PostalCode(record[PostalCode.ac], region); + } +} + +/** + * City name. + * See autocomplete="address-level2" + */ +class City extends AddressField { + static ac = "address-level2"; + + #city = null; + + constructor(value, region) { + super(value, region); + + const options = { + ignore_case: true, + }; + this.#city = this.normalizeUserValue(options); + } + + get city() { + return this.#city; + } + + isValid() { + return this.userValue ? !!/[\p{Letter}]/u.exec(this.userValue) : true; + } + + equals(other) { + return this.city == other.city; + } + + contains(other) { + const options = { + ignore_case: true, + replace_punctuation: " ", + merge_whitespace: true, + }; + + const selfTokens = new Tokens(this.normalizeUserValue(options)); + const otherTokens = new Tokens(other.normalizeUserValue(options)); + + return otherTokens.isSubsetInOrder(selfTokens, (a, b) => + this.localeCompare(a, b) + ); + } + + static fromRecord(record, region) { + return new City(record[City.ac], region); + } +} + +/** + * State. + * See autocomplete="address-level1" + */ +class State extends AddressField { + static ac = "address-level1"; + + // The abbreviated region name. For example, California is abbreviated as CA + #state = null; + + constructor(value, region) { + super(value, region); + + if (!this.userValue) { + return; + } + + const options = { + merge_whitespace: true, + remove_punctuation: true, + }; + this.#state = lazy.FormAutofillUtils.getAbbreviatedSubregionName( + this.normalizeUserValue(options), + region + ); + } + + get state() { + return this.#state; + } + + isValid() { + // If we can't get the abbreviated name, assume this is an invalid state name + return !!this.#state; + } + + equals(other) { + // If we have an abbreviated name, compare with it. + if (this.state) { + return this.state == other.state; + } + + // If we don't have an abbreviated name, just compare the userValue + return this.userValue == other.userValue; + } + + contains(other) { + return this.equals(other); + } + + static fromRecord(record, region) { + return new State(record[State.ac], region); + } +} + +/** + * A country or territory code. + * See autocomplete="country" + */ +class Country extends AddressField { + static ac = "country"; + + // iso 3166 2-alpha code + #country_code = null; + + constructor(value, region) { + super(value, region); + + if (this.isEmpty()) { + return; + } + + const options = { + merge_whitespace: true, + remove_punctuation: true, + }; + + const country = this.normalizeUserValue(options); + this.#country_code = lazy.FormAutofillUtils.identifyCountryCode(country); + + // When the country name is not a valid one, we use the current region instead + if (!this.#country_code) { + this.#country_code = lazy.FormAutofillUtils.identifyCountryCode(region); + } + } + + get country_code() { + return this.#country_code; + } + + isValid() { + return !!this.#country_code; + } + + equals(other) { + return this.country_code == other.country_code; + } + + contains(other) { + return false; + } + + static fromRecord(record, region) { + return new Country(record[Country.ac], region); + } +} + +/** + * The field expects the value to be a person's full name. + * See autocomplete="name" + */ +class Name extends AddressField { + static ac = "name"; + + constructor(value, region) { + super(value, region); + } + + // Reference: + // https://source.chromium.org/chromium/chromium/src/+/main:components/autofill/core/browser/data_model/autofill_profile_comparator.cc;drc=566369da19275cc306eeb51a3d3451885299dabb;bpv=1;bpt=1;l=935 + static createNameVariants(name) { + let tokens = name.trim().split(" "); + + let variants = [""]; + if (!tokens[0]) { + return variants; + } + + for (const token of tokens) { + let tmp = []; + for (const variant of variants) { + tmp.push(variant + " " + token); + tmp.push(variant + " " + token[0]); + } + variants = variants.concat(tmp); + } + + const options = { + merge_whitespace: true, + }; + return variants.map(v => lazy.AddressParser.normalizeString(v, options)); + } + + isValid() { + return this.userValue ? !!/[\p{Letter}]/u.exec(this.userValue) : true; + } + + equals(other) { + const options = { + ignore_case: true, + }; + return ( + this.normalizeUserValue(options) == other.normalizeUserValue(options) + ); + } + + contains(other) { + // Unify puncutation while comparing so users can choose the right one + // if the only different part is puncutation + // Ex. John O'Brian is similar to John O`Brian + let options = { + ignore_case: true, + replace_punctuation: " ", + merge_whitespace: true, + }; + let selfName = this.normalizeUserValue(options); + let otherName = other.normalizeUserValue(options); + let selfTokens = new Tokens(selfName); + let otherTokens = new Tokens(otherName); + + if ( + otherTokens.isSubsetInOrder(selfTokens, (a, b) => + this.localeCompare(a, b) + ) + ) { + return true; + } + + // Remove puncutation from self and test whether current contains other + // Ex. John O'Brian is similar to John OBrian + selfName = this.normalizeUserValue({ + ignore_case: true, + remove_punctuation: true, + merge_whitespace: true, + }); + otherName = other.normalizeUserValue({ + ignore_case: true, + remove_punctuation: true, + merge_whitespace: true, + }); + + selfTokens = new Tokens(selfName); + otherTokens = new Tokens(otherName); + if ( + otherTokens.isSubsetInOrder(selfTokens, (a, b) => + this.localeCompare(a, b) + ) + ) { + return true; + } + + // Create variants of the names by generating initials for given and middle names. + + selfName = lazy.FormAutofillNameUtils.splitName(selfName); + otherName = lazy.FormAutofillNameUtils.splitName(otherName); + // In the following we compare cases when people abbreviate first name + // and middle name with initials. So if family name is different, + // we can just skip and assume the two names are different + if (!this.localeCompare(selfName.family, otherName.family)) { + return false; + } + + const otherNameWithoutFamily = lazy.FormAutofillNameUtils.joinNameParts({ + given: otherName.given, + middle: otherName.middle, + }); + let givenVariants = Name.createNameVariants(selfName.given); + let middleVariants = Name.createNameVariants(selfName.middle); + + for (const given of givenVariants) { + for (const middle of middleVariants) { + const nameVariant = lazy.FormAutofillNameUtils.joinNameParts({ + given, + middle, + }); + + if (this.localeCompare(nameVariant, otherNameWithoutFamily)) { + return true; + } + } + } + + // Check cases when given name and middle name are abbreviated with initial + // and the initials are put together. ex. John Michael Doe to JM. Doe + if (selfName.given && selfName.middle) { + const nameVariant = [ + ...selfName.given.split(" "), + ...selfName.middle.split(" "), + ].reduce((initials, name) => { + initials += name[0]; + return initials; + }, ""); + + if (this.localeCompare(nameVariant, otherNameWithoutFamily)) { + return true; + } + } + + return false; + } + + static fromRecord(record, region) { + return new Name(record[Name.ac], region); + } +} + +/** + * A full telephone number, including the country code. + * See autocomplete="tel" + */ +class Tel extends AddressField { + static ac = "tel"; + + #valid = false; + + // The country code part of a telphone number, such as "1" for the United States + #country_code = null; + + // The national part of a telphone number. For example, the phone number "+1 520-248-6621" + // national part is "520-248-6621". + #national_number = null; + + constructor(value, region) { + super(value, region); + + if (!this.userValue) { + return; + } + + // TODO: Support parse telephone extension + // We compress all tel-related fields into a single tel field when an an form + // is submitted, so we need to decompress it here. + const parsed_tel = lazy.PhoneNumber.Parse(this.userValue, region); + if (parsed_tel) { + this.#national_number = parsed_tel?.nationalNumber; + this.#country_code = parsed_tel?.countryCode; + + this.#valid = true; + } else { + this.#national_number = lazy.PhoneNumberNormalizer.Normalize( + this.userValue + ); + + const md = lazy.PhoneNumber.FindMetaDataForRegion(region); + this.#country_code = md ? "+" + md.nationalPrefix : null; + + this.#valid = lazy.PhoneNumber.IsValid(this.#national_number, md); + } + } + + get country_code() { + return this.#country_code; + } + + get national_number() { + return this.#national_number; + } + + isValid() { + return this.#valid; + } + + equals(other) { + return ( + this.national_number == other.national_number && + this.country_code == other.country_code + ); + } + + contains(other) { + if (!this.country_code || this.country_code != other.country_code) { + return false; + } + + return this.national_number.endsWith(other.national_number); + } + + toString() { + return `${this.constructor.name}: ${this.country_code} ${this.national_number}\n`; + } + + static fromRecord(record, region) { + return new Tel(record[Tel.ac], region); + } +} + +/** + * A company or organization name. + * See autocomplete="organization". + */ +class Organization extends AddressField { + static ac = "organization"; + + constructor(value, region) { + super(value, region); + } + + isValid() { + return this.userValue + ? !!/[\p{Letter}\p{Number}]/u.exec(this.userValue) + : true; + } + + /** + * Two company names are considered equal only when everything is the same. + */ + equals(other) { + return this.userValue == other.userValue; + } + + // Mergeable use locale compare + contains(other) { + const options = { + replace_punctuation: " ", // mozilla org vs mozilla-org + merge_whitespace: true, + ignore_case: true, // mozilla vs Mozilla + }; + + // If every token in B can be found in A without considering order + // Example, 'Food & Pharmacy' contains 'Pharmacy & Food' + const selfTokens = new Tokens(this.normalizeUserValue(options)); + const otherTokens = new Tokens(other.normalizeUserValue(options)); + + return otherTokens.isSubset(selfTokens, (a, b) => this.localeCompare(a, b)); + } + + static fromRecord(record, region) { + return new Organization(record[Organization.ac], region); + } +} + +/** + * An email address + * See autocomplete="email". + */ +class Email extends AddressField { + static ac = "email"; + + constructor(value, region) { + super(value, region); + } + + // Since we are using the valid check to determine whether we capture the email field when users submitting a forma, + // use a less restrict email verification method so we capture an email for most of the cases. + // The current algorithm is based on the regular expression defined in + // https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address + // + // We might also change this to something similar to the algorithm used in + // EmailInputType::IsValidEmailAddress if we want a more strict email validation algorithm. + isValid() { + const regex = + /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + const match = this.userValue.match(regex); + if (!match) { + return false; + } + + return true; + } + + /* + // JS version of EmailInputType::IsValidEmailAddress + isValid() { + const regex = /^([a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+)@([a-zA-Z0-9-]+\.[a-zA-Z]{2,})$/; + const match = this.userValue.match(regex); + if (!match) { + return false; + } + const local = match[1]; + const domain = match[2]; + + // The domain name can't begin with a dot or a dash. + if (['-', '.'].includes(domain[0])) { + return false; + } + + // A dot can't follow a dot or a dash. + // A dash can't follow a dot. + const pattern = /(\.\.)|(\.-)|(-\.)/; + if (pattern.test(domain)) { + return false; + } + + return true; + } +*/ + + equals(other) { + const options = { + ignore_case: true, + }; + + // email is case-insenstive + return ( + this.normalizeUserValue(options) == other.normalizeUserValue(options) + ); + } + + contains(other) { + return false; + } + + static fromRecord(record, region) { + return new Email(record[Email.ac], region); + } +} + +/** + * The AddressComparison class compares two AddressComponent instances and + * provides information about the differences or similarities between them. + * + * The comparison result is stored and the object and can be retrieved by calling + * 'result' getter. + */ +export class AddressComparison { + // Const to define the comparison result for two address fields + static BOTH_EMPTY = 0; + static A_IS_EMPTY = 1; + static B_IS_EMPTY = 2; + static A_CONTAINS_B = 3; + static B_CONTAINS_A = 4; + // When A contains B and B contains A Ex. "Pizza & Food vs Food & Pizza" + static SIMILAR = 5; + static SAME = 6; + static DIFFERENT = 7; + + // The comparion result, keyed by field name. + #result = {}; + + /** + * Constructs AddressComparison by comparing two AddressComponent objects. + * + * @class + * @param {AddressComponent} addressA - The first address to compare. + * @param {AddressComponent} addressB - The second address to compare. + */ + constructor(addressA, addressB) { + for (const fieldA of addressA.getAllFields()) { + const fieldName = fieldA.constructor.ac; + const fieldB = addressB.getField(fieldName); + if (fieldB) { + this.#result[fieldName] = AddressComparison.compare(fieldA, fieldB); + } else { + this.#result[fieldName] = AddressComparison.B_IS_EMPTY; + } + } + + for (const fieldB of addressB.getAllFields()) { + const fieldName = fieldB.constructor.ac; + if (!addressB.getField(fieldName)) { + this.#result[fieldName] = AddressComparison.A_IS_EMPTY; + } + } + } + + /** + * Retrieves the result object containing the comparison results. + * + * @returns {object} The result object with keys corresponding to field names + * and values being comparison constants. + */ + get result() { + return this.#result; + } + + /** + * Compares two address fields and returns the comparison result. + * + * @param {AddressField} fieldA The first field to compare. + * @param {AddressField} fieldB The second field to compare. + * @returns {number} A constant representing the comparison result. + */ + static compare(fieldA, fieldB) { + if (fieldA.isEmpty()) { + return fieldB.isEmpty() + ? AddressComparison.BOTH_EMPTY + : AddressComparison.A_IS_EMPTY; + } else if (fieldB.isEmpty()) { + return AddressComparison.B_IS_EMPTY; + } + + if (fieldA.equals(fieldB)) { + return AddressComparison.SAME; + } + + if (fieldB.contains(fieldA)) { + if (fieldA.contains(fieldB)) { + return AddressComparison.SIMILAR; + } + return AddressComparison.B_CONTAINS_A; + } else if (fieldA.contains(fieldB)) { + return AddressComparison.A_CONTAINS_B; + } + + return AddressComparison.DIFFERENT; + } + + /** + * Converts a comparison result constant to a readable string. + * + * @param {number} result The comparison result constant. + * @returns {string} A readable string representing the comparison result. + */ + static resultToString(result) { + switch (result) { + case AddressComparison.BOTH_EMPTY: + return "both fields are empty"; + case AddressComparison.A_IS_EMPTY: + return "field A is empty"; + case AddressComparison.B_IS_EMPTY: + return "field B is empty"; + case AddressComparison.A_CONTAINS_B: + return "field A contains field B"; + case AddressComparison.B_CONTAINS_B: + return "field B contains field A"; + case AddressComparison.SIMILAR: + return "field A and field B are similar"; + case AddressComparison.SAME: + return "two fields are the same"; + case AddressComparison.DIFFERENT: + return "two fields are different"; + } + return ""; + } + + /** + * Returns a formatted string representing the comparison results for each field. + * + * @returns {string} A formatted string with field names and their respective + * comparison results. + */ + toString() { + let string = "Comparison Result:\n"; + for (const [name, result] of Object.entries(this.#result)) { + string += `${name}: ${AddressComparison.resultToString(result)}\n`; + } + return string; + } +} + +/** + * The AddressComponent class represents a structured address that is transformed + * from address record created in FormAutofillHandler 'createRecord' function. + * + * An AddressComponent object consisting of various fields such as state, city, + * country, postal code, etc. The class provides a compare methods + * to compare another AddressComponent against the current instance. + * + * Note. This class assumes records that pass to it have already been normalized. + */ +export class AddressComponent { + /** + * An object that stores individual address field instances + * (e.g., class State, class City, class Country, etc.), keyed by the + * field's clas name. + */ + #fields = {}; + + /** + * Constructs an AddressComponent object by converting passed address record object. + * + * @class + * @param {object} record The address record object containing address data. + * @param {object} [options = {}] a list of options for this method + * @param {boolean} [options.ignoreInvalid = true] Whether to ignore invalid address + * fields in the AddressComponent object. If set to true, + * invalid fields will be ignored. + */ + constructor(record, { ignoreInvalid = true } = {}) { + this.record = {}; + + // Get country code first so we can use it to parse other fields + const country = new Country( + record[Country.ac], + FormAutofill.DEFAULT_REGION + ); + const region = + country.country_code || + lazy.FormAutofillUtils.identifyCountryCode(FormAutofill.DEFAULT_REGION); + + // Build an mapping that the key is field name and the value is the AddressField object + [ + country, + new StreetAddress(record[StreetAddress.ac], region), + new PostalCode(record[PostalCode.ac], region), + new State(record[State.ac], region), + new City(record[City.ac], region), + new Name(record[Name.ac], region), + new Tel(record[Tel.ac], region), + new Organization(record[Organization.ac], region), + new Email(record[Email.ac], region), + ].forEach(addressField => { + if ( + !addressField.isEmpty() && + (!ignoreInvalid || addressField.isValid()) + ) { + const fieldName = addressField.constructor.ac; + this.#fields[fieldName] = addressField; + this.record[fieldName] = record[fieldName]; + } + }); + } + + /** + * Retrieves all the address fields. + * + * @returns {Array} An array of address field objects. + */ + getAllFields() { + return Object.values(this.#fields); + } + + /** + * Retrieves the field object with the specified name. + * + * @param {string} name The name of the field to retrieve. + * @returns {object} The address field object with the specified name, + * or undefined if the field is not found. + */ + getField(name) { + return this.#fields[name]; + } + + /** + * Compares the current AddressComponent with another AddressComponent. + * + * @param {AddressComponent} address The AddressComponent object to compare + * against the current one. + * @returns {object} An object containing comparison results. The keys of the object represent + * individual address field, and the values are strings indicating the comparison result: + * - "same" if both components are either empty or the same, + * - "superset" if the current contains the input or the input is empty, + * - "subset" if the input contains the current or the current is empty, + * - "similar" if the two address components are similar, + * - "different" if the two address components are different. + */ + compare(address) { + let result = {}; + + const comparison = new AddressComparison(this, address); + for (const [k, v] of Object.entries(comparison.result)) { + if ([AddressComparison.BOTH_EMPTY, AddressComparison.SAME].includes(v)) { + result[k] = "same"; + } else if ( + [AddressComparison.B_IS_EMPTY, AddressComparison.A_CONTAINS_B].includes( + v + ) + ) { + result[k] = "superset"; + } else if ( + [AddressComparison.A_IS_EMPTY, AddressComparison.B_CONTAINS_A].includes( + v + ) + ) { + result[k] = "subset"; + } else if ([AddressComparison.SIMILAR].includes(v)) { + result[k] = "similar"; + } else { + result[k] = "different"; + } + } + return result; + } + + /** + * Print all the fields in this AddressComponent object. + */ + toString() { + let string = ""; + for (const field of Object.values(this.#fields)) { + string += field.toString(); + } + return string; + } +} diff --git a/toolkit/components/formautofill/shared/AddressMetaData.sys.mjs b/toolkit/components/formautofill/shared/AddressMetaData.sys.mjs new file mode 100644 index 0000000000..7f80a220af --- /dev/null +++ b/toolkit/components/formautofill/shared/AddressMetaData.sys.mjs @@ -0,0 +1,2451 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// The data below is initially copied from +// https://chromium-i18n.appspot.com/ssl-aggregate-address + +// See https://github.com/googlei18n/libaddressinput/wiki/AddressValidationMetadata for +// documentation on how to use the data. + +// WARNING: DO NOT change any value or add additional properties in addressData. +// We only accept the metadata of the supported countries that is copied from libaddressinput directly. +// Please edit AddressMetaDataExtension.sys.mjs instead if you want to add new property as complement +// or overwrite the existing properties. + +export const AddressMetaData = { + "data/AD": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/AD", + key: "AD", + lang: "ca", + languages: "ca", + name: "ANDORRA", + posturl: + "http://www.correos.es/comun/CodigosPostales/1010_s-CodPostal.asp?Provincia=", + sub_isoids: "07~02~03~08~04~05~06", + sub_keys: + "Parròquia d'Andorra la Vella~Canillo~Encamp~Escaldes-Engordany~La Massana~Ordino~Sant Julià de Lòria", + sub_names: + "Andorra la Vella~Canillo~Encamp~Escaldes-Engordany~La Massana~Ordino~Sant Julià de Lòria", + sub_zipexs: "AD500~AD100~AD200~AD700~AD400~AD300~AD600", + sub_zips: "AD50[01]~AD10[01]~AD20[01]~AD70[01]~AD40[01]~AD30[01]~AD60[01]", + zip: "AD[1-7]0\\d", + zipex: "AD100,AD501,AD700", + }, + "data/AE": { + fmt: "%N%n%O%n%A%n%S", + id: "data/AE", + key: "AE", + lang: "ar", + languages: "ar", + lfmt: "%N%n%O%n%A%n%S", + name: "UNITED ARAB EMIRATES", + require: "AS", + state_name_type: "emirate", + sub_isoids: "AZ~SH~FU~UQ~DU~RK~AJ", + sub_keys: + "أبو ظبي~إمارة الشارقةّ~الفجيرة~ام القيوين~إمارة دبيّ~إمارة رأس الخيمة~عجمان", + sub_lnames: + "Abu Dhabi~Sharjah~Fujairah~Umm Al Quwain~Dubai~Ras al Khaimah~Ajman", + sub_names: "أبو ظبي~الشارقة~الفجيرة~ام القيوين~دبي~رأس الخيمة~عجمان", + }, + "data/AF": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/AF", + key: "AF", + name: "AFGHANISTAN", + zip: "\\d{4}", + zipex: "1001,2601,3801", + }, + "data/AG": { + id: "data/AG", + key: "AG", + name: "ANTIGUA AND BARBUDA", + require: "A", + }, + "data/AI": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/AI", + key: "AI", + name: "ANGUILLA", + zip: "(?:AI-)?2640", + zipex: "2640", + }, + "data/AL": { + fmt: "%N%n%O%n%A%n%Z%n%C", + id: "data/AL", + key: "AL", + name: "ALBANIA", + zip: "\\d{4}", + zipex: "1001,1017,3501", + }, + "data/AM": { + fmt: "%N%n%O%n%A%n%Z%n%C%n%S", + id: "data/AM", + key: "AM", + lang: "hy", + languages: "hy", + lfmt: "%N%n%O%n%A%n%Z%n%C%n%S", + name: "ARMENIA", + sub_isoids: "AG~AR~AV~GR~ER~LO~KT~SH~SU~VD~TV", + sub_keys: + "Արագածոտն~Արարատ~Արմավիր~Գեղարքունիք~Երևան~Լոռի~Կոտայք~Շիրակ~Սյունիք~Վայոց ձոր~Տավուշ", + sub_lnames: + "Aragatsotn~Ararat~Armavir~Gegharkunik~Yerevan~Lori~Kotayk~Shirak~Syunik~Vayots Dzor~Tavush", + sub_zipexs: + "0201,0514~0601,0823~0901,1149~1201,1626~0000,0099~1701,2117~2201,2506~2601,3126~3201,3519~3601,3810~3901,4216", + sub_zips: + "0[2-5]~0[6-8]~09|1[01]~1[2-6]~00~1[7-9]|2[01]~2[2-5]~2[6-9]|3[01]~3[2-5]~3[6-8]~39|4[0-2]", + zip: "(?:37)?\\d{4}", + zipex: "375010,0002,0010", + }, + "data/AO": { id: "data/AO", key: "AO", name: "ANGOLA" }, + "data/AQ": { id: "data/AQ", key: "AQ", name: "ANTARCTICA" }, + "data/AR": { + fmt: "%N%n%O%n%A%n%Z %C%n%S", + id: "data/AR", + key: "AR", + lang: "es", + languages: "es", + name: "ARGENTINA", + posturl: "http://www.correoargentino.com.ar/formularios/cpa", + sub_isoids: "B~K~H~U~C~X~W~E~P~Y~L~F~M~N~Q~R~A~J~D~Z~S~G~V~T", + sub_keys: + "Buenos Aires~Catamarca~Chaco~Chubut~Ciudad Autónoma de Buenos Aires~Córdoba~Corrientes~Entre Ríos~Formosa~Jujuy~La Pampa~La Rioja~Mendoza~Misiones~Neuquén~Río Negro~Salta~San Juan~San Luis~Santa Cruz~Santa Fe~Santiago del Estero~Tierra del Fuego~Tucumán", + sub_names: + "Buenos Aires~Catamarca~Chaco~Chubut~Ciudad Autónoma de Buenos Aires~Córdoba~Corrientes~Entre Ríos~Formosa~Jujuy~La Pampa~La Rioja~Mendoza~Misiones~Neuquén~Río Negro~Salta~San Juan~San Luis~Santa Cruz~Santa Fe~Santiago del Estero~Tierra del Fuego~Tucumán", + sub_zips: + "B?[1-36-8]~K?[45]~H?3~U?[89]~C?1~X?[235-8]~W?3~E?[1-3]~P?[37]~Y?4~L?[3568]~F?5~M?[56]~N?3~Q?[38]~R?[89]~A?[34]~J?5~D?[4-6]~Z?[89]~S?[2368]~G?[2-5]~V?9~T?[45]", + upper: "ACZ", + zip: "((?:[A-HJ-NP-Z])?\\d{4})([A-Z]{3})?", + zipex: "C1070AAM,C1000WAM,B1000TBU,X5187XAB", + }, + "data/AS": { + fmt: "%N%n%O%n%A%n%C %S %Z", + id: "data/AS", + key: "AS", + name: "AMERICAN SAMOA", + posturl: "http://zip4.usps.com/zip4/welcome.jsp", + require: "ACSZ", + state_name_type: "state", + upper: "ACNOS", + zip: "(96799)(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "96799", + }, + "data/AT": { + fmt: "%O%n%N%n%A%n%Z %C", + id: "data/AT", + key: "AT", + name: "AUSTRIA", + posturl: "http://www.post.at/post_subsite_postleitzahlfinder.php", + require: "ACZ", + zip: "\\d{4}", + zipex: "1010,3741", + }, + "data/AU": { + fmt: "%O%n%N%n%A%n%C %S %Z", + id: "data/AU", + key: "AU", + lang: "en", + languages: "en", + locality_name_type: "suburb", + name: "AUSTRALIA", + posturl: "http://www1.auspost.com.au/postcodes/", + require: "ACSZ", + state_name_type: "state", + sub_isoids: "ACT~NSW~NT~QLD~SA~TAS~VIC~WA", + sub_keys: "ACT~NSW~NT~QLD~SA~TAS~VIC~WA", + sub_names: + "Australian Capital Territory~New South Wales~Northern Territory~Queensland~South Australia~Tasmania~Victoria~Western Australia", + sub_zipexs: + "0200,2540,2618,2999~1000,2888,3585,3707~0800,0999~4000,9999~5000~7000,7999~3000,8000~6000,0872", + sub_zips: + "29|2540|260|261[0-8]|02|2620~1|2[0-57-8]|26[2-9]|261[189]|3500|358[56]|3644|3707~0[89]~[49]~5|0872~7~[38]~6|0872", + upper: "CS", + zip: "\\d{4}", + zipex: "2060,3171,6430,4000,4006,3001", + }, + "data/AW": { id: "data/AW", key: "AW", name: "ARUBA" }, + "data/AZ": { + fmt: "%N%n%O%n%A%nAZ %Z %C", + id: "data/AZ", + key: "AZ", + name: "AZERBAIJAN", + postprefix: "AZ ", + zip: "\\d{4}", + zipex: "1000", + }, + "data/BA": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/BA", + key: "BA", + name: "BOSNIA AND HERZEGOVINA", + zip: "\\d{5}", + zipex: "71000", + }, + "data/BB": { + fmt: "%N%n%O%n%A%n%C, %S %Z", + id: "data/BB", + key: "BB", + name: "BARBADOS", + state_name_type: "parish", + zip: "BB\\d{5}", + zipex: "BB23026,BB22025", + }, + "data/BD": { + fmt: "%N%n%O%n%A%n%C - %Z", + id: "data/BD", + key: "BD", + name: "BANGLADESH", + posturl: "http://www.bangladeshpost.gov.bd/PostCode.asp", + zip: "\\d{4}", + zipex: "1340,1000", + }, + "data/BE": { + fmt: "%O%n%N%n%A%n%Z %C", + id: "data/BE", + key: "BE", + name: "BELGIUM", + posturl: + "http://www.post.be/site/nl/residential/customerservice/search/postal_codes.html", + require: "ACZ", + zip: "\\d{4}", + zipex: "4000,1000", + }, + "data/BF": { + fmt: "%N%n%O%n%A%n%C %X", + id: "data/BF", + key: "BF", + name: "BURKINA FASO", + }, + "data/BG": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/BG", + key: "BG", + name: "BULGARIA (REP.)", + posturl: "http://www.bgpost.bg/?cid=5", + zip: "\\d{4}", + zipex: "1000,1700", + }, + "data/BH": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/BH", + key: "BH", + name: "BAHRAIN", + zip: "(?:\\d|1[0-2])\\d{2}", + zipex: "317", + }, + "data/BI": { id: "data/BI", key: "BI", name: "BURUNDI" }, + "data/BJ": { id: "data/BJ", key: "BJ", name: "BENIN", upper: "AC" }, + "data/BL": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/BL", + key: "BL", + name: "SAINT BARTHELEMY", + posturl: + "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal", + require: "ACZ", + upper: "ACX", + zip: "9[78][01]\\d{2}", + zipex: "97100", + }, + "data/BM": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/BM", + key: "BM", + name: "BERMUDA", + posturl: "http://www.landvaluation.bm/", + zip: "[A-Z]{2} ?[A-Z0-9]{2}", + zipex: "FL 07,HM GX,HM 12", + }, + "data/BN": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/BN", + key: "BN", + name: "BRUNEI DARUSSALAM", + posturl: "http://www.post.gov.bn/SitePages/postcodes.aspx", + zip: "[A-Z]{2} ?\\d{4}", + zipex: "BT2328,KA1131,BA1511", + }, + "data/BO": { id: "data/BO", key: "BO", name: "BOLIVIA", upper: "AC" }, + "data/BQ": { + id: "data/BQ", + key: "BQ", + name: "BONAIRE, SINT EUSTATIUS, AND SABA", + }, + "data/BR": { + fmt: "%O%n%N%n%A%n%D%n%C-%S%n%Z", + id: "data/BR", + key: "BR", + lang: "pt", + languages: "pt", + name: "BRAZIL", + posturl: "http://www.buscacep.correios.com.br/", + require: "ASCZ", + state_name_type: "state", + sub_isoids: + "AC~AL~AP~AM~BA~CE~DF~ES~GO~MA~MT~MS~MG~PA~PB~PR~PE~PI~RJ~RN~RS~RO~RR~SC~SP~SE~TO", + sub_keys: + "AC~AL~AP~AM~BA~CE~DF~ES~GO~MA~MT~MS~MG~PA~PB~PR~PE~PI~RJ~RN~RS~RO~RR~SC~SP~SE~TO", + sub_mores: + "true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true", + sub_names: + "Acre~Alagoas~Amapá~Amazonas~Bahia~Ceará~Distrito Federal~Espírito Santo~Goiás~Maranhão~Mato Grosso~Mato Grosso do Sul~Minas Gerais~Pará~Paraíba~Paraná~Pernambuco~Piauí~Rio de Janeiro~Rio Grande do Norte~Rio Grande do Sul~Rondônia~Roraima~Santa Catarina~São Paulo~Sergipe~Tocantins", + sub_zipexs: + "69900-000,69999-999~57000-000,57999-999~68900-000,68999-999~69000-000,69400-123~40000-000,48999-999~60000-000,63999-999~70000-000,73500-123~29000-000,29999-999~72800-000,73700-123~65000-000,65999-999~78000-000,78899-999~79000-000,79999-999~30000-000,39999-999~66000-000,68899-999~58000-000,58999-999~80000-000,87999-999~50000-000,56999-999~64000-000,64999-999~20000-000,28999-999~59000-000,59999-999~90000-000,99999-999~76800-000,78900-000,78999-999~69300-000,69399-999~88000-000,89999-999~01000-000,13000-123~49000-000,49999-999~77000-000,77999-999", + sub_zips: + "699~57~689~69[0-24-8]~4[0-8]~6[0-3]~7[0-1]|72[0-7]|73[0-6]~29~72[89]|73[7-9]|7[4-6]~65~78[0-8]~79~3~6[6-7]|68[0-8]~58~8[0-7]~5[0-6]~64~2[0-8]~59~9~76[89]|789~693~8[89]~[01][1-9]~49~77", + sublocality_name_type: "neighborhood", + upper: "CS", + zip: "\\d{5}-?\\d{3}", + zipex: "40301-110,70002-900", + }, + "data/BS": { + fmt: "%N%n%O%n%A%n%C, %S", + id: "data/BS", + key: "BS", + lang: "en", + languages: "en", + name: "BAHAMAS", + state_name_type: "island", + sub_isoids: "~AK~~BY~BI~CI~~~EX~~HI~IN~LI~MG~~RI~RC~SS~SW", + sub_keys: + "Abaco~Acklins~Andros~Berry Islands~Bimini~Cat Island~Crooked Island~Eleuthera~Exuma~Grand Bahama~Harbour Island~Inagua~Long Island~Mayaguana~N.P.~Ragged Island~Rum Cay~San Salvador~Spanish Wells", + sub_names: + "Abaco Islands~Acklins~Andros Island~Berry Islands~Bimini~Cat Island~Crooked Island~Eleuthera~Exuma and Cays~Grand Bahama~Harbour Island~Inagua~Long Island~Mayaguana~New Providence~Ragged Island~Rum Cay~San Salvador~Spanish Wells", + }, + "data/BT": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/BT", + key: "BT", + name: "BHUTAN", + posturl: "http://www.bhutanpost.bt/postcodes/", + zip: "\\d{5}", + zipex: "11001,31101,35003", + }, + "data/BV": { id: "data/BV", key: "BV", name: "BOUVET ISLAND" }, + "data/BW": { id: "data/BW", key: "BW", name: "BOTSWANA" }, + "data/BY": { + fmt: "%S%n%Z %C%n%A%n%O%n%N", + id: "data/BY", + key: "BY", + name: "BELARUS", + posturl: "http://ex.belpost.by/addressbook/", + zip: "\\d{6}", + zipex: "223016,225860,220050", + }, + "data/BZ": { id: "data/BZ", key: "BZ", name: "BELIZE" }, + "data/CA": { + fmt: "%N%n%O%n%A%n%C %S %Z", + id: "data/CA", + key: "CA", + lang: "en", + languages: "en~fr", + name: "CANADA", + posturl: "https://www.canadapost.ca/cpo/mc/personal/postalcode/fpc.jsf", + require: "ACSZ", + sub_isoids: "AB~BC~MB~NB~NL~NT~NS~NU~ON~PE~QC~SK~YT", + sub_keys: "AB~BC~MB~NB~NL~NT~NS~NU~ON~PE~QC~SK~YT", + sub_names: + "Alberta~British Columbia~Manitoba~New Brunswick~Newfoundland and Labrador~Northwest Territories~Nova Scotia~Nunavut~Ontario~Prince Edward Island~Quebec~Saskatchewan~Yukon", + sub_zips: + "T~V~R~E~A~X0E|X0G|X1A~B~X0A|X0B|X0C~K|L|M|N|P~C~G|H|J|K1A~S|R8A~Y", + upper: "ACNOSZ", + zip: "[ABCEGHJKLMNPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z] ?\\d[ABCEGHJ-NPRSTV-Z]\\d", + zipex: "H3Z 2Y7,V8X 3X4,T0L 1K0,T0H 1A0,K1A 0B1", + }, + "data/CA--fr": { + fmt: "%N%n%O%n%A%n%C %S %Z", + id: "data/CA--fr", + key: "CA", + lang: "fr", + name: "CANADA", + posturl: "https://www.canadapost.ca/cpo/mc/personal/postalcode/fpc.jsf", + require: "ACSZ", + sub_isoids: "AB~BC~PE~MB~NB~NS~NU~ON~QC~SK~NL~NT~YT", + sub_keys: "AB~BC~PE~MB~NB~NS~NU~ON~QC~SK~NL~NT~YT", + sub_names: + "Alberta~Colombie-Britannique~Île-du-Prince-Édouard~Manitoba~Nouveau-Brunswick~Nouvelle-Écosse~Nunavut~Ontario~Québec~Saskatchewan~Terre-Neuve-et-Labrador~Territoires du Nord-Ouest~Yukon", + sub_zips: + "T~V~C~R~E~B~X0A|X0B|X0C~K|L|M|N|P~G|H|J|K1A~S|R8A~A~X0E|X0G|X1A~Y", + upper: "ACNOSZ", + zip: "[ABCEGHJKLMNPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z] ?\\d[ABCEGHJ-NPRSTV-Z]\\d", + zipex: "H3Z 2Y7,V8X 3X4,T0L 1K0,T0H 1A0,K1A 0B1", + }, + "data/CC": { + fmt: "%O%n%N%n%A%n%C %S %Z", + id: "data/CC", + key: "CC", + name: "COCOS (KEELING) ISLANDS", + upper: "CS", + zip: "6799", + zipex: "6799", + }, + "data/CD": { id: "data/CD", key: "CD", name: "CONGO (DEM. REP.)" }, + "data/CF": { id: "data/CF", key: "CF", name: "CENTRAL AFRICAN REPUBLIC" }, + "data/CG": { id: "data/CG", key: "CG", name: "CONGO (REP.)" }, + "data/CH": { + fmt: "%O%n%N%n%A%nCH-%Z %C", + id: "data/CH", + key: "CH", + name: "SWITZERLAND", + postprefix: "CH-", + posturl: "http://www.post.ch/db/owa/pv_plz_pack/pr_main", + require: "ACZ", + upper: "", + zip: "\\d{4}", + zipex: "2544,1211,1556,3030", + }, + "data/CI": { + fmt: "%N%n%O%n%X %A %C %X", + id: "data/CI", + key: "CI", + name: "COTE D'IVOIRE", + }, + "data/CK": { id: "data/CK", key: "CK", name: "COOK ISLANDS" }, + "data/CL": { + fmt: "%N%n%O%n%A%n%Z %C%n%S", + id: "data/CL", + key: "CL", + lang: "es", + languages: "es", + name: "CHILE", + posturl: "http://www.correos.cl/SitePages/home.aspx", + sub_isoids: "AN~AR~AP~AT~AI~BI~CO~LI~LL~LR~MA~ML~RM~TA~VS", + sub_keys: + "Antofagasta~Araucanía~Arica y Parinacota~Atacama~Aysén~Biobío~Coquimbo~O'Higgins~Los Lagos~Los Ríos~Magallanes~Maule~Región Metropolitana~Tarapacá~Valparaíso", + sub_mores: + "true~true~true~true~true~true~true~true~true~true~true~true~true~true~true", + sub_names: + "Antofagasta~Araucanía~Arica y Parinacota~Atacama~Aysén del General Carlos Ibáñez del Campo~Biobío~Coquimbo~Libertador General Bernardo O'Higgins~Los Lagos~Los Ríos~Magallanes y de la Antártica Chilena~Maule~Metropolitana de Santiago~Tarapacá~Valparaíso", + zip: "\\d{7}", + zipex: "8340457,8720019,1230000,8329100", + }, + "data/CM": { id: "data/CM", key: "CM", name: "CAMEROON" }, + "data/CN": { + fmt: "%Z%n%S%C%D%n%A%n%O%n%N", + id: "data/CN", + key: "CN", + lang: "zh", + languages: "zh", + lfmt: "%N%n%O%n%A%n%D%n%C%n%S, %Z", + name: "CHINA", + posturl: "http://www.ems.com.cn/serviceguide/you_bian_cha_xun.html", + require: "ACSZ", + sub_isoids: + "34~92~11~50~35~62~44~45~52~46~13~41~23~42~43~22~32~36~21~15~64~63~37~14~61~31~51~71~12~54~91~65~53~33", + sub_keys: + "安徽省~澳门~北京市~重庆市~福建省~甘肃省~广东省~广西壮族自治区~贵州省~海南省~河北省~河南省~黑龙江省~湖北省~湖南省~吉林省~江苏省~江西省~辽宁省~内蒙古自治区~宁夏回族自治区~青海省~山东省~山西省~陕西省~上海市~四川省~台湾~天津市~西藏自治区~香港~新疆维吾尔自治区~云南省~浙江省", + sub_lnames: + "Anhui Sheng~Macau~Beijing Shi~Chongqing Shi~Fujian Sheng~Gansu Sheng~Guangdong Sheng~Guangxi Zhuangzuzizhiqu~Guizhou Sheng~Hainan Sheng~Hebei Sheng~Henan Sheng~Heilongjiang Sheng~Hubei Sheng~Hunan Sheng~Jilin Sheng~Jiangsu Sheng~Jiangxi Sheng~Liaoning Sheng~Neimenggu Zizhiqu~Ningxia Huizuzizhiqu~Qinghai Sheng~Shandong Sheng~Shanxi Sheng~Shaanxi Sheng~Shanghai Shi~Sichuan Sheng~Taiwan~Tianjin Shi~Xizang Zizhiqu~Hong Kong~Xinjiang Weiwuerzizhiqu~Yunnan Sheng~Zhejiang Sheng", + sub_mores: + "true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true", + sub_names: + "安徽省~澳门~北京市~重庆市~福建省~甘肃省~广东省~广西~贵州省~海南省~河北省~河南省~黑龙江省~湖北省~湖南省~吉林省~江苏省~江西省~辽宁省~内蒙古~宁夏~青海省~山东省~山西省~陕西省~上海市~四川省~台湾~天津市~西藏~香港~新疆~云南省~浙江省", + sub_xrequires: "~A~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ACS~~~", + sub_xzips: "~999078~~~~~~~~~~~~~~~~~~~~~~~~~~\\d{3}(\\d{2})?~~~999077~~~", + sublocality_name_type: "district", + upper: "S", + zip: "\\d{6}", + zipex: "266033,317204,100096,100808", + }, + "data/CO": { + fmt: "%N%n%O%n%A%n%C, %S, %Z", + id: "data/CO", + key: "CO", + name: "COLOMBIA", + posturl: "http://www.codigopostal.gov.co/", + require: "AS", + state_name_type: "department", + zip: "\\d{6}", + zipex: "111221,130001,760011", + }, + "data/CR": { + fmt: "%N%n%O%n%A%n%S, %C%n%Z", + id: "data/CR", + key: "CR", + name: "COSTA RICA", + posturl: "https://www.correos.go.cr/nosotros/codigopostal/busqueda.html", + require: "ACS", + zip: "\\d{4,5}|\\d{3}-\\d{4}", + zipex: "1000,2010,1001", + }, + "data/CU": { + fmt: "%N%n%O%n%A%n%C %S%n%Z", + id: "data/CU", + key: "CU", + lang: "es", + languages: "es", + name: "CUBA", + sub_isoids: "15~09~08~06~12~14~11~99~03~10~04~16~01~07~13~05", + sub_keys: + "Artemisa~Camagüey~Ciego de Ávila~Cienfuegos~Granma~Guantánamo~Holguín~Isla de la Juventud~La Habana~Las Tunas~Matanzas~Mayabeque~Pinar del Río~Sancti Spíritus~Santiago de Cuba~Villa Clara", + zip: "\\d{5}", + zipex: "10700", + }, + "data/CV": { + fmt: "%N%n%O%n%A%n%Z %C%n%S", + id: "data/CV", + key: "CV", + lang: "pt", + languages: "pt", + name: "CAPE VERDE", + state_name_type: "island", + sub_isoids: "BV~BR~~MA~SL~~~~SV", + sub_keys: + "Boa Vista~Brava~Fogo~Maio~Sal~Santiago~Santo Antão~São Nicolau~São Vicente", + zip: "\\d{4}", + zipex: "7600", + }, + "data/CW": { id: "data/CW", key: "CW", name: "CURACAO" }, + "data/CX": { + fmt: "%O%n%N%n%A%n%C %S %Z", + id: "data/CX", + key: "CX", + name: "CHRISTMAS ISLAND", + upper: "CS", + zip: "6798", + zipex: "6798", + }, + "data/CY": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/CY", + key: "CY", + name: "CYPRUS", + zip: "\\d{4}", + zipex: "2008,3304,1900", + }, + "data/CZ": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/CZ", + key: "CZ", + name: "CZECH REP.", + posturl: "http://psc.ceskaposta.cz/CleanForm.action", + require: "ACZ", + zip: "\\d{3} ?\\d{2}", + zipex: "100 00,251 66,530 87,110 00,225 99", + }, + "data/DE": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/DE", + key: "DE", + name: "GERMANY", + posturl: "http://www.postdirekt.de/plzserver/", + require: "ACZ", + zip: "\\d{5}", + zipex: "26133,53225", + }, + "data/DJ": { id: "data/DJ", key: "DJ", name: "DJIBOUTI" }, + "data/DK": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/DK", + key: "DK", + name: "DENMARK", + posturl: + "http://www.postdanmark.dk/da/Privat/Kundeservice/postnummerkort/Sider/Find-postnummer.aspx", + require: "ACZ", + zip: "\\d{4}", + zipex: "8660,1566", + }, + "data/DM": { id: "data/DM", key: "DM", name: "DOMINICA" }, + "data/DO": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/DO", + key: "DO", + name: "DOMINICAN REP.", + posturl: "http://inposdom.gob.do/codigo-postal/", + zip: "\\d{5}", + zipex: "11903,10101", + }, + "data/DZ": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/DZ", + key: "DZ", + name: "ALGERIA", + zip: "\\d{5}", + zipex: "40304,16027", + }, + "data/EC": { + fmt: "%N%n%O%n%A%n%Z%n%C", + id: "data/EC", + key: "EC", + name: "ECUADOR", + posturl: "http://www.codigopostal.gob.ec/", + upper: "CZ", + zip: "\\d{6}", + zipex: "090105,092301", + }, + "data/EE": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/EE", + key: "EE", + name: "ESTONIA", + posturl: "https://www.omniva.ee/era/sihtnumbrite_otsing", + zip: "\\d{5}", + zipex: "69501,11212", + }, + "data/EG": { + fmt: "%N%n%O%n%A%n%C%n%S%n%Z", + id: "data/EG", + key: "EG", + lang: "ar", + languages: "ar", + lfmt: "%N%n%O%n%A%n%C%n%S%n%Z", + name: "EGYPT", + sub_isoids: + "ASN~AST~ALX~IS~LX~BA~BH~GZ~DK~SUZ~SHR~GH~FYM~C~KB~MNF~MN~WAD~BNS~PTS~JS~DT~SHG~SIN~KN~KFS~MT", + sub_keys: + "أسوان~أسيوط~الإسكندرية~الإسماعيلية~الأقصر~البحر الأحمر~البحيرة~الجيزة~الدقهلية~السويس~الشرقية~الغربية~الفيوم~القاهرة~القليوبية~المنوفية~المنيا~الوادي الجديد~بني سويف~بورسعيد~جنوب سيناء~دمياط~سوهاج~شمال سيناء~قنا~كفر الشيخ~مطروح", + sub_lnames: + "Aswan Governorate~Asyut Governorate~Alexandria Governorate~Ismailia Governorate~Luxor Governorate~Red Sea Governorate~El Beheira Governorate~Giza Governorate~Dakahlia Governorate~Suez Governorate~Ash Sharqia Governorate~Gharbia Governorate~Faiyum Governorate~Cairo Governorate~Qalyubia Governorate~Menofia Governorate~Menia Governorate~New Valley Governorate~Beni Suef Governorate~Port Said Governorate~South Sinai Governorate~Damietta Governorate~Sohag Governorate~North Sinai Governorate~Qena Governorate~Kafr El Sheikh Governorate~Matrouh Governorate", + sub_zipexs: + "81000~71000~21000,23000~41000~85000~84000~22000~12000~35000~43000~44000~31000~63000~11000~13000~32000~61000~72000~62000~42000~46000~34000~82000~45000~83000~33000~51000", + sub_zips: + "81~71~2[13]~41~85~84~22~12~35~43~44~31~63~11~13~32~61~72~62~42~46~34~82~45~83~33~51", + zip: "\\d{5}", + zipex: "12411,11599", + }, + "data/EH": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/EH", + key: "EH", + name: "WESTERN SAHARA", + zip: "\\d{5}", + zipex: "70000,72000", + }, + "data/ER": { id: "data/ER", key: "ER", name: "ERITREA" }, + "data/ES": { + fmt: "%N%n%O%n%A%n%Z %C %S", + id: "data/ES", + key: "ES", + lang: "es", + languages: "es~ca~gl~eu", + name: "SPAIN", + posturl: + "http://www.correos.es/contenido/13-MenuRec2/04-MenuRec24/1010_s-CodPostal.asp", + require: "ACSZ", + sub_keys: + "VI~AB~A~AL~O~AV~BA~B~BU~CC~CA~S~CS~CE~CR~CO~CU~GI~GR~GU~SS~H~HU~PM~J~C~LO~GC~LE~L~LU~M~MA~ML~MU~NA~OR~P~PO~SA~TF~SG~SE~SO~T~TE~TO~V~VA~BI~ZA~Z", + sub_names: + "Álava~Albacete~Alicante~Almería~Asturias~Ávila~Badajoz~Barcelona~Burgos~Cáceres~Cádiz~Cantabria~Castellón~Ceuta~Ciudad Real~Córdoba~Cuenca~Girona~Granada~Guadalajara~Guipúzcoa~Huelva~Huesca~Islas Baleares~Jaén~La Coruña~La Rioja~Las Palmas~León~Lérida~Lugo~Madrid~Málaga~Melilla~Murcia~Navarra~Ourense~Palencia~Pontevedra~Salamanca~Santa Cruz de Tenerife~Segovia~Sevilla~Soria~Tarragona~Teruel~Toledo~Valencia~Valladolid~Vizcaya~Zamora~Zaragoza", + sub_zips: + "01~02~03~04~33~05~06~08~09~10~11~39~12~51~13~14~16~17~18~19~20~21~22~07~23~15~26~35~24~25~27~28~29~52~30~31~32~34~36~37~38~40~41~42~43~44~45~46~47~48~49~50", + upper: "CS", + zip: "\\d{5}", + zipex: "28039,28300,28070", + }, + "data/ES--ca": { + fmt: "%N%n%O%n%A%n%Z %C %S", + id: "data/ES--ca", + key: "ES", + lang: "ca", + name: "SPAIN", + posturl: + "http://www.correos.es/contenido/13-MenuRec2/04-MenuRec24/1010_s-CodPostal.asp", + require: "ACSZ", + sub_keys: + "A~AB~AL~VI~O~AV~BA~B~BI~BU~CC~CA~S~CS~CE~CR~CO~CU~GI~GR~GU~SS~H~HU~PM~J~C~LO~GC~LE~L~LU~M~MA~ML~MU~NA~OR~P~PO~SA~TF~SG~SE~SO~T~TE~TO~V~VA~ZA~Z", + sub_names: + "Alacant~Albacete~Almeria~Araba~Asturias~Àvila~Badajoz~Barcelona~Bizkaia~Burgos~Cáceres~Cadis~Cantabria~Castelló~Ceuta~Ciudad Real~Córdoba~Cuenca~Girona~Granada~Guadalajara~Guipúscoa~Huelva~Huesca~Illes Balears~Jaén~La Corunya~La Rioja~Las Palmas~León~Lleida~Lugo~Madrid~Málaga~Melilla~Murcia~Navarra~Ourense~Palencia~Pontevedra~Salamanca~Santa Cruz de Tenerife~Segovia~Sevilla~Soria~Tarragona~Teruel~Toledo~València~Valladolid~Zamora~Zaragoza", + sub_zips: + "03~02~04~01~33~05~06~08~48~09~10~11~39~12~51~13~14~16~17~18~19~20~21~22~07~23~15~26~35~24~25~27~28~29~52~30~31~32~34~36~37~38~40~41~42~43~44~45~46~47~49~50", + upper: "CS", + zip: "\\d{5}", + zipex: "28039,28300,28070", + }, + "data/ES--eu": { + fmt: "%N%n%O%n%A%n%Z %C %S", + id: "data/ES--eu", + key: "ES", + lang: "eu", + name: "SPAIN", + posturl: + "http://www.correos.es/contenido/13-MenuRec2/04-MenuRec24/1010_s-CodPostal.asp", + require: "ACSZ", + sub_keys: + "A~AB~AL~VI~O~AV~BA~B~BI~BU~CC~CA~S~CS~CE~CR~C~CU~SS~GI~GR~GU~H~HU~PM~J~CO~LO~GC~LE~L~LU~M~MA~ML~MU~NA~OR~P~PO~SA~TF~SG~SE~SO~T~TE~TO~V~VA~ZA~Z", + sub_names: + "Alacant~Albacete~Almería~Araba~Asturias~Ávila~Badajoz~Barcelona~Bizkaia~Burgos~Cáceres~Cádiz~Cantabria~Castelló~Ceuta~Ciudad Real~Coruña~Cuenca~Gipuzkoa~Girona~Granada~Guadalajara~Huelva~Huesca~Illes Balears~Jaén~Kordoba~La Rioja~Las Palmas~León~Lleida~Lugo~Madrid~Málaga~Melilla~Murtzia~Nafarroa~Ourense~Palentzia~Pontevedra~Salamanca~Santa Cruz Tenerifekoa~Segovia~Sevilla~Soria~Tarragona~Teruel~Toledo~Valentzia~Valladolid~Zamora~Zaragoza", + sub_zips: + "03~02~04~01~33~05~06~08~48~09~10~11~39~12~51~13~15~16~20~17~18~19~21~22~07~23~14~26~35~24~25~27~28~29~52~30~31~32~34~36~37~38~40~41~42~43~44~45~46~47~49~50", + upper: "CS", + zip: "\\d{5}", + zipex: "28039,28300,28070", + }, + "data/ES--gl": { + fmt: "%N%n%O%n%A%n%Z %C %S", + id: "data/ES--gl", + key: "ES", + lang: "gl", + name: "SPAIN", + posturl: + "http://www.correos.es/contenido/13-MenuRec2/04-MenuRec24/1010_s-CodPostal.asp", + require: "ACSZ", + sub_keys: + "C~A~VI~AB~AL~GC~O~AV~BA~B~BI~BU~CC~CA~S~CS~CE~CR~CO~CU~GR~GU~SS~H~HU~PM~LO~LE~L~LU~M~MA~ML~MU~NA~OR~P~PO~SA~TF~SG~SE~SO~T~TE~TO~V~VA~J~GI~ZA~Z", + sub_names: + "A Coruña~Alacant~Álava~Albacete~Almería~As Palmas~Asturias~Ávila~Badaxoz~Barcelona~Biscaia~Burgos~Cáceres~Cádiz~Cantabria~Castelló~Ceuta~Cidade Real~Córdoba~Cuenca~Granada~Guadalajara~Guipúscoa~Huelva~Huesca~Illas Baleares~La Rioja~León~Lleida~Lugo~Madrid~Málaga~Melilla~Murcia~Navarra~Ourense~Palencia~Pontevedra~Salamanca~Santa Cruz de Tenerife~Segovia~Sevilla~Soria~Tarragona~Teruel~Toledo~Valencia~Valladolid~Xaén~Xirona~Zamora~Zaragoza", + sub_zips: + "15~03~01~02~04~35~33~05~06~08~48~09~10~11~39~12~51~13~14~16~18~19~20~21~22~07~26~24~25~27~28~29~52~30~31~32~34~36~37~38~40~41~42~43~44~45~46~47~23~17~49~50", + upper: "CS", + zip: "\\d{5}", + zipex: "28039,28300,28070", + }, + "data/ET": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/ET", + key: "ET", + name: "ETHIOPIA", + zip: "\\d{4}", + zipex: "1000", + }, + "data/FI": { + fmt: "%O%n%N%n%A%nFI-%Z %C", + id: "data/FI", + key: "FI", + name: "FINLAND", + postprefix: "FI-", + posturl: "http://www.verkkoposti.com/e3/postinumeroluettelo", + require: "ACZ", + zip: "\\d{5}", + zipex: "00550,00011", + }, + "data/FJ": { id: "data/FJ", key: "FJ", name: "FIJI" }, + "data/FK": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/FK", + key: "FK", + name: "FALKLAND ISLANDS (MALVINAS)", + require: "ACZ", + upper: "CZ", + zip: "FIQQ 1ZZ", + zipex: "FIQQ 1ZZ", + }, + "data/FM": { + fmt: "%N%n%O%n%A%n%C %S %Z", + id: "data/FM", + key: "FM", + name: "MICRONESIA (Federated State of)", + posturl: "http://zip4.usps.com/zip4/welcome.jsp", + require: "ACSZ", + state_name_type: "state", + upper: "ACNOS", + zip: "(9694[1-4])(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "96941,96944", + }, + "data/FO": { + fmt: "%N%n%O%n%A%nFO%Z %C", + id: "data/FO", + key: "FO", + name: "FAROE ISLANDS", + postprefix: "FO", + posturl: "http://www.postur.fo/", + zip: "\\d{3}", + zipex: "100", + }, + "data/FR": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/FR", + key: "FR", + name: "FRANCE", + posturl: + "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal", + require: "ACZ", + upper: "CX", + zip: "\\d{2} ?\\d{3}", + zipex: "33380,34092,33506", + }, + "data/GA": { id: "data/GA", key: "GA", name: "GABON" }, + "data/GB": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/GB", + key: "GB", + locality_name_type: "post_town", + name: "UNITED KINGDOM", + posturl: "http://www.royalmail.com/postcode-finder", + require: "ACZ", + upper: "CZ", + zip: "GIR ?0AA|(?:(?:AB|AL|B|BA|BB|BD|BF|BH|BL|BN|BR|BS|BT|BX|CA|CB|CF|CH|CM|CO|CR|CT|CV|CW|DA|DD|DE|DG|DH|DL|DN|DT|DY|E|EC|EH|EN|EX|FK|FY|G|GL|GY|GU|HA|HD|HG|HP|HR|HS|HU|HX|IG|IM|IP|IV|JE|KA|KT|KW|KY|L|LA|LD|LE|LL|LN|LS|LU|M|ME|MK|ML|N|NE|NG|NN|NP|NR|NW|OL|OX|PA|PE|PH|PL|PO|PR|RG|RH|RM|S|SA|SE|SG|SK|SL|SM|SN|SO|SP|SR|SS|ST|SW|SY|TA|TD|TF|TN|TQ|TR|TS|TW|UB|W|WA|WC|WD|WF|WN|WR|WS|WV|YO|ZE)(?:\\d[\\dA-Z]? ?\\d[ABD-HJLN-UW-Z]{2}))|BFPO ?\\d{1,4}", + zipex: + "EC1Y 8SY,GIR 0AA,M2 5BQ,M34 4AB,CR0 2YR,DN16 9AA,W1A 4ZZ,EC1A 1HQ,OX14 4PG,BS18 8HF,NR25 7HG,RH6 0NP,BH23 6AA,B6 5BA,SO23 9AP,PO1 3AX,BFPO 61", + }, + "data/GD": { id: "data/GD", key: "GD", name: "GRENADA (WEST INDIES)" }, + "data/GE": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/GE", + key: "GE", + name: "GEORGIA", + posturl: "http://www.georgianpost.ge/index.php?page=10", + zip: "\\d{4}", + zipex: "0101", + }, + "data/GF": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/GF", + key: "GF", + name: "FRENCH GUIANA", + posturl: + "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal", + require: "ACZ", + upper: "ACX", + zip: "9[78]3\\d{2}", + zipex: "97300", + }, + "data/GG": { + fmt: "%N%n%O%n%A%n%C%nGUERNSEY%n%Z", + id: "data/GG", + key: "GG", + name: "CHANNEL ISLANDS", + posturl: "http://www.guernseypost.com/postcode_finder/", + require: "ACZ", + upper: "CZ", + zip: "GY\\d[\\dA-Z]? ?\\d[ABD-HJLN-UW-Z]{2}", + zipex: "GY1 1AA,GY2 2BT", + }, + "data/GH": { id: "data/GH", key: "GH", name: "GHANA" }, + "data/GI": { + fmt: "%N%n%O%n%A%nGIBRALTAR%n%Z", + id: "data/GI", + key: "GI", + name: "GIBRALTAR", + require: "A", + zip: "GX11 1AA", + zipex: "GX11 1AA", + }, + "data/GL": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/GL", + key: "GL", + name: "GREENLAND", + require: "ACZ", + zip: "39\\d{2}", + zipex: "3900,3950,3911", + }, + "data/GM": { id: "data/GM", key: "GM", name: "GAMBIA" }, + "data/GN": { + fmt: "%N%n%O%n%Z %A %C", + id: "data/GN", + key: "GN", + name: "GUINEA", + zip: "\\d{3}", + zipex: "001,200,100", + }, + "data/GP": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/GP", + key: "GP", + name: "GUADELOUPE", + posturl: + "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal", + require: "ACZ", + upper: "ACX", + zip: "9[78][01]\\d{2}", + zipex: "97100", + }, + "data/GQ": { id: "data/GQ", key: "GQ", name: "EQUATORIAL GUINEA" }, + "data/GR": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/GR", + key: "GR", + name: "GREECE", + posturl: "http://www.elta.gr/findapostcode.aspx", + require: "ACZ", + zip: "\\d{3} ?\\d{2}", + zipex: "151 24,151 10,101 88", + }, + "data/GS": { + fmt: "%N%n%O%n%A%n%n%C%n%Z", + id: "data/GS", + key: "GS", + name: "SOUTH GEORGIA", + require: "ACZ", + upper: "CZ", + zip: "SIQQ 1ZZ", + zipex: "SIQQ 1ZZ", + }, + "data/GT": { + fmt: "%N%n%O%n%A%n%Z- %C", + id: "data/GT", + key: "GT", + name: "GUATEMALA", + zip: "\\d{5}", + zipex: "09001,01501", + }, + "data/GU": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/GU", + key: "GU", + name: "GUAM", + posturl: "http://zip4.usps.com/zip4/welcome.jsp", + require: "ACZ", + upper: "ACNO", + zip: "(969(?:[12]\\d|3[12]))(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "96910,96931", + }, + "data/GW": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/GW", + key: "GW", + name: "GUINEA-BISSAU", + zip: "\\d{4}", + zipex: "1000,1011", + }, + "data/GY": { id: "data/GY", key: "GY", name: "GUYANA" }, + "data/HK": { + fmt: "%S%n%C%n%A%n%O%n%N", + id: "data/HK", + key: "HK", + lang: "zh-Hant", + languages: "zh-Hant~en", + lfmt: "%N%n%O%n%A%n%C%n%S", + locality_name_type: "district", + name: "HONG KONG", + require: "AS", + state_name_type: "area", + sub_keys: "Kowloon~Hong Kong Island~New Territories", + sub_mores: "true~true~true", + sub_names: "九龍~香港島~新界", + upper: "S", + }, + "data/HK--en": { + fmt: "%S%n%C%n%A%n%O%n%N", + id: "data/HK--en", + key: "HK", + lang: "en", + lfmt: "%N%n%O%n%A%n%C%n%S", + locality_name_type: "district", + name: "HONG KONG", + require: "AS", + state_name_type: "area", + sub_keys: "Hong Kong Island~Kowloon~New Territories", + sub_lnames: "Hong Kong Island~Kowloon~New Territories", + sub_mores: "true~true~true", + upper: "S", + }, + "data/HM": { + fmt: "%O%n%N%n%A%n%C %S %Z", + id: "data/HM", + key: "HM", + name: "HEARD AND MCDONALD ISLANDS", + upper: "CS", + zip: "\\d{4}", + zipex: "7050", + }, + "data/HN": { + fmt: "%N%n%O%n%A%n%C, %S%n%Z", + id: "data/HN", + key: "HN", + name: "HONDURAS", + require: "ACS", + zip: "\\d{5}", + zipex: "31301", + }, + "data/HR": { + fmt: "%N%n%O%n%A%nHR-%Z %C", + id: "data/HR", + key: "HR", + name: "CROATIA", + postprefix: "HR-", + posturl: "http://www.posta.hr/default.aspx?pretpum", + zip: "\\d{5}", + zipex: "10000,21001,10002", + }, + "data/HT": { + fmt: "%N%n%O%n%A%nHT%Z %C", + id: "data/HT", + key: "HT", + name: "HAITI", + postprefix: "HT", + zip: "\\d{4}", + zipex: "6120,5310,6110,8510", + }, + "data/HU": { + fmt: "%N%n%O%n%C%n%A%n%Z", + id: "data/HU", + key: "HU", + name: "HUNGARY (Rep.)", + posturl: "http://posta.hu/ugyfelszolgalat/iranyitoszam_kereso", + require: "ACZ", + upper: "ACNO", + zip: "\\d{4}", + zipex: "1037,2380,1540", + }, + "data/ID": { + fmt: "%N%n%O%n%A%n%C%n%S %Z", + id: "data/ID", + key: "ID", + lang: "id", + languages: "id", + name: "INDONESIA", + require: "AS", + sub_isoids: + "AC~BA~BT~BE~YO~JK~GO~JA~JB~JT~JI~KB~KS~KT~KI~KU~BB~KR~LA~MA~MU~NB~NT~PA~PB~RI~SR~SN~ST~SG~SA~SB~SS~SU", + sub_keys: + "Aceh~Bali~Banten~Bengkulu~Daerah Istimewa Yogyakarta~DKI Jakarta~Gorontalo~Jambi~Jawa Barat~Jawa Tengah~Jawa Timur~Kalimantan Barat~Kalimantan Selatan~Kalimantan Tengah~Kalimantan Timur~Kalimantan Utara~Kepulauan Bangka Belitung~Kepulauan Riau~Lampung~Maluku~Maluku Utara~Nusa Tenggara Barat~Nusa Tenggara Timur~Papua~Papua Barat~Riau~Sulawesi Barat~Sulawesi Selatan~Sulawesi Tengah~Sulawesi Tenggara~Sulawesi Utara~Sumatera Barat~Sumatera Selatan~Sumatera Utara", + zip: "\\d{5}", + zipex: "40115", + }, + "data/IE": { + fmt: "%N%n%O%n%A%n%D%n%C%n%S %Z", + id: "data/IE", + key: "IE", + lang: "en", + languages: "en", + name: "IRELAND", + posturl: "https://finder.eircode.ie", + state_name_type: "county", + sub_isoids: + "CW~CN~CE~C~DL~D~G~KY~KE~KK~LS~LM~LK~LD~LH~MO~MH~MN~OY~RN~SO~TA~WD~WH~WX~WW", + sub_keys: + "Co. Carlow~Co. Cavan~Co. Clare~Co. Cork~Co. Donegal~Co. Dublin~Co. Galway~Co. Kerry~Co. Kildare~Co. Kilkenny~Co. Laois~Co. Leitrim~Co. Limerick~Co. Longford~Co. Louth~Co. Mayo~Co. Meath~Co. Monaghan~Co. Offaly~Co. Roscommon~Co. Sligo~Co. Tipperary~Co. Waterford~Co. Westmeath~Co. Wexford~Co. Wicklow", + sublocality_name_type: "townland", + zip: "[\\dA-Z]{3} ?[\\dA-Z]{4}", + zip_name_type: "eircode", + zipex: "A65 F4E2", + }, + "data/IL": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/IL", + key: "IL", + name: "ISRAEL", + posturl: "http://www.israelpost.co.il/zipcode.nsf/demozip?openform", + zip: "\\d{5}(?:\\d{2})?", + zipex: "9614303", + }, + "data/IM": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/IM", + key: "IM", + name: "ISLE OF MAN", + posturl: "https://www.iompost.com/tools-forms/postcode-finder/", + require: "ACZ", + upper: "CZ", + zip: "IM\\d[\\dA-Z]? ?\\d[ABD-HJLN-UW-Z]{2}", + zipex: "IM2 1AA,IM99 1PS", + }, + "data/IN": { + fmt: "%N%n%O%n%A%n%C %Z%n%S", + id: "data/IN", + key: "IN", + lang: "en", + languages: "en~hi", + name: "INDIA", + posturl: "https://www.indiapost.gov.in/vas/pages/FindPinCode.aspx", + require: "ACSZ", + state_name_type: "state", + sub_isoids: + "AN~AP~AR~AS~BR~CH~CT~DN~DD~DL~GA~GJ~HR~HP~JK~JH~KA~KL~LD~MP~MH~MN~ML~MZ~NL~OR~PY~PB~RJ~SK~TN~TG~TR~UP~UT~WB", + sub_keys: + "Andaman and Nicobar Islands~Andhra Pradesh~Arunachal Pradesh~Assam~Bihar~Chandigarh~Chhattisgarh~Dadra and Nagar Haveli~Daman and Diu~Delhi~Goa~Gujarat~Haryana~Himachal Pradesh~Jammu and Kashmir~Jharkhand~Karnataka~Kerala~Lakshadweep~Madhya Pradesh~Maharashtra~Manipur~Meghalaya~Mizoram~Nagaland~Odisha~Puducherry~Punjab~Rajasthan~Sikkim~Tamil Nadu~Telangana~Tripura~Uttar Pradesh~Uttarakhand~West Bengal", + sub_names: + "Andaman & Nicobar~Andhra Pradesh~Arunachal Pradesh~Assam~Bihar~Chandigarh~Chhattisgarh~Dadra & Nagar Haveli~Daman & Diu~Delhi~Goa~Gujarat~Haryana~Himachal Pradesh~Jammu & Kashmir~Jharkhand~Karnataka~Kerala~Lakshadweep~Madhya Pradesh~Maharashtra~Manipur~Meghalaya~Mizoram~Nagaland~Odisha~Puducherry~Punjab~Rajasthan~Sikkim~Tamil Nadu~Telangana~Tripura~Uttar Pradesh~Uttarakhand~West Bengal", + sub_zips: + "744~5[0-3]~79[0-2]~78~8[0-5]~16|1440[3-9]~49~396~396~11~403~3[6-9]~1[23]~17~1[89]~81[4-9]|82|83[0-5]~5[4-9]|53[7-9]~6[7-9]|6010|607008|777~682~4[5-8]|490~4[0-4]~79[56]~79[34]~796~79[78]~7[5-7]~60[579]~1[456]~3[0-4]~737|750~6[0-6]|536~5[0-3]~799~2[0-35-8]|24[0-7]|26[12]~24[46-9]|254|26[23]~7[0-4]", + zip: "\\d{6}", + zip_name_type: "pin", + zipex: "110034,110001", + }, + "data/IN--hi": { + fmt: "%N%n%O%n%A%n%C %Z%n%S", + id: "data/IN--hi", + key: "IN", + lang: "hi", + name: "INDIA", + posturl: "https://www.indiapost.gov.in/vas/pages/FindPinCode.aspx", + require: "ACSZ", + state_name_type: "state", + sub_isoids: + "AN~AR~AS~AP~UP~UT~OR~KA~KL~GJ~GA~CH~CT~JK~JH~TN~TG~TR~DD~DN~DL~NL~PB~WB~PY~BR~MN~MP~MH~MZ~ML~RJ~LD~SK~HR~HP", + sub_keys: + "Andaman & Nicobar~Arunachal Pradesh~Assam~Andhra Pradesh~Uttar Pradesh~Uttarakhand~Odisha~Karnataka~Kerala~Gujarat~Goa~Chandigarh~Chhattisgarh~Jammu & Kashmir~Jharkhand~Tamil Nadu~Telangana~Tripura~Daman & Diu~Dadra & Nagar Haveli~Delhi~Nagaland~Punjab~West Bengal~Puducherry~Bihar~Manipur~Madhya Pradesh~Maharashtra~Mizoram~Meghalaya~Rajasthan~Lakshadweep~Sikkim~Haryana~Himachal Pradesh", + sub_names: + "अंडमान और निकोबार द्वीपसमूह~अरुणाचल प्रदेश~असम~आंध्र प्रदेश~उत्तर प्रदेश~उत्तराखण्ड~ओड़िशा~कर्नाटक~केरल~गुजरात~गोआ~चंडीगढ़~छत्तीसगढ़~जम्मू और कश्मीर~झारखण्ड~तमिल नाडु~तेलंगाना~त्रिपुरा~दमन और दीव~दादरा और नगर हवेली~दिल्ली~नागालैंड~पंजाब~पश्चिम बंगाल~पांडिचेरी~बिहार~मणिपुर~मध्य प्रदेश~महाराष्ट्र~मिजोरम~मेघालय~राजस्थान~लक्षद्वीप~सिक्किम~हरियाणा~हिमाचल प्रदेश", + sub_zips: + "744~79[0-2]~78~5[0-3]~2[0-35-8]|24[0-7]|26[12]~24[46-9]|254|26[23]~7[5-7]~5[4-9]|53[7-9]~6[7-9]|6010|607008|777~3[6-9]~403~16|1440[3-9]~49~1[89]~81[4-9]|82|83[0-5]~6[0-6]|536~5[0-3]~799~396~396~11~79[78]~1[456]~7[0-4]~60[579]~8[0-5]~79[56]~4[5-8]|490~4[0-4]~796~79[34]~3[0-4]~682~737|750~1[23]~17", + zip: "\\d{6}", + zip_name_type: "pin", + zipex: "110034,110001", + }, + "data/IO": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/IO", + key: "IO", + name: "BRITISH INDIAN OCEAN TERRITORY", + require: "ACZ", + upper: "CZ", + zip: "BBND 1ZZ", + zipex: "BBND 1ZZ", + }, + "data/IQ": { + fmt: "%O%n%N%n%A%n%C, %S%n%Z", + id: "data/IQ", + key: "IQ", + name: "IRAQ", + require: "ACS", + upper: "CS", + zip: "\\d{5}", + zipex: "31001", + }, + "data/IR": { + fmt: "%O%n%N%n%S%n%C, %D%n%A%n%Z", + id: "data/IR", + key: "IR", + lang: "fa", + languages: "fa", + name: "IRAN", + sub_isoids: + "01~02~03~04~32~05~06~07~08~29~30~31~10~11~12~13~14~28~26~16~15~17~18~27~19~20~21~22~23~24~25", + sub_keys: + "استان آذربایجان شرقی~استان آذربایجان غربی~استان اردبیل~استان اصفهان~استان البرز~استان ایلام~استان بوشهر~استان تهران~استان چهارمحال و بختیاری~استان خراسان جنوبی~استان خراسان رضوی~استان خراسان شمالی~استان خوزستان~استان زنجان~استان سمنان~استان سیستان و بلوچستان~استان فارس~استان قزوین~استان قم~استان کردستان~استان کرمان~استان کرمانشاه~استان کهگیلویه و بویراحمد~استان گلستان~استان گیلان~استان لرستان~استان مازندران~استان مرکزی~استان هرمزگان~استان همدان~استان یزد", + sub_lnames: + "East Azerbaijan Province~West Azerbaijan Province~Ardabil Province~Isfahan Province~Alborz Province~Ilam Province~Bushehr Province~Tehran Province~Chaharmahal and Bakhtiari Province~South Khorasan Province~Razavi Khorasan Province~North Khorasan Province~Khuzestan Province~Zanjan Province~Semnan Province~Sistan and Baluchestan Province~Fars Province~Qazvin Province~Qom Province~Kurdistan Province~Kerman Province~Kermanshah Province~Kohgiluyeh and Boyer-Ahmad Province~Golestan Province~Gilan Province~Lorestan Province~Mazandaran Province~Markazi Province~Hormozgan Province~Hamadan Province~Yazd Province", + sublocality_name_type: "neighborhood", + zip: "\\d{5}-?\\d{5}", + zipex: "11936-12345", + }, + "data/IS": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/IS", + key: "IS", + name: "ICELAND", + posturl: "http://www.postur.is/einstaklingar/posthus/postnumer/", + zip: "\\d{3}", + zipex: "320,121,220,110", + }, + "data/IT": { + fmt: "%N%n%O%n%A%n%Z %C %S", + id: "data/IT", + key: "IT", + lang: "it", + languages: "it", + name: "ITALY", + posturl: "http://www.poste.it/online/cercacap/", + require: "ACSZ", + sub_isoids: + "AG~AL~AN~AO~AR~AP~AT~AV~BA~BT~BL~BN~BG~BI~BO~BZ~BS~BR~CA~CL~CB~CI~CE~CT~CZ~CH~CO~CS~CR~KR~CN~EN~FM~FE~FI~FG~FC~FR~GE~GO~GR~IM~IS~AQ~SP~LT~LE~LC~LI~LO~LU~MC~MN~MS~MT~VS~ME~MI~MO~MB~NA~NO~NU~OG~OT~OR~PD~PA~PR~PV~PG~PU~PE~PC~PI~PT~PN~PZ~PO~RG~RA~RC~RE~RI~RN~RM~RO~SA~SS~SV~SI~SR~SO~TA~TE~TR~TO~TP~TN~TV~TS~UD~VA~VE~VB~VC~VR~VV~VI~VT", + sub_keys: + "AG~AL~AN~AO~AR~AP~AT~AV~BA~BT~BL~BN~BG~BI~BO~BZ~BS~BR~CA~CL~CB~CI~CE~CT~CZ~CH~CO~CS~CR~KR~CN~EN~FM~FE~FI~FG~FC~FR~GE~GO~GR~IM~IS~AQ~SP~LT~LE~LC~LI~LO~LU~MC~MN~MS~MT~VS~ME~MI~MO~MB~NA~NO~NU~OG~OT~OR~PD~PA~PR~PV~PG~PU~PE~PC~PI~PT~PN~PZ~PO~RG~RA~RC~RE~RI~RN~RM~RO~SA~SS~SV~SI~SR~SO~TA~TE~TR~TO~TP~TN~TV~TS~UD~VA~VE~VB~VC~VR~VV~VI~VT", + sub_names: + "Agrigento~Alessandria~Ancona~Aosta~Arezzo~Ascoli Piceno~Asti~Avellino~Bari~Barletta-Andria-Trani~Belluno~Benevento~Bergamo~Biella~Bologna~Bolzano~Brescia~Brindisi~Cagliari~Caltanissetta~Campobasso~Carbonia-Iglesias~Caserta~Catania~Catanzaro~Chieti~Como~Cosenza~Cremona~Crotone~Cuneo~Enna~Fermo~Ferrara~Firenze~Foggia~Forlì-Cesena~Frosinone~Genova~Gorizia~Grosseto~Imperia~Isernia~L'Aquila~La Spezia~Latina~Lecce~Lecco~Livorno~Lodi~Lucca~Macerata~Mantova~Massa-Carrara~Matera~Medio Campidano~Messina~Milano~Modena~Monza e Brianza~Napoli~Novara~Nuoro~Ogliastra~Olbia-Tempio~Oristano~Padova~Palermo~Parma~Pavia~Perugia~Pesaro e Urbino~Pescara~Piacenza~Pisa~Pistoia~Pordenone~Potenza~Prato~Ragusa~Ravenna~Reggio Calabria~Reggio Emilia~Rieti~Rimini~Roma~Rovigo~Salerno~Sassari~Savona~Siena~Siracusa~Sondrio~Taranto~Teramo~Terni~Torino~Trapani~Trento~Treviso~Trieste~Udine~Varese~Venezia~Verbano-Cusio-Ossola~Vercelli~Verona~Vibo Valentia~Vicenza~Viterbo", + sub_zips: + "92~15~60~11~52~63~14~83~70~76[01]~32~82~24~13[89]~40~39~25~72~0912[1-9]|0913[0-4]|0901[0289]|0902[03468]|0903[0234]|0904|0803[035]|08043~93~860[1-4]|86100~0901[013-7]~81~95~88[01]~66~22~87~26[01]~88[89]~12|18025~94~638|63900~44~50~71~47[015]~03~16~34[01]7~58~18~860[7-9]|86170~67~19~04~73~23[89]~57~26[89]~55~62~46~54~75~0902[012579]|0903[015-9]|09040~98~20~41~208|20900~80~28[01]~080[1-3]|08100~08037|0804[024-9]~08020|0702|0703[08]~090[7-9]|09170|0801[039]|0803[04]~35~90~43~27~06~61~65~29~56~51~330[7-9]|33170~85~59~97~48~89[01]~42~02~47[89]~00~45~84~070[14]|0703[0-79]|07100~17|12071~53~96~23[01]~74~64~05~10~91~38~31~3401|341[0-689]|34062~330[1-5]|33100~21~30~28[89]~13[01]~37~89[89]~36~01", + upper: "CS", + zip: "\\d{5}", + zipex: "00144,47037,39049", + }, + "data/JE": { + fmt: "%N%n%O%n%A%n%C%nJERSEY%n%Z", + id: "data/JE", + key: "JE", + name: "CHANNEL ISLANDS", + posturl: "http://www.jerseypost.com/tools/postcode-address-finder/", + require: "ACZ", + upper: "CZ", + zip: "JE\\d[\\dA-Z]? ?\\d[ABD-HJLN-UW-Z]{2}", + zipex: "JE1 1AA,JE2 2BT", + }, + "data/JM": { + fmt: "%N%n%O%n%A%n%C%n%S %X", + id: "data/JM", + key: "JM", + lang: "en", + languages: "en", + name: "JAMAICA", + require: "ACS", + state_name_type: "parish", + sub_isoids: "13~09~01~12~04~02~06~14~11~08~05~03~07~10", + sub_keys: + "Clarendon~Hanover~Kingston~Manchester~Portland~St. Andrew~St. Ann~St. Catherine~St. Elizabeth~St. James~St. Mary~St. Thomas~Trelawny~Westmoreland", + }, + "data/JO": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/JO", + key: "JO", + name: "JORDAN", + zip: "\\d{5}", + zipex: "11937,11190", + }, + "data/JP": { + fmt: "〒%Z%n%S%n%A%n%O%n%N", + id: "data/JP", + key: "JP", + lang: "ja", + languages: "ja", + lfmt: "%N%n%O%n%A, %S%n%Z", + name: "JAPAN", + posturl: "http://www.post.japanpost.jp/zipcode/", + require: "ASZ", + state_name_type: "prefecture", + sub_isoids: + "01~02~03~04~05~06~07~08~09~10~11~12~13~14~15~16~17~18~19~20~21~22~23~24~25~26~27~28~29~30~31~32~33~34~35~36~37~38~39~40~41~42~43~44~45~46~47", + sub_keys: + "北海道~青森県~岩手県~宮城県~秋田県~山形県~福島県~茨城県~栃木県~群馬県~埼玉県~千葉県~東京都~神奈川県~新潟県~富山県~石川県~福井県~山梨県~長野県~岐阜県~静岡県~愛知県~三重県~滋賀県~京都府~大阪府~兵庫県~奈良県~和歌山県~鳥取県~島根県~岡山県~広島県~山口県~徳島県~香川県~愛媛県~高知県~福岡県~佐賀県~長崎県~熊本県~大分県~宮崎県~鹿児島県~沖縄県", + sub_lnames: + "Hokkaido~Aomori~Iwate~Miyagi~Akita~Yamagata~Fukushima~Ibaraki~Tochigi~Gunma~Saitama~Chiba~Tokyo~Kanagawa~Niigata~Toyama~Ishikawa~Fukui~Yamanashi~Nagano~Gifu~Shizuoka~Aichi~Mie~Shiga~Kyoto~Osaka~Hyogo~Nara~Wakayama~Tottori~Shimane~Okayama~Hiroshima~Yamaguchi~Tokushima~Kagawa~Ehime~Kochi~Fukuoka~Saga~Nagasaki~Kumamoto~Oita~Miyazaki~Kagoshima~Okinawa", + sub_zips: + "0[4-9]|00[1-7]~03|018~02~98~01~99~9[67]~3[01]~32|311|349~37|38[49]~3[3-6]~2[6-9]~1[0-8]|19[0-8]|20~2[1-5]|199~9[45]|389~93~92|939~91|922~40~3[89]|949~50~4[1-9]~4[4-9]|431~51|498|647~52~6[0-2]|520~5[3-9]|618|630~6[5-7]|563~63|64[78]~64|519~68~69|68[45]~7[01]~7[23]~7[45]~77~76~79~78~8[0-3]|871~84~85|81[17]|848~86~87|839~88~89~90", + upper: "S", + zip: "\\d{3}-?\\d{4}", + zipex: "154-0023,350-1106,951-8073,112-0001,208-0032,231-0012", + }, + "data/KE": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/KE", + key: "KE", + name: "KENYA", + zip: "\\d{5}", + zipex: "20100,00100", + }, + "data/KG": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/KG", + key: "KG", + name: "KYRGYZSTAN", + zip: "\\d{6}", + zipex: "720001", + }, + "data/KH": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/KH", + key: "KH", + name: "CAMBODIA", + zip: "\\d{5}", + zipex: "12203,14206,12000", + }, + "data/KI": { + fmt: "%N%n%O%n%A%n%S%n%C", + id: "data/KI", + key: "KI", + name: "KIRIBATI", + state_name_type: "island", + upper: "ACNOS", + }, + "data/KM": { id: "data/KM", key: "KM", name: "COMOROS", upper: "AC" }, + "data/KN": { + fmt: "%N%n%O%n%A%n%C, %S", + id: "data/KN", + key: "KN", + lang: "en", + languages: "en", + name: "SAINT KITTS AND NEVIS", + require: "ACS", + state_name_type: "island", + sub_isoids: "N~K", + sub_keys: "Nevis~St. Kitts", + }, + "data/KP": { + fmt: "%Z%n%S%n%C%n%A%n%O%n%N", + id: "data/KP", + key: "KP", + lang: "ko", + languages: "ko", + lfmt: "%N%n%O%n%A%n%C%n%S, %Z", + name: "NORTH KOREA", + sub_isoids: "07~13~10~04~02~03~01~08~09~05~06", + sub_keys: + "강원도~라선 특별시~량강도~자강도~평안 남도~평안 북도~평양 직할시~함경 남도~함경 북도~황해남도~황해북도", + sub_lnames: + "Kangwon~Rason~Ryanggang~Chagang~South Pyongan~North Pyongan~Pyongyang~South Hamgyong~North Hamgyong~South Hwanghae~North Hwanghae", + }, + "data/KR": { + fmt: "%S %C%D%n%A%n%O%n%N%n%Z", + id: "data/KR", + key: "KR", + lang: "ko", + languages: "ko", + lfmt: "%N%n%O%n%A%n%D%n%C%n%S%n%Z", + name: "SOUTH KOREA", + posturl: "http://www.epost.go.kr/search/zipcode/search5.jsp", + require: "ACSZ", + state_name_type: "do_si", + sub_isoids: "42~41~48~47~29~27~30~26~11~50~31~28~46~45~49~44~43", + sub_keys: + "강원도~경기도~경상남도~경상북도~광주광역시~대구광역시~대전광역시~부산광역시~서울특별시~세종특별자치시~울산광역시~인천광역시~전라남도~전라북도~제주특별자치도~충청남도~충청북도", + sub_lnames: + "Gangwon-do~Gyeonggi-do~Gyeongsangnam-do~Gyeongsangbuk-do~Gwangju~Daegu~Daejeon~Busan~Seoul~Sejong~Ulsan~Incheon~Jeollanam-do~Jeollabuk-do~Jeju-do~Chungcheongnam-do~Chungcheongbuk-do", + sub_mores: + "true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true", + sub_names: + "강원~경기~경남~경북~광주~대구~대전~부산~서울~세종~울산~인천~전남~전북~제주~충남~충북", + sub_zipexs: + "25627~12410~53286~38540~62394~42456~34316~46706~06321~30065~44782~23024~59222~56445~63563~32832~28006", + sub_zips: + "2[456]\\d{2}~1[0-8]\\d{2}~5[0-3]\\d{2}~(?:3[6-9]|40)\\d{2}~6[12]\\d{2}~4[12]\\d{2}~3[45]\\d{2}~4[6-9]\\d{2}~0[1-8]\\d{2}~30[01]\\d~4[45]\\d{2}~2[1-3]\\d{2}~5[7-9]\\d{2}~5[4-6]\\d{2}~63[0-356]\\d~3[1-3]\\d{2}~2[789]\\d{2}", + sublocality_name_type: "district", + upper: "Z", + zip: "\\d{5}", + zipex: "03051", + }, + "data/KW": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/KW", + key: "KW", + name: "KUWAIT", + zip: "\\d{5}", + zipex: "54541,54551,54404,13009", + }, + "data/KY": { + fmt: "%N%n%O%n%A%n%S %Z", + id: "data/KY", + key: "KY", + lang: "en", + languages: "en", + name: "CAYMAN ISLANDS", + posturl: "http://www.caymanpost.gov.ky/", + require: "AS", + state_name_type: "island", + sub_keys: "Cayman Brac~Grand Cayman~Little Cayman", + zip: "KY\\d-\\d{4}", + zipex: "KY1-1100,KY1-1702,KY2-2101", + }, + "data/KZ": { + fmt: "%Z%n%S%n%C%n%A%n%O%n%N", + id: "data/KZ", + key: "KZ", + name: "KAZAKHSTAN", + zip: "\\d{6}", + zipex: "040900,050012", + }, + "data/LA": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/LA", + key: "LA", + name: "LAO (PEOPLE'S DEM. REP.)", + zip: "\\d{5}", + zipex: "01160,01000", + }, + "data/LB": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/LB", + key: "LB", + name: "LEBANON", + zip: "(?:\\d{4})(?: ?(?:\\d{4}))?", + zipex: "2038 3054,1107 2810,1000", + }, + "data/LC": { id: "data/LC", key: "LC", name: "SAINT LUCIA" }, + "data/LI": { + fmt: "%O%n%N%n%A%nFL-%Z %C", + id: "data/LI", + key: "LI", + name: "LIECHTENSTEIN", + postprefix: "FL-", + posturl: "http://www.post.ch/db/owa/pv_plz_pack/pr_main", + require: "ACZ", + zip: "948[5-9]|949[0-8]", + zipex: "9496,9491,9490,9485", + }, + "data/LK": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/LK", + key: "LK", + name: "SRI LANKA", + posturl: "http://www.slpost.gov.lk/", + zip: "\\d{5}", + zipex: "20000,00100", + }, + "data/LR": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/LR", + key: "LR", + name: "LIBERIA", + zip: "\\d{4}", + zipex: "1000", + }, + "data/LS": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/LS", + key: "LS", + name: "LESOTHO", + zip: "\\d{3}", + zipex: "100", + }, + "data/LT": { + fmt: "%O%n%N%n%A%nLT-%Z %C", + id: "data/LT", + key: "LT", + name: "LITHUANIA", + postprefix: "LT-", + posturl: "http://www.post.lt/lt/?id=316", + zip: "\\d{5}", + zipex: "04340,03500", + }, + "data/LU": { + fmt: "%O%n%N%n%A%nL-%Z %C", + id: "data/LU", + key: "LU", + name: "LUXEMBOURG", + postprefix: "L-", + posturl: + "https://www.post.lu/fr/grandes-entreprises/solutions-postales/rechercher-un-code-postal", + require: "ACZ", + zip: "\\d{4}", + zipex: "4750,2998", + }, + "data/LV": { + fmt: "%N%n%O%n%A%n%C, %Z", + id: "data/LV", + key: "LV", + name: "LATVIA", + posturl: "http://www.pasts.lv/lv/uzzinas/nodalas/", + zip: "LV-\\d{4}", + zipex: "LV-1073,LV-1000", + }, + "data/LY": { id: "data/LY", key: "LY", name: "LIBYA" }, + "data/MA": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/MA", + key: "MA", + name: "MOROCCO", + zip: "\\d{5}", + zipex: "53000,10000,20050,16052", + }, + "data/MC": { + fmt: "%N%n%O%n%A%nMC-%Z %C %X", + id: "data/MC", + key: "MC", + name: "MONACO", + postprefix: "MC-", + zip: "980\\d{2}", + zipex: "98000,98020,98011,98001", + }, + "data/MD": { + fmt: "%N%n%O%n%A%nMD-%Z %C", + id: "data/MD", + key: "MD", + name: "Rep. MOLDOVA", + postprefix: "MD-", + zip: "\\d{4}", + zipex: "2012,2019", + }, + "data/ME": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/ME", + key: "ME", + name: "MONTENEGRO", + zip: "8\\d{4}", + zipex: "81257,81258,81217,84314,85366", + }, + "data/MF": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/MF", + key: "MF", + name: "SAINT MARTIN", + posturl: + "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal", + require: "ACZ", + upper: "ACX", + zip: "9[78][01]\\d{2}", + zipex: "97100", + }, + "data/MG": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/MG", + key: "MG", + name: "MADAGASCAR", + zip: "\\d{3}", + zipex: "501,101", + }, + "data/MH": { + fmt: "%N%n%O%n%A%n%C %S %Z", + id: "data/MH", + key: "MH", + name: "MARSHALL ISLANDS", + posturl: "http://zip4.usps.com/zip4/welcome.jsp", + require: "ACSZ", + state_name_type: "state", + upper: "ACNOS", + zip: "(969[67]\\d)(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "96960,96970", + }, + "data/MK": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/MK", + key: "MK", + name: "MACEDONIA", + zip: "\\d{4}", + zipex: "1314,1321,1443,1062", + }, + "data/ML": { id: "data/ML", key: "ML", name: "MALI" }, + "data/MM": { + fmt: "%N%n%O%n%A%n%C, %Z", + id: "data/MM", + key: "MM", + name: "MYANMAR", + zip: "\\d{5}", + zipex: "11181", + }, + "data/MN": { + fmt: "%N%n%O%n%A%n%C%n%S %Z", + id: "data/MN", + key: "MN", + name: "MONGOLIA", + posturl: "http://www.zipcode.mn/", + zip: "\\d{5}", + zipex: "65030,65270", + }, + "data/MO": { + fmt: "%A%n%O%n%N", + id: "data/MO", + key: "MO", + lfmt: "%N%n%O%n%A", + name: "MACAO", + require: "A", + }, + "data/MP": { + fmt: "%N%n%O%n%A%n%C %S %Z", + id: "data/MP", + key: "MP", + name: "NORTHERN MARIANA ISLANDS", + posturl: "http://zip4.usps.com/zip4/welcome.jsp", + require: "ACSZ", + state_name_type: "state", + upper: "ACNOS", + zip: "(9695[012])(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "96950,96951,96952", + }, + "data/MQ": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/MQ", + key: "MQ", + name: "MARTINIQUE", + posturl: + "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal", + require: "ACZ", + upper: "ACX", + zip: "9[78]2\\d{2}", + zipex: "97220", + }, + "data/MR": { id: "data/MR", key: "MR", name: "MAURITANIA", upper: "AC" }, + "data/MS": { id: "data/MS", key: "MS", name: "MONTSERRAT" }, + "data/MT": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/MT", + key: "MT", + name: "MALTA", + posturl: "http://postcodes.maltapost.com/", + upper: "CZ", + zip: "[A-Z]{3} ?\\d{2,4}", + zipex: "NXR 01,ZTN 05,GPO 01,BZN 1130,SPB 6031,VCT 1753", + }, + "data/MU": { + fmt: "%N%n%O%n%A%n%Z%n%C", + id: "data/MU", + key: "MU", + name: "MAURITIUS", + upper: "CZ", + zip: "\\d{3}(?:\\d{2}|[A-Z]{2}\\d{3})", + zipex: "42602", + }, + "data/MV": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/MV", + key: "MV", + name: "MALDIVES", + posturl: "http://www.maldivespost.com/?lid=10", + zip: "\\d{5}", + zipex: "20026", + }, + "data/MW": { + fmt: "%N%n%O%n%A%n%C %X", + id: "data/MW", + key: "MW", + name: "MALAWI", + }, + "data/MX": { + fmt: "%N%n%O%n%A%n%D%n%Z %C, %S", + id: "data/MX", + key: "MX", + lang: "es", + languages: "es", + name: "MEXICO", + posturl: + "http://www.correosdemexico.gob.mx/ServiciosLinea/Paginas/ccpostales.aspx", + require: "ACZ", + state_name_type: "state", + sub_isoids: + "AGU~BCN~BCS~CAM~CHP~CHH~CMX~COA~COL~DUR~MEX~GUA~GRO~HID~JAL~MIC~MOR~NAY~NLE~OAX~PUE~QUE~ROO~SLP~SIN~SON~TAB~TAM~TLA~VER~YUC~ZAC", + sub_keys: + "Ags.~B.C.~B.C.S.~Camp.~Chis.~Chih.~CDMX~Coah.~Col.~Dgo.~Méx.~Gto.~Gro.~Hgo.~Jal.~Mich.~Mor.~Nay.~N.L.~Oax.~Pue.~Qro.~Q.R.~S.L.P.~Sin.~Son.~Tab.~Tamps.~Tlax.~Ver.~Yuc.~Zac.", + sub_names: + "Aguascalientes~Baja California~Baja California Sur~Campeche~Chiapas~Chihuahua~Ciudad de México~Coahuila de Zaragoza~Colima~Durango~Estado de México~Guanajuato~Guerrero~Hidalgo~Jalisco~Michoacán~Morelos~Nayarit~Nuevo León~Oaxaca~Puebla~Querétaro~Quintana Roo~San Luis Potosí~Sinaloa~Sonora~Tabasco~Tamaulipas~Tlaxcala~Veracruz~Yucatán~Zacatecas", + sub_zipexs: + "20000,20999~21000,22999~23000,23999~24000,24999~29000,30999~31000,33999~00000,16999~25000,27999~28000,28999~34000,35999~50000,57999~36000,38999~39000,41999~42000,43999~44000,49999~58000,61999~62000,62999~63000,63999~64000,67999~68000,71999~72000,75999~76000,76999~77000,77999~78000,79999~80000,82999~83000,85999~86000,86999~87000,89999~90000,90999~91000,96999~97000,97999~98000,99999", + sub_zips: + "20~2[12]~23~24~29|30~3[1-3]~0|1[0-6]~2[5-7]~28~3[45]~5[0-7]~3[6-8]~39|4[01]~4[23]~4[4-9]~5[89]|6[01]~62~63~6[4-7]~6[89]|7[01]~7[2-5]~76~77~7[89]~8[0-2]~8[3-5]~86~8[7-9]~90~9[1-6]~97~9[89]", + sublocality_name_type: "neighborhood", + upper: "CSZ", + zip: "\\d{5}", + zipex: "02860,77520,06082", + }, + "data/MY": { + fmt: "%N%n%O%n%A%n%D%n%Z %C%n%S", + id: "data/MY", + key: "MY", + lang: "ms", + languages: "ms", + name: "MALAYSIA", + posturl: "http://www.pos.com.my", + require: "ACZ", + state_name_type: "state", + sub_isoids: "01~02~03~14~15~04~05~06~08~09~07~16~12~13~10~11", + sub_keys: + "Johor~Kedah~Kelantan~Kuala Lumpur~Labuan~Melaka~Negeri Sembilan~Pahang~Perak~Perlis~Pulau Pinang~Putrajaya~Sabah~Sarawak~Selangor~Terengganu", + sub_zipexs: + "79000,86999~05000,09999,34950~15000,18599~50000,60000~87000,87999~75000,78399~70000,73599~25000,28999,39000,49000,69000~30000,36899,39000~01000,02799~10000,14999~62000,62999~88000,91999~93000,98999~40000,48999,63000,68199~20000,24999", + sub_zips: + "79|8[0-6]~0[5-9]|34950~1[5-9]~5|60~87~7[5-8]~7[0-4]~2[5-8]|[346]9~3[0-6]|39000~0[12]~1[0-4]~62~8[89]|9[01]~9[3-8]~4[0-8]|6[3-8]~2[0-4]", + sublocality_name_type: "village_township", + upper: "CS", + zip: "\\d{5}", + zipex: "43000,50754,88990,50670", + }, + "data/MZ": { + fmt: "%N%n%O%n%A%n%Z %C%S", + id: "data/MZ", + key: "MZ", + lang: "pt", + languages: "pt", + name: "MOZAMBIQUE", + sub_isoids: "P~MPM~G~I~B~L~N~A~S~T~Q", + sub_keys: + "Cabo Delgado~Cidade de Maputo~Gaza~Inhambane~Manica~Maputo~Nampula~Niassa~Sofala~Tete~Zambezia", + zip: "\\d{4}", + zipex: "1102,1119,3212", + }, + "data/NA": { id: "data/NA", key: "NA", name: "NAMIBIA" }, + "data/NC": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/NC", + key: "NC", + name: "NEW CALEDONIA", + posturl: + "http://poste.opt.nc/index.php?option=com_content&view=article&id=80&Itemid=131", + require: "ACZ", + upper: "ACX", + zip: "988\\d{2}", + zipex: "98814,98800,98810", + }, + "data/NE": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/NE", + key: "NE", + name: "NIGER", + zip: "\\d{4}", + zipex: "8001", + }, + "data/NF": { + fmt: "%O%n%N%n%A%n%C %S %Z", + id: "data/NF", + key: "NF", + name: "NORFOLK ISLAND", + upper: "CS", + zip: "2899", + zipex: "2899", + }, + "data/NG": { + fmt: "%N%n%O%n%A%n%D%n%C %Z%n%S", + id: "data/NG", + key: "NG", + lang: "en", + languages: "en", + name: "NIGERIA", + posturl: "http://www.nigeriapostcodes.com/", + state_name_type: "state", + sub_isoids: + "AB~AD~AK~AN~BA~BY~BE~BO~CR~DE~EB~ED~EK~EN~FC~GO~IM~JI~KD~KN~KT~KE~KO~KW~LA~NA~NI~OG~ON~OS~OY~PL~RI~SO~TA~YO~ZA", + sub_keys: + "Abia~Adamawa~Akwa Ibom~Anambra~Bauchi~Bayelsa~Benue~Borno~Cross River~Delta~Ebonyi~Edo~Ekiti~Enugu~Federal Capital Territory~Gombe~Imo~Jigawa~Kaduna~Kano~Katsina~Kebbi~Kogi~Kwara~Lagos~Nasarawa~Niger~Ogun State~Ondo~Osun~Oyo~Plateau~Rivers~Sokoto~Taraba~Yobe~Zamfara", + upper: "CS", + zip: "\\d{6}", + zipex: "930283,300001,931104", + }, + "data/NI": { + fmt: "%N%n%O%n%A%n%Z%n%C, %S", + id: "data/NI", + key: "NI", + lang: "es", + languages: "es", + name: "NICARAGUA", + posturl: "http://www.correos.gob.ni/index.php/codigo-postal-2", + state_name_type: "department", + sub_isoids: "BO~CA~CI~CO~ES~GR~JI~LE~MD~MN~MS~MT~NS~AN~AS~SJ~RI", + sub_keys: + "Boaco~Carazo~Chinandega~Chontales~Esteli~Granada~Jinotega~Leon~Madriz~Managua~Masaya~Matagalpa~Nueva Segovia~Raan~Raas~Rio San Juan~Rivas", + sub_zips: + "5[12]~4[56]~2[5-7]~5[56]~3[12]~4[34]~6[56]~2[12]~3[45]~1[0-6]~4[12]~6[1-3]~3[7-9]~7[12]~8[1-3]~9[12]~4[78]", + upper: "CS", + zip: "\\d{5}", + zipex: "52000", + }, + "data/NL": { + fmt: "%O%n%N%n%A%n%Z %C", + id: "data/NL", + key: "NL", + name: "NETHERLANDS", + posturl: "http://www.postnl.nl/voorthuis/", + require: "ACZ", + zip: "\\d{4} ?[A-Z]{2}", + zipex: "1234 AB,2490 AA", + }, + "data/NO": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/NO", + key: "NO", + locality_name_type: "post_town", + name: "NORWAY", + posturl: "http://adressesok.posten.no/nb/postal_codes/search", + require: "ACZ", + zip: "\\d{4}", + zipex: "0025,0107,6631", + }, + "data/NP": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/NP", + key: "NP", + name: "NEPAL", + posturl: "http://www.gpo.gov.np/Home/Postalcode", + zip: "\\d{5}", + zipex: "44601", + }, + "data/NR": { + fmt: "%N%n%O%n%A%n%S", + id: "data/NR", + key: "NR", + lang: "en", + languages: "en", + name: "NAURU CENTRAL PACIFIC", + require: "AS", + state_name_type: "district", + sub_isoids: "01~02~03~04~05~06~07~08~09~10~11~12~13~14", + sub_keys: + "Aiwo District~Anabar District~Anetan District~Anibare District~Baiti District~Boe District~Buada District~Denigomodu District~Ewa District~Ijuw District~Meneng District~Nibok District~Uaboe District~Yaren District", + }, + "data/NU": { id: "data/NU", key: "NU", name: "NIUE" }, + "data/NZ": { + fmt: "%N%n%O%n%A%n%D%n%C %Z", + id: "data/NZ", + key: "NZ", + name: "NEW ZEALAND", + posturl: + "http://www.nzpost.co.nz/Cultures/en-NZ/OnlineTools/PostCodeFinder/", + require: "ACZ", + zip: "\\d{4}", + zipex: "6001,6015,6332,8252,1030", + }, + "data/OM": { + fmt: "%N%n%O%n%A%n%Z%n%C", + id: "data/OM", + key: "OM", + name: "OMAN", + zip: "(?:PC )?\\d{3}", + zipex: "133,112,111", + }, + "data/PA": { + fmt: "%N%n%O%n%A%n%C%n%S", + id: "data/PA", + key: "PA", + name: "PANAMA (REP.)", + upper: "CS", + }, + "data/PE": { + fmt: "%N%n%O%n%A%n%C %Z%n%S", + id: "data/PE", + key: "PE", + lang: "es", + languages: "es", + locality_name_type: "district", + name: "PERU", + posturl: "http://www.serpost.com.pe/cpostal/codigo", + sub_isoids: + "AMA~ANC~APU~ARE~AYA~CAJ~CAL~CUS~LIM~HUV~HUC~ICA~JUN~LAL~LAM~LOR~MDD~MOQ~LMA~PAS~PIU~PUN~SAM~TAC~TUM~UCA", + sub_keys: + "Amazonas~Áncash~Apurímac~Arequipa~Ayacucho~Cajamarca~Callao~Cuzco~Gobierno Regional de Lima~Huancavelica~Huánuco~Ica~Junín~La Libertad~Lambayeque~Loreto~Madre de Dios~Moquegua~Municipalidad Metropolitana de Lima~Pasco~Piura~Puno~San Martín~Tacna~Tumbes~Ucayali", + zip: "(?:LIMA \\d{1,2}|CALLAO 0?\\d)|[0-2]\\d{4}", + zipex: "LIMA 23,LIMA 42,CALLAO 2,02001", + }, + "data/PF": { + fmt: "%N%n%O%n%A%n%Z %C %S", + id: "data/PF", + key: "PF", + name: "FRENCH POLYNESIA", + require: "ACSZ", + state_name_type: "island", + upper: "CS", + zip: "987\\d{2}", + zipex: "98709", + }, + "data/PG": { + fmt: "%N%n%O%n%A%n%C %Z %S", + id: "data/PG", + key: "PG", + name: "PAPUA NEW GUINEA", + require: "ACS", + zip: "\\d{3}", + zipex: "111", + }, + "data/PH": { + fmt: "%N%n%O%n%A%n%D, %C%n%Z %S", + id: "data/PH", + key: "PH", + lang: "en", + languages: "en", + name: "PHILIPPINES", + posturl: "http://www.philpost.gov.ph/", + sub_isoids: + "ABR~AGN~AGS~AKL~ALB~ANT~APA~AUR~BAS~BAN~BTN~BTG~BEN~BIL~BOH~BUK~BUL~CAG~CAN~CAS~CAM~CAP~CAT~CAV~CEB~COM~NCO~DAV~DAS~DVO~DAO~DIN~EAS~GUI~IFU~ILN~ILS~ILI~ISA~KAL~LUN~LAG~LAN~LAS~LEY~MAG~MAD~MAS~00~MDC~MDR~MSC~MSR~MOU~NEC~NER~NSA~NUE~NUV~PLW~PAM~PAN~QUE~QUI~RIZ~ROM~WSA~SAR~SIG~SOR~SCO~SLE~SUK~SLU~SUN~SUR~TAR~TAW~ZMB~ZAN~ZAS~ZSI", + sub_keys: + "Abra~Agusan del Norte~Agusan del Sur~Aklan~Albay~Antique~Apayao~Aurora~Basilan~Bataan~Batanes~Batangas~Benguet~Biliran~Bohol~Bukidnon~Bulacan~Cagayan~Camarines Norte~Camarines Sur~Camiguin~Capiz~Catanduanes~Cavite~Cebu~Compostela Valley~Cotabato~Davao del Norte~Davao del Sur~Davao Occidental~Davao Oriental~Dinagat Islands~Eastern Samar~Guimaras~Ifugao~Ilocos Norte~Ilocos Sur~Iloilo~Isabela~Kalinga~La Union~Laguna~Lanao del Norte~Lanao del Sur~Leyte~Maguindanao~Marinduque~Masbate~Metro Manila~Mindoro Occidental~Mindoro Oriental~Misamis Occidental~Misamis Oriental~Mountain Province~Negros Occidental~Negros Oriental~Northern Samar~Nueva Ecija~Nueva Vizcaya~Palawan~Pampanga~Pangasinan~Quezon Province~Quirino~Rizal~Romblon~Samar~Sarangani~Siquijor~Sorsogon~South Cotabato~Southern Leyte~Sultan Kudarat~Sulu~Surigao del Norte~Surigao del Sur~Tarlac~Tawi-Tawi~Zambales~Zamboanga del Norte~Zamboanga del Sur~Zamboanga Sibuguey", + sub_zipexs: + "2800,2826~8600,8611~8500,8513~5600,5616~4500,4517~5700,5717~3800,3806,3808~3200,3207~7300,7306~2100,2114~3900,3905~4200,4234~2600,2615~6543,6550~6300,6337~8700,8723~3000,3024~3500,3528~4600,4612~4400,4436~9100,9104~5800,5816~4800,4810~4100,4126~6000,6053~8800,8810~9400,9417~8100,8120~8000,8010~8015,8013~8200,8210~8426,8412~6800,6822~5044,5046~3600,3610~2900,2922~2700,2733~5000,5043~3300,3336~3807,3809,3814~2500,2520~4000,4033~9200,9223~9300,9321,9700,9716~6500,6542~9600,9619~4900,4905~5400,5421~~5100,5111~5200,5214~7200,7215~9000,9025~2616,2625~6100,6132~6200,6224~6400,6423~3100,3133~3700,3714~5300,5322~2000,2022~2400,2447~4300,4342~3400,3405~1850,1990~5500,5516~6700,6725~8015~6225,6230~4700,4715~9500,9513~6600,6613~9800,9811~7400,7416~8400,8425~8300,8319~2300,2318~7500,7509~2200,2213~7100,7124~7000,7043~7000,7043", + sub_zips: + "28[0-2]~86[01]~85[01]~56[01]~45[01]~57[01]~380[0-68]~320~730~21[01]~390~42[0-3]~26(0|1[0-5])~65(4[3-9]|5)~63[0-3]~87[0-2]~30[0-2]~35[0-2]~46[01]~44[0-3]~910~58[01]~48[01]~41[0-2]~60[0-5]~88[01]~94[01]~81[0-2]~80[01]~801[1-5]~82[01]~84[12]~68[0-2]~504[4-6]~36[01]~29[0-2]~27[0-3]~50([0-3]|4[0-3])~33[0-3]~38(0[79]|1[0-4])~25[0-2]~40[0-3]~92[0-2]~9(3[0-2]|7[01])~65([0-3]|4[0-2])~96[01]~490~54[0-2]~~51[01]~52[01]~72[01]~90[0-2]~26(1[6-9]|2[0-5])~61[0-3]~62[0-2]~64[0-2]~31[0-3]~37[01]~53[0-2]~20[0-2]~24[0-4]~43[0-4]~340~1[89]~55[01]~67[0-2]~8015~62(2[5-9]|30)~47[01]~95[01]~66[10]~98[01]~74[01]~84[0-2]~83[01]~23[01]~750~22[01]~71[0-2]~70[0-4]~70[0-4]", + zip: "\\d{4}", + zipex: "1008,1050,1135,1207,2000,1000", + }, + "data/PK": { + fmt: "%N%n%O%n%A%n%C-%Z", + id: "data/PK", + key: "PK", + name: "PAKISTAN", + posturl: "http://www.pakpost.gov.pk/postcode.php", + zip: "\\d{5}", + zipex: "44000", + }, + "data/PL": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/PL", + key: "PL", + name: "POLAND", + posturl: "http://kody.poczta-polska.pl/", + require: "ACZ", + zip: "\\d{2}-\\d{3}", + zipex: "00-950,05-470,48-300,32-015,00-940", + }, + "data/PM": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/PM", + key: "PM", + name: "ST. PIERRE AND MIQUELON", + require: "ACZ", + upper: "ACX", + zip: "9[78]5\\d{2}", + zipex: "97500", + }, + "data/PN": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/PN", + key: "PN", + name: "PITCAIRN", + require: "ACZ", + upper: "CZ", + zip: "PCRN 1ZZ", + zipex: "PCRN 1ZZ", + }, + "data/PR": { + fmt: "%N%n%O%n%A%n%C PR %Z", + id: "data/PR", + key: "PR", + name: "PUERTO RICO", + postprefix: "PR ", + posturl: "http://zip4.usps.com/zip4/welcome.jsp", + require: "ACZ", + upper: "ACNO", + zip: "(00[679]\\d{2})(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "00930", + }, + "data/PT": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/PT", + key: "PT", + name: "PORTUGAL", + posturl: "http://www.ctt.pt/feapl_2/app/open/tools.jspx?tool=1", + require: "ACZ", + zip: "\\d{4}-\\d{3}", + zipex: "2725-079,1250-096,1201-950,2860-571,1208-148", + }, + "data/PW": { + fmt: "%N%n%O%n%A%n%C %S %Z", + id: "data/PW", + key: "PW", + name: "PALAU", + posturl: "http://zip4.usps.com/zip4/welcome.jsp", + require: "ACSZ", + state_name_type: "state", + upper: "ACNOS", + zip: "(969(?:39|40))(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "96940", + }, + "data/PY": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/PY", + key: "PY", + name: "PARAGUAY", + zip: "\\d{4}", + zipex: "1536,1538,1209", + }, + "data/QA": { id: "data/QA", key: "QA", name: "QATAR", upper: "AC" }, + "data/RE": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/RE", + key: "RE", + name: "REUNION", + posturl: + "http://www.laposte.fr/Particulier/Utiliser-nos-outils-pratiques/Outils-et-documents/Trouvez-un-code-postal", + require: "ACZ", + upper: "ACX", + zip: "9[78]4\\d{2}", + zipex: "97400", + }, + "data/RO": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/RO", + key: "RO", + name: "ROMANIA", + posturl: "http://www.posta-romana.ro/zip_codes", + upper: "AC", + zip: "\\d{6}", + zipex: "060274,061357,200716", + }, + "data/RS": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/RS", + key: "RS", + name: "REPUBLIC OF SERBIA", + posturl: + "http://www.posta.rs/struktura/lat/aplikacije/pronadji/nadji-postu.asp", + zip: "\\d{5,6}", + zipex: "106314", + }, + "data/RU": { + fmt: "%N%n%O%n%A%n%C%n%S%n%Z", + id: "data/RU", + key: "RU", + lang: "ru", + languages: "ru", + lfmt: "%N%n%O%n%A%n%C%n%S%n%Z", + name: "RUSSIAN FEDERATION", + posturl: "http://info.russianpost.ru/servlet/department", + require: "ACSZ", + state_name_type: "oblast", + sub_isoids: + "ALT~AMU~ARK~AST~BEL~BRY~VLA~VGG~VLG~VOR~YEV~ZAB~IVA~IRK~KB~KGD~KLU~KAM~KC~KEM~KIR~KOS~KDA~KYA~KGN~KRS~LEN~LIP~MAG~MOW~MOS~MUR~NEN~NIZ~NGR~NVS~OMS~ORE~ORL~PNZ~PER~PRI~PSK~AD~AL~BA~BU~DA~IN~KL~KR~KO~~ME~MO~SA~SE~TA~TY~UD~KK~ROS~RYA~SAM~SPE~SAR~SAK~SVE~~SMO~STA~TAM~TVE~TOM~TUL~TYU~ULY~KHA~KHM~CHE~CE~CU~CHU~YAN~YAR", + sub_keys: + "Алтайский край~Амурская область~Архангельская область~Астраханская область~Белгородская область~Брянская область~Владимирская область~Волгоградская область~Вологодская область~Воронежская область~Еврейская автономная область~Забайкальский край~Ивановская область~Иркутская область~Кабардино-Балкарская Республика~Калининградская область~Калужская область~Камчатский край~Карачаево-Черкесская Республика~Кемеровская область~Кировская область~Костромская область~Краснодарский край~Красноярский край~Курганская область~Курская область~Ленинградская область~Липецкая область~Магаданская область~Москва~Московская область~Мурманская область~Ненецкий автономный округ~Нижегородская область~Новгородская область~Новосибирская область~Омская область~Оренбургская область~Орловская область~Пензенская область~Пермский край~Приморский край~Псковская область~Республика Адыгея~Республика Алтай~Республика Башкортостан~Республика Бурятия~Республика Дагестан~Республика Ингушетия~Республика Калмыкия~Республика Карелия~Республика Коми~Автономна Республіка Крим~Республика Марий Эл~Республика Мордовия~Республика Саха (Якутия)~Республика Северная Осетия-Алания~Республика Татарстан~Республика Тыва~Республика Удмуртия~Республика Хакасия~Ростовская область~Рязанская область~Самарская область~Санкт-Петербург~Саратовская область~Сахалинская область~Свердловская область~Севастополь~Смоленская область~Ставропольский край~Тамбовская область~Тверская область~Томская область~Тульская область~Тюменская область~Ульяновская область~Хабаровский край~Ханты-Мансийский автономный округ~Челябинская область~Чеченская Республика~Чувашская Республика~Чукотский автономный округ~Ямало-Ненецкий автономный округ~Ярославская область", + sub_lnames: + "Altayskiy kray~Amurskaya oblast'~Arkhangelskaya oblast'~Astrakhanskaya oblast'~Belgorodskaya oblast'~Bryanskaya oblast'~Vladimirskaya oblast'~Volgogradskaya oblast'~Vologodskaya oblast'~Voronezhskaya oblast'~Evreyskaya avtonomnaya oblast'~Zabaykalskiy kray~Ivanovskaya oblast'~Irkutskaya oblast'~Kabardino-Balkarskaya Republits~Kaliningradskaya oblast'~Kaluzhskaya oblast'~Kamchatskiy kray~Karachaevo-Cherkesskaya Republits~Kemerovskaya oblast'~Kirovskaya oblast'~Kostromskaya oblast'~Krasnodarskiy kray~Krasnoyarskiy kray~Kurganskaya oblast'~Kurskaya oblast'~Leningradskaya oblast'~Lipetskaya oblast'~Magadanskaya oblast'~Moskva~Moskovskaya oblast'~Murmanskaya oblast'~Nenetskiy~Nizhegorodskaya oblast'~Novgorodskaya oblast'~Novosibirskaya oblast'~Omskaya oblast'~Orenburgskaya oblast'~Orlovskaya oblast'~Penzenskaya oblast'~Permskiy kray~Primorskiy kray~Pskovskaya oblast'~Respublika Adygeya~Altay Republits~Bashkortostan Republits~Buryatiya Republits~Dagestan Republits~Ingushetiya Republits~Respublika Kalmykiya~Kareliya Republits~Komi Republits~Respublika Krym~Respublika Mariy El~Respublika Mordoviya~Sakha (Yakutiya) Republits~Respublika Severnaya Osetiya-Alaniya~Respublika Tatarstan~Tyva Republits~Respublika Udmurtiya~Khakasiya Republits~Rostovskaya oblast'~Ryazanskaya oblast'~Samarskaya oblast'~Sankt-Peterburg~Saratovskaya oblast'~Sakhalinskaya oblast'~Sverdlovskaya oblast'~Sevastopol'~Smolenskaya oblast'~Stavropolskiy kray~Tambovskaya oblast'~Tverskaya oblast'~Tomskaya oblast'~Tulskaya oblast'~Tyumenskaya oblast'~Ulyanovskaya oblast'~Khabarovskiy kray~Khanty-Mansiyskiy avtonomnyy okrug~Chelyabinskaya oblast'~Chechenskaya Republits~Chuvashia~Chukotskiy~Yamalo-Nenetskiy~Yaroslavskaya oblast'", + sub_names: + "Алтайский край~Амурская область~Архангельская область~Астраханская область~Белгородская область~Брянская область~Владимирская область~Волгоградская область~Вологодская область~Воронежская область~Еврейская автономная область~Забайкальский край~Ивановская область~Иркутская область~Кабардино-Балкарская Республика~Калининградская область~Калужская область~Камчатский край~Карачаево-Черкесская Республика~Кемеровская область~Кировская область~Костромская область~Краснодарский край~Красноярский край~Курганская область~Курская область~Ленинградская область~Липецкая область~Магаданская область~Москва~Московская область~Мурманская область~Ненецкий автономный округ~Нижегородская область~Новгородская область~Новосибирская область~Омская область~Оренбургская область~Орловская область~Пензенская область~Пермский край~Приморский край~Псковская область~Республика Адыгея~Республика Алтай~Республика Башкортостан~Республика Бурятия~Республика Дагестан~Республика Ингушетия~Республика Калмыкия~Республика Карелия~Республика Коми~Республика Крым~Республика Марий Эл~Республика Мордовия~Республика Саха (Якутия)~Республика Северная Осетия-Алания~Республика Татарстан~Республика Тыва~Республика Удмуртия~Республика Хакасия~Ростовская область~Рязанская область~Самарская область~Санкт-Петербург~Саратовская область~Сахалинская область~Свердловская область~Севастополь~Смоленская область~Ставропольский край~Тамбовская область~Тверская область~Томская область~Тульская область~Тюменская область~Ульяновская область~Хабаровский край~Ханты-Мансийский автономный округ~Челябинская область~Чеченская Республика~Чувашская Республика~Чукотский автономный округ~Ямало-Ненецкий автономный округ~Ярославская область", + sub_zips: + "65[6-9]~67[56]~16[3-5]~41[4-6]~30[89]~24[1-3]~60[0-2]~40[0-4]~16[0-2]~39[4-7]~679~6(?:7[2-4]|87)~15[3-5]~66[4-9]~36[01]~23[6-8]~24[89]~68[348]~369~65[0-4]~61[0-3]~15[67]~35[0-4]~6(?:6[0-3]|4[78])~64[01]~30[5-7]~18[78]~39[89]~68[56]~1(?:0[1-9]|1|2|3[0-5]|4[0-4])~14[0-4]~18[34]~166~60[3-7]~17[3-5]~63[0-3]~64[4-6]~46[0-2]~30[23]~44[0-2]~61[4-9]~69[0-2]~18[0-2]~385~649~45[0-3]~67[01]~36[78]~386~35[89]~18[56]~16[7-9]~29[5-8]~42[45]~43[01]~67[78]~36[23]~42[0-3]~66[78]~42[67]~655~34[4-7]~39[01]~44[3-6]~19~41[0-3]~69[34]~62[0-4]~299~21[4-6]~35[5-7]~39[23]~17[0-2]~63[4-6]~30[01]~62[5-7]~43[23]~68[0-2]~628~45[4-7]~36[4-6]~42[89]~689~629~15[0-2]", + upper: "AC", + zip: "\\d{6}", + zipex: "247112,103375,188300", + }, + "data/RW": { id: "data/RW", key: "RW", name: "RWANDA", upper: "AC" }, + "data/SA": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/SA", + key: "SA", + name: "SAUDI ARABIA", + zip: "\\d{5}", + zipex: "11564,11187,11142", + }, + "data/SB": { id: "data/SB", key: "SB", name: "SOLOMON ISLANDS" }, + "data/SC": { + fmt: "%N%n%O%n%A%n%C%n%S", + id: "data/SC", + key: "SC", + name: "SEYCHELLES", + state_name_type: "island", + upper: "S", + }, + "data/SD": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/SD", + key: "SD", + locality_name_type: "district", + name: "SUDAN", + zip: "\\d{5}", + zipex: "11042,11113", + }, + "data/SE": { + fmt: "%O%n%N%n%A%nSE-%Z %C", + id: "data/SE", + key: "SE", + locality_name_type: "post_town", + name: "SWEDEN", + postprefix: "SE-", + posturl: + "http://www.posten.se/sv/Kundservice/Sidor/Sok-postnummer-resultat.aspx", + require: "ACZ", + zip: "\\d{3} ?\\d{2}", + zipex: "11455,12345,10500", + }, + "data/SG": { + fmt: "%N%n%O%n%A%nSINGAPORE %Z", + id: "data/SG", + key: "SG", + name: "REP. OF SINGAPORE", + posturl: "https://www.singpost.com/find-postal-code", + require: "AZ", + zip: "\\d{6}", + zipex: "546080,308125,408600", + }, + "data/SH": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/SH", + key: "SH", + name: "SAINT HELENA", + require: "ACZ", + upper: "CZ", + zip: "(?:ASCN|STHL) 1ZZ", + zipex: "STHL 1ZZ", + }, + "data/SI": { + fmt: "%N%n%O%n%A%nSI-%Z %C", + id: "data/SI", + key: "SI", + name: "SLOVENIA", + postprefix: "SI-", + zip: "\\d{4}", + zipex: "4000,1001,2500", + }, + "data/SK": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/SK", + key: "SK", + name: "SLOVAKIA", + posturl: "http://psc.posta.sk", + require: "ACZ", + zip: "\\d{3} ?\\d{2}", + zipex: "010 01,023 14,972 48,921 01,975 99", + }, + "data/SL": { id: "data/SL", key: "SL", name: "SIERRA LEONE" }, + "data/SM": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/SM", + key: "SM", + name: "SAN MARINO", + posturl: "http://www.poste.it/online/cercacap/", + require: "AZ", + zip: "4789\\d", + zipex: "47890,47891,47895,47899", + }, + "data/SN": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/SN", + key: "SN", + name: "SENEGAL", + zip: "\\d{5}", + zipex: "12500,46024,16556,10000", + }, + "data/SO": { + fmt: "%N%n%O%n%A%n%C, %S %Z", + id: "data/SO", + key: "SO", + lang: "so", + languages: "so", + name: "SOMALIA", + require: "ACS", + sub_isoids: "AW~BK~BN~BR~BY~GA~GE~HI~JD~JH~MU~NU~SA~SD~SH~SO~TO~WO", + sub_keys: "AD~BK~BN~BR~BY~GG~GD~HR~JD~JH~MD~NG~SG~SD~SH~SL~TG~WG", + sub_names: + "Awdal~Bakool~Banaadir~Bari~Bay~Galguduud~Gedo~Hiiraan~Jubbada Dhexe~Jubbada Hoose~Mudug~Nugaal~Sanaag~Shabeellaha Dhexe~Shabeellaha Hoose~Sool~Togdheer~Woqooyi Galbeed", + upper: "ACS", + zip: "[A-Z]{2} ?\\d{5}", + zipex: "JH 09010,AD 11010", + }, + "data/SR": { + fmt: "%N%n%O%n%A%n%C%n%S", + id: "data/SR", + key: "SR", + lang: "nl", + languages: "nl", + name: "SURINAME", + sub_isoids: "BR~CM~CR~MA~NI~PR~PM~SA~SI~WA", + sub_keys: + "Brokopondo~Commewijne~Coronie~Marowijne~Nickerie~Para~Paramaribo~Saramacca~Sipaliwini~Wanica", + upper: "AS", + }, + "data/SS": { id: "data/SS", key: "SS", name: "SOUTH SUDAN" }, + "data/ST": { id: "data/ST", key: "ST", name: "SAO TOME AND PRINCIPE" }, + "data/SV": { + fmt: "%N%n%O%n%A%n%Z-%C%n%S", + id: "data/SV", + key: "SV", + lang: "es", + languages: "es", + name: "EL SALVADOR", + require: "ACS", + sub_isoids: "AH~CA~CH~CU~LI~PA~UN~MO~SM~SS~SV~SA~SO~US", + sub_keys: + "Ahuachapan~Cabanas~Calatenango~Cuscatlan~La Libertad~La Paz~La Union~Morazan~San Miguel~San Salvador~San Vicente~Santa Ana~Sonsonate~Usulutan", + sub_names: + "Ahuachapán~Cabañas~Chalatenango~Cuscatlán~La Libertad~La Paz~La Unión~Morazán~San Miguel~San Salvador~San Vicente~Santa Ana~Sonsonate~Usulután", + sub_zipexs: + "CP 2101~CP 1201~CP 1301~CP 1401~CP 1501~CP 1601~CP 3101~CP 3201~CP 3301~CP 1101~CP 1701~CP 2201~CP 2301~CP 3401", + sub_zips: + "CP 21~CP 12~CP 13~CP 14~CP 15~CP 16~CP 31~CP 32~CP 33~CP 11~CP 17~CP 22~CP 23~CP 34", + upper: "CSZ", + zip: "CP [1-3][1-7][0-2]\\d", + zipex: "CP 1101", + }, + "data/SX": { id: "data/SX", key: "SX", name: "SINT MAARTEN" }, + "data/SY": { + id: "data/SY", + key: "SY", + locality_name_type: "district", + name: "SYRIA", + }, + "data/SZ": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/SZ", + key: "SZ", + name: "SWAZILAND", + posturl: "http://www.sptc.co.sz/swazipost/codes/index.php", + upper: "ACZ", + zip: "[HLMS]\\d{3}", + zipex: "H100", + }, + "data/TC": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/TC", + key: "TC", + name: "TURKS AND CAICOS ISLANDS", + require: "ACZ", + upper: "CZ", + zip: "TKCA 1ZZ", + zipex: "TKCA 1ZZ", + }, + "data/TD": { id: "data/TD", key: "TD", name: "CHAD" }, + "data/TF": { id: "data/TF", key: "TF", name: "FRENCH SOUTHERN TERRITORIES" }, + "data/TG": { id: "data/TG", key: "TG", name: "TOGO" }, + "data/TH": { + fmt: "%N%n%O%n%A%n%D %C%n%S %Z", + id: "data/TH", + key: "TH", + lang: "th", + languages: "th", + lfmt: "%N%n%O%n%A%n%D, %C%n%S %Z", + name: "THAILAND", + sub_isoids: + "81~10~71~46~62~40~38~22~24~20~18~36~86~57~50~92~23~63~26~73~48~30~80~60~12~96~55~31~13~77~25~94~14~56~82~93~66~65~76~67~54~83~44~49~58~35~95~45~85~21~70~16~52~51~42~33~47~90~91~11~75~74~27~19~17~64~72~84~32~43~39~15~37~41~53~61~34", + sub_keys: + "กระบี่~กรุงเทพมหานคร~กาญจนบุรี~กาฬสินธุ์~กำแพงเพชร~ขอนแก่น~จังหวัด บึงกาฬ~จันทบุรี~ฉะเชิงเทรา~ชลบุรี~ชัยนาท~ชัยภูมิ~ชุมพร~เชียงราย~เชียงใหม่~ตรัง~ตราด~ตาก~นครนายก~นครปฐม~นครพนม~นครราชสีมา~นครศรีธรรมราช~นครสวรรค์~นนทบุรี~นราธิวาส~น่าน~บุรีรัมย์~ปทุมธานี~ประจวบคีรีขันธ์~ปราจีนบุรี~ปัตตานี~พระนครศรีอยุธยา~พะเยา~พังงา~พัทลุง~พิจิตร~พิษณุโลก~เพชรบุรี~เพชรบูรณ์~แพร่~ภูเก็ต~มหาสารคาม~มุกดาหาร~แม่ฮ่องสอน~ยโสธร~ยะลา~ร้อยเอ็ด~ระนอง~ระยอง~ราชบุรี~ลพบุรี~ลำปาง~ลำพูน~เลย~ศรีสะเกษ~สกลนคร~สงขลา~สตูล~สมุทรปราการ~สมุทรสงคราม~สมุทรสาคร~สระแก้ว~สระบุรี~สิงห์บุรี~สุโขทัย~สุพรรณบุรี~สุราษฎร์ธานี~สุรินทร์~หนองคาย~หนองบัวลำภู~อ่างทอง~อำนาจเจริญ~อุดรธานี~อุตรดิตถ์~อุทัยธานี~อุบลราชธานี", + sub_lnames: + "Krabi~Bangkok~Kanchanaburi~Kalasin~Kamphaeng Phet~Khon Kaen~Bueng Kan~Chanthaburi~Chachoengsao~Chon Buri~Chai Nat~Chaiyaphum~Chumpon~Chiang Rai~Chiang Mai~Trang~Trat~Tak~Nakhon Nayok~Nakhon Pathom~Nakhon Phanom~Nakhon Ratchasima~Nakhon Si Thammarat~Nakhon Sawan~Nonthaburi~Narathiwat~Nan~Buri Ram~Pathum Thani~Prachuap Khiri Khan~Prachin Buri~Pattani~Phra Nakhon Si Ayutthaya~Phayao~Phang Nga~Phattalung~Phichit~Phitsanulok~Phetchaburi~Phetchabun~Phrae~Phuket~Maha Sarakham~Mukdahan~Mae Hong Son~Yasothon~Yala~Roi Et~Ranong~Rayong~Ratchaburi~Lop Buri~Lampang~Lamphun~Loei~Si Sa Ket~Sakon Nakhon~Songkhla~Satun~Samut Prakan~Samut Songkhram~Samut Sakhon~Sa Kaeo~Saraburi~Sing Buri~Sukhothai~Suphanburi~Surat Thani~Surin~Nong Khai~Nong Bua Lam Phu~Ang Thong~Amnat Charoen~Udon Thani~Uttaradit~Uthai Thani~Ubon Ratchathani", + sub_zips: + "81~10~71~46~62~40~~22~24~20~17~36~86~57~50~92~23~63~26~73~48~30~80~60~11~96~55~31~12~77~25~94~13~56~82~93~66~65~76~67~54~83~44~49~58~35~95~45~85~21~70~15~52~51~42~33~47~90~91~10~75~74~27~18~16~64~72~84~32~43~39~14~37~41~53~61~34", + upper: "S", + zip: "\\d{5}", + zipex: "10150,10210", + }, + "data/TJ": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/TJ", + key: "TJ", + name: "TAJIKISTAN", + zip: "\\d{6}", + zipex: "735450,734025", + }, + "data/TK": { id: "data/TK", key: "TK", name: "TOKELAU" }, + "data/TL": { id: "data/TL", key: "TL", name: "TIMOR-LESTE" }, + "data/TM": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/TM", + key: "TM", + name: "TURKMENISTAN", + zip: "\\d{6}", + zipex: "744000", + }, + "data/TN": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/TN", + key: "TN", + name: "TUNISIA", + posturl: "http://www.poste.tn/codes.php", + zip: "\\d{4}", + zipex: "1002,8129,3100,1030", + }, + "data/TO": { id: "data/TO", key: "TO", name: "TONGA" }, + "data/TR": { + fmt: "%N%n%O%n%A%n%Z %C/%S", + id: "data/TR", + key: "TR", + lang: "tr", + languages: "tr", + locality_name_type: "district", + name: "TURKEY", + posturl: "http://postakodu.ptt.gov.tr/", + require: "ACZ", + sub_isoids: + "01~02~03~04~68~05~06~07~75~08~09~10~74~72~69~11~12~13~14~15~16~17~18~19~20~21~81~22~23~24~25~26~27~28~29~30~31~76~32~34~35~46~78~70~36~37~38~71~39~40~79~41~42~43~44~45~47~33~48~49~50~51~52~80~53~54~55~56~57~58~63~73~59~60~61~62~64~65~77~66~67", + sub_keys: + "Adana~Adıyaman~Afyon~Ağrı~Aksaray~Amasya~Ankara~Antalya~Ardahan~Artvin~Aydın~Balıkesir~Bartın~Batman~Bayburt~Bilecik~Bingöl~Bitlis~Bolu~Burdur~Bursa~Çanakkale~Çankırı~Çorum~Denizli~Diyarbakır~Düzce~Edirne~Elazığ~Erzincan~Erzurum~Eskişehir~Gaziantep~Giresun~Gümüşhane~Hakkari~Hatay~Iğdır~Isparta~İstanbul~İzmir~Kahramanmaraş~Karabük~Karaman~Kars~Kastamonu~Kayseri~Kırıkkale~Kırklareli~Kırşehir~Kilis~Kocaeli~Konya~Kütahya~Malatya~Manisa~Mardin~Mersin~Muğla~Muş~Nevşehir~Niğde~Ordu~Osmaniye~Rize~Sakarya~Samsun~Siirt~Sinop~Sivas~Şanlıurfa~Şırnak~Tekirdağ~Tokat~Trabzon~Tunceli~Uşak~Van~Yalova~Yozgat~Zonguldak", + sub_zips: + "01~02~03~04~68~05~06~07~75~08~09~10~74~72~69~11~12~13~14~15~16~17~18~19~20~21~81~22~23~24~25~26~27~28~29~30~31~76~32~34~35~46~78~70~36~37~38~71~39~40~79~41~42~43~44~45~47~33~48~49~50~51~52~80~53~54~55~56~57~58~63~73~59~60~61~62~64~65~77~66~67", + zip: "\\d{5}", + zipex: "01960,06101", + }, + "data/TT": { id: "data/TT", key: "TT", name: "TRINIDAD AND TOBAGO" }, + "data/TV": { + fmt: "%N%n%O%n%A%n%C%n%S", + id: "data/TV", + key: "TV", + lang: "tyv", + languages: "tyv", + name: "TUVALU", + state_name_type: "island", + sub_isoids: "FUN~NMG~NMA~~NIT~NUI~NKF~NKL~VAI", + sub_keys: + "Funafuti~Nanumanga~Nanumea~Niulakita~Niutao~Nui~Nukufetau~Nukulaelae~Vaitupu", + upper: "ACS", + }, + "data/TW": { + fmt: "%Z%n%S%C%n%A%n%O%n%N", + id: "data/TW", + key: "TW", + lang: "zh-Hant", + languages: "zh-Hant", + lfmt: "%N%n%O%n%A%n%C, %S %Z", + name: "TAIWAN", + posturl: + "http://www.post.gov.tw/post/internet/f_searchzone/index.jsp?ID=190102", + require: "ACSZ", + state_name_type: "county", + sub_isoids: + "TXG~TPE~TTT~TNN~ILA~HUA~~NAN~PIF~MIA~TAO~KHH~KEE~~YUN~NWT~HSZ~HSQ~CYI~CYQ~CHA~PEN", + sub_keys: + "台中市~台北市~台東縣~台南市~宜蘭縣~花蓮縣~金門縣~南投縣~屏東縣~苗栗縣~桃園市~高雄市~基隆市~連江縣~雲林縣~新北市~新竹市~新竹縣~嘉義市~嘉義縣~彰化縣~澎湖縣", + sub_lnames: + "Taichung City~Taipei City~Taitung County~Tainan City~Yilan County~Hualien County~Kinmen County~Nantou County~Pingtung County~Miaoli County~Taoyuan City~Kaohsiung City~Keelung City~Lienchiang County~Yunlin County~New Taipei City~Hsinchu City~Hsinchu County~Chiayi City~Chiayi County~Changhua County~Penghu County", + sub_mores: + "true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true~true", + sub_zipexs: + "400,408,411,439~100,119~950,966~700,745~260,272~970,983~890,896~540,558~900,947~350,369~320,338~800,815,817,852~200,206~209,212~630,655~207,208,220,253~~302,315~~602,625~500,530~880,885", + sub_zips: + "4[0-3]~1[01]~9[56]~7[0-4]~2[67]~9[78]~89~5[45]~9[0-4]~3[56]~3[23]~8[02-5]|81[1-579]~20[0-6]~209|21[012]~6[3-5]~20[78]|2[2345]~300~30[2-8]|31~600~60[1-9]|6[12]~5[0123]~88", + zip: "\\d{3}(?:\\d{2})?", + zipex: "104,106,10603,40867", + }, + "data/TZ": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/TZ", + key: "TZ", + name: "TANZANIA (UNITED REP.)", + zip: "\\d{4,5}", + zipex: "6090,34413", + }, + "data/UA": { + fmt: "%N%n%O%n%A%n%C%n%S%n%Z", + id: "data/UA", + key: "UA", + lang: "uk", + languages: "uk", + lfmt: "%N%n%O%n%A%n%C%n%S%n%Z", + name: "UKRAINE", + posturl: "http://services.ukrposhta.com/postindex_new/", + require: "ACSZ", + state_name_type: "oblast", + sub_isoids: + "43~05~07~12~14~18~21~23~26~30~32~35~09~46~48~51~53~56~40~59~61~63~65~68~71~77~74", + sub_keys: + "Автономна Республіка Крим~Вінницька область~Волинська область~Дніпропетровська область~Донецька область~Житомирська область~Закарпатська область~Запорізька область~Івано-Франківська область~місто Київ~Київська область~Кіровоградська область~Луганська область~Львівська область~Миколаївська область~Одеська область~Полтавська область~Рівненська область~місто Севастополь~Сумська область~Тернопільська область~Харківська область~Херсонська область~Хмельницька область~Черкаська область~Чернівецька область~Чернігівська область", + sub_lnames: + "Crimea~Vinnyts'ka oblast~Volyns'ka oblast~Dnipropetrovsk oblast~Donetsk oblast~Zhytomyrs'ka oblast~Zakarpats'ka oblast~Zaporiz'ka oblast~Ivano-Frankivs'ka oblast~Kyiv city~Kiev oblast~Kirovohrads'ka oblast~Luhans'ka oblast~Lviv oblast~Mykolaivs'ka oblast~Odessa oblast~Poltavs'ka oblast~Rivnens'ka oblast~Sevastopol' city~Sums'ka oblast~Ternopil's'ka oblast~Kharkiv oblast~Khersons'ka oblast~Khmel'nyts'ka oblast~Cherkas'ka oblast~Chernivets'ka oblast~Chernihivs'ka oblast", + sub_names: + "Автономна Республіка Крим~Вінницька область~Волинська область~Дніпропетровська область~Донецька область~Житомирська область~Закарпатська область~Запорізька область~Івано-Франківська область~Київ~Київська область~Кіровоградська область~Луганська область~Львівська область~Миколаївська область~Одеська область~Полтавська область~Рівненська область~Севастополь~Сумська область~Тернопільська область~Харківська область~Херсонська область~Хмельницька область~Черкаська область~Чернівецька область~Чернігівська область", + sub_zips: + "9[5-8]~2[1-4]~4[3-5]~49|5[0-3]~8[3-7]~1[0-3]~8[89]|90~69|7[0-2]~7[6-8]~0[1-6]~0[7-9]~2[5-8]~9[1-4]~79|8[0-2]~5[4-7]~6[5-8]~3[6-9]~3[3-5]~99~4[0-2]~4[6-8]~6[1-4]~7[3-5]~29|3[0-2]~1[89]|20~5[89]|60~1[4-7]", + zip: "\\d{5}", + zipex: "15432,01055,01001", + }, + "data/UG": { id: "data/UG", key: "UG", name: "UGANDA" }, + "data/US": { + fmt: "%N%n%O%n%A%n%C, %S %Z", + id: "data/US", + key: "US", + lang: "en", + languages: "en", + name: "UNITED STATES", + posturl: "https://tools.usps.com/go/ZipLookupAction!input.action", + require: "ACSZ", + state_name_type: "state", + sub_isoids: + "AL~AK~~AZ~AR~~~~CA~CO~CT~DE~DC~FL~GA~~HI~ID~IL~IN~IA~KS~KY~LA~ME~~MD~MA~MI~~MN~MS~MO~MT~NE~NV~NH~NJ~NM~NY~NC~ND~~OH~OK~OR~~PA~~RI~SC~SD~TN~TX~UT~VT~~VA~WA~WV~WI~WY", + sub_keys: + "AL~AK~AS~AZ~AR~AA~AE~AP~CA~CO~CT~DE~DC~FL~GA~GU~HI~ID~IL~IN~IA~KS~KY~LA~ME~MH~MD~MA~MI~FM~MN~MS~MO~MT~NE~NV~NH~NJ~NM~NY~NC~ND~MP~OH~OK~OR~PW~PA~PR~RI~SC~SD~TN~TX~UT~VT~VI~VA~WA~WV~WI~WY", + sub_names: + "Alabama~Alaska~American Samoa~Arizona~Arkansas~Armed Forces (AA)~Armed Forces (AE)~Armed Forces (AP)~California~Colorado~Connecticut~Delaware~District of Columbia~Florida~Georgia~Guam~Hawaii~Idaho~Illinois~Indiana~Iowa~Kansas~Kentucky~Louisiana~Maine~Marshall Islands~Maryland~Massachusetts~Michigan~Micronesia~Minnesota~Mississippi~Missouri~Montana~Nebraska~Nevada~New Hampshire~New Jersey~New Mexico~New York~North Carolina~North Dakota~Northern Mariana Islands~Ohio~Oklahoma~Oregon~Palau~Pennsylvania~Puerto Rico~Rhode Island~South Carolina~South Dakota~Tennessee~Texas~Utah~Vermont~Virgin Islands~Virginia~Washington~West Virginia~Wisconsin~Wyoming", + sub_zipexs: + "35000,36999~99500,99999~96799~85000,86999~71600,72999~34000,34099~09000,09999~96200,96699~90000,96199~80000,81999~06000,06999~19700,19999~20000,56999~32000,34999~30000,39901~96910,96932~96700,96899~83200,83999~60000,62999~46000,47999~50000,52999~66000,67999~40000,42799~70000,71599~03900,04999~96960,96979~20600,21999~01000,05544~48000,49999~96941,96944~55000,56799~38600,39799~63000,65999~59000,59999~68000,69999~88900,89999~03000,03899~07000,08999~87000,88499~10000,00544~27000,28999~58000,58999~96950,96952~43000,45999~73000,74999~97000,97999~96940~15000,19699~00600,00999~02800,02999~29000,29999~57000,57999~37000,38599~75000,73344~84000,84999~05000,05999~00800,00899~20100,24699~98000,99499~24700,26999~53000,54999~82000,83414", + sub_zips: + "3[56]~99[5-9]~96799~8[56]~71[6-9]|72~340~09~96[2-6]~9[0-5]|96[01]~8[01]~06~19[7-9]~20[02-5]|569~3[23]|34[1-9]~3[01]|398|39901~969([1-2]\\d|3[12])~967[0-8]|9679[0-8]|968~83[2-9]~6[0-2]~4[67]~5[0-2]~6[67]~4[01]|42[0-7]~70|71[0-5]~039|04~969[67]~20[6-9]|21~01|02[0-7]|05501|05544~4[89]~9694[1-4]~55|56[0-7]~38[6-9]|39[0-7]~6[3-5]~59~6[89]~889|89~03[0-8]~0[78]~87|88[0-4]~1[0-4]|06390|00501|00544~2[78]~58~9695[0-2]~4[3-5]~7[34]~97~969(39|40)~1[5-8]|19[0-6]~00[679]~02[89]~29~57~37|38[0-5]~7[5-9]|885|73301|73344~84~05~008~201|2[23]|24[0-6]~98|99[0-4]~24[7-9]|2[56]~5[34]~82|83[01]|83414", + upper: "CS", + zip: "(\\d{5})(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "95014,22162-1010", + }, + "data/UY": { + fmt: "%N%n%O%n%A%n%Z %C %S", + id: "data/UY", + key: "UY", + lang: "es", + languages: "es", + name: "URUGUAY", + posturl: + "http://www.correo.com.uy/index.asp?codPag=codPost&switchMapa=codPost", + sub_isoids: "AR~CA~CL~CO~DU~FS~FD~LA~MA~MO~PA~RN~RV~RO~SA~SJ~SO~TA~TT", + sub_keys: + "Artigas~Canelones~Cerro Largo~Colonia~Durazno~Flores~Florida~Lavalleja~Maldonado~Montevideo~Paysandú~Río Negro~Rivera~Rocha~Salto~San José~Soriano~Tacuarembó~Treinta y Tres", + sub_zips: + "55~9[01]|1[456]~37~70|75204~97~85~94|9060|97005~30~20~1|91600~60~65|60002~40~27~50~80~75|70003~45~33|30203|30204|30302|37007", + upper: "CS", + zip: "\\d{5}", + zipex: "11600", + }, + "data/UZ": { + fmt: "%N%n%O%n%A%n%Z %C%n%S", + id: "data/UZ", + key: "UZ", + name: "UZBEKISTAN", + posturl: "http://www.pochta.uz/ru/uslugi/indexsearch.html", + upper: "CS", + zip: "\\d{6}", + zipex: "702100,700000", + }, + "data/VA": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/VA", + key: "VA", + name: "VATICAN", + zip: "00120", + zipex: "00120", + }, + "data/VC": { + fmt: "%N%n%O%n%A%n%C %Z", + id: "data/VC", + key: "VC", + name: "SAINT VINCENT AND THE GRENADINES (ANTILLES)", + posturl: + "http://www.svgpost.gov.vc/?option=com_content&view=article&id=3&Itemid=16", + zip: "VC\\d{4}", + zipex: "VC0100,VC0110,VC0400", + }, + "data/VE": { + fmt: "%N%n%O%n%A%n%C %Z, %S", + id: "data/VE", + key: "VE", + lang: "es", + languages: "es", + name: "VENEZUELA", + posturl: "http://www.ipostel.gob.ve/index.php/oficinas-postales", + require: "ACS", + state_name_type: "state", + sub_isoids: "Z~B~C~D~E~F~G~H~Y~W~A~I~J~K~L~M~N~O~P~R~S~T~X~U~V", + sub_keys: + "Amazonas~Anzoátegui~Apure~Aragua~Barinas~Bolívar~Carabobo~Cojedes~Delta Amacuro~Dependencias Federales~Distrito Federal~Falcón~Guárico~Lara~Mérida~Miranda~Monagas~Nueva Esparta~Portuguesa~Sucre~Táchira~Trujillo~Vargas~Yaracuy~Zulia", + upper: "CS", + zip: "\\d{4}", + zipex: "1010,3001,8011,1020", + }, + "data/VG": { + fmt: "%N%n%O%n%A%n%C%n%Z", + id: "data/VG", + key: "VG", + name: "VIRGIN ISLANDS (BRITISH)", + require: "A", + zip: "VG\\d{4}", + zipex: "VG1110,VG1150,VG1160", + }, + "data/VI": { + fmt: "%N%n%O%n%A%n%C %S %Z", + id: "data/VI", + key: "VI", + name: "VIRGIN ISLANDS (U.S.)", + posturl: "http://zip4.usps.com/zip4/welcome.jsp", + require: "ACSZ", + state_name_type: "state", + upper: "ACNOS", + zip: "(008(?:(?:[0-4]\\d)|(?:5[01])))(?:[ \\-](\\d{4}))?", + zip_name_type: "zip", + zipex: "00802-1222,00850-9802", + }, + "data/VN": { + fmt: "%N%n%O%n%A%n%C%n%S %Z", + id: "data/VN", + key: "VN", + lang: "vi", + languages: "vi", + lfmt: "%N%n%O%n%A%n%C%n%S %Z", + name: "VIET NAM", + posturl: "http://postcode.vnpost.vn/services/search.aspx", + sub_isoids: + "44~43~55~54~53~56~50~57~31~58~40~59~04~CT~DN~33~72~71~39~45~30~03~63~HN~23~61~HP~73~14~66~34~47~28~01~09~02~35~41~67~22~18~36~68~32~24~27~29~13~25~52~05~37~20~69~21~SG~26~46~51~07~49~70~06", + sub_keys: + "An Giang~Bà Rịa–Vũng Tàu~Bạc Liêu~Bắc Giang~Bắc Kạn~Bắc Ninh~Bến Tre~Bình Dương~Bình Định~Bình Phước~Bình Thuận~Cà Mau~Cao Bằng~Cần Thơ~Đà Nẵng~Đắk Lắk~Đăk Nông~Điện Biên~Đồng Nai~Đồng Tháp~Gia Lai~Hà Giang~Hà Nam~Hà Nội~Hà Tĩnh~Hải Dương~Hải Phòng~Hậu Giang~Hòa Bình~Hưng Yên~Khánh Hòa~Kiên Giang~Kon Tum~Lai Châu~Lạng Sơn~Lào Cai~Lâm Đồng~Long An~Nam Định~Nghệ An~Ninh Bình~Ninh Thuận~Phú Thọ~Phú Yên~Quảng Bình~Quảng Nam~Quảng Ngãi~Quảng Ninh~Quảng Trị~Sóc Trăng~Sơn La~Tây Ninh~Thái Bình~Thái Nguyên~Thanh Hóa~Thành phố Hồ Chí Minh~Thừa Thiên–Huế~Tiền Giang~Trà Vinh~Tuyên Quang~Vĩnh Long~Vĩnh Phúc~Yên Bái", + sub_lnames: + "An Giang Province~Ba Ria-Vung Tau Province~Bac Lieu Province~Bac Giang Province~Bac Kan Province~Bac Ninh Province~Ben Tre Province~Binh Duong Province~Binh Dinh Province~Binh Phuoc Province~Binh Thuan Province~Ca Mau Province~Cao Bang Province~Can Tho City~Da Nang City~Dak Lak Province~Dak Nong Province~Dien Bien Province~Dong Nai Province~Dong Thap Province~Gia Lai Province~Ha Giang Province~Ha Nam Province~Hanoi City~Ha Tinh Province~Hai Duong Province~Haiphong City~Hau Giang Province~Hoa Binh Province~Hung Yen Province~Khanh Hoa Province~Kien Giang Province~Kon Tum Province~Lai Chau Province~Lang Song Province~Lao Cai Province~Lam Dong Province~Long An Province~Nam Dinh Province~Nghe An Province~Ninh Binh Province~Ninh Thuan Province~Phu Tho Province~Phu Yen Province~Quang Binh Province~Quang Nam Province~Quang Ngai Province~Quang Ninh Province~Quang Tri Province~Soc Trang Province~Son La Province~Tay Ninh Province~Thai Binh Province~Thai Nguyen Province~Thanh Hoa Province~Ho Chi Minh City~Thua Thien-Hue Province~Tien Giang Province~Tra Vinh Province~Tuyen Quang Province~Vinh Long Province~Vinh Phuc Province~Yen Bai Province", + zip: "\\d{5}\\d?", + zipex: "70010,55999", + }, + "data/VU": { id: "data/VU", key: "VU", name: "VANUATU" }, + "data/WF": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/WF", + key: "WF", + name: "WALLIS AND FUTUNA ISLANDS", + require: "ACZ", + upper: "ACX", + zip: "986\\d{2}", + zipex: "98600", + }, + "data/WS": { id: "data/WS", key: "WS", name: "SAMOA" }, + "data/XK": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/XK", + key: "XK", + name: "KOSOVO", + zip: "[1-7]\\d{4}", + zipex: "10000", + }, + "data/YE": { id: "data/YE", key: "YE", name: "YEMEN" }, + "data/YT": { + fmt: "%O%n%N%n%A%n%Z %C %X", + id: "data/YT", + key: "YT", + name: "MAYOTTE", + require: "ACZ", + upper: "ACX", + zip: "976\\d{2}", + zipex: "97600", + }, + "data/ZA": { + fmt: "%N%n%O%n%A%n%D%n%C%n%Z", + id: "data/ZA", + key: "ZA", + name: "SOUTH AFRICA", + posturl: "https://www.postoffice.co.za/Questions/postalcode.html", + require: "ACZ", + zip: "\\d{4}", + zipex: "0083,1451,0001", + }, + "data/ZM": { + fmt: "%N%n%O%n%A%n%Z %C", + id: "data/ZM", + key: "ZM", + name: "ZAMBIA", + zip: "\\d{5}", + zipex: "50100,50101", + }, + "data/ZW": { id: "data/ZW", key: "ZW", name: "ZIMBABWE" }, +}; +export default AddressMetaData; diff --git a/toolkit/components/formautofill/shared/AddressMetaDataExtension.sys.mjs b/toolkit/components/formautofill/shared/AddressMetaDataExtension.sys.mjs new file mode 100644 index 0000000000..da13b66784 --- /dev/null +++ b/toolkit/components/formautofill/shared/AddressMetaDataExtension.sys.mjs @@ -0,0 +1,765 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export const AddressMetaDataExtension = { + "data/AF": { + alpha_3_code: "AFG", + }, + "data/AX": { + alpha_3_code: "ALA", + }, + "data/AL": { + alpha_3_code: "ALB", + }, + "data/DZ": { + alpha_3_code: "DZA", + }, + "data/AS": { + alpha_3_code: "ASM", + }, + "data/AD": { + alpha_3_code: "AND", + }, + "data/AO": { + alpha_3_code: "AGO", + }, + "data/AI": { + alpha_3_code: "AIA", + }, + "data/AQ": { + alpha_3_code: "ATA", + }, + "data/AG": { + alpha_3_code: "ATG", + }, + "data/AR": { + alpha_3_code: "ARG", + }, + "data/AM": { + alpha_3_code: "ARM", + }, + "data/AW": { + alpha_3_code: "ABW", + }, + "data/AU": { + alpha_3_code: "AUS", + }, + "data/AT": { + alpha_3_code: "AUT", + }, + "data/AZ": { + alpha_3_code: "AZE", + }, + "data/BS": { + alpha_3_code: "BHS", + }, + "data/BH": { + alpha_3_code: "BHR", + }, + "data/BD": { + alpha_3_code: "BGD", + }, + "data/BB": { + alpha_3_code: "BRB", + }, + "data/BY": { + alpha_3_code: "BLR", + }, + "data/BE": { + alpha_3_code: "BEL", + }, + "data/BZ": { + alpha_3_code: "BLZ", + }, + "data/BJ": { + alpha_3_code: "BEN", + }, + "data/BM": { + alpha_3_code: "BMU", + }, + "data/BT": { + alpha_3_code: "BTN", + }, + "data/BO": { + alpha_3_code: "BOL", + }, + "data/BQ": { + alpha_3_code: "BES", + }, + "data/BA": { + alpha_3_code: "BIH", + }, + "data/BW": { + alpha_3_code: "BWA", + }, + "data/BV": { + alpha_3_code: "BVT", + }, + "data/BR": { + alpha_3_code: "BRA", + }, + "data/IO": { + alpha_3_code: "IOT", + }, + "data/BN": { + alpha_3_code: "BRN", + }, + "data/BG": { + alpha_3_code: "BGR", + }, + "data/BF": { + alpha_3_code: "BFA", + }, + "data/BI": { + alpha_3_code: "BDI", + }, + "data/CV": { + alpha_3_code: "CPV", + }, + "data/KH": { + alpha_3_code: "KHM", + }, + "data/CM": { + alpha_3_code: "CMR", + }, + "data/CA": { + alpha_3_code: "CAN", + }, + "data/KY": { + alpha_3_code: "CYM", + }, + "data/CF": { + alpha_3_code: "CAF", + }, + "data/TD": { + alpha_3_code: "TCD", + }, + "data/CL": { + alpha_3_code: "CHL", + }, + "data/CN": { + alpha_3_code: "CHN", + }, + "data/CX": { + alpha_3_code: "CXR", + }, + "data/CC": { + alpha_3_code: "CCK", + }, + "data/CO": { + alpha_3_code: "COL", + }, + "data/KM": { + alpha_3_code: "COM", + }, + "data/CG": { + alpha_3_code: "COG", + }, + "data/CD": { + alpha_3_code: "COD", + }, + "data/CK": { + alpha_3_code: "COK", + }, + "data/CR": { + alpha_3_code: "CRI", + }, + "data/CI": { + alpha_3_code: "CIV", + }, + "data/HR": { + alpha_3_code: "HRV", + }, + "data/CU": { + alpha_3_code: "CUB", + }, + "data/CW": { + alpha_3_code: "CUW", + }, + "data/CY": { + alpha_3_code: "CYP", + }, + "data/CZ": { + alpha_3_code: "CZE", + }, + "data/DK": { + alpha_3_code: "DNK", + }, + "data/DJ": { + alpha_3_code: "DJI", + }, + "data/DM": { + alpha_3_code: "DMA", + }, + "data/DO": { + alpha_3_code: "DOM", + }, + "data/EC": { + alpha_3_code: "ECU", + }, + "data/EG": { + alpha_3_code: "EGY", + }, + "data/SV": { + alpha_3_code: "SLV", + }, + "data/GQ": { + alpha_3_code: "GNQ", + }, + "data/ER": { + alpha_3_code: "ERI", + }, + "data/EE": { + alpha_3_code: "EST", + }, + "data/SZ": { + alpha_3_code: "SWZ", + }, + "data/ET": { + alpha_3_code: "ETH", + }, + "data/FK": { + alpha_3_code: "FLK", + }, + "data/FO": { + alpha_3_code: "FRO", + }, + "data/FJ": { + alpha_3_code: "FJI", + }, + "data/FI": { + alpha_3_code: "FIN", + }, + "data/FR": { + alpha_3_code: "FRA", + }, + "data/GF": { + alpha_3_code: "GUF", + }, + "data/PF": { + alpha_3_code: "PYF", + }, + "data/TF": { + alpha_3_code: "ATF", + }, + "data/GA": { + alpha_3_code: "GAB", + }, + "data/GM": { + alpha_3_code: "GMB", + }, + "data/GE": { + alpha_3_code: "GEO", + }, + "data/DE": { + alpha_3_code: "DEU", + }, + "data/GH": { + alpha_3_code: "GHA", + }, + "data/GI": { + alpha_3_code: "GIB", + }, + "data/GR": { + alpha_3_code: "GRC", + }, + "data/GL": { + alpha_3_code: "GRL", + }, + "data/GD": { + alpha_3_code: "GRD", + }, + "data/GP": { + alpha_3_code: "GLP", + }, + "data/GU": { + alpha_3_code: "GUM", + }, + "data/GT": { + alpha_3_code: "GTM", + }, + "data/GG": { + alpha_3_code: "GGY", + }, + "data/GN": { + alpha_3_code: "GIN", + }, + "data/GW": { + alpha_3_code: "GNB", + }, + "data/GY": { + alpha_3_code: "GUY", + }, + "data/HT": { + alpha_3_code: "HTI", + }, + "data/HM": { + alpha_3_code: "HMD", + }, + "data/VA": { + alpha_3_code: "VAT", + }, + "data/HN": { + alpha_3_code: "HND", + }, + "data/HK": { + alpha_3_code: "HKG", + }, + "data/HU": { + alpha_3_code: "HUN", + }, + "data/IS": { + alpha_3_code: "ISL", + }, + "data/IN": { + alpha_3_code: "IND", + }, + "data/ID": { + alpha_3_code: "IDN", + }, + "data/IR": { + alpha_3_code: "IRN", + }, + "data/IQ": { + alpha_3_code: "IRQ", + }, + "data/IE": { + alpha_3_code: "IRL", + }, + "data/IM": { + alpha_3_code: "IMN", + }, + "data/IL": { + alpha_3_code: "ISR", + }, + "data/IT": { + alpha_3_code: "ITA", + }, + "data/JM": { + alpha_3_code: "JAM", + }, + "data/JP": { + alpha_3_code: "JPN", + }, + "data/JE": { + alpha_3_code: "JEY", + }, + "data/JO": { + alpha_3_code: "JOR", + }, + "data/KZ": { + alpha_3_code: "KAZ", + }, + "data/KE": { + alpha_3_code: "KEN", + }, + "data/KI": { + alpha_3_code: "KIR", + }, + "data/KP": { + alpha_3_code: "PRK", + }, + "data/KR": { + alpha_3_code: "KOR", + }, + "data/KW": { + alpha_3_code: "KWT", + }, + "data/KG": { + alpha_3_code: "KGZ", + }, + "data/LA": { + alpha_3_code: "LAO", + }, + "data/LV": { + alpha_3_code: "LVA", + }, + "data/LB": { + alpha_3_code: "LBN", + }, + "data/LS": { + alpha_3_code: "LSO", + }, + "data/LR": { + alpha_3_code: "LBR", + }, + "data/LY": { + alpha_3_code: "LBY", + }, + "data/LI": { + alpha_3_code: "LIE", + }, + "data/LT": { + alpha_3_code: "LTU", + }, + "data/LU": { + alpha_3_code: "LUX", + }, + "data/MO": { + alpha_3_code: "MAC", + }, + "data/MG": { + alpha_3_code: "MDG", + }, + "data/MW": { + alpha_3_code: "MWI", + }, + "data/MY": { + alpha_3_code: "MYS", + }, + "data/MV": { + alpha_3_code: "MDV", + }, + "data/ML": { + alpha_3_code: "MLI", + }, + "data/MT": { + alpha_3_code: "MLT", + }, + "data/MH": { + alpha_3_code: "MHL", + }, + "data/MQ": { + alpha_3_code: "MTQ", + }, + "data/MR": { + alpha_3_code: "MRT", + }, + "data/MU": { + alpha_3_code: "MUS", + }, + "data/YT": { + alpha_3_code: "MYT", + }, + "data/MX": { + alpha_3_code: "MEX", + }, + "data/FM": { + alpha_3_code: "FSM", + }, + "data/MD": { + alpha_3_code: "MDA", + }, + "data/MC": { + alpha_3_code: "MCO", + }, + "data/MN": { + alpha_3_code: "MNG", + }, + "data/ME": { + alpha_3_code: "MNE", + }, + "data/MS": { + alpha_3_code: "MSR", + }, + "data/MA": { + alpha_3_code: "MAR", + }, + "data/MZ": { + alpha_3_code: "MOZ", + }, + "data/MM": { + alpha_3_code: "MMR", + }, + "data/NA": { + alpha_3_code: "NAM", + }, + "data/NR": { + alpha_3_code: "NRU", + }, + "data/NP": { + alpha_3_code: "NPL", + }, + "data/NL": { + alpha_3_code: "NLD", + }, + "data/NC": { + alpha_3_code: "NCL", + }, + "data/NZ": { + alpha_3_code: "NZL", + }, + "data/NI": { + alpha_3_code: "NIC", + }, + "data/NE": { + alpha_3_code: "NER", + }, + "data/NG": { + alpha_3_code: "NGA", + }, + "data/NU": { + alpha_3_code: "NIU", + }, + "data/NF": { + alpha_3_code: "NFK", + }, + "data/MK": { + alpha_3_code: "MKD", + }, + "data/MP": { + alpha_3_code: "MNP", + }, + "data/NO": { + alpha_3_code: "NOR", + }, + "data/OM": { + alpha_3_code: "OMN", + }, + "data/PK": { + alpha_3_code: "PAK", + }, + "data/PW": { + alpha_3_code: "PLW", + }, + "data/PS": { + alpha_3_code: "PSE", + }, + "data/PA": { + alpha_3_code: "PAN", + }, + "data/PG": { + alpha_3_code: "PNG", + }, + "data/PY": { + alpha_3_code: "PRY", + }, + "data/PE": { + alpha_3_code: "PER", + }, + "data/PH": { + alpha_3_code: "PHL", + }, + "data/PN": { + alpha_3_code: "PCN", + }, + "data/PL": { + alpha_3_code: "POL", + }, + "data/PT": { + alpha_3_code: "PRT", + }, + "data/PR": { + alpha_3_code: "PRI", + }, + "data/QA": { + alpha_3_code: "QAT", + }, + "data/RE": { + alpha_3_code: "REU", + }, + "data/RO": { + alpha_3_code: "ROU", + }, + "data/RU": { + alpha_3_code: "RUS", + }, + "data/RW": { + alpha_3_code: "RWA", + }, + "data/BL": { + alpha_3_code: "BLM", + }, + "data/SH": { + alpha_3_code: "SHN", + }, + "data/KN": { + alpha_3_code: "KNA", + }, + "data/LC": { + alpha_3_code: "LCA", + }, + "data/MF": { + alpha_3_code: "MAF", + }, + "data/PM": { + alpha_3_code: "SPM", + }, + "data/VC": { + alpha_3_code: "VCT", + }, + "data/WS": { + alpha_3_code: "WSM", + }, + "data/SM": { + alpha_3_code: "SMR", + }, + "data/ST": { + alpha_3_code: "STP", + }, + "data/SA": { + alpha_3_code: "SAU", + }, + "data/SN": { + alpha_3_code: "SEN", + }, + "data/RS": { + alpha_3_code: "SRB", + }, + "data/SC": { + alpha_3_code: "SYC", + }, + "data/SL": { + alpha_3_code: "SLE", + }, + "data/SG": { + alpha_3_code: "SGP", + }, + "data/SX": { + alpha_3_code: "SXM", + }, + "data/SK": { + alpha_3_code: "SVK", + }, + "data/SI": { + alpha_3_code: "SVN", + }, + "data/SB": { + alpha_3_code: "SLB", + }, + "data/SO": { + alpha_3_code: "SOM", + }, + "data/ZA": { + alpha_3_code: "ZAF", + }, + "data/GS": { + alpha_3_code: "SGS", + }, + "data/SS": { + alpha_3_code: "SSD", + }, + "data/ES": { + alpha_3_code: "ESP", + }, + "data/LK": { + alpha_3_code: "LKA", + }, + "data/SD": { + alpha_3_code: "SDN", + }, + "data/SR": { + alpha_3_code: "SUR", + }, + "data/SJ": { + alpha_3_code: "SJM", + }, + "data/SE": { + alpha_3_code: "SWE", + }, + "data/CH": { + alpha_3_code: "CHE", + }, + "data/SY": { + alpha_3_code: "SYR", + }, + "data/TW": { + alpha_3_code: "TWN", + }, + "data/TJ": { + alpha_3_code: "TJK", + }, + "data/TZ": { + alpha_3_code: "TZA", + }, + "data/TH": { + alpha_3_code: "THA", + }, + "data/TL": { + alpha_3_code: "TLS", + }, + "data/TG": { + alpha_3_code: "TGO", + }, + "data/TK": { + alpha_3_code: "TKL", + }, + "data/TO": { + alpha_3_code: "TON", + }, + "data/TT": { + alpha_3_code: "TTO", + }, + "data/TN": { + alpha_3_code: "TUN", + }, + "data/TR": { + alpha_3_code: "TUR", + }, + "data/TM": { + alpha_3_code: "TKM", + }, + "data/TC": { + alpha_3_code: "TCA", + }, + "data/TV": { + alpha_3_code: "TUV", + }, + "data/UG": { + alpha_3_code: "UGA", + }, + "data/UA": { + alpha_3_code: "UKR", + }, + "data/AE": { + alpha_3_code: "ARE", + }, + "data/GB": { + alpha_3_code: "GBR", + }, + "data/US": { + alternative_names: [ + "US", + "United States of America", + "United States", + "America", + "U.S.", + "USA", + "U.S.A.", + "U.S.A", + ], + alpha_3_code: "USA", + }, + "data/UM": { + alpha_3_code: "UMI", + }, + "data/UY": { + alpha_3_code: "URY", + }, + "data/UZ": { + alpha_3_code: "UZB", + }, + "data/VU": { + alpha_3_code: "VUT", + }, + "data/VE": { + alpha_3_code: "VEN", + }, + "data/VN": { + alpha_3_code: "VNM", + }, + "data/VG": { + alpha_3_code: "VGB", + }, + "data/VI": { + alpha_3_code: "VIR", + }, + "data/WF": { + alpha_3_code: "WLF", + }, + "data/EH": { + alpha_3_code: "ESH", + }, + "data/YE": { + alpha_3_code: "YEM", + }, + "data/ZM": { + alpha_3_code: "ZMB", + }, + "data/ZW": { + alpha_3_code: "ZWE", + }, +}; + +export default AddressMetaDataExtension; diff --git a/toolkit/components/formautofill/shared/AddressMetaDataLoader.sys.mjs b/toolkit/components/formautofill/shared/AddressMetaDataLoader.sys.mjs new file mode 100644 index 0000000000..a7be227921 --- /dev/null +++ b/toolkit/components/formautofill/shared/AddressMetaDataLoader.sys.mjs @@ -0,0 +1,168 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + AddressMetaData: "resource://gre/modules/shared/AddressMetaData.sys.mjs", + AddressMetaDataExtension: + "resource://gre/modules/shared/AddressMetaDataExtension.sys.mjs", +}); + +export class AddressMetaDataLoader { + // Status of address data loading. We'll load all the countries with basic level 1 + // information while requesting conutry information, and set country to true. + // Level 1 Set is for recording which country's level 1/level 2 data is loaded, + // since we only load this when getCountryAddressData called with level 1 parameter. + static dataLoaded = { + country: false, + level1: new Set(), + }; + + static addressData = {}; + + static DATA_PREFIX = "data/"; + + /** + * Load address meta data and extension into one object. + * + * @returns {object} + * An object containing address data object with properties from extension. + */ + static loadAddressMetaData() { + const addressMetaData = lazy.AddressMetaData; + + for (const key in lazy.AddressMetaDataExtension) { + let addressDataForKey = addressMetaData[key]; + if (!addressDataForKey) { + addressDataForKey = addressMetaData[key] = {}; + } + + Object.assign(addressDataForKey, lazy.AddressMetaDataExtension[key]); + } + return addressMetaData; + } + + /** + * Convert certain properties' string value into array. We should make sure + * the cached data is parsed. + * + * @param {object} data Original metadata from addressReferences. + * @returns {object} parsed metadata with property value that converts to array. + */ + static #parse(data) { + if (!data) { + return null; + } + + const properties = [ + "languages", + "sub_keys", + "sub_isoids", + "sub_names", + "sub_lnames", + ]; + for (const key of properties) { + if (!data[key]) { + continue; + } + // No need to normalize data if the value is array already. + if (Array.isArray(data[key])) { + return data; + } + + data[key] = data[key].split("~"); + } + return data; + } + + /** + * We'll cache addressData in the loader once the data loaded from scripts. + * It'll become the example below after loading addressReferences with extension: + * addressData: { + * "data/US": {"lang": ["en"], ...// Data defined in libaddressinput metadata + * "alternative_names": ... // Data defined in extension } + * "data/CA": {} // Other supported country metadata + * "data/TW": {} // Other supported country metadata + * "data/TW/台北市": {} // Other supported country level 1 metadata + * } + * + * @param {string} country + * @param {string?} level1 + * @returns {object} Default locale metadata + */ + static #loadData(country, level1 = null) { + // Load the addressData if needed + if (!this.dataLoaded.country) { + this.addressData = this.loadAddressMetaData(); + this.dataLoaded.country = true; + } + if (!level1) { + return this.#parse(this.addressData[`${this.DATA_PREFIX}${country}`]); + } + // If level1 is set, load addressReferences under country folder with specific + // country/level 1 for level 2 information. + if (!this.dataLoaded.level1.has(country)) { + Object.assign(this.addressData, this.loadAddressMetaData()); + this.dataLoaded.level1.add(country); + } + return this.#parse( + this.addressData[`${this.DATA_PREFIX}${country}/${level1}`] + ); + } + + /** + * Return the region metadata with default locale and other locales (if exists). + * + * @param {string} country + * @param {string?} level1 + * @returns {object} Return default locale and other locales metadata. + */ + static getData(country, level1 = null) { + const defaultLocale = this.#loadData(country, level1); + if (!defaultLocale) { + return null; + } + + const countryData = this.#parse( + this.addressData[`${this.DATA_PREFIX}${country}`] + ); + let locales = []; + // TODO: Should be able to support multi-locale level 1/ level 2 metadata query + // in Bug 1421886 + if (countryData.languages) { + const list = countryData.languages.filter( + key => key !== countryData.lang + ); + locales = list.map(key => + this.#parse(this.addressData[`${defaultLocale.id}--${key}`]) + ); + } + return { defaultLocale, locales }; + } + + /** + * Return an array containing countries alpha2 codes. + * + * @returns {Array} Return an array containing countries alpha2 codes. + */ + static get #countryCodes() { + return Object.keys(lazy.AddressMetaDataExtension).map(dataKey => + dataKey.replace(this.DATA_PREFIX, "") + ); + } + + static getCountries(locales = []) { + const displayNames = new Intl.DisplayNames(locales, { + type: "region", + fallback: "none", + }); + const countriesMap = new Map(); + for (const countryCode of this.#countryCodes) { + countriesMap.set(countryCode, displayNames.of(countryCode)); + } + return countriesMap; + } +} + +export default AddressMetaDataLoader; diff --git a/toolkit/components/formautofill/shared/AddressParser.sys.mjs b/toolkit/components/formautofill/shared/AddressParser.sys.mjs new file mode 100644 index 0000000000..5cb76934c1 --- /dev/null +++ b/toolkit/components/formautofill/shared/AddressParser.sys.mjs @@ -0,0 +1,285 @@ +/* eslint-disable no-useless-concat */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// NamedCaptureGroup class represents a named capturing group in a regular expression +class NamedCaptureGroup { + // The named of this capturing group + #name = null; + + // The capturing group + #capture = null; + + // The matched result + #match = null; + + constructor(name, capture) { + this.#name = name; + this.#capture = capture; + } + + get name() { + return this.#name; + } + + get capture() { + return this.#capture; + } + + get match() { + return this.#match; + } + + // Setter for the matched result based on the match groups + setMatch(matchGroups) { + this.#match = matchGroups[this.#name]; + } +} + +// Base class for different part of a street address regular expression. +// The regular expression is constructed with prefix, pattern, suffix +// and separator to extract "value" part. +// For examplem, when we write "apt 4." to for floor number, its prefix is `apt`, +// suffix is `.` and value to represent apartment number is `4`. +class StreetAddressPartRegExp extends NamedCaptureGroup { + constructor(name, prefix, pattern, suffix, sep, optional = false) { + prefix = prefix ?? ""; + suffix = suffix ?? ""; + super( + name, + `((?:${prefix})(?<${name}>${pattern})(?:${suffix})(?:${sep})+)${ + optional ? "?" : "" + }` + ); + } +} + +// A regular expression to match the street number portion of a street address, +class StreetNumberRegExp extends StreetAddressPartRegExp { + static PREFIX = "((no|°|º|number)(\\.|-|\\s)*)?"; // From chromium source + + static PATTERN = "\\d+\\w?"; + + // TODO: possible suffix : (th\\.|\\.)? + static SUFFIX = null; + + constructor(sep, optional) { + super( + StreetNumberRegExp.name, + StreetNumberRegExp.PREFIX, + StreetNumberRegExp.PATTERN, + StreetNumberRegExp.SUFFIX, + sep, + optional + ); + } +} + +// A regular expression to match the street name portion of a street address, +class StreetNameRegExp extends StreetAddressPartRegExp { + static PREFIX = null; + + static PATTERN = "(?:[^\\s,]+(?:[^\\S\\r\\n]+[^\\s,]+)*?)"; // From chromium source + + // TODO: Should we consider suffix like (ave|st)? + static SUFFIX = null; + + constructor(sep, optional) { + super( + StreetNameRegExp.name, + StreetNameRegExp.PREFIX, + StreetNameRegExp.PATTERN, + StreetNameRegExp.SUFFIX, + sep, + optional + ); + } +} + +// A regular expression to match the apartment number portion of a street address, +class ApartmentNumberRegExp extends StreetAddressPartRegExp { + static keyword = "apt|apartment|wohnung|apto|-" + "|unit|suite|ste|#|room"; // From chromium source // Firefox specific + static PREFIX = `(${ApartmentNumberRegExp.keyword})(\\.|\\s|-)*`; + + static PATTERN = "\\w*([-|\\/]\\w*)?"; + + static SUFFIX = "(\\.|\\s|-)*(ª)?"; // From chromium source + + constructor(sep, optional) { + super( + ApartmentNumberRegExp.name, + ApartmentNumberRegExp.PREFIX, + ApartmentNumberRegExp.PATTERN, + ApartmentNumberRegExp.SUFFIX, + sep, + optional + ); + } +} + +// A regular expression to match the floor number portion of a street address, +class FloorNumberRegExp extends StreetAddressPartRegExp { + static keyword = + "floor|flur|fl|og|obergeschoss|ug|untergeschoss|geschoss|andar|piso|º" + // From chromium source + "|level|lvl"; // Firefox specific + static PREFIX = `(${FloorNumberRegExp.keyword})?(\\.|\\s|-)*`; // TODO + static PATTERN = "\\d{1,3}\\w?"; + static SUFFIX = `(st|nd|rd|th)?(\\.|\\s|-)*(${FloorNumberRegExp.keyword})?`; // TODO + + constructor(sep, optional) { + super( + FloorNumberRegExp.name, + FloorNumberRegExp.PREFIX, + FloorNumberRegExp.PATTERN, + FloorNumberRegExp.SUFFIX, + sep, + optional + ); + } +} + +/** + * Class represents a street address with the following fields: + * - street number + * - street name + * - apartment number + * - floor number + */ +export class StructuredStreetAddress { + #street_number = null; + #street_name = null; + #apartment_number = null; + #floor_number = null; + + constructor(street_number, street_name, apartment_number, floor_number) { + this.#street_number = street_number?.toString(); + this.#street_name = street_name?.toString(); + this.#apartment_number = apartment_number?.toString(); + this.#floor_number = floor_number?.toString(); + } + + get street_number() { + return this.#street_number; + } + + get street_name() { + return this.#street_name; + } + + get apartment_number() { + return this.#apartment_number; + } + + get floor_number() { + return this.#floor_number; + } + + toString() { + return ` + street number: ${this.#street_number}\n + street name: ${this.#street_name}\n + apartment number: ${this.#apartment_number}\n + floor number: ${this.#floor_number}\n + `; + } +} + +export class AddressParser { + /** + * Parse street address with the following pattern. + * street number, street name, apartment number(optional), floor number(optional) + * For example, 2 Harrison St #175 floor 2 + * + * @param {string} address The street address to be parsed. + * @returns {StructuredStreetAddress} + */ + static parseStreetAddress(address) { + if (!address) { + return null; + } + + const separator = "(\\s|,|$)"; + + const regexpes = [ + new StreetNumberRegExp(separator), + new StreetNameRegExp(separator), + new ApartmentNumberRegExp(separator, true), + new FloorNumberRegExp(separator, true), + ]; + + return AddressParser.parse(address, regexpes) + ? new StructuredStreetAddress(...regexpes.map(regexp => regexp.match)) + : null; + } + + static parse(address, regexpes) { + const options = { + trim: true, + merge_whitespace: true, + ignore_case: true, + }; + address = AddressParser.normalizeString(address, options); + + const match = address.match( + new RegExp(`^(${regexpes.map(regexp => regexp.capture).join("")})$`) + ); + if (!match) { + return null; + } + + regexpes.forEach(regexp => regexp.setMatch(match.groups)); + return regexpes.reduce((acc, current) => { + return { ...acc, [current.name]: current.match }; + }, {}); + } + + static normalizeString(s, options) { + if (typeof s != "string") { + return s; + } + + if (options.ignore_case) { + s = s.toLowerCase(); + } + + // process punctuation before whitespace because if a punctuation + // is replaced with whitespace, we might want to merge it later + if (options.remove_punctuation) { + s = AddressParser.replacePunctuation(s, ""); + } else if ("replace_punctuation" in options) { + const replace = options.replace_punctuation; + s = AddressParser.replacePunctuation(s, replace); + } + + // process whitespace + if (options.merge_whitespace) { + s = AddressParser.mergeWhitespace(s); + } else if (options.remove_whitespace) { + s = AddressParser.removeWhitespace(s); + } + + return s.trim(); + } + + static replacePunctuation(s, replace) { + const regex = /\p{Punctuation}/gu; + return s?.replace(regex, replace); + } + + static removePunctuation(s) { + return s?.replace(/[.,\/#!$%\^&\*;:{}=\-_~()]/g, ""); + } + + static replaceControlCharacters(s, replace) { + return s?.replace(/[\t\n\r]/g, " "); + } + + static removeWhitespace(s) { + return s?.replace(/[\s]/g, ""); + } + + static mergeWhitespace(s) { + return s?.replace(/\s{2,}/g, " "); + } +} diff --git a/toolkit/components/formautofill/shared/CreditCardRecord.sys.mjs b/toolkit/components/formautofill/shared/CreditCardRecord.sys.mjs new file mode 100644 index 0000000000..97235e8cdd --- /dev/null +++ b/toolkit/components/formautofill/shared/CreditCardRecord.sys.mjs @@ -0,0 +1,66 @@ +/* eslint-disable no-useless-concat */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { CreditCard } from "resource://gre/modules/CreditCard.sys.mjs"; +import { FormAutofillNameUtils } from "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs"; + +/** + * The CreditCardRecord class serves to handle and normalize internal credit card records. + * Unlike the CreditCard class, which represents actual card data, CreditCardRecord is used + * for processing and consistent data representation. + */ +export class CreditCardRecord { + static normalizeFields(creditCard) { + this.#normalizeCCNameFields(creditCard); + this.#normalizeCCNumberFields(creditCard); + this.#normalizeCCExpirationDateFields(creditCard); + this.#normalizeCCTypeFields(creditCard); + } + + static #normalizeCCNameFields(creditCard) { + if (!creditCard["cc-name"]) { + creditCard["cc-name"] = FormAutofillNameUtils.joinNameParts({ + given: creditCard["cc-given-name"] ?? "", + middle: creditCard["cc-additional-name"] ?? "", + family: creditCard["cc-family-name"] ?? "", + }); + } + + delete creditCard["cc-given-name"]; + delete creditCard["cc-additional-name"]; + delete creditCard["cc-family-name"]; + } + + static #normalizeCCNumberFields(creditCard) { + if (!("cc-number" in creditCard)) { + return; + } + + if (!CreditCard.isValidNumber(creditCard["cc-number"])) { + delete creditCard["cc-number"]; + return; + } + + const card = new CreditCard({ number: creditCard["cc-number"] }); + creditCard["cc-number"] = card.number; + } + + static #normalizeCCExpirationDateFields(creditCard) { + let normalizedExpiration = CreditCard.normalizeExpiration({ + expirationMonth: creditCard["cc-exp-month"], + expirationYear: creditCard["cc-exp-year"], + expirationString: creditCard["cc-exp"], + }); + + creditCard["cc-exp-month"] = normalizedExpiration.month ?? ""; + creditCard["cc-exp-year"] = normalizedExpiration.year ?? ""; + delete creditCard["cc-exp"]; + } + + static #normalizeCCTypeFields(creditCard) { + // Let's overwrite the credit card type with auto-detect algorithm + creditCard["cc-type"] = CreditCard.getType(creditCard["cc-number"]) ?? ""; + } +} diff --git a/toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs b/toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs new file mode 100644 index 0000000000..26651fe65a --- /dev/null +++ b/toolkit/components/formautofill/shared/CreditCardRuleset.sys.mjs @@ -0,0 +1,1221 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Fathom ML model for identifying the fields of credit-card forms + * + * This is developed out-of-tree at https://github.com/mozilla-services/fathom- + * form-autofill, where there is also over a GB of training, validation, and + * testing data. To make changes, do your edits there (whether adding new + * training pages, adding new rules, or both), retrain and evaluate as + * documented at https://mozilla.github.io/fathom/training.html, paste the + * coefficients emitted by the trainer into the ruleset, and finally copy the + * ruleset's "CODE TO COPY INTO PRODUCTION" section to this file's "CODE FROM + * TRAINING REPOSITORY" section. + */ + +/** + * CODE UNIQUE TO PRODUCTION--NOT IN THE TRAINING REPOSITORY: + */ + +import { + element as clickedElement, + out, + rule, + ruleset, + score, + type, +} from "resource://gre/modules/third_party/fathom/fathom.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; +import { + CreditCard, + NETWORK_NAMES, +} from "resource://gre/modules/CreditCard.sys.mjs"; + +import { FormLikeFactory } from "resource://gre/modules/FormLikeFactory.sys.mjs"; +import { LabelUtils } from "resource://gre/modules/shared/LabelUtils.sys.mjs"; + +/** + * Callthrough abstraction to allow .getAutocompleteInfo() to be mocked out + * during training + * + * @param {Element} element DOM element to get info about + * @returns {object} Page-author-provided autocomplete metadata + */ +function getAutocompleteInfo(element) { + return element.getAutocompleteInfo(); +} + +/** + * @param {string} selector A CSS selector that prunes away ineligible elements + * @returns {Lhs} An LHS yielding the element the user has clicked or, if + * pruned, none + */ +function queriedOrClickedElements(selector) { + return clickedElement(selector); +} + +/** + * START OF CODE PASTED FROM TRAINING REPOSITORY + */ + +var FathomHeuristicsRegExp = { + RULES: { + "cc-name": undefined, + "cc-number": undefined, + "cc-exp-month": undefined, + "cc-exp-year": undefined, + "cc-exp": undefined, + "cc-type": undefined, + }, + + RULE_SETS: [ + { + /* eslint-disable */ + // Let us keep our consistent wrapping. + "cc-name": + // Firefox-specific rules + "account.*holder.*name" + + "|^(credit[-\\s]?card|card).*name" + + // de-DE + "|^(kredit)?(karten|konto)inhaber" + + "|^(name).*karte" + + // fr-FR + "|nom.*(titulaire|détenteur)" + + "|(titulaire|détenteur).*(carte)" + + // it-IT + "|titolare.*carta" + + // pl-PL + "|posiadacz.*karty" + + // es-ES + "|nombre.*(titular|tarjeta)" + + // nl-NL + "|naam.*op.*kaart" + + // Rules from Bitwarden + "|cc-?name" + + "|card-?name" + + "|cardholder-?name" + + "|(^nom$)" + + // Rules are from Chromium source codes + "|card.?(?:holder|owner)|name.*(\\b)?on(\\b)?.*card" + + "|(?:card|cc).?name|cc.?full.?name" + + "|(?:card|cc).?owner" + + "|nom.*carte" + // fr-FR + "|nome.*cart" + // it-IT + "|名前" + // ja-JP + "|Имя.*карты" + // ru + "|信用卡开户名|开户名|持卡人姓名" + // zh-CN + "|持卡人姓名", // zh-TW + + "cc-number": + // Firefox-specific rules + // de-DE + "(cc|kk)nr" + + "|(kredit)?(karten)(nummer|nr)" + + // it-IT + "|numero.*carta" + + // fr-FR + "|(numero|número|numéro).*(carte)" + + // pl-PL + "|numer.*karty" + + // es-ES + "|(número|numero).*tarjeta" + + // nl-NL + "|kaartnummer" + + // Rules from Bitwarden + "|cc-?number" + + "|cc-?num" + + "|card-?number" + + "|card-?num" + + "|cc-?no" + + "|card-?no" + + "|numero-?carte" + + "|num-?carte" + + "|cb-?num" + + // Rules are from Chromium source codes + "|(add)?(?:card|cc|acct).?(?:number|#|no|num)" + + "|カード番号" + // ja-JP + "|Номер.*карты" + // ru + "|信用卡号|信用卡号码" + // zh-CN + "|信用卡卡號" + // zh-TW + "|카드", // ko-KR + + "cc-exp": + // Firefox-specific rules + "mm\\s*(\/|\\|-)\\s*(yy|jj|aa)" + + "|(month|mois)\\s*(\/|\\|-|et)\\s*(year|année)" + + // de-DE + // fr-FR + // Rules from Bitwarden + "|(^cc-?exp$)" + + "|(^card-?exp$)" + + "|(^cc-?expiration$)" + + "|(^card-?expiration$)" + + "|(^cc-?ex$)" + + "|(^card-?ex$)" + + "|(^card-?expire$)" + + "|(^card-?expiry$)" + + "|(^validite$)" + + "|(^expiration$)" + + "|(^expiry$)" + + "|mm-?yy" + + "|mm-?yyyy" + + "|yy-?mm" + + "|yyyy-?mm" + + "|expiration-?date" + + "|payment-?card-?expiration" + + "|(^payment-?cc-?date$)" + + // Rules are from Chromium source codes + "|expir|exp.*date|^expfield$" + + "|ablaufdatum|gueltig|gültig" + // de-DE + "|fecha" + // es + "|date.*exp" + // fr-FR + "|scadenza" + // it-IT + "|有効期限" + // ja-JP + "|validade" + // pt-BR, pt-PT + "|Срок действия карты", // ru + + "cc-exp-month": + // Firefox-specific rules + "(cc|kk)month" + // de-DE + // Rules from Bitwarden + "|(^exp-?month$)" + + "|(^cc-?exp-?month$)" + + "|(^cc-?month$)" + + "|(^card-?month$)" + + "|(^cc-?mo$)" + + "|(^card-?mo$)" + + "|(^exp-?mo$)" + + "|(^card-?exp-?mo$)" + + "|(^cc-?exp-?mo$)" + + "|(^card-?expiration-?month$)" + + "|(^expiration-?month$)" + + "|(^cc-?mm$)" + + "|(^cc-?m$)" + + "|(^card-?mm$)" + + "|(^card-?m$)" + + "|(^card-?exp-?mm$)" + + "|(^cc-?exp-?mm$)" + + "|(^exp-?mm$)" + + "|(^exp-?m$)" + + "|(^expire-?month$)" + + "|(^expire-?mo$)" + + "|(^expiry-?month$)" + + "|(^expiry-?mo$)" + + "|(^card-?expire-?month$)" + + "|(^card-?expire-?mo$)" + + "|(^card-?expiry-?month$)" + + "|(^card-?expiry-?mo$)" + + "|(^mois-?validite$)" + + "|(^mois-?expiration$)" + + "|(^m-?validite$)" + + "|(^m-?expiration$)" + + "|(^expiry-?date-?field-?month$)" + + "|(^expiration-?date-?month$)" + + "|(^expiration-?date-?mm$)" + + "|(^exp-?mon$)" + + "|(^validity-?mo$)" + + "|(^exp-?date-?mo$)" + + "|(^cb-?date-?mois$)" + + "|(^date-?m$)" + + // Rules are from Chromium source codes + "|exp.*mo|ccmonth|cardmonth|addmonth" + + "|monat" + // de-DE + // "|fecha" + // es + // "|date.*exp" + // fr-FR + // "|scadenza" + // it-IT + // "|有効期限" + // ja-JP + // "|validade" + // pt-BR, pt-PT + // "|Срок действия карты" + // ru + "|月", // zh-CN + + "cc-exp-year": + // Firefox-specific rules + "(cc|kk)year" + // de-DE + // Rules from Bitwarden + "|(^exp-?year$)" + + "|(^cc-?exp-?year$)" + + "|(^cc-?year$)" + + "|(^card-?year$)" + + "|(^cc-?yr$)" + + "|(^card-?yr$)" + + "|(^exp-?yr$)" + + "|(^card-?exp-?yr$)" + + "|(^cc-?exp-?yr$)" + + "|(^card-?expiration-?year$)" + + "|(^expiration-?year$)" + + "|(^cc-?yy$)" + + "|(^cc-?y$)" + + "|(^card-?yy$)" + + "|(^card-?y$)" + + "|(^card-?exp-?yy$)" + + "|(^cc-?exp-?yy$)" + + "|(^exp-?yy$)" + + "|(^exp-?y$)" + + "|(^cc-?yyyy$)" + + "|(^card-?yyyy$)" + + "|(^card-?exp-?yyyy$)" + + "|(^cc-?exp-?yyyy$)" + + "|(^expire-?year$)" + + "|(^expire-?yr$)" + + "|(^expiry-?year$)" + + "|(^expiry-?yr$)" + + "|(^card-?expire-?year$)" + + "|(^card-?expire-?yr$)" + + "|(^card-?expiry-?year$)" + + "|(^card-?expiry-?yr$)" + + "|(^an-?validite$)" + + "|(^an-?expiration$)" + + "|(^annee-?validite$)" + + "|(^annee-?expiration$)" + + "|(^expiry-?date-?field-?year$)" + + "|(^expiration-?date-?year$)" + + "|(^cb-?date-?ann$)" + + "|(^expiration-?date-?yy$)" + + "|(^expiration-?date-?yyyy$)" + + "|(^validity-?year$)" + + "|(^exp-?date-?year$)" + + "|(^date-?y$)" + + // Rules are from Chromium source codes + "|(add)?year" + + "|jahr" + // de-DE + // "|fecha" + // es + // "|scadenza" + // it-IT + // "|有効期限" + // ja-JP + // "|validade" + // pt-BR, pt-PT + // "|Срок действия карты" + // ru + "|年|有效期", // zh-CN + + "cc-type": + // Firefox-specific rules + "type" + + // de-DE + "|Kartenmarke" + + // Rules from Bitwarden + "|(^cc-?type$)" + + "|(^card-?type$)" + + "|(^card-?brand$)" + + "|(^cc-?brand$)" + + "|(^cb-?type$)", + // Rules are from Chromium source codes + }, + ], + + _getRule(name) { + let rules = []; + this.RULE_SETS.forEach(set => { + if (set[name]) { + rules.push(`(${set[name]})`.normalize("NFKC")); + } + }); + + const value = new RegExp(rules.join("|"), "iu"); + Object.defineProperty(this.RULES, name, { get: undefined }); + Object.defineProperty(this.RULES, name, { value }); + return value; + }, + + init() { + Object.keys(this.RULES).forEach(field => + Object.defineProperty(this.RULES, field, { + get() { + return FathomHeuristicsRegExp._getRule(field); + }, + }) + ); + }, +}; + +FathomHeuristicsRegExp.init(); + +const MMRegExp = /^mm$|\(mm\)/i; +const YYorYYYYRegExp = /^(yy|yyyy)$|\(yy\)|\(yyyy\)/i; +const monthRegExp = /month/i; +const yearRegExp = /year/i; +const MMYYRegExp = /mm\s*(\/|\\)\s*yy/i; +const VisaCheckoutRegExp = /visa(-|\s)checkout/i; +const CREDIT_CARD_NETWORK_REGEXP = new RegExp( + CreditCard.getSupportedNetworks() + .concat(Object.keys(NETWORK_NAMES)) + .join("|"), + "gui" + ); +const TwoDigitYearRegExp = /(?:exp.*date[^y\\n\\r]*|mm\\s*[-/]?\\s*)yy(?:[^y]|$)/i; +const FourDigitYearRegExp = /(?:exp.*date[^y\\n\\r]*|mm\\s*[-/]?\\s*)yyyy(?:[^y]|$)/i; +const dwfrmRegExp = /^dwfrm/i; +const bmlRegExp = /bml/i; +const templatedValue = /^\{\{.*\}\}$/; +const firstRegExp = /first/i; +const lastRegExp = /last/i; +const giftRegExp = /gift/i; +const subscriptionRegExp = /subscription/i; + +function autocompleteStringMatches(element, ccString) { + const info = getAutocompleteInfo(element); + return info.fieldName === ccString; +} + +function getFillableFormElements(element) { + const formLike = FormLikeFactory.createFromField(element); + return Array.from(formLike.elements).filter(el => + FormAutofillUtils.isCreditCardOrAddressFieldType(el) + ); +} + +function nextFillableFormField(element) { + const fillableFormElements = getFillableFormElements(element); + const elementIndex = fillableFormElements.indexOf(element); + return fillableFormElements[elementIndex + 1]; +} + +function previousFillableFormField(element) { + const fillableFormElements = getFillableFormElements(element); + const elementIndex = fillableFormElements.indexOf(element); + return fillableFormElements[elementIndex - 1]; +} + +function nextFieldPredicateIsTrue(element, predicate) { + const nextField = nextFillableFormField(element); + return !!nextField && predicate(nextField); +} + +function previousFieldPredicateIsTrue(element, predicate) { + const previousField = previousFillableFormField(element); + return !!previousField && predicate(previousField); +} + +function nextFieldMatchesExpYearAutocomplete(fnode) { + return nextFieldPredicateIsTrue(fnode.element, nextField => + autocompleteStringMatches(nextField, "cc-exp-year") + ); +} + +function previousFieldMatchesExpMonthAutocomplete(fnode) { + return previousFieldPredicateIsTrue(fnode.element, previousField => + autocompleteStringMatches(previousField, "cc-exp-month") + ); +} + +////////////////////////////////////////////// +// Attribute Regular Expression Rules +function idOrNameMatchRegExp(element, regExp) { + for (const str of [element.id, element.name]) { + if (regExp.test(str)) { + return true; + } + } + return false; +} + +function getElementLabels(element) { + return { + *[Symbol.iterator]() { + const labels = LabelUtils.findLabelElements(element); + for (let label of labels) { + yield* LabelUtils.extractLabelStrings(label); + } + }, + }; +} + +function labelsMatchRegExp(element, regExp) { + const elemStrings = getElementLabels(element); + for (const str of elemStrings) { + if (regExp.test(str)) { + return true; + } + } + + const parentElement = element.parentElement; + // Bug 1634819: element.parentElement is null if element.parentNode is a ShadowRoot + if (!parentElement) { + return false; + } + // Check if the input is in a <td>, and, if so, check the textContent of the containing <tr> + if (parentElement.tagName === "TD" && parentElement.parentElement) { + // TODO: How bad is the assumption that the <tr> won't be the parent of the <td>? + return regExp.test(parentElement.parentElement.textContent); + } + + // Check if the input is in a <dd>, and, if so, check the textContent of the preceding <dt> + if ( + parentElement.tagName === "DD" && + // previousElementSibling can be null + parentElement.previousElementSibling + ) { + return regExp.test(parentElement.previousElementSibling.textContent); + } + return false; +} + +function closestLabelMatchesRegExp(element, regExp) { + const previousElementSibling = element.previousElementSibling; + if ( + previousElementSibling !== null && + previousElementSibling.tagName === "LABEL" + ) { + return regExp.test(previousElementSibling.textContent); + } + + const nextElementSibling = element.nextElementSibling; + if (nextElementSibling !== null && nextElementSibling.tagName === "LABEL") { + return regExp.test(nextElementSibling.textContent); + } + + return false; +} + +function ariaLabelMatchesRegExp(element, regExp) { + const ariaLabel = element.getAttribute("aria-label"); + return !!ariaLabel && regExp.test(ariaLabel); +} + +function placeholderMatchesRegExp(element, regExp) { + const placeholder = element.getAttribute("placeholder"); + return !!placeholder && regExp.test(placeholder); +} + +function nextFieldIdOrNameMatchRegExp(element, regExp) { + return nextFieldPredicateIsTrue(element, nextField => + idOrNameMatchRegExp(nextField, regExp) + ); +} + +function nextFieldLabelsMatchRegExp(element, regExp) { + return nextFieldPredicateIsTrue(element, nextField => + labelsMatchRegExp(nextField, regExp) + ); +} + +function nextFieldPlaceholderMatchesRegExp(element, regExp) { + return nextFieldPredicateIsTrue(element, nextField => + placeholderMatchesRegExp(nextField, regExp) + ); +} + +function nextFieldAriaLabelMatchesRegExp(element, regExp) { + return nextFieldPredicateIsTrue(element, nextField => + ariaLabelMatchesRegExp(nextField, regExp) + ); +} + +function previousFieldIdOrNameMatchRegExp(element, regExp) { + return previousFieldPredicateIsTrue(element, previousField => + idOrNameMatchRegExp(previousField, regExp) + ); +} + +function previousFieldLabelsMatchRegExp(element, regExp) { + return previousFieldPredicateIsTrue(element, previousField => + labelsMatchRegExp(previousField, regExp) + ); +} + +function previousFieldPlaceholderMatchesRegExp(element, regExp) { + return previousFieldPredicateIsTrue(element, previousField => + placeholderMatchesRegExp(previousField, regExp) + ); +} + +function previousFieldAriaLabelMatchesRegExp(element, regExp) { + return previousFieldPredicateIsTrue(element, previousField => + ariaLabelMatchesRegExp(previousField, regExp) + ); +} +////////////////////////////////////////////// + +function isSelectWithCreditCardOptions(fnode) { + // Check every select for options that match credit card network names in + // value or label. + const element = fnode.element; + if (element.tagName === "SELECT") { + for (let option of element.querySelectorAll("option")) { + if ( + CreditCard.getNetworkFromName(option.value) || + CreditCard.getNetworkFromName(option.text) + ) { + return true; + } + } + } + return false; +} + +/** + * If any of the regular expressions match multiple times, we assume the tested + * string belongs to a radio button for payment type instead of card type. + * + * @param {Fnode} fnode + * @returns {boolean} + */ +function isRadioWithCreditCardText(fnode) { + const element = fnode.element; + const inputType = element.type; + if (!!inputType && inputType === "radio") { + const valueMatches = element.value.match(CREDIT_CARD_NETWORK_REGEXP); + if (valueMatches) { + return valueMatches.length === 1; + } + + // Here we are checking that only one label matches only one entry in the regular expression. + const labels = getElementLabels(element); + let labelsMatched = 0; + for (const label of labels) { + const labelMatches = label.match(CREDIT_CARD_NETWORK_REGEXP); + if (labelMatches) { + if (labelMatches.length > 1) { + return false; + } + labelsMatched++; + } + } + if (labelsMatched > 0) { + return labelsMatched === 1; + } + + const textContentMatches = element.textContent.match( + CREDIT_CARD_NETWORK_REGEXP + ); + if (textContentMatches) { + return textContentMatches.length === 1; + } + } + return false; +} + +function matchContiguousSubArray(array, subArray) { + return array.some((elm, i) => + subArray.every((sElem, j) => sElem === array[i + j]) + ); +} + +function isExpirationMonthLikely(element) { + if (element.tagName !== "SELECT") { + return false; + } + + const options = [...element.options]; + const desiredValues = Array(12) + .fill(1) + .map((v, i) => v + i); + + // The number of month options shouldn't be less than 12 or larger than 13 + // including the default option. + if (options.length < 12 || options.length > 13) { + return false; + } + + return ( + matchContiguousSubArray( + options.map(e => +e.value), + desiredValues + ) || + matchContiguousSubArray( + options.map(e => +e.label), + desiredValues + ) + ); +} + +function isExpirationYearLikely(element) { + if (element.tagName !== "SELECT") { + return false; + } + + const options = [...element.options]; + // A normal expiration year select should contain at least the last three years + // in the list. + const curYear = new Date().getFullYear(); + const desiredValues = Array(3) + .fill(0) + .map((v, i) => v + curYear + i); + + return ( + matchContiguousSubArray( + options.map(e => +e.value), + desiredValues + ) || + matchContiguousSubArray( + options.map(e => +e.label), + desiredValues + ) + ); +} + +function nextFieldIsExpirationYearLikely(fnode) { + return nextFieldPredicateIsTrue(fnode.element, isExpirationYearLikely); +} + +function previousFieldIsExpirationMonthLikely(fnode) { + return previousFieldPredicateIsTrue(fnode.element, isExpirationMonthLikely); +} + +function attrsMatchExpWith2Or4DigitYear(fnode, regExpMatchingFunction) { + const element = fnode.element; + return ( + regExpMatchingFunction(element, TwoDigitYearRegExp) || + regExpMatchingFunction(element, FourDigitYearRegExp) + ); +} + +function maxLengthIs(fnode, maxLengthValue) { + return fnode.element.maxLength === maxLengthValue; +} + +function roleIsMenu(fnode) { + const role = fnode.element.getAttribute("role"); + return !!role && role === "menu"; +} + +function idOrNameMatchDwfrmAndBml(fnode) { + return ( + idOrNameMatchRegExp(fnode.element, dwfrmRegExp) && + idOrNameMatchRegExp(fnode.element, bmlRegExp) + ); +} + +function hasTemplatedValue(fnode) { + const value = fnode.element.getAttribute("value"); + return !!value && templatedValue.test(value); +} + +function inputTypeNotNumbery(fnode) { + const inputType = fnode.element.type; + if (inputType) { + return !["text", "tel", "number"].includes(inputType); + } + return false; +} + +function idOrNameMatchFirstAndLast(fnode) { + return ( + idOrNameMatchRegExp(fnode.element, firstRegExp) && + idOrNameMatchRegExp(fnode.element, lastRegExp) + ); +} + +/** + * Compactly generate a series of rules that all take a single LHS type with no + * .when() clause and have only a score() call on the right- hand side. + * + * @param {Lhs} inType The incoming fnode type that all rules take + * @param {object} ruleMap A simple object used as a map with rule names + * pointing to scoring callbacks + * @yields {Rule} + */ +function* simpleScoringRules(inType, ruleMap) { + for (const [name, scoringCallback] of Object.entries(ruleMap)) { + yield rule(type(inType), score(scoringCallback), { name }); + } +} + +function makeRuleset(coeffs, biases) { + return ruleset( + [ + /** + * Factor out the page scan just for a little more speed during training. + * This selector is good for most fields. cardType is an exception: it + * cannot be type=month. + */ + rule( + queriedOrClickedElements( + "input:not([type]), input[type=text], input[type=textbox], input[type=email], input[type=tel], input[type=number], input[type=month], select, button" + ), + type("typicalCandidates") + ), + + /** + * number rules + */ + rule(type("typicalCandidates"), type("cc-number")), + ...simpleScoringRules("cc-number", { + idOrNameMatchNumberRegExp: fnode => + idOrNameMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-number"] + ), + labelsMatchNumberRegExp: fnode => + labelsMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-number"]), + closestLabelMatchesNumberRegExp: fnode => + closestLabelMatchesRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-number"]), + placeholderMatchesNumberRegExp: fnode => + placeholderMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-number"] + ), + ariaLabelMatchesNumberRegExp: fnode => + ariaLabelMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-number"] + ), + idOrNameMatchGift: fnode => + idOrNameMatchRegExp(fnode.element, giftRegExp), + labelsMatchGift: fnode => labelsMatchRegExp(fnode.element, giftRegExp), + placeholderMatchesGift: fnode => + placeholderMatchesRegExp(fnode.element, giftRegExp), + ariaLabelMatchesGift: fnode => + ariaLabelMatchesRegExp(fnode.element, giftRegExp), + idOrNameMatchSubscription: fnode => + idOrNameMatchRegExp(fnode.element, subscriptionRegExp), + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + inputTypeNotNumbery, + }), + rule(type("cc-number"), out("cc-number")), + + /** + * name rules + */ + rule(type("typicalCandidates"), type("cc-name")), + ...simpleScoringRules("cc-name", { + idOrNameMatchNameRegExp: fnode => + idOrNameMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-name"]), + labelsMatchNameRegExp: fnode => + labelsMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-name"]), + closestLabelMatchesNameRegExp: fnode => + closestLabelMatchesRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-name"]), + placeholderMatchesNameRegExp: fnode => + placeholderMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-name"] + ), + ariaLabelMatchesNameRegExp: fnode => + ariaLabelMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-name"] + ), + idOrNameMatchFirst: fnode => + idOrNameMatchRegExp(fnode.element, firstRegExp), + labelsMatchFirst: fnode => + labelsMatchRegExp(fnode.element, firstRegExp), + placeholderMatchesFirst: fnode => + placeholderMatchesRegExp(fnode.element, firstRegExp), + ariaLabelMatchesFirst: fnode => + ariaLabelMatchesRegExp(fnode.element, firstRegExp), + idOrNameMatchLast: fnode => + idOrNameMatchRegExp(fnode.element, lastRegExp), + labelsMatchLast: fnode => labelsMatchRegExp(fnode.element, lastRegExp), + placeholderMatchesLast: fnode => + placeholderMatchesRegExp(fnode.element, lastRegExp), + ariaLabelMatchesLast: fnode => + ariaLabelMatchesRegExp(fnode.element, lastRegExp), + idOrNameMatchSubscription: fnode => + idOrNameMatchRegExp(fnode.element, subscriptionRegExp), + idOrNameMatchFirstAndLast, + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + }), + rule(type("cc-name"), out("cc-name")), + + /** + * cardType rules + */ + rule( + queriedOrClickedElements( + "input:not([type]), input[type=text], input[type=textbox], input[type=email], input[type=tel], input[type=number], input[type=radio], select, button" + ), + type("cc-type") + ), + ...simpleScoringRules("cc-type", { + idOrNameMatchTypeRegExp: fnode => + idOrNameMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-type"]), + labelsMatchTypeRegExp: fnode => + labelsMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-type"]), + closestLabelMatchesTypeRegExp: fnode => + closestLabelMatchesRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-type"]), + idOrNameMatchVisaCheckout: fnode => + idOrNameMatchRegExp(fnode.element, VisaCheckoutRegExp), + ariaLabelMatchesVisaCheckout: fnode => + ariaLabelMatchesRegExp(fnode.element, VisaCheckoutRegExp), + isSelectWithCreditCardOptions, + isRadioWithCreditCardText, + idOrNameMatchSubscription: fnode => + idOrNameMatchRegExp(fnode.element, subscriptionRegExp), + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + }), + rule(type("cc-type"), out("cc-type")), + + /** + * expiration rules + */ + rule(type("typicalCandidates"), type("cc-exp")), + ...simpleScoringRules("cc-exp", { + labelsMatchExpRegExp: fnode => + labelsMatchRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-exp"]), + closestLabelMatchesExpRegExp: fnode => + closestLabelMatchesRegExp(fnode.element, FathomHeuristicsRegExp.RULES["cc-exp"]), + placeholderMatchesExpRegExp: fnode => + placeholderMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp"] + ), + labelsMatchExpWith2Or4DigitYear: fnode => + attrsMatchExpWith2Or4DigitYear(fnode, labelsMatchRegExp), + placeholderMatchesExpWith2Or4DigitYear: fnode => + attrsMatchExpWith2Or4DigitYear(fnode, placeholderMatchesRegExp), + labelsMatchMMYY: fnode => labelsMatchRegExp(fnode.element, MMYYRegExp), + placeholderMatchesMMYY: fnode => + placeholderMatchesRegExp(fnode.element, MMYYRegExp), + maxLengthIs7: fnode => maxLengthIs(fnode, 7), + idOrNameMatchSubscription: fnode => + idOrNameMatchRegExp(fnode.element, subscriptionRegExp), + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + isExpirationMonthLikely: fnode => + isExpirationMonthLikely(fnode.element), + isExpirationYearLikely: fnode => isExpirationYearLikely(fnode.element), + idOrNameMatchMonth: fnode => + idOrNameMatchRegExp(fnode.element, monthRegExp), + idOrNameMatchYear: fnode => + idOrNameMatchRegExp(fnode.element, yearRegExp), + idOrNameMatchExpMonthRegExp: fnode => + idOrNameMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + idOrNameMatchExpYearRegExp: fnode => + idOrNameMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + idOrNameMatchValidation: fnode => + idOrNameMatchRegExp(fnode.element, /validate|validation/i), + }), + rule(type("cc-exp"), out("cc-exp")), + + /** + * expirationMonth rules + */ + rule(type("typicalCandidates"), type("cc-exp-month")), + ...simpleScoringRules("cc-exp-month", { + idOrNameMatchExpMonthRegExp: fnode => + idOrNameMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + labelsMatchExpMonthRegExp: fnode => + labelsMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + closestLabelMatchesExpMonthRegExp: fnode => + closestLabelMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + placeholderMatchesExpMonthRegExp: fnode => + placeholderMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + ariaLabelMatchesExpMonthRegExp: fnode => + ariaLabelMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + idOrNameMatchMonth: fnode => + idOrNameMatchRegExp(fnode.element, monthRegExp), + labelsMatchMonth: fnode => + labelsMatchRegExp(fnode.element, monthRegExp), + placeholderMatchesMonth: fnode => + placeholderMatchesRegExp(fnode.element, monthRegExp), + ariaLabelMatchesMonth: fnode => + ariaLabelMatchesRegExp(fnode.element, monthRegExp), + nextFieldIdOrNameMatchExpYearRegExp: fnode => + nextFieldIdOrNameMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + nextFieldLabelsMatchExpYearRegExp: fnode => + nextFieldLabelsMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + nextFieldPlaceholderMatchExpYearRegExp: fnode => + nextFieldPlaceholderMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + nextFieldAriaLabelMatchExpYearRegExp: fnode => + nextFieldAriaLabelMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + nextFieldIdOrNameMatchYear: fnode => + nextFieldIdOrNameMatchRegExp(fnode.element, yearRegExp), + nextFieldLabelsMatchYear: fnode => + nextFieldLabelsMatchRegExp(fnode.element, yearRegExp), + nextFieldPlaceholderMatchesYear: fnode => + nextFieldPlaceholderMatchesRegExp(fnode.element, yearRegExp), + nextFieldAriaLabelMatchesYear: fnode => + nextFieldAriaLabelMatchesRegExp(fnode.element, yearRegExp), + nextFieldMatchesExpYearAutocomplete, + isExpirationMonthLikely: fnode => + isExpirationMonthLikely(fnode.element), + nextFieldIsExpirationYearLikely, + maxLengthIs2: fnode => maxLengthIs(fnode, 2), + placeholderMatchesMM: fnode => + placeholderMatchesRegExp(fnode.element, MMRegExp), + roleIsMenu, + idOrNameMatchSubscription: fnode => + idOrNameMatchRegExp(fnode.element, subscriptionRegExp), + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + }), + rule(type("cc-exp-month"), out("cc-exp-month")), + + /** + * expirationYear rules + */ + rule(type("typicalCandidates"), type("cc-exp-year")), + ...simpleScoringRules("cc-exp-year", { + idOrNameMatchExpYearRegExp: fnode => + idOrNameMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + labelsMatchExpYearRegExp: fnode => + labelsMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + closestLabelMatchesExpYearRegExp: fnode => + closestLabelMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + placeholderMatchesExpYearRegExp: fnode => + placeholderMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + ariaLabelMatchesExpYearRegExp: fnode => + ariaLabelMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-year"] + ), + idOrNameMatchYear: fnode => + idOrNameMatchRegExp(fnode.element, yearRegExp), + labelsMatchYear: fnode => labelsMatchRegExp(fnode.element, yearRegExp), + placeholderMatchesYear: fnode => + placeholderMatchesRegExp(fnode.element, yearRegExp), + ariaLabelMatchesYear: fnode => + ariaLabelMatchesRegExp(fnode.element, yearRegExp), + previousFieldIdOrNameMatchExpMonthRegExp: fnode => + previousFieldIdOrNameMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + previousFieldLabelsMatchExpMonthRegExp: fnode => + previousFieldLabelsMatchRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + previousFieldPlaceholderMatchExpMonthRegExp: fnode => + previousFieldPlaceholderMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + previousFieldAriaLabelMatchExpMonthRegExp: fnode => + previousFieldAriaLabelMatchesRegExp( + fnode.element, + FathomHeuristicsRegExp.RULES["cc-exp-month"] + ), + previousFieldIdOrNameMatchMonth: fnode => + previousFieldIdOrNameMatchRegExp(fnode.element, monthRegExp), + previousFieldLabelsMatchMonth: fnode => + previousFieldLabelsMatchRegExp(fnode.element, monthRegExp), + previousFieldPlaceholderMatchesMonth: fnode => + previousFieldPlaceholderMatchesRegExp(fnode.element, monthRegExp), + previousFieldAriaLabelMatchesMonth: fnode => + previousFieldAriaLabelMatchesRegExp(fnode.element, monthRegExp), + previousFieldMatchesExpMonthAutocomplete, + isExpirationYearLikely: fnode => isExpirationYearLikely(fnode.element), + previousFieldIsExpirationMonthLikely, + placeholderMatchesYYOrYYYY: fnode => + placeholderMatchesRegExp(fnode.element, YYorYYYYRegExp), + roleIsMenu, + idOrNameMatchSubscription: fnode => + idOrNameMatchRegExp(fnode.element, subscriptionRegExp), + idOrNameMatchDwfrmAndBml, + hasTemplatedValue, + }), + rule(type("cc-exp-year"), out("cc-exp-year")), + ], + coeffs, + biases + ); +} + +const coefficients = { + "cc-number": [ + ["idOrNameMatchNumberRegExp", 7.679469585418701], + ["labelsMatchNumberRegExp", 5.122580051422119], + ["closestLabelMatchesNumberRegExp", 2.1256935596466064], + ["placeholderMatchesNumberRegExp", 9.471800804138184], + ["ariaLabelMatchesNumberRegExp", 6.067715644836426], + ["idOrNameMatchGift", -22.946273803710938], + ["labelsMatchGift", -7.852959632873535], + ["placeholderMatchesGift", -2.355496406555176], + ["ariaLabelMatchesGift", -2.940307855606079], + ["idOrNameMatchSubscription", 0.11255314946174622], + ["idOrNameMatchDwfrmAndBml", -0.0006645023822784424], + ["hasTemplatedValue", -0.11370040476322174], + ["inputTypeNotNumbery", -3.750155210494995] + ], + "cc-name": [ + ["idOrNameMatchNameRegExp", 7.496212959289551], + ["labelsMatchNameRegExp", 6.081472873687744], + ["closestLabelMatchesNameRegExp", 2.600574254989624], + ["placeholderMatchesNameRegExp", 5.750874042510986], + ["ariaLabelMatchesNameRegExp", 5.162227153778076], + ["idOrNameMatchFirst", -6.742659091949463], + ["labelsMatchFirst", -0.5234538912773132], + ["placeholderMatchesFirst", -3.4615235328674316], + ["ariaLabelMatchesFirst", -1.3145145177841187], + ["idOrNameMatchLast", -12.561869621276855], + ["labelsMatchLast", -0.27417105436325073], + ["placeholderMatchesLast", -1.434966802597046], + ["ariaLabelMatchesLast", -2.9319725036621094], + ["idOrNameMatchFirstAndLast", 24.123435974121094], + ["idOrNameMatchSubscription", 0.08349418640136719], + ["idOrNameMatchDwfrmAndBml", 0.01882520318031311], + ["hasTemplatedValue", 0.182317852973938] + ], + "cc-type": [ + ["idOrNameMatchTypeRegExp", 2.0581533908843994], + ["labelsMatchTypeRegExp", 1.0784518718719482], + ["closestLabelMatchesTypeRegExp", 0.6995877623558044], + ["idOrNameMatchVisaCheckout", -3.320356845855713], + ["ariaLabelMatchesVisaCheckout", -3.4196767807006836], + ["isSelectWithCreditCardOptions", 10.337477684020996], + ["isRadioWithCreditCardText", 4.530318737030029], + ["idOrNameMatchSubscription", -3.7206356525421143], + ["idOrNameMatchDwfrmAndBml", -0.08782318234443665], + ["hasTemplatedValue", 0.1772511601448059] + ], + "cc-exp": [ + ["labelsMatchExpRegExp", 7.588159561157227], + ["closestLabelMatchesExpRegExp", 1.41484534740448], + ["placeholderMatchesExpRegExp", 8.759064674377441], + ["labelsMatchExpWith2Or4DigitYear", -3.876218795776367], + ["placeholderMatchesExpWith2Or4DigitYear", 2.8364884853363037], + ["labelsMatchMMYY", 8.836017608642578], + ["placeholderMatchesMMYY", -0.5231751799583435], + ["maxLengthIs7", 1.3565447330474854], + ["idOrNameMatchSubscription", 0.1779913753271103], + ["idOrNameMatchDwfrmAndBml", 0.21037884056568146], + ["hasTemplatedValue", 0.14900512993335724], + ["isExpirationMonthLikely", -3.223409652709961], + ["isExpirationYearLikely", -2.536919593811035], + ["idOrNameMatchMonth", -3.6893014907836914], + ["idOrNameMatchYear", -3.108184337615967], + ["idOrNameMatchExpMonthRegExp", -2.264357089996338], + ["idOrNameMatchExpYearRegExp", -2.7957723140716553], + ["idOrNameMatchValidation", -2.29402756690979] + ], + "cc-exp-month": [ + ["idOrNameMatchExpMonthRegExp", 0.2787344455718994], + ["labelsMatchExpMonthRegExp", 1.298413634300232], + ["closestLabelMatchesExpMonthRegExp", -11.206244468688965], + ["placeholderMatchesExpMonthRegExp", 1.2605619430541992], + ["ariaLabelMatchesExpMonthRegExp", 1.1330018043518066], + ["idOrNameMatchMonth", 6.1464314460754395], + ["labelsMatchMonth", 0.7051732540130615], + ["placeholderMatchesMonth", 0.7463492751121521], + ["ariaLabelMatchesMonth", 1.8244760036468506], + ["nextFieldIdOrNameMatchExpYearRegExp", 0.06347066164016724], + ["nextFieldLabelsMatchExpYearRegExp", -0.1692247837781906], + ["nextFieldPlaceholderMatchExpYearRegExp", 1.0434566736221313], + ["nextFieldAriaLabelMatchExpYearRegExp", 1.751156210899353], + ["nextFieldIdOrNameMatchYear", -0.532447338104248], + ["nextFieldLabelsMatchYear", 1.3248541355133057], + ["nextFieldPlaceholderMatchesYear", 0.604235827922821], + ["nextFieldAriaLabelMatchesYear", 1.5364223718643188], + ["nextFieldMatchesExpYearAutocomplete", 6.285938262939453], + ["isExpirationMonthLikely", 13.117807388305664], + ["nextFieldIsExpirationYearLikely", 7.182341575622559], + ["maxLengthIs2", 4.477289199829102], + ["placeholderMatchesMM", 14.403288841247559], + ["roleIsMenu", 5.770959854125977], + ["idOrNameMatchSubscription", -0.043085768818855286], + ["idOrNameMatchDwfrmAndBml", 0.02823038399219513], + ["hasTemplatedValue", 0.07234494388103485] + ], + "cc-exp-year": [ + ["idOrNameMatchExpYearRegExp", 5.426016807556152], + ["labelsMatchExpYearRegExp", 1.3240209817886353], + ["closestLabelMatchesExpYearRegExp", -8.702284812927246], + ["placeholderMatchesExpYearRegExp", 0.9059725999832153], + ["ariaLabelMatchesExpYearRegExp", 0.5550334453582764], + ["idOrNameMatchYear", 5.362994194030762], + ["labelsMatchYear", 2.7185044288635254], + ["placeholderMatchesYear", 0.7883157134056091], + ["ariaLabelMatchesYear", 0.311492383480072], + ["previousFieldIdOrNameMatchExpMonthRegExp", 1.8155208826065063], + ["previousFieldLabelsMatchExpMonthRegExp", -0.46133187413215637], + ["previousFieldPlaceholderMatchExpMonthRegExp", 1.0374903678894043], + ["previousFieldAriaLabelMatchExpMonthRegExp", -0.5901495814323425], + ["previousFieldIdOrNameMatchMonth", -5.960310935974121], + ["previousFieldLabelsMatchMonth", 0.6495584845542908], + ["previousFieldPlaceholderMatchesMonth", 0.7198042273521423], + ["previousFieldAriaLabelMatchesMonth", 3.4590985774993896], + ["previousFieldMatchesExpMonthAutocomplete", 2.986003875732422], + ["isExpirationYearLikely", 4.021566390991211], + ["previousFieldIsExpirationMonthLikely", 9.298635482788086], + ["placeholderMatchesYYOrYYYY", 10.457176208496094], + ["roleIsMenu", 1.1051956415176392], + ["idOrNameMatchSubscription", 0.000688597559928894], + ["idOrNameMatchDwfrmAndBml", 0.15687309205532074], + ["hasTemplatedValue", -0.19141331315040588] + ], +}; + +const biases = [ + ["cc-number", -4.948795795440674], + ["cc-name", -5.3578081130981445], + ["cc-type", -5.979659557342529], + ["cc-exp", -5.849575996398926], + ["cc-exp-month", -8.844199180603027], + ["cc-exp-year", -6.499860763549805], +]; + +/** + * END OF CODE PASTED FROM TRAINING REPOSITORY + */ + +/** + * MORE CODE UNIQUE TO PRODUCTION--NOT IN THE TRAINING REPOSITORY: + */ +// Currently there is a bug when a ruleset has multple types (ex, cc-name, cc-number) +// and those types also has the same rules (ex. rule `hasTemplatedValue` is used in +// all the tyoes). When the above case exists, the coefficient of the rule will be +// overwritten, which means, we can't have different coefficient for the same rule on +// different types. To workaround this issue, we create a new ruleset for each type. +export var CreditCardRulesets = { + init() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "supportedTypes", + "extensions.formautofill.creditCards.heuristics.fathom.types", + null, + null, + val => val.split(",") + ); + + for (const type of this.types) { + this[type] = makeRuleset([...coefficients[type]], biases); + } + }, + + get types() { + return this.supportedTypes; + }, +}; + +CreditCardRulesets.init(); + +export default CreditCardRulesets; diff --git a/toolkit/components/formautofill/shared/FieldScanner.sys.mjs b/toolkit/components/formautofill/shared/FieldScanner.sys.mjs new file mode 100644 index 0000000000..22adfdabe8 --- /dev/null +++ b/toolkit/components/formautofill/shared/FieldScanner.sys.mjs @@ -0,0 +1,224 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Represents the detailed information about a form field, including + * the inferred field name, the approach used for inferring, and additional metadata. + */ +export class FieldDetail { + // Reference to the elemenet + elementWeakRef = null; + + // id/name. This is only used for debugging + identifier = ""; + + // The inferred field name for this element + fieldName = null; + + // The approach we use to infer the information for this element + // The possible values are "autocomplete", "fathom", and "regex-heuristic" + reason = null; + + /* + * The "section", "addressType", and "contactType" values are + * used to identify the exact field when the serializable data is received + * from the backend. There cannot be multiple fields which have + * the same exact combination of these values. + */ + + // Which section the field belongs to. The value comes from autocomplete attribute. + // See https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofill-detail-tokens for more details + section = ""; + addressType = ""; + contactType = ""; + credentialType = ""; + + // When a field is split into N fields, we use part to record which field it is + // For example, a credit card number field is split into 4 fields, the value of + // "part" for the first cc-number field is 1, for the last one is 4. + // If the field is not split, the value is null + part = null; + + // Confidence value when the field name is inferred by "fathom" + confidence = null; + + constructor( + element, + fieldName = null, + { autocompleteInfo = {}, confidence = null } = {} + ) { + this.elementWeakRef = new WeakRef(element); + this.identifier = `${element.id}/${element.name}`; + this.fieldName = fieldName; + + if (autocompleteInfo) { + this.reason = "autocomplete"; + this.section = autocompleteInfo.section; + this.addressType = autocompleteInfo.addressType; + this.contactType = autocompleteInfo.contactType; + this.credentialType = autocompleteInfo.credentialType; + } else if (confidence) { + this.reason = "fathom"; + this.confidence = confidence; + } else { + this.reason = "regex-heuristic"; + } + } + + get element() { + return this.elementWeakRef.deref(); + } + + get sectionName() { + return this.section || this.addressType; + } +} + +/** + * A scanner for traversing all elements in a form. It also provides a + * cursor (parsingIndex) to indicate which element is waiting for parsing. + * + * The scanner retrives the field detail by calling heuristics handlers + * `inferFieldInfo` function. + */ +export class FieldScanner { + #elementsWeakRef = null; + #inferFieldInfoFn = null; + + #parsingIndex = 0; + + fieldDetails = []; + + /** + * Create a FieldScanner based on form elements with the existing + * fieldDetails. + * + * @param {Array.DOMElement} elements + * The elements from a form for each parser. + * @param {Funcion} inferFieldInfoFn + * The callback function that is used to infer the field info of a given element + */ + constructor(elements, inferFieldInfoFn) { + this.#elementsWeakRef = new WeakRef(elements); + this.#inferFieldInfoFn = inferFieldInfoFn; + } + + get #elements() { + return this.#elementsWeakRef.deref(); + } + + /** + * This cursor means the index of the element which is waiting for parsing. + * + * @returns {number} + * The index of the element which is waiting for parsing. + */ + get parsingIndex() { + return this.#parsingIndex; + } + + get parsingFinished() { + return this.parsingIndex >= this.#elements.length; + } + + /** + * Move the parsingIndex to the next elements. Any elements behind this index + * means the parsing tasks are finished. + * + * @param {number} index + * The latest index of elements waiting for parsing. + */ + set parsingIndex(index) { + if (index > this.#elements.length) { + throw new Error("The parsing index is out of range."); + } + this.#parsingIndex = index; + } + + /** + * Retrieve the field detail by the index. If the field detail is not ready, + * the elements will be traversed until matching the index. + * + * @param {number} index + * The index of the element that you want to retrieve. + * @returns {object} + * The field detail at the specific index. + */ + getFieldDetailByIndex(index) { + if (index >= this.#elements.length) { + return null; + } + + if (index < this.fieldDetails.length) { + return this.fieldDetails[index]; + } + + for (let i = this.fieldDetails.length; i < index + 1; i++) { + this.pushDetail(); + } + + return this.fieldDetails[index]; + } + + /** + * This function retrieves the first unparsed element and obtains its + * information by invoking the `inferFieldInfoFn` callback function. + * The field information is then stored in a FieldDetail object and + * appended to the `fieldDetails` array. + * + * Any element without the related detail will be used for adding the detail + * to the end of field details. + */ + pushDetail() { + const elementIndex = this.fieldDetails.length; + if (elementIndex >= this.#elements.length) { + throw new Error("Try to push the non-existing element info."); + } + const element = this.#elements[elementIndex]; + const [fieldName, autocompleteInfo, confidence] = + this.#inferFieldInfoFn(element); + const fieldDetail = new FieldDetail(element, fieldName, { + autocompleteInfo, + confidence, + }); + + this.fieldDetails.push(fieldDetail); + } + + /** + * When a field detail should be changed its fieldName after parsing, use + * this function to update the fieldName which is at a specific index. + * + * @param {number} index + * The index indicates a field detail to be updated. + * @param {string} fieldName + * The new name of the field + * @param {boolean} [ignoreAutocomplete=false] + * Whether to change the field name when the field name is determined by + * autocomplete attribute + */ + updateFieldName(index, fieldName, ignoreAutocomplete = false) { + if (index >= this.fieldDetails.length) { + throw new Error("Try to update the non-existing field detail."); + } + + const fieldDetail = this.fieldDetails[index]; + if (fieldDetail.fieldName == fieldName) { + return; + } + + if (!ignoreAutocomplete && fieldDetail.reason == "autocomplete") { + return; + } + + this.fieldDetails[index].fieldName = fieldName; + this.fieldDetails[index].reason = "update-heuristic"; + } + + elementExisting(index) { + return index < this.#elements.length; + } +} + +export default FieldScanner; diff --git a/toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs new file mode 100644 index 0000000000..49f79be77a --- /dev/null +++ b/toolkit/components/formautofill/shared/FormAutofillHandler.sys.mjs @@ -0,0 +1,411 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; +import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + FormAutofillAddressSection: + "resource://gre/modules/shared/FormAutofillSection.sys.mjs", + FormAutofillCreditCardSection: + "resource://gre/modules/shared/FormAutofillSection.sys.mjs", + FormAutofillHeuristics: + "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs", + FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs", + FormSection: "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs", +}); + +const { FIELD_STATES } = FormAutofillUtils; + +/** + * Handles profile autofill for a DOM Form element. + */ +export class FormAutofillHandler { + // The window to which this form belongs + window = null; + + // A WindowUtils reference of which Window the form belongs + winUtils = null; + + // DOM Form element to which this object is attached + form = null; + + // An array of section that are found in this form + sections = []; + + // The section contains the focused input + #focusedSection = null; + + // Caches the element to section mapping + #cachedSectionByElement = new WeakMap(); + + // Keeps track of filled state for all identified elements + #filledStateByElement = new WeakMap(); + /** + * Array of collected data about relevant form fields. Each item is an object + * storing the identifying details of the field and a reference to the + * originally associated element from the form. + * + * The "section", "addressType", "contactType", and "fieldName" values are + * used to identify the exact field when the serializable data is received + * from the backend. There cannot be multiple fields which have + * the same exact combination of these values. + * + * A direct reference to the associated element cannot be sent to the user + * interface because processing may be done in the parent process. + */ + fieldDetails = null; + + /** + * Initialize the form from `FormLike` object to handle the section or form + * operations. + * + * @param {FormLike} form Form that need to be auto filled + * @param {Function} onFormSubmitted Function that can be invoked + * to simulate form submission. Function is passed + * four arguments: (1) a FormLike for the form being + * submitted, (2) the reason for infering the form + * submission (3) the corresponding Window, and (4) + * the responsible FormAutofillHandler. + * @param {Function} onAutofillCallback Function that can be invoked + * when we want to suggest autofill on a form. + */ + constructor(form, onFormSubmitted = () => {}, onAutofillCallback = () => {}) { + this._updateForm(form); + + this.window = this.form.rootElement.ownerGlobal; + this.winUtils = this.window.windowUtils; + + // Enum for form autofill MANUALLY_MANAGED_STATES values + this.FIELD_STATE_ENUM = { + // not themed + [FIELD_STATES.NORMAL]: null, + // highlighted + [FIELD_STATES.AUTO_FILLED]: "autofill", + // highlighted && grey color text + [FIELD_STATES.PREVIEW]: "-moz-autofill-preview", + }; + + /** + * This function is used if the form handler (or one of its sections) + * determines that it needs to act as if the form had been submitted. + */ + this.onFormSubmitted = formSubmissionReason => { + onFormSubmitted(this.form, formSubmissionReason, this.window, this); + }; + + this.onAutofillCallback = onAutofillCallback; + + ChromeUtils.defineLazyGetter(this, "log", () => + FormAutofill.defineLogGetter(this, "FormAutofillHandler") + ); + } + + handleEvent(event) { + switch (event.type) { + case "input": { + if (!event.isTrusted) { + return; + } + const target = event.target; + const targetFieldDetail = this.getFieldDetailByElement(target); + const isCreditCardField = FormAutofillUtils.isCreditCardField( + targetFieldDetail.fieldName + ); + + // If the user manually blanks a credit card field, then + // we want the popup to be activated. + if ( + !HTMLSelectElement.isInstance(target) && + isCreditCardField && + target.value === "" + ) { + this.onAutofillCallback(); + } + + if (this.getFilledStateByElement(target) == FIELD_STATES.NORMAL) { + return; + } + + this.changeFieldState(targetFieldDetail, FIELD_STATES.NORMAL); + const section = this.getSectionByElement(targetFieldDetail.element); + section?.clearFilled(targetFieldDetail); + } + } + } + + set focusedInput(element) { + const section = this.getSectionByElement(element); + if (!section) { + return; + } + + this.#focusedSection = section; + this.#focusedSection.focusedInput = element; + } + + getSectionByElement(element) { + const section = + this.#cachedSectionByElement.get(element) ?? + this.sections.find(s => s.getFieldDetailByElement(element)); + if (!section) { + return null; + } + + this.#cachedSectionByElement.set(element, section); + return section; + } + + getFieldDetailByElement(element) { + for (const section of this.sections) { + const detail = section.getFieldDetailByElement(element); + if (detail) { + return detail; + } + } + return null; + } + + get activeSection() { + return this.#focusedSection; + } + + /** + * Check the form is necessary to be updated. This function should be able to + * detect any changes including all control elements in the form. + * + * @param {HTMLElement} element The element supposed to be in the form. + * @returns {boolean} FormAutofillHandler.form is updated or not. + */ + updateFormIfNeeded(element) { + // When the following condition happens, FormAutofillHandler.form should be + // updated: + // * The count of form controls is changed. + // * When the element can not be found in the current form. + // + // However, we should improve the function to detect the element changes. + // e.g. a tel field is changed from type="hidden" to type="tel". + + let _formLike; + const getFormLike = () => { + if (!_formLike) { + _formLike = lazy.FormLikeFactory.createFromField(element); + } + return _formLike; + }; + + const currentForm = element.form ?? getFormLike(); + if (currentForm.elements.length != this.form.elements.length) { + this.log.debug("The count of form elements is changed."); + this._updateForm(getFormLike()); + return true; + } + + if (!this.form.elements.includes(element)) { + this.log.debug("The element can not be found in the current form."); + this._updateForm(getFormLike()); + return true; + } + + return false; + } + + /** + * Update the form with a new FormLike, and the related fields should be + * updated or clear to ensure the data consistency. + * + * @param {FormLike} form a new FormLike to replace the original one. + */ + _updateForm(form) { + this.form = form; + + this.fieldDetails = null; + + this.sections = []; + this.#cachedSectionByElement = new WeakMap(); + } + + /** + * Set fieldDetails from the form about fields that can be autofilled. + * + * @returns {Array} The valid address and credit card details. + */ + collectFormFields(ignoreInvalid = true) { + const sections = lazy.FormAutofillHeuristics.getFormInfo(this.form); + const allValidDetails = []; + for (const section of sections) { + // We don't support csc field, so remove csc fields from section + const fieldDetails = section.fieldDetails.filter( + f => !["cc-csc"].includes(f.fieldName) + ); + if (!fieldDetails.length) { + continue; + } + + let autofillableSection; + if (section.type == lazy.FormSection.ADDRESS) { + autofillableSection = new lazy.FormAutofillAddressSection( + fieldDetails, + this + ); + } else { + autofillableSection = new lazy.FormAutofillCreditCardSection( + fieldDetails, + this + ); + } + + // Do not include section that is either disabled or invalid. + // We only include invalid section for testing purpose. + if ( + !autofillableSection.isEnabled() || + (ignoreInvalid && !autofillableSection.isValidSection()) + ) { + continue; + } + + this.sections.push(autofillableSection); + allValidDetails.push(...autofillableSection.fieldDetails); + } + + this.fieldDetails = allValidDetails; + return allValidDetails; + } + + #hasFilledSection() { + return this.sections.some(section => section.isFilled()); + } + + getFilledStateByElement(element) { + return this.#filledStateByElement.get(element); + } + + /** + * Change the state of a field to correspond with different presentations. + * + * @param {object} fieldDetail + * A fieldDetail of which its element is about to update the state. + * @param {string} nextState + * Used to determine the next state + */ + changeFieldState(fieldDetail, nextState) { + const element = fieldDetail.element; + if (!element) { + this.log.warn( + fieldDetail.fieldName, + "is unreachable while changing state" + ); + return; + } + if (!(nextState in this.FIELD_STATE_ENUM)) { + this.log.warn( + fieldDetail.fieldName, + "is trying to change to an invalid state" + ); + return; + } + + if (this.#filledStateByElement.get(element) == nextState) { + return; + } + + let nextStateValue = null; + for (const [state, mmStateValue] of Object.entries(this.FIELD_STATE_ENUM)) { + // The NORMAL state is simply the absence of other manually + // managed states so we never need to add or remove it. + if (!mmStateValue) { + continue; + } + + if (state == nextState) { + nextStateValue = mmStateValue; + } else { + this.winUtils.removeManuallyManagedState(element, mmStateValue); + } + } + + if (nextStateValue) { + this.winUtils.addManuallyManagedState(element, nextStateValue); + } + + if (nextState == FIELD_STATES.AUTO_FILLED) { + element.addEventListener("input", this, { mozSystemGroup: true }); + } + + this.#filledStateByElement.set(element, nextState); + } + + /** + * Processes form fields that can be autofilled, and populates them with the + * profile provided by backend. + * + * @param {object} profile + * A profile to be filled in. + */ + async autofillFormFields(profile) { + const noFilledSectionsPreviously = !this.#hasFilledSection(); + await this.activeSection.autofillFields(profile); + + const onChangeHandler = e => { + if (!e.isTrusted) { + return; + } + if (e.type == "reset") { + this.sections.map(section => section.resetFieldStates()); + } + // Unregister listeners once no field is in AUTO_FILLED state. + if (!this.#hasFilledSection()) { + this.form.rootElement.removeEventListener("input", onChangeHandler, { + mozSystemGroup: true, + }); + this.form.rootElement.removeEventListener("reset", onChangeHandler, { + mozSystemGroup: true, + }); + } + }; + + if (noFilledSectionsPreviously) { + // Handle the highlight style resetting caused by user's correction afterward. + this.log.debug("register change handler for filled form:", this.form); + this.form.rootElement.addEventListener("input", onChangeHandler, { + mozSystemGroup: true, + }); + this.form.rootElement.addEventListener("reset", onChangeHandler, { + mozSystemGroup: true, + }); + } + } + + /** + * Collect the filled sections within submitted form and convert all the valid + * field data into multiple records. + * + * @returns {object} records + * {Array.<Object>} records.address + * {Array.<Object>} records.creditCard + */ + createRecords() { + const records = { + address: [], + creditCard: [], + }; + + for (const section of this.sections) { + const secRecord = section.createRecord(); + if (!secRecord) { + continue; + } + if (section instanceof lazy.FormAutofillAddressSection) { + records.address.push(secRecord); + } else if (section instanceof lazy.FormAutofillCreditCardSection) { + records.creditCard.push(secRecord); + } else { + throw new Error("Unknown section type"); + } + } + + return records; + } +} diff --git a/toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs new file mode 100644 index 0000000000..4ee1fc1fe1 --- /dev/null +++ b/toolkit/components/formautofill/shared/FormAutofillHeuristics.sys.mjs @@ -0,0 +1,1213 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; +import { HeuristicsRegExp } from "resource://gre/modules/shared/HeuristicsRegExp.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", + CreditCardRulesets: "resource://gre/modules/shared/CreditCardRuleset.sys.mjs", + FieldScanner: "resource://gre/modules/shared/FieldScanner.sys.mjs", + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", + LabelUtils: "resource://gre/modules/shared/LabelUtils.sys.mjs", +}); + +/** + * To help us classify sections, we want to know what fields can appear + * multiple times in a row. + * Such fields, like `address-line{X}`, should not break sections. + */ +const MULTI_FIELD_NAMES = [ + "address-level3", + "address-level2", + "address-level1", + "tel", + "postal-code", + "email", + "street-address", +]; + +/** + * To help us classify sections that can appear only N times in a row. + * For example, the only time multiple cc-number fields are valid is when + * there are four of these fields in a row. + * Otherwise, multiple cc-number fields should be in separate sections. + */ +const MULTI_N_FIELD_NAMES = { + "cc-number": 4, +}; + +export class FormSection { + static ADDRESS = "address"; + static CREDIT_CARD = "creditCard"; + + #fieldDetails = []; + + #name = ""; + + constructor(fieldDetails) { + if (!fieldDetails.length) { + throw new TypeError("A section should contain at least one field"); + } + + fieldDetails.forEach(field => this.addField(field)); + + const fieldName = fieldDetails[0].fieldName; + if (lazy.FormAutofillUtils.isAddressField(fieldName)) { + this.type = FormSection.ADDRESS; + } else if (lazy.FormAutofillUtils.isCreditCardField(fieldName)) { + this.type = FormSection.CREDIT_CARD; + } else { + throw new Error("Unknown field type to create a section."); + } + } + + get fieldDetails() { + return this.#fieldDetails; + } + + get name() { + return this.#name; + } + + addField(fieldDetail) { + this.#name ||= fieldDetail.sectionName; + this.#fieldDetails.push(fieldDetail); + } +} + +/** + * Returns the autocomplete information of fields according to heuristics. + */ +export const FormAutofillHeuristics = { + RULES: HeuristicsRegExp.getRules(), + LABEL_RULES: HeuristicsRegExp.getLabelRules(), + + CREDIT_CARD_FIELDNAMES: [], + ADDRESS_FIELDNAMES: [], + /** + * Try to find a contiguous sub-array within an array. + * + * @param {Array} array + * @param {Array} subArray + * + * @returns {boolean} + * Return whether subArray was found within the array or not. + */ + _matchContiguousSubArray(array, subArray) { + return array.some((elm, i) => + subArray.every((sElem, j) => sElem == array[i + j]) + ); + }, + + /** + * Try to find the field that is look like a month select. + * + * @param {DOMElement} element + * @returns {boolean} + * Return true if we observe the trait of month select in + * the current element. + */ + _isExpirationMonthLikely(element) { + if (!HTMLSelectElement.isInstance(element)) { + return false; + } + + const options = [...element.options]; + const desiredValues = Array(12) + .fill(1) + .map((v, i) => v + i); + + // The number of month options shouldn't be less than 12 or larger than 13 + // including the default option. + if (options.length < 12 || options.length > 13) { + return false; + } + + return ( + this._matchContiguousSubArray( + options.map(e => +e.value), + desiredValues + ) || + this._matchContiguousSubArray( + options.map(e => +e.label), + desiredValues + ) + ); + }, + + /** + * Try to find the field that is look like a year select. + * + * @param {DOMElement} element + * @returns {boolean} + * Return true if we observe the trait of year select in + * the current element. + */ + _isExpirationYearLikely(element) { + if (!HTMLSelectElement.isInstance(element)) { + return false; + } + + const options = [...element.options]; + // A normal expiration year select should contain at least the last three years + // in the list. + const curYear = new Date().getFullYear(); + const desiredValues = Array(3) + .fill(0) + .map((v, i) => v + curYear + i); + + return ( + this._matchContiguousSubArray( + options.map(e => +e.value), + desiredValues + ) || + this._matchContiguousSubArray( + options.map(e => +e.label), + desiredValues + ) + ); + }, + + /** + * Try to match the telephone related fields to the grammar + * list to see if there is any valid telephone set and correct their + * field names. + * + * @param {FieldScanner} scanner + * The current parsing status for all elements + * @returns {boolean} + * Return true if there is any field can be recognized in the parser, + * otherwise false. + */ + _parsePhoneFields(scanner, detail) { + let matchingResult; + const GRAMMARS = this.PHONE_FIELD_GRAMMARS; + + function isGrammarSeparator(index) { + return !GRAMMARS[index][0]; + } + + const savedIndex = scanner.parsingIndex; + for (let ruleFrom = 0; ruleFrom < GRAMMARS.length; ) { + const detailStart = scanner.parsingIndex; + let ruleTo = ruleFrom; + for (let count = 0; ruleTo < GRAMMARS.length; ruleTo++, count++) { + // Bail out when reaching the end of the current set of grammars + // or there are no more elements to parse + if ( + isGrammarSeparator(ruleTo) || + !scanner.elementExisting(detailStart + count) + ) { + break; + } + + const [category, , length] = GRAMMARS[ruleTo]; + const detail = scanner.getFieldDetailByIndex(detailStart + count); + + // If the field is not what this grammar rule is interested in, skip processing. + if ( + !detail || + detail.fieldName != category || + detail.reason == "autocomplete" + ) { + break; + } + + const element = detail.element; + if (length && (!element.maxLength || length < element.maxLength)) { + break; + } + } + + // if we reach the grammar separator, that means all the previous rules are matched. + // Set the matchingResult so we update field names accordingly. + if (isGrammarSeparator(ruleTo)) { + matchingResult = { ruleFrom, ruleTo }; + break; + } + + // Fast forward to the next rule set. + for (; ruleFrom < GRAMMARS.length; ) { + if (isGrammarSeparator(ruleFrom++)) { + break; + } + } + } + + if (matchingResult) { + const { ruleFrom, ruleTo } = matchingResult; + for (let i = ruleFrom; i < ruleTo; i++) { + scanner.updateFieldName(scanner.parsingIndex, GRAMMARS[i][1]); + scanner.parsingIndex++; + } + } + + // If the previous parsed field is a "tel" field, run heuristic to see + // if the current field is a "tel-extension" field + const field = scanner.getFieldDetailByIndex(scanner.parsingIndex); + if (field && field.reason != "autocomplete") { + const prev = scanner.getFieldDetailByIndex(scanner.parsingIndex - 1); + if ( + prev && + lazy.FormAutofillUtils.getCategoryFromFieldName(prev.fieldName) == "tel" + ) { + const regExpTelExtension = new RegExp( + "\\bext|ext\\b|extension|ramal", // pt-BR, pt-PT + "iug" + ); + if (this._matchRegexp(field.element, regExpTelExtension)) { + scanner.updateFieldName(scanner.parsingIndex, "tel-extension"); + scanner.parsingIndex++; + } + } + } + return savedIndex != scanner.parsingIndex; + }, + + /** + * Try to find the correct address-line[1-3] sequence and correct their field + * names. + * + * @param {FieldScanner} scanner + * The current parsing status for all elements + * @returns {boolean} + * Return true if there is any field can be recognized in the parser, + * otherwise false. + */ + _parseStreetAddressFields(scanner, fieldDetail) { + const INTERESTED_FIELDS = [ + "street-address", + "address-line1", + "address-line2", + "address-line3", + ]; + + const fields = []; + for (let idx = scanner.parsingIndex; !scanner.parsingFinished; idx++) { + const detail = scanner.getFieldDetailByIndex(idx); + if (!INTERESTED_FIELDS.includes(detail?.fieldName)) { + break; + } + fields.push(detail); + } + + if (!fields.length) { + return false; + } + + switch (fields.length) { + case 1: + if ( + fields[0].reason != "autocomplete" && + ["address-line2", "address-line3"].includes(fields[0].fieldName) + ) { + scanner.updateFieldName(scanner.parsingIndex, "address-line1"); + } + break; + case 2: + if (fields[0].reason == "autocomplete") { + if ( + fields[0].fieldName == "street-address" && + (fields[1].fieldName == "address-line2" || + fields[1].reason != "autocomplete") + ) { + scanner.updateFieldName( + scanner.parsingIndex, + "address-line1", + true + ); + } + } else { + scanner.updateFieldName(scanner.parsingIndex, "address-line1"); + } + + scanner.updateFieldName(scanner.parsingIndex + 1, "address-line2"); + break; + case 3: + default: + scanner.updateFieldName(scanner.parsingIndex, "address-line1"); + scanner.updateFieldName(scanner.parsingIndex + 1, "address-line2"); + scanner.updateFieldName(scanner.parsingIndex + 2, "address-line3"); + break; + } + + scanner.parsingIndex += fields.length; + return true; + }, + + _parseAddressFields(scanner, fieldDetail) { + const INTERESTED_FIELDS = ["address-level1", "address-level2"]; + + if (!INTERESTED_FIELDS.includes(fieldDetail.fieldName)) { + return false; + } + + const fields = []; + for (let idx = scanner.parsingIndex; !scanner.parsingFinished; idx++) { + const detail = scanner.getFieldDetailByIndex(idx); + if (!INTERESTED_FIELDS.includes(detail?.fieldName)) { + break; + } + fields.push(detail); + } + + if (!fields.length) { + return false; + } + + // State & City(address-level2) + if (fields.length == 1) { + if (fields[0].fieldName == "address-level2") { + const prev = scanner.getFieldDetailByIndex(scanner.parsingIndex - 1); + if ( + prev && + !prev.fieldName && + HTMLSelectElement.isInstance(prev.element) + ) { + scanner.updateFieldName(scanner.parsingIndex - 1, "address-level1"); + scanner.parsingIndex += 1; + return true; + } + const next = scanner.getFieldDetailByIndex(scanner.parsingIndex + 1); + if ( + next && + !next.fieldName && + HTMLSelectElement.isInstance(next.element) + ) { + scanner.updateFieldName(scanner.parsingIndex + 1, "address-level1"); + scanner.parsingIndex += 2; + return true; + } + } + } + + scanner.parsingIndex += fields.length; + return true; + }, + + /** + * Try to look for expiration date fields and revise the field names if needed. + * + * @param {FieldScanner} scanner + * The current parsing status for all elements + * @returns {boolean} + * Return true if there is any field can be recognized in the parser, + * otherwise false. + */ + _parseCreditCardExpiryFields(scanner, fieldDetail) { + const INTERESTED_FIELDS = ["cc-exp", "cc-exp-month", "cc-exp-year"]; + + if (!INTERESTED_FIELDS.includes(fieldDetail.fieldName)) { + return false; + } + + const fields = []; + for (let idx = scanner.parsingIndex; ; idx++) { + const detail = scanner.getFieldDetailByIndex(idx); + if (!INTERESTED_FIELDS.includes(detail?.fieldName)) { + break; + } + fields.push(detail); + } + + // Don't process the fields if expiration month and expiration year are already + // matched by regex in correct order. + if ( + (fields.length == 1 && fields[0].fieldName == "cc-exp") || + (fields.length == 2 && + fields[0].fieldName == "cc-exp-month" && + fields[1].fieldName == "cc-exp-year") + ) { + scanner.parsingIndex += fields.length; + return true; + } + + const prevCCFields = new Set(); + for (let idx = scanner.parsingIndex - 1; ; idx--) { + const detail = scanner.getFieldDetailByIndex(idx); + if ( + lazy.FormAutofillUtils.getCategoryFromFieldName(detail?.fieldName) != + "creditCard" + ) { + break; + } + prevCCFields.add(detail.fieldName); + } + // We update the "cc-exp-*" fields to correct "cc-ex-*" fields order when + // the following conditions are met: + // 1. The previous elements are identified as credit card fields and + // cc-number is in it + // 2. There is no "cc-exp-*" fields in the previous credit card elements + if ( + ["cc-number", "cc-name"].some(f => prevCCFields.has(f)) && + !["cc-exp", "cc-exp-month", "cc-exp-year"].some(f => prevCCFields.has(f)) + ) { + if (fields.length == 1) { + scanner.updateFieldName(scanner.parsingIndex, "cc-exp"); + } else if (fields.length == 2) { + scanner.updateFieldName(scanner.parsingIndex, "cc-exp-month"); + scanner.updateFieldName(scanner.parsingIndex + 1, "cc-exp-year"); + } + scanner.parsingIndex += fields.length; + return true; + } + + // Set field name to null as it failed to match any patterns. + for (let idx = 0; idx < fields.length; idx++) { + scanner.updateFieldName(scanner.parsingIndex + idx, null); + } + return false; + }, + + /** + * Look for cc-*-name fields when *-name field is present + * + * @param {FieldScanner} scanner + * The current parsing status for all elements + * @returns {boolean} + * Return true if there is any field can be recognized in the parser, + * otherwise false. + */ + _parseCreditCardNameFields(scanner, fieldDetail) { + const INTERESTED_FIELDS = [ + "name", + "given-name", + "additional-name", + "family-name", + ]; + + if (!INTERESTED_FIELDS.includes(fieldDetail.fieldName)) { + return false; + } + + const fields = []; + for (let idx = scanner.parsingIndex; ; idx++) { + const detail = scanner.getFieldDetailByIndex(idx); + if (!INTERESTED_FIELDS.includes(detail?.fieldName)) { + break; + } + fields.push(detail); + } + + const prevCCFields = new Set(); + for (let idx = scanner.parsingIndex - 1; ; idx--) { + const detail = scanner.getFieldDetailByIndex(idx); + if ( + lazy.FormAutofillUtils.getCategoryFromFieldName(detail?.fieldName) != + "creditCard" + ) { + break; + } + prevCCFields.add(detail.fieldName); + } + + // We update the "name" fields to "cc-name" fields when the following + // conditions are met: + // 1. The preceding fields are identified as credit card fields and + // contain the "cc-number" field. + // 2. No "cc-name-*" field is found among the preceding credit card fields. + // 3. The "cc-csc" field is not present among the preceding credit card fields. + if ( + ["cc-number"].some(f => prevCCFields.has(f)) && + !["cc-name", "cc-given-name", "cc-family-name", "cc-csc"].some(f => + prevCCFields.has(f) + ) + ) { + // If there is only one field, assume the name field a `cc-name` field + if (fields.length == 1) { + scanner.updateFieldName(scanner.parsingIndex, `cc-name`); + scanner.parsingIndex += 1; + } else { + // update *-name to cc-*-name + for (const field of fields) { + scanner.updateFieldName( + scanner.parsingIndex, + `cc-${field.fieldName}` + ); + scanner.parsingIndex += 1; + } + } + return true; + } + + return false; + }, + + /** + * This function should provide all field details of a form which are placed + * in the belonging section. The details contain the autocomplete info + * (e.g. fieldName, section, etc). + * + * @param {HTMLFormElement} form + * the elements in this form to be predicted the field info. + * @returns {Array<FormSection>} + * all sections within its field details in the form. + */ + getFormInfo(form) { + let elements = this.getFormElements(form); + + const scanner = new lazy.FieldScanner(elements, element => + this.inferFieldInfo(element, elements) + ); + + while (!scanner.parsingFinished) { + const savedIndex = scanner.parsingIndex; + + // First, we get the inferred field info + const fieldDetail = scanner.getFieldDetailByIndex(scanner.parsingIndex); + + if ( + this._parsePhoneFields(scanner, fieldDetail) || + this._parseStreetAddressFields(scanner, fieldDetail) || + this._parseAddressFields(scanner, fieldDetail) || + this._parseCreditCardExpiryFields(scanner, fieldDetail) || + this._parseCreditCardNameFields(scanner, fieldDetail) + ) { + continue; + } + + // If there is no field parsed, the parsing cursor can be moved + // forward to the next one. + if (savedIndex == scanner.parsingIndex) { + scanner.parsingIndex++; + } + } + + lazy.LabelUtils.clearLabelMap(); + + const fields = scanner.fieldDetails; + const sections = [ + ...this._classifySections( + fields.filter(f => lazy.FormAutofillUtils.isAddressField(f.fieldName)) + ), + ...this._classifySections( + fields.filter(f => + lazy.FormAutofillUtils.isCreditCardField(f.fieldName) + ) + ), + ]; + + return sections.sort( + (a, b) => + fields.indexOf(a.fieldDetails[0]) - fields.indexOf(b.fieldDetails[0]) + ); + }, + + /** + * Get focusable form elements that are of credit card or address type + * + * @param {HTMLElement} form + * @returns {Array<HTMLElement>} focusable elements + */ + getFormElements(form) { + let elements = Array.from(form.elements).filter( + element => + lazy.FormAutofillUtils.isCreditCardOrAddressFieldType(element) && + lazy.FormAutofillUtils.isFieldFocusable(element) + ); + + return elements; + }, + + /** + * The result is an array contains the sections with its belonging field details. + * + * @param {Array<FieldDetails>} fieldDetails field detail array to be classified + * @returns {Array<FormSection>} The array with the sections. + */ + _classifySections(fieldDetails) { + let sections = []; + for (let i = 0; i < fieldDetails.length; i++) { + const fieldName = fieldDetails[i].fieldName; + const sectionName = fieldDetails[i].sectionName; + + const [currentSection] = sections.slice(-1); + + // The section this field might belong to + let candidateSection = null; + + // If the field doesn't have a section name, MAYBE put it to the previous + // section if exists. If the field has a section name, maybe put it to the + // nearest section that either has the same name or it doesn't has a name. + // Otherwise, create a new section. + if (!currentSection || !sectionName) { + candidateSection = currentSection; + } else if (sectionName) { + for (let idx = sections.length - 1; idx >= 0; idx--) { + if (!sections[idx].name || sections[idx].name == sectionName) { + candidateSection = sections[idx]; + break; + } + } + } + + // We got an candidate section to put the field to, check whether the section + // already has a field with the same field name. If yes, only add the field to when + // the type of the field might appear multiple times in a row. + if (candidateSection) { + let createNewSection = true; + if (candidateSection.fieldDetails.find(f => f.fieldName == fieldName)) { + const [lastFieldDetail] = candidateSection.fieldDetails.slice(-1); + if (lastFieldDetail.fieldName == fieldName) { + if (MULTI_FIELD_NAMES.includes(fieldName)) { + createNewSection = false; + } else if (fieldName in MULTI_N_FIELD_NAMES) { + // This is the heuristic to handle special cases where we can have multiple + // fields in one section, but only if the field has appeared N times in a row. + // For example, websites can use 4 consecutive 4-digit `cc-number` fields + // instead of one 16-digit `cc-number` field. + + const N = MULTI_N_FIELD_NAMES[fieldName]; + if (lastFieldDetail.part) { + // If `part` is set, we have already identified this field can be + // merged previously + if (lastFieldDetail.part < N) { + createNewSection = false; + fieldDetails[i].part = lastFieldDetail.part + 1; + } + // If the next N fields are all the same field, we can merge them + } else if ( + N == 2 || + fieldDetails + .slice(i + 1, i + N - 1) + .every(f => f.fieldName == fieldName) + ) { + lastFieldDetail.part = 1; + fieldDetails[i].part = 2; + createNewSection = false; + } + } + } + } else { + // The field doesn't exist in the candidate section, add it. + createNewSection = false; + } + + if (!createNewSection) { + candidateSection.addField(fieldDetails[i]); + continue; + } + } + + // Create a new section + sections.push(new FormSection([fieldDetails[i]])); + } + + return sections; + }, + + _getPossibleFieldNames(element) { + let fieldNames = []; + const isAutoCompleteOff = + element.autocomplete == "off" || element.form?.autocomplete == "off"; + if (!isAutoCompleteOff || FormAutofill.creditCardsAutocompleteOff) { + fieldNames.push(...this.CREDIT_CARD_FIELDNAMES); + } + if (!isAutoCompleteOff || FormAutofill.addressesAutocompleteOff) { + fieldNames.push(...this.ADDRESS_FIELDNAMES); + } + + if (HTMLSelectElement.isInstance(element)) { + const FIELDNAMES_FOR_SELECT_ELEMENT = [ + "address-level1", + "address-level2", + "country", + "cc-exp-month", + "cc-exp-year", + "cc-exp", + "cc-type", + ]; + fieldNames = fieldNames.filter(name => + FIELDNAMES_FOR_SELECT_ELEMENT.includes(name) + ); + } + + return fieldNames; + }, + + /** + * Get inferred information about an input element using autocomplete info, fathom and regex-based heuristics. + * + * @param {HTMLElement} element - The input element to infer information about. + * @param {Array<HTMLElement>} elements - See `getFathomField` for details + * @returns {Array} - An array containing: + * [0]the inferred field name + * [1]autocomplete information if the element has autocompelte attribute, null otherwise. + * [2]fathom confidence if fathom considers it a cc field, null otherwise. + */ + inferFieldInfo(element, elements = []) { + const autocompleteInfo = element.getAutocompleteInfo(); + + // An input[autocomplete="on"] will not be early return here since it stll + // needs to find the field name. + if ( + autocompleteInfo?.fieldName && + !["on", "off"].includes(autocompleteInfo.fieldName) + ) { + return [autocompleteInfo.fieldName, autocompleteInfo, null]; + } + + const fields = this._getPossibleFieldNames(element); + + // "email" type of input is accurate for heuristics to determine its Email + // field or not. However, "tel" type is used for ZIP code for some web site + // (e.g. HomeDepot, BestBuy), so "tel" type should be not used for "tel" + // prediction. + if (element.type == "email" && fields.includes("email")) { + return ["email", null, null]; + } + + if (lazy.FormAutofillUtils.isFathomCreditCardsEnabled()) { + // We don't care fields that are not supported by fathom + const fathomFields = fields.filter(r => + lazy.CreditCardRulesets.types.includes(r) + ); + const [matchedFieldName, confidence] = this.getFathomField( + element, + fathomFields, + elements + ); + // At this point, use fathom's recommendation if it has one + if (matchedFieldName) { + return [matchedFieldName, null, confidence]; + } + + // Continue to run regex-based heuristics even when fathom doesn't recognize + // the field. Since the regex-based heuristic has good search coverage but + // has a worse precision. We use it in conjunction with fathom to maximize + // our search coverage. For example, when a <input> is not considered cc-name + // by fathom but is considered cc-name by regex-based heuristic, if the form + // also contains a cc-number identified by fathom, we will treat the form as a + // valid cc form; hence both cc-number & cc-name are identified. + } + + // Check every select for options that + // match credit card network names in value or label. + if (HTMLSelectElement.isInstance(element)) { + if (this._isExpirationMonthLikely(element)) { + return ["cc-exp-month", null, null]; + } else if (this._isExpirationYearLikely(element)) { + return ["cc-exp-year", null, null]; + } + + const options = Array.from(element.querySelectorAll("option")); + if ( + options.find( + option => + lazy.CreditCard.getNetworkFromName(option.value) || + lazy.CreditCard.getNetworkFromName(option.text) + ) + ) { + return ["cc-type", null, null]; + } + + // At least two options match the country name, otherwise some state name might + // also match a country name, ex, Georgia. We check the last two + // options rather than the first, as selects often start with a non-country display option. + const countryDisplayNames = Array.from(FormAutofill.countries.values()); + if ( + options.length >= 2 && + options + .slice(-2) + .every( + option => + countryDisplayNames.includes(option.value) || + countryDisplayNames.includes(option.text) + ) + ) { + return ["country", null, null]; + } + } + + // Find a matched field name using regexp-based heuristics + const matchedFieldName = this._findMatchedFieldName(element, fields); + return [matchedFieldName, null, null]; + }, + + /** + * Using Fathom, say what kind of CC field an element is most likely to be. + * This function deoesn't only run fathom on the passed elements. It also + * runs fathom for all elements in the FieldScanner for optimization purpose. + * + * @param {HTMLElement} element + * @param {Array} fields + * @param {Array<HTMLElement>} elements - All other eligible elements in the same form. This is mainly used as an + * optimization approach to run fathom model on all eligible elements + * once instead of one by one + * @returns {Array} A tuple of [field name, probability] describing the + * highest-confidence classification + */ + getFathomField(element, fields, elements = []) { + if (!fields.length) { + return [null, null]; + } + + if (!this._fathomConfidences?.get(element)) { + this._fathomConfidences = new Map(); + + // This should not throw unless we run into an OOM situation, at which + // point we have worse problems and this failing is not a big deal. + elements = elements.includes(element) ? elements : [element]; + const confidences = this.getFormAutofillConfidences(elements); + + for (let i = 0; i < elements.length; i++) { + this._fathomConfidences.set(elements[i], confidences[i]); + } + } + + const elementConfidences = this._fathomConfidences.get(element); + if (!elementConfidences) { + return [null, null]; + } + + let highestField = null; + let highestConfidence = lazy.FormAutofillUtils.ccFathomConfidenceThreshold; // Start with a threshold of 0.5 + for (let [key, value] of Object.entries(elementConfidences)) { + if (!fields.includes(key)) { + // ignore field that we don't care + continue; + } + + if (value > highestConfidence) { + highestConfidence = value; + highestField = key; + } + } + + if (!highestField) { + return [null, null]; + } + + // Used by test ONLY! This ensure testcases always get the same confidence + if (lazy.FormAutofillUtils.ccFathomTestConfidence > 0) { + highestConfidence = lazy.FormAutofillUtils.ccFathomTestConfidence; + } + + return [highestField, highestConfidence]; + }, + + /** + * @param {Array} elements Array of elements that we want to get result from fathom cc rules + * @returns {object} Fathom confidence keyed by field-type. + */ + getFormAutofillConfidences(elements) { + if ( + lazy.FormAutofillUtils.ccHeuristicsMode == + lazy.FormAutofillUtils.CC_FATHOM_NATIVE + ) { + const confidences = ChromeUtils.getFormAutofillConfidences(elements); + return confidences.map(c => { + let result = {}; + for (let [fieldName, confidence] of Object.entries(c)) { + let type = + lazy.FormAutofillUtils.formAutofillConfidencesKeyToCCFieldType( + fieldName + ); + result[type] = confidence; + } + return result; + }); + } + + return elements.map(element => { + /** + * Return how confident our ML model is that `element` is a field of the + * given type. + * + * @param {string} fieldName The Fathom type to check against. This is + * conveniently the same as the autocomplete attribute value that means + * the same thing. + * @returns {number} Confidence in range [0, 1] + */ + function confidence(fieldName) { + const ruleset = lazy.CreditCardRulesets[fieldName]; + const fnodes = ruleset.against(element).get(fieldName); + + // fnodes is either 0 or 1 item long, since we ran the ruleset + // against a single element: + return fnodes.length ? fnodes[0].scoreFor(fieldName) : 0; + } + + // Bang the element against the ruleset for every type of field: + const confidences = {}; + lazy.CreditCardRulesets.types.map(fieldName => { + confidences[fieldName] = confidence(fieldName); + }); + + return confidences; + }); + }, + + /** + * @typedef ElementStrings + * @type {object} + * @yields {string} id - element id. + * @yields {string} name - element name. + * @yields {Array<string>} labels - extracted labels. + */ + + /** + * Extract all the signature strings of an element. + * + * @param {HTMLElement} element + * @returns {Array<string>} + */ + _getElementStrings(element) { + return [element.id, element.name, element.placeholder?.trim()]; + }, + + /** + * Extract all the label strings associated with an element. + * + * @param {HTMLElement} element + * @returns {ElementStrings} + */ + _getElementLabelStrings(element) { + return { + *[Symbol.iterator]() { + const labels = lazy.LabelUtils.findLabelElements(element); + for (let label of labels) { + yield* lazy.LabelUtils.extractLabelStrings(label); + } + + const ariaLabels = element.getAttribute("aria-label"); + if (ariaLabels) { + yield* [ariaLabels]; + } + }, + }; + }, + + // In order to support webkit we need to avoid usage of negative lookbehind due to low support + // First safari version with support is 16.4 (Release Date: 27th March 2023) + // https://caniuse.com/js-regexp-lookbehind + // We can mimic the behaviour of negative lookbehinds by using a named capture group + // (?<!not)word -> (?<neg>notword)|word + // TODO: Bug 1829583 + testRegex(regex, string) { + const matches = string?.matchAll(regex); + if (!matches) { + return false; + } + + const excludeNegativeCaptureGroups = []; + + for (const match of matches) { + excludeNegativeCaptureGroups.push( + ...match.filter(m => m !== match?.groups?.neg).filter(Boolean) + ); + } + return excludeNegativeCaptureGroups?.length > 0; + }, + + /** + * Find the first matching field name from a given list of field names + * that matches an HTML element. + * + * The function first tries to match the element against a set of + * pre-defined regular expression rules. If no match is found, it + * then checks for label-specific rules, if they exist. + * + * Note: For label rules, the keyword is often more general + * (e.g., "^\\W*address"), hence they are only searched within labels + * to reduce the occurrence of false positives. + * + * @param {HTMLElement} element The element to match. + * @param {Array<string>} fieldNames An array of field names to compare against. + * @returns {string|null} The name of the matched field, or null if no match was found. + */ + _findMatchedFieldName(element, fieldNames) { + if (!fieldNames.length) { + return null; + } + + // Attempt to match the element against the default set of rules + let matchedFieldName = fieldNames.find(fieldName => + this._matchRegexp(element, this.RULES[fieldName]) + ); + + // If no match is found, and if a label rule exists for the field, + // attempt to match against the label rules + if (!matchedFieldName) { + matchedFieldName = fieldNames.find(fieldName => { + const regexp = this.LABEL_RULES[fieldName]; + return this._matchRegexp(element, regexp, { attribute: false }); + }); + } + return matchedFieldName; + }, + + /** + * Determine whether the regexp can match any of element strings. + * + * @param {HTMLElement} element The HTML element to match. + * @param {RegExp} regexp The regular expression to match against. + * @param {object} [options] Optional parameters for matching. + * @param {boolean} [options.attribute=true] + * Whether to match against the element's attributes. + * @param {boolean} [options.label=true] + * Whether to match against the element's labels. + * @returns {boolean} True if a match is found, otherwise false. + */ + _matchRegexp(element, regexp, { attribute = true, label = true } = {}) { + if (!regexp) { + return false; + } + + if (attribute) { + const elemStrings = this._getElementStrings(element); + if (elemStrings.find(s => this.testRegex(regexp, s?.toLowerCase()))) { + return true; + } + } + + if (label) { + const elementLabelStrings = this._getElementLabelStrings(element); + for (const s of elementLabelStrings) { + if (this.testRegex(regexp, s?.toLowerCase())) { + return true; + } + } + } + + return false; + }, + + /** + * Phone field grammars - first matched grammar will be parsed. Grammars are + * separated by { REGEX_SEPARATOR, FIELD_NONE, 0 }. Suffix and extension are + * parsed separately unless they are necessary parts of the match. + * The following notation is used to describe the patterns: + * <cc> - country code field. + * <ac> - area code field. + * <phone> - phone or prefix. + * <suffix> - suffix. + * <ext> - extension. + * :N means field is limited to N characters, otherwise it is unlimited. + * (pattern <field>)? means pattern is optional and matched separately. + * + * This grammar list from Chromium will be enabled partially once we need to + * support more cases of Telephone fields. + */ + PHONE_FIELD_GRAMMARS: [ + // Country code: <cc> Area Code: <ac> Phone: <phone> (- <suffix> + + // (Ext: <ext>)?)? + // {REGEX_COUNTRY, FIELD_COUNTRY_CODE, 0}, + // {REGEX_AREA, FIELD_AREA_CODE, 0}, + // {REGEX_PHONE, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // \( <ac> \) <phone>:3 <suffix>:4 (Ext: <ext>)? + // {REGEX_AREA_NOTEXT, FIELD_AREA_CODE, 3}, + // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 3}, + // {REGEX_PHONE, FIELD_SUFFIX, 4}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <cc> <ac>:3 - <phone>:3 - <suffix>:4 (Ext: <ext>)? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0}, + // {REGEX_PHONE, FIELD_AREA_CODE, 3}, + // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 3}, + // {REGEX_SUFFIX_SEPARATOR, FIELD_SUFFIX, 4}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <cc>:3 <ac>:3 <phone>:3 <suffix>:4 (Ext: <ext>)? + ["tel", "tel-country-code", 3], + ["tel", "tel-area-code", 3], + ["tel", "tel-local-prefix", 3], + ["tel", "tel-local-suffix", 4], + [null, null, 0], + + // Area Code: <ac> Phone: <phone> (- <suffix> (Ext: <ext>)?)? + // {REGEX_AREA, FIELD_AREA_CODE, 0}, + // {REGEX_PHONE, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <ac> <phone>:3 <suffix>:4 (Ext: <ext>)? + // {REGEX_PHONE, FIELD_AREA_CODE, 0}, + // {REGEX_PHONE, FIELD_PHONE, 3}, + // {REGEX_PHONE, FIELD_SUFFIX, 4}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <cc> \( <ac> \) <phone> (- <suffix> (Ext: <ext>)?)? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0}, + // {REGEX_AREA_NOTEXT, FIELD_AREA_CODE, 0}, + // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: \( <ac> \) <phone> (- <suffix> (Ext: <ext>)?)? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0}, + // {REGEX_AREA_NOTEXT, FIELD_AREA_CODE, 0}, + // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <cc> - <ac> - <phone> - <suffix> (Ext: <ext>)? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0}, + // {REGEX_PREFIX_SEPARATOR, FIELD_AREA_CODE, 0}, + // {REGEX_PREFIX_SEPARATOR, FIELD_PHONE, 0}, + // {REGEX_SUFFIX_SEPARATOR, FIELD_SUFFIX, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Area code: <ac>:3 Prefix: <prefix>:3 Suffix: <suffix>:4 (Ext: <ext>)? + // {REGEX_AREA, FIELD_AREA_CODE, 3}, + // {REGEX_PREFIX, FIELD_PHONE, 3}, + // {REGEX_SUFFIX, FIELD_SUFFIX, 4}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <ac> Prefix: <phone> Suffix: <suffix> (Ext: <ext>)? + // {REGEX_PHONE, FIELD_AREA_CODE, 0}, + // {REGEX_PREFIX, FIELD_PHONE, 0}, + // {REGEX_SUFFIX, FIELD_SUFFIX, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <ac> - <phone>:3 - <suffix>:4 (Ext: <ext>)? + ["tel", "tel-area-code", 0], + ["tel", "tel-local-prefix", 3], + ["tel", "tel-local-suffix", 4], + [null, null, 0], + + // Phone: <cc> - <ac> - <phone> (Ext: <ext>)? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 0}, + // {REGEX_PREFIX_SEPARATOR, FIELD_AREA_CODE, 0}, + // {REGEX_SUFFIX_SEPARATOR, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <ac> - <phone> (Ext: <ext>)? + // {REGEX_AREA, FIELD_AREA_CODE, 0}, + // {REGEX_PHONE, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <cc>:3 - <phone>:10 (Ext: <ext>)? + // {REGEX_PHONE, FIELD_COUNTRY_CODE, 3}, + // {REGEX_PHONE, FIELD_PHONE, 10}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Ext: <ext> + // {REGEX_EXTENSION, FIELD_EXTENSION, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + + // Phone: <phone> (Ext: <ext>)? + // {REGEX_PHONE, FIELD_PHONE, 0}, + // {REGEX_SEPARATOR, FIELD_NONE, 0}, + ], +}; + +ChromeUtils.defineLazyGetter( + FormAutofillHeuristics, + "CREDIT_CARD_FIELDNAMES", + () => + Object.keys(FormAutofillHeuristics.RULES).filter(name => + lazy.FormAutofillUtils.isCreditCardField(name) + ) +); + +ChromeUtils.defineLazyGetter(FormAutofillHeuristics, "ADDRESS_FIELDNAMES", () => + Object.keys(FormAutofillHeuristics.RULES).filter(name => + lazy.FormAutofillUtils.isAddressField(name) + ) +); + +export default FormAutofillHeuristics; diff --git a/toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs new file mode 100644 index 0000000000..8a1d5ba55e --- /dev/null +++ b/toolkit/components/formautofill/shared/FormAutofillNameUtils.sys.mjs @@ -0,0 +1,406 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// FormAutofillNameUtils is initially translated from +// https://cs.chromium.org/chromium/src/components/autofill/core/browser/autofill_data_util.cc?rcl=b861deff77abecff11ae6a9f6946e9cc844b9817 +export var FormAutofillNameUtils = { + NAME_PREFIXES: [ + "1lt", + "1st", + "2lt", + "2nd", + "3rd", + "admiral", + "capt", + "captain", + "col", + "cpt", + "dr", + "gen", + "general", + "lcdr", + "lt", + "ltc", + "ltg", + "ltjg", + "maj", + "major", + "mg", + "mr", + "mrs", + "ms", + "pastor", + "prof", + "rep", + "reverend", + "rev", + "sen", + "st", + ], + + NAME_SUFFIXES: [ + "b.a", + "ba", + "d.d.s", + "dds", + "i", + "ii", + "iii", + "iv", + "ix", + "jr", + "m.a", + "m.d", + "ma", + "md", + "ms", + "ph.d", + "phd", + "sr", + "v", + "vi", + "vii", + "viii", + "x", + ], + + FAMILY_NAME_PREFIXES: [ + "d'", + "de", + "del", + "der", + "di", + "la", + "le", + "mc", + "san", + "st", + "ter", + "van", + "von", + ], + + // The common and non-ambiguous CJK surnames (last names) that have more than + // one character. + COMMON_CJK_MULTI_CHAR_SURNAMES: [ + // Korean, taken from the list of surnames: + // https://ko.wikipedia.org/wiki/%ED%95%9C%EA%B5%AD%EC%9D%98_%EC%84%B1%EC%94%A8_%EB%AA%A9%EB%A1%9D + "남궁", + "사공", + "서문", + "선우", + "제갈", + "황보", + "독고", + "망절", + + // Chinese, taken from the top 10 Chinese 2-character surnames: + // https://zh.wikipedia.org/wiki/%E8%A4%87%E5%A7%93#.E5.B8.B8.E8.A6.8B.E7.9A.84.E8.A4.87.E5.A7.93 + // Simplified Chinese (mostly mainland China) + "欧阳", + "令狐", + "皇甫", + "上官", + "司徒", + "诸葛", + "司马", + "宇文", + "呼延", + "端木", + // Traditional Chinese (mostly Taiwan) + "張簡", + "歐陽", + "諸葛", + "申屠", + "尉遲", + "司馬", + "軒轅", + "夏侯", + ], + + // All Korean surnames that have more than one character, even the + // rare/ambiguous ones. + KOREAN_MULTI_CHAR_SURNAMES: [ + "강전", + "남궁", + "독고", + "동방", + "망절", + "사공", + "서문", + "선우", + "소봉", + "어금", + "장곡", + "제갈", + "황목", + "황보", + ], + + // The whitespace definition based on + // https://cs.chromium.org/chromium/src/base/strings/string_util_constants.cc?l=9&rcl=b861deff77abecff11ae6a9f6946e9cc844b9817 + WHITESPACE: [ + "\u0009", // CHARACTER TABULATION + "\u000A", // LINE FEED (LF) + "\u000B", // LINE TABULATION + "\u000C", // FORM FEED (FF) + "\u000D", // CARRIAGE RETURN (CR) + "\u0020", // SPACE + "\u0085", // NEXT LINE (NEL) + "\u00A0", // NO-BREAK SPACE + "\u1680", // OGHAM SPACE MARK + "\u2000", // EN QUAD + "\u2001", // EM QUAD + "\u2002", // EN SPACE + "\u2003", // EM SPACE + "\u2004", // THREE-PER-EM SPACE + "\u2005", // FOUR-PER-EM SPACE + "\u2006", // SIX-PER-EM SPACE + "\u2007", // FIGURE SPACE + "\u2008", // PUNCTUATION SPACE + "\u2009", // THIN SPACE + "\u200A", // HAIR SPACE + "\u2028", // LINE SEPARATOR + "\u2029", // PARAGRAPH SEPARATOR + "\u202F", // NARROW NO-BREAK SPACE + "\u205F", // MEDIUM MATHEMATICAL SPACE + "\u3000", // IDEOGRAPHIC SPACE + ], + + // The middle dot is used as a separator for foreign names in Japanese. + MIDDLE_DOT: [ + "\u30FB", // KATAKANA MIDDLE DOT + "\u00B7", // A (common?) typo for "KATAKANA MIDDLE DOT" + ], + + // The Unicode range is based on Wiki: + // https://en.wikipedia.org/wiki/CJK_Unified_Ideographs + // https://en.wikipedia.org/wiki/Hangul + // https://en.wikipedia.org/wiki/Japanese_writing_system + CJK_RANGE: [ + "\u1100-\u11FF", // Hangul Jamo + "\u3040-\u309F", // Hiragana + "\u30A0-\u30FF", // Katakana + "\u3105-\u312C", // Bopomofo + "\u3130-\u318F", // Hangul Compatibility Jamo + "\u31F0-\u31FF", // Katakana Phonetic Extensions + "\u3200-\u32FF", // Enclosed CJK Letters and Months + "\u3400-\u4DBF", // CJK unified ideographs Extension A + "\u4E00-\u9FFF", // CJK Unified Ideographs + "\uA960-\uA97F", // Hangul Jamo Extended-A + "\uAC00-\uD7AF", // Hangul Syllables + "\uD7B0-\uD7FF", // Hangul Jamo Extended-B + "\uFF00-\uFFEF", // Halfwidth and Fullwidth Forms + ], + + HANGUL_RANGE: [ + "\u1100-\u11FF", // Hangul Jamo + "\u3130-\u318F", // Hangul Compatibility Jamo + "\uA960-\uA97F", // Hangul Jamo Extended-A + "\uAC00-\uD7AF", // Hangul Syllables + "\uD7B0-\uD7FF", // Hangul Jamo Extended-B + ], + + _dataLoaded: false, + + // Returns true if |set| contains |token|, modulo a final period. + _containsString(set, token) { + let target = token.replace(/\.$/, "").toLowerCase(); + return set.includes(target); + }, + + // Removes common name prefixes from |name_tokens|. + _stripPrefixes(nameTokens) { + for (let i in nameTokens) { + if (!this._containsString(this.NAME_PREFIXES, nameTokens[i])) { + return nameTokens.slice(i); + } + } + return []; + }, + + // Removes common name suffixes from |name_tokens|. + _stripSuffixes(nameTokens) { + for (let i = nameTokens.length - 1; i >= 0; i--) { + if (!this._containsString(this.NAME_SUFFIXES, nameTokens[i])) { + return nameTokens.slice(0, i + 1); + } + } + return []; + }, + + _isCJKName(name) { + // The name is considered to be a CJK name if it is only CJK characters, + // spaces, and "middle dot" separators, with at least one CJK character, and + // no more than 2 words. + // + // Chinese and Japanese names are usually spelled out using the Han + // characters (logographs), which constitute the "CJK Unified Ideographs" + // block in Unicode, also referred to as Unihan. Korean names are usually + // spelled out in the Korean alphabet (Hangul), although they do have a Han + // equivalent as well. + + if (!name) { + return false; + } + + let previousWasCJK = false; + let wordCount = 0; + + for (let c of name) { + let isMiddleDot = this.MIDDLE_DOT.includes(c); + let isCJK = !isMiddleDot && this.reCJK.test(c); + if (!isCJK && !isMiddleDot && !this.WHITESPACE.includes(c)) { + return false; + } + if (isCJK && !previousWasCJK) { + wordCount++; + } + previousWasCJK = isCJK; + } + + return wordCount > 0 && wordCount < 3; + }, + + // Tries to split a Chinese, Japanese, or Korean name into its given name & + // surname parts. If splitting did not work for whatever reason, returns null. + _splitCJKName(nameTokens) { + // The convention for CJK languages is to put the surname (last name) first, + // and the given name (first name) second. In a continuous text, there is + // normally no space between the two parts of the name. When entering their + // name into a field, though, some people add a space to disambiguate. CJK + // names (almost) never have a middle name. + + let reHangulName = new RegExp( + "^[" + this.HANGUL_RANGE.join("") + this.WHITESPACE.join("") + "]+$", + "u" + ); + let nameParts = { + given: "", + middle: "", + family: "", + }; + + if (nameTokens.length == 1) { + // There is no space between the surname and given name. Try to infer + // where to separate between the two. Most Chinese and Korean surnames + // have only one character, but there are a few that have 2. If the name + // does not start with a surname from a known list, default to one + // character. + let name = nameTokens[0]; + let isKorean = reHangulName.test(name); + let surnameLength = 0; + + // 4-character Korean names are more likely to be 2/2 than 1/3, so use + // the full list of Korean 2-char surnames. (instead of only the common + // ones) + let multiCharSurnames = + isKorean && name.length > 3 + ? this.KOREAN_MULTI_CHAR_SURNAMES + : this.COMMON_CJK_MULTI_CHAR_SURNAMES; + + // Default to 1 character if the surname is not in the list. + surnameLength = multiCharSurnames.some(surname => + name.startsWith(surname) + ) + ? 2 + : 1; + + nameParts.family = name.substr(0, surnameLength); + nameParts.given = name.substr(surnameLength); + } else if (nameTokens.length == 2) { + // The user entered a space between the two name parts. This makes our job + // easier. Family name first, given name second. + nameParts.family = nameTokens[0]; + nameParts.given = nameTokens[1]; + } else { + return null; + } + + return nameParts; + }, + + init() { + if (this._dataLoaded) { + return; + } + this._dataLoaded = true; + + this.reCJK = new RegExp("[" + this.CJK_RANGE.join("") + "]", "u"); + }, + + splitName(name) { + let nameParts = { + given: "", + middle: "", + family: "", + }; + + if (!name) { + return nameParts; + } + + let nameTokens = name.trim().split(/[ ,\u3000\u30FB\u00B7]+/); + nameTokens = this._stripPrefixes(nameTokens); + + if (this._isCJKName(name)) { + let parts = this._splitCJKName(nameTokens); + if (parts) { + return parts; + } + } + + // Don't assume "Ma" is a suffix in John Ma. + if (nameTokens.length > 2) { + nameTokens = this._stripSuffixes(nameTokens); + } + + if (!nameTokens.length) { + // Bad things have happened; just assume the whole thing is a given name. + nameParts.given = name; + return nameParts; + } + + // Only one token, assume given name. + if (nameTokens.length == 1) { + nameParts.given = nameTokens[0]; + return nameParts; + } + + // 2 or more tokens. Grab the family, which is the last word plus any + // recognizable family prefixes. + let familyTokens = [nameTokens.pop()]; + while (nameTokens.length) { + let lastToken = nameTokens[nameTokens.length - 1]; + if (!this._containsString(this.FAMILY_NAME_PREFIXES, lastToken)) { + break; + } + familyTokens.unshift(lastToken); + nameTokens.pop(); + } + nameParts.family = familyTokens.join(" "); + + // Take the last remaining token as the middle name (if there are at least 2 + // tokens). + if (nameTokens.length >= 2) { + nameParts.middle = nameTokens.pop(); + } + + // Remainder is given name. + nameParts.given = nameTokens.join(" "); + + return nameParts; + }, + + joinNameParts({ given, middle, family }) { + if (this._isCJKName(given) && this._isCJKName(family) && !middle) { + return family + given; + } + return [given, middle, family] + .filter(part => part && part.length) + .join(" "); + }, +}; + +FormAutofillNameUtils.init(); diff --git a/toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs new file mode 100644 index 0000000000..1c7696432a --- /dev/null +++ b/toolkit/components/formautofill/shared/FormAutofillSection.sys.mjs @@ -0,0 +1,1292 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs"; +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + AutofillTelemetry: "resource://autofill/AutofillTelemetry.sys.mjs", + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", + FormAutofillNameUtils: + "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs", + LabelUtils: "resource://gre/modules/shared/LabelUtils.sys.mjs", +}); + +const { FIELD_STATES } = FormAutofillUtils; + +export class FormAutofillSection { + static SHOULD_FOCUS_ON_AUTOFILL = true; + #focusedInput = null; + + #fieldDetails = []; + + constructor(fieldDetails, handler) { + this.#fieldDetails = fieldDetails; + + if (!this.isValidSection()) { + return; + } + + this.handler = handler; + this.filledRecordGUID = null; + + ChromeUtils.defineLazyGetter(this, "log", () => + FormAutofill.defineLogGetter(this, "FormAutofillHandler") + ); + + this._cacheValue = { + allFieldNames: null, + matchingSelectOption: null, + }; + + // Identifier used to correlate events relating to the same form + this.flowId = Services.uuid.generateUUID().toString(); + this.log.debug( + "Creating new credit card section with flowId =", + this.flowId + ); + } + + get fieldDetails() { + return this.#fieldDetails; + } + + /* + * Examine the section is a valid section or not based on its fieldDetails or + * other information. This method must be overrided. + * + * @returns {boolean} True for a valid section, otherwise false + * + */ + isValidSection() { + throw new TypeError("isValidSection method must be overrided"); + } + + /* + * Examine the section is an enabled section type or not based on its + * preferences. This method must be overrided. + * + * @returns {boolean} True for an enabled section type, otherwise false + * + */ + isEnabled() { + throw new TypeError("isEnabled method must be overrided"); + } + + /* + * Examine the section is createable for storing the profile. This method + * must be overrided. + * + * @param {Object} record The record for examining createable + * @returns {boolean} True for the record is createable, otherwise false + * + */ + isRecordCreatable(record) { + throw new TypeError("isRecordCreatable method must be overridden"); + } + + /** + * Override this method if the profile is needed to apply some transformers. + * + * @param {object} profile + * A profile should be converted based on the specific requirement. + */ + applyTransformers(profile) {} + + /** + * Override this method if the profile is needed to be customized for + * previewing values. + * + * @param {object} profile + * A profile for pre-processing before previewing values. + */ + preparePreviewProfile(profile) {} + + /** + * Override this method if the profile is needed to be customized for filling + * values. + * + * @param {object} profile + * A profile for pre-processing before filling values. + * @returns {boolean} Whether the profile should be filled. + */ + async prepareFillingProfile(profile) { + return true; + } + + /** + * Override this method if the profile is needed to be customized for filling + * values. + * + * @param {object} fieldDetail A fieldDetail of the related element. + * @param {object} profile The profile to fill. + * @returns {string} The value to fill for the given field. + */ + getFilledValueFromProfile(fieldDetail, profile) { + return ( + profile[`${fieldDetail.fieldName}-formatted`] || + profile[fieldDetail.fieldName] + ); + } + + /* + * Override this method if there is any field value needs to compute for a + * specific case. Return the original value in the default case. + * @param {String} value + * The original field value. + * @param {Object} fieldDetail + * A fieldDetail of the related element. + * @param {HTMLElement} element + * A element for checking converting value. + * + * @returns {String} + * A string of the converted value. + */ + computeFillingValue(value, fieldName, element) { + return value; + } + + set focusedInput(element) { + this.#focusedInput = element; + } + + getFieldDetailByElement(element) { + return this.fieldDetails.find(detail => detail.element == element); + } + + getFieldDetailByName(fieldName) { + return this.fieldDetails.find(detail => detail.fieldName == fieldName); + } + + get allFieldNames() { + if (!this._cacheValue.allFieldNames) { + this._cacheValue.allFieldNames = this.fieldDetails.map( + record => record.fieldName + ); + } + return this._cacheValue.allFieldNames; + } + + matchSelectOptions(profile) { + if (!this._cacheValue.matchingSelectOption) { + this._cacheValue.matchingSelectOption = new WeakMap(); + } + + for (const fieldName in profile) { + const fieldDetail = this.getFieldDetailByName(fieldName); + const element = fieldDetail?.element; + + if (!HTMLSelectElement.isInstance(element)) { + continue; + } + + const cache = this._cacheValue.matchingSelectOption.get(element) || {}; + const value = profile[fieldName]; + if (cache[value] && cache[value].deref()) { + continue; + } + + const option = FormAutofillUtils.findSelectOption( + element, + profile, + fieldName + ); + + if (option) { + cache[value] = new WeakRef(option); + this._cacheValue.matchingSelectOption.set(element, cache); + } else { + if (cache[value]) { + delete cache[value]; + this._cacheValue.matchingSelectOption.set(element, cache); + } + // Skip removing cc-type since this is needed for displaying the icon for credit card network + // TODO(Bug 1874339): Cleanup transformation and normalization of data to not remove any + // fields and be more consistent + if (!["cc-type"].includes(fieldName)) { + // Delete the field so the phishing hint won't treat it as a "also fill" + // field. + delete profile[fieldName]; + } + } + } + } + + adaptFieldMaxLength(profile) { + for (let key in profile) { + let detail = this.getFieldDetailByName(key); + if (!detail) { + continue; + } + + let element = detail.element; + if (!element) { + continue; + } + + let maxLength = element.maxLength; + if ( + maxLength === undefined || + maxLength < 0 || + profile[key].toString().length <= maxLength + ) { + continue; + } + + if (maxLength) { + switch (typeof profile[key]) { + case "string": + // If this is an expiration field and our previous + // adaptations haven't resulted in a string that is + // short enough to satisfy the field length, and the + // field is constrained to a length of 4 or 5, then we + // assume it is intended to hold an expiration of the + // form "MMYY" or "MM/YY". + if (key == "cc-exp" && (maxLength == 4 || maxLength == 5)) { + const month2Digits = ( + "0" + profile["cc-exp-month"].toString() + ).slice(-2); + const year2Digits = profile["cc-exp-year"].toString().slice(-2); + const separator = maxLength == 5 ? "/" : ""; + profile[key] = `${month2Digits}${separator}${year2Digits}`; + } else if (key == "cc-number") { + // We want to show the last four digits of credit card so that + // the masked credit card previews correctly and appears correctly + // in the autocomplete menu + profile[key] = profile[key].substr( + profile[key].length - maxLength + ); + } else { + profile[key] = profile[key].substr(0, maxLength); + } + break; + case "number": + // There's no way to truncate a number smaller than a + // single digit. + if (maxLength < 1) { + maxLength = 1; + } + // The only numbers we store are expiration month/year, + // and if they truncate, we want the final digits, not + // the initial ones. + profile[key] = profile[key] % Math.pow(10, maxLength); + break; + default: + } + } else { + delete profile[key]; + delete profile[`${key}-formatted`]; + } + } + } + + fillFieldValue(element, value) { + if (FormAutofillUtils.focusOnAutofill) { + element.focus({ preventScroll: true }); + } + if (HTMLInputElement.isInstance(element)) { + element.setUserInput(value); + } else if (HTMLSelectElement.isInstance(element)) { + // Set the value of the select element so that web event handlers can react accordingly + element.value = value; + element.dispatchEvent( + new element.ownerGlobal.Event("input", { bubbles: true }) + ); + element.dispatchEvent( + new element.ownerGlobal.Event("change", { bubbles: true }) + ); + } + } + + getAdaptedProfiles(originalProfiles) { + for (let profile of originalProfiles) { + this.applyTransformers(profile); + } + return originalProfiles; + } + + /** + * Processes form fields that can be autofilled, and populates them with the + * profile provided by backend. + * + * @param {object} profile + * A profile to be filled in. + * @returns {boolean} + * True if successful, false if failed + */ + async autofillFields(profile) { + if (!this.#focusedInput) { + throw new Error("No focused input."); + } + + const focusedDetail = this.getFieldDetailByElement(this.#focusedInput); + if (!focusedDetail) { + throw new Error("No fieldDetail for the focused input."); + } + + this.getAdaptedProfiles([profile]); + if (!(await this.prepareFillingProfile(profile))) { + this.log.debug("profile cannot be filled"); + return false; + } + + this.filledRecordGUID = profile.guid; + for (const fieldDetail of this.fieldDetails) { + // Avoid filling field value in the following cases: + // 1. a non-empty input field for an unfocused input + // 2. the invalid value set + // 3. value already chosen in select element + + const element = fieldDetail.element; + // Skip the field if it is null or readonly or disabled + if (!FormAutofillUtils.isFieldAutofillable(element)) { + continue; + } + + element.previewValue = ""; + // Bug 1687679: Since profile appears to be presentation ready data, we need to utilize the "x-formatted" field + // that is generated when presentation ready data doesn't fit into the autofilling element. + // For example, autofilling expiration month into an input element will not work as expected if + // the month is less than 10, since the input is expected a zero-padded string. + // See Bug 1722941 for follow up. + const value = this.getFilledValueFromProfile(fieldDetail, profile); + if (HTMLInputElement.isInstance(element) && value) { + // For the focused input element, it will be filled with a valid value + // anyway. + // For the others, the fields should be only filled when their values are empty + // or their values are equal to the site prefill value + // or are the result of an earlier auto-fill. + if ( + element == this.#focusedInput || + (element != this.#focusedInput && + (!element.value || element.value == element.defaultValue)) || + this.handler.getFilledStateByElement(element) == + FIELD_STATES.AUTO_FILLED + ) { + this.fillFieldValue(element, value); + this.handler.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); + } + } else if (HTMLSelectElement.isInstance(element)) { + let cache = this._cacheValue.matchingSelectOption.get(element) || {}; + let option = cache[value] && cache[value].deref(); + if (!option) { + continue; + } + // Do not change value or dispatch events if the option is already selected. + // Use case for multiple select is not considered here. + if (!option.selected) { + option.selected = true; + this.fillFieldValue(element, option.value); + } + // Autofill highlight appears regardless if value is changed or not + this.handler.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED); + } + } + this.#focusedInput.focus({ preventScroll: true }); + + lazy.AutofillTelemetry.recordFormInteractionEvent("filled", this, { + profile, + }); + + return true; + } + + /** + * Populates result to the preview layers with given profile. + * + * @param {object} profile + * A profile to be previewed with + */ + previewFormFields(profile) { + this.preparePreviewProfile(profile); + + for (const fieldDetail of this.fieldDetails) { + let element = fieldDetail.element; + // Skip the field if it is null or readonly or disabled + if (!FormAutofillUtils.isFieldAutofillable(element)) { + continue; + } + + let value = + profile[`${fieldDetail.fieldName}-formatted`] || + profile[fieldDetail.fieldName] || + ""; + if (HTMLSelectElement.isInstance(element)) { + // Unlike text input, select element is always previewed even if + // the option is already selected. + if (value) { + const cache = + this._cacheValue.matchingSelectOption.get(element) ?? {}; + const option = cache[value]?.deref(); + value = option?.text ?? ""; + } + } else if (element.value && element.value != element.defaultValue) { + // Skip the field if the user has already entered text and that text is not the site prefilled value. + continue; + } + element.previewValue = value?.toString().replaceAll("*", "•"); + this.handler.changeFieldState( + fieldDetail, + value ? FIELD_STATES.PREVIEW : FIELD_STATES.NORMAL + ); + } + } + + /** + * Clear a previously autofilled field in this section + */ + clearFilled(fieldDetail) { + lazy.AutofillTelemetry.recordFormInteractionEvent("filled_modified", this, { + fieldName: fieldDetail.fieldName, + }); + + let isAutofilled = false; + const dimFieldDetails = []; + for (const fieldDetail of this.fieldDetails) { + const element = fieldDetail.element; + + if (HTMLSelectElement.isInstance(element)) { + // Dim fields are those we don't attempt to revert their value + // when clear the target set, such as <select>. + dimFieldDetails.push(fieldDetail); + } else { + isAutofilled |= + this.handler.getFilledStateByElement(element) == + FIELD_STATES.AUTO_FILLED; + } + } + if (!isAutofilled) { + // Restore the dim fields to initial state as well once we knew + // that user had intention to clear the filled form manually. + for (const fieldDetail of dimFieldDetails) { + // If we can't find a selected option, then we should just reset to the first option's value + let element = fieldDetail.element; + this._resetSelectElementValue(element); + this.handler.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); + } + this.filledRecordGUID = null; + } + } + + /** + * Clear preview text and background highlight of all fields. + */ + clearPreviewedFormFields() { + this.log.debug("clear previewed fields"); + + for (const fieldDetail of this.fieldDetails) { + let element = fieldDetail.element; + if (!element) { + this.log.warn(fieldDetail.fieldName, "is unreachable"); + continue; + } + + element.previewValue = ""; + + // We keep the state if this field has + // already been auto-filled. + if ( + this.handler.getFilledStateByElement(element) == + FIELD_STATES.AUTO_FILLED + ) { + continue; + } + + this.handler.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); + } + } + + /** + * Clear value and highlight style of all filled fields. + */ + clearPopulatedForm() { + for (let fieldDetail of this.fieldDetails) { + let element = fieldDetail.element; + if (!element) { + this.log.warn(fieldDetail.fieldName, "is unreachable"); + continue; + } + + if ( + this.handler.getFilledStateByElement(element) == + FIELD_STATES.AUTO_FILLED + ) { + if (HTMLInputElement.isInstance(element)) { + element.setUserInput(""); + } else if (HTMLSelectElement.isInstance(element)) { + // If we can't find a selected option, then we should just reset to the first option's value + this._resetSelectElementValue(element); + } + } + } + } + + resetFieldStates() { + for (const fieldDetail of this.fieldDetails) { + const element = fieldDetail.element; + element.removeEventListener("input", this, { mozSystemGroup: true }); + this.handler.changeFieldState(fieldDetail, FIELD_STATES.NORMAL); + } + this.filledRecordGUID = null; + } + + isFilled() { + return !!this.filledRecordGUID; + } + + /** + * Condenses multiple credit card number fields into one fieldDetail + * in order to submit the credit card record correctly. + * + * @param {Array.<object>} condensedDetails + * An array of fieldDetails + * @memberof FormAutofillSection + */ + _condenseMultipleCCNumberFields(condensedDetails) { + let countOfCCNumbers = 0; + // We ignore the cases where there are more than or less than four credit card number + // fields in a form as this is not a valid case for filling the credit card number. + for (let i = condensedDetails.length - 1; i >= 0; i--) { + if (condensedDetails[i].fieldName == "cc-number") { + countOfCCNumbers++; + if (countOfCCNumbers == 4) { + countOfCCNumbers = 0; + condensedDetails[i].fieldValue = + condensedDetails[i].element?.value + + condensedDetails[i + 1].element?.value + + condensedDetails[i + 2].element?.value + + condensedDetails[i + 3].element?.value; + condensedDetails.splice(i + 1, 3); + } + } else { + countOfCCNumbers = 0; + } + } + } + /** + * Return the record that is converted from `fieldDetails` and only valid + * form record is included. + * + * @returns {object | null} + * A record object consists of three properties: + * - guid: The id of the previously-filled profile or null if omitted. + * - record: A valid record converted from details with trimmed result. + * - untouchedFields: Fields that aren't touched after autofilling. + * Return `null` for any uncreatable or invalid record. + */ + createRecord() { + let details = this.fieldDetails; + if (!this.isEnabled() || !details || !details.length) { + return null; + } + + let data = { + guid: this.filledRecordGUID, + record: {}, + untouchedFields: [], + section: this, + }; + if (this.flowId) { + data.flowId = this.flowId; + } + let condensedDetails = this.fieldDetails; + + // TODO: This is credit card specific code... + this._condenseMultipleCCNumberFields(condensedDetails); + + condensedDetails.forEach(detail => { + const element = detail.element; + // Remove the unnecessary spaces + let value = detail.fieldValue ?? (element && element.value.trim()); + value = this.computeFillingValue(value, detail, element); + + if (!value || value.length > FormAutofillUtils.MAX_FIELD_VALUE_LENGTH) { + // Keep the property and preserve more information for updating + data.record[detail.fieldName] = ""; + return; + } + + data.record[detail.fieldName] = value; + + if ( + this.handler.getFilledStateByElement(element) == + FIELD_STATES.AUTO_FILLED + ) { + data.untouchedFields.push(detail.fieldName); + } + }); + + const telFields = this.fieldDetails.filter( + f => FormAutofillUtils.getCategoryFromFieldName(f.fieldName) == "tel" + ); + if ( + telFields.length && + telFields.every(f => data.untouchedFields.includes(f.fieldName)) + ) { + // No need to verify it if none of related fields are modified after autofilling. + if (!data.untouchedFields.includes("tel")) { + data.untouchedFields.push("tel"); + } + } + + if (!this.isRecordCreatable(data.record)) { + return null; + } + + return data; + } + + /** + * Resets a <select> element to its selected option or the first option if there is none selected. + * + * @param {HTMLElement} element + * @memberof FormAutofillSection + */ + _resetSelectElementValue(element) { + if (!element.options.length) { + return; + } + let selected = [...element.options].find(option => + option.hasAttribute("selected") + ); + element.value = selected ? selected.value : element.options[0].value; + element.dispatchEvent( + new element.ownerGlobal.Event("input", { bubbles: true }) + ); + element.dispatchEvent( + new element.ownerGlobal.Event("change", { bubbles: true }) + ); + } +} + +export class FormAutofillAddressSection extends FormAutofillSection { + constructor(fieldDetails, handler) { + super(fieldDetails, handler); + + if (!this.isValidSection()) { + return; + } + + this._cacheValue.oneLineStreetAddress = null; + + lazy.AutofillTelemetry.recordDetectedSectionCount(this); + lazy.AutofillTelemetry.recordFormInteractionEvent("detected", this); + } + + isValidSection() { + const fields = new Set(this.fieldDetails.map(f => f.fieldName)); + return fields.size >= FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD; + } + + isEnabled() { + return FormAutofill.isAutofillAddressesEnabled; + } + + isRecordCreatable(record) { + const country = FormAutofillUtils.identifyCountryCode( + record.country || record["country-name"] + ); + if ( + country && + !FormAutofill.isAutofillAddressesAvailableInCountry(country) + ) { + // We don't want to save data in the wrong fields due to not having proper + // heuristic regexes in countries we don't yet support. + this.log.warn( + "isRecordCreatable: Country not supported:", + record.country + ); + return false; + } + + // Multiple name or tel fields are treat as 1 field while countng whether + // the number of fields exceed the valid address secton threshold + const categories = Object.entries(record) + .filter(e => !!e[1]) + .map(e => FormAutofillUtils.getCategoryFromFieldName(e[0])); + + return ( + categories.reduce( + (acc, category) => + ["name", "tel"].includes(category) && acc.includes(category) + ? acc + : [...acc, category], + [] + ).length >= FormAutofillUtils.AUTOFILL_FIELDS_THRESHOLD + ); + } + + _getOneLineStreetAddress(address) { + if (!this._cacheValue.oneLineStreetAddress) { + this._cacheValue.oneLineStreetAddress = {}; + } + if (!this._cacheValue.oneLineStreetAddress[address]) { + this._cacheValue.oneLineStreetAddress[address] = + FormAutofillUtils.toOneLineAddress(address); + } + return this._cacheValue.oneLineStreetAddress[address]; + } + + addressTransformer(profile) { + if (profile["street-address"]) { + // "-moz-street-address-one-line" is used by the labels in + // ProfileAutoCompleteResult. + profile["-moz-street-address-one-line"] = this._getOneLineStreetAddress( + profile["street-address"] + ); + let streetAddressDetail = this.getFieldDetailByName("street-address"); + if ( + streetAddressDetail && + HTMLInputElement.isInstance(streetAddressDetail.element) + ) { + profile["street-address"] = profile["-moz-street-address-one-line"]; + } + + let waitForConcat = []; + for (let f of ["address-line3", "address-line2", "address-line1"]) { + waitForConcat.unshift(profile[f]); + if (this.getFieldDetailByName(f)) { + if (waitForConcat.length > 1) { + profile[f] = FormAutofillUtils.toOneLineAddress(waitForConcat); + } + waitForConcat = []; + } + } + } + } + + /** + * Replace tel with tel-national if tel violates the input element's + * restriction. + * + * @param {object} profile + * A profile to be converted. + */ + telTransformer(profile) { + if (!profile.tel || !profile["tel-national"]) { + return; + } + + let detail = this.getFieldDetailByName("tel"); + if (!detail) { + return; + } + + let element = detail.element; + let _pattern; + let testPattern = str => { + if (!_pattern) { + // The pattern has to match the entire value. + _pattern = new RegExp("^(?:" + element.pattern + ")$", "u"); + } + return _pattern.test(str); + }; + if (element.pattern) { + if (testPattern(profile.tel)) { + return; + } + } else if (element.maxLength) { + if ( + detail.reason == "autocomplete" && + profile.tel.length <= element.maxLength + ) { + return; + } + } + + if (detail.reason != "autocomplete") { + // Since we only target people living in US and using en-US websites in + // MVP, it makes more sense to fill `tel-national` instead of `tel` + // if the field is identified by heuristics and no other clues to + // determine which one is better. + // TODO: [Bug 1407545] This should be improved once more countries are + // supported. + profile.tel = profile["tel-national"]; + } else if (element.pattern) { + if (testPattern(profile["tel-national"])) { + profile.tel = profile["tel-national"]; + } + } else if (element.maxLength) { + if (profile["tel-national"].length <= element.maxLength) { + profile.tel = profile["tel-national"]; + } + } + } + + /* + * Apply all address related transformers. + * + * @param {Object} profile + * A profile for adjusting address related value. + * @override + */ + applyTransformers(profile) { + this.addressTransformer(profile); + this.telTransformer(profile); + this.matchSelectOptions(profile); + this.adaptFieldMaxLength(profile); + } + + computeFillingValue(value, fieldDetail, element) { + // Try to abbreviate the value of select element. + if ( + fieldDetail.fieldName == "address-level1" && + HTMLSelectElement.isInstance(element) + ) { + // Don't save the record when the option value is empty *OR* there + // are multiple options being selected. The empty option is usually + // assumed to be default along with a meaningless text to users. + if (!value || element.selectedOptions.length != 1) { + // Keep the property and preserve more information for address updating + value = ""; + } else { + let text = element.selectedOptions[0].text.trim(); + value = + FormAutofillUtils.getAbbreviatedSubregionName([value, text]) || text; + } + } + return value; + } +} + +export class FormAutofillCreditCardSection extends FormAutofillSection { + /** + * Credit Card Section Constructor + * + * @param {Array<FieldDetails>} fieldDetails + * The fieldDetail objects for the fields in this section + * @param {Object<FormAutofillHandler>} handler + * The handler responsible for this section + */ + constructor(fieldDetails, handler) { + super(fieldDetails, handler); + + if (!this.isValidSection()) { + return; + } + + lazy.AutofillTelemetry.recordDetectedSectionCount(this); + lazy.AutofillTelemetry.recordFormInteractionEvent("detected", this); + + // Check whether the section is in an <iframe>; and, if so, + // watch for the <iframe> to pagehide. + if (handler.window.location != handler.window.parent?.location) { + this.log.debug( + "Credit card form is in an iframe -- watching for pagehide", + fieldDetails + ); + handler.window.addEventListener( + "pagehide", + this._handlePageHide.bind(this) + ); + } + } + + _handlePageHide(event) { + this.handler.window.removeEventListener( + "pagehide", + this._handlePageHide.bind(this) + ); + this.log.debug("Credit card subframe is pagehideing", this.handler.form); + + const formSubmissionReason = + FormAutofillUtils.FORM_SUBMISSION_REASON.IFRAME_PAGEHIDE; + this.handler.onFormSubmitted(formSubmissionReason); + } + + /** + * Determine whether a set of cc fields identified by our heuristics form a + * valid credit card section. + * There are 4 different cases when a field is considered a credit card field + * 1. Identified by autocomplete attribute. ex <input autocomplete="cc-number"> + * 2. Identified by fathom and fathom is pretty confident (when confidence + * value is higher than `highConfidenceThreshold`) + * 3. Identified by fathom. Confidence value is between `fathom.confidenceThreshold` + * and `fathom.highConfidenceThreshold` + * 4. Identified by regex-based heurstic. There is no confidence value in thise case. + * + * A form is considered a valid credit card form when one of the following condition + * is met: + * A. One of the cc field is identified by autocomplete (case 1) + * B. One of the cc field is identified by fathom (case 2 or 3), and there is also + * another cc field found by any of our heuristic (case 2, 3, or 4) + * C. Only one cc field is found in the section, but fathom is very confident (Case 2). + * Currently we add an extra restriction to this rule to decrease the false-positive + * rate. See comments below for details. + * + * @returns {boolean} True for a valid section, otherwise false + */ + isValidSection() { + let ccNumberDetail = null; + let ccNameDetail = null; + let ccExpiryDetail = null; + + for (let detail of this.fieldDetails) { + switch (detail.fieldName) { + case "cc-number": + ccNumberDetail = detail; + break; + case "cc-name": + case "cc-given-name": + case "cc-additional-name": + case "cc-family-name": + ccNameDetail = detail; + break; + case "cc-exp": + case "cc-exp-month": + case "cc-exp-year": + ccExpiryDetail = detail; + break; + } + } + + // Condition A. Always trust autocomplete attribute. A section is considered a valid + // cc section as long as a field has autocomplete=cc-number, cc-name or cc-exp* + if ( + ccNumberDetail?.reason == "autocomplete" || + ccNameDetail?.reason == "autocomplete" || + ccExpiryDetail?.reason == "autocomplete" + ) { + return true; + } + + // Condition B. One of the field is identified by fathom, if this section also + // contains another cc field found by our heuristic (Case 2, 3, or 4), we consider + // this section a valid credit card seciton + if (ccNumberDetail?.reason == "fathom") { + if (ccNameDetail || ccExpiryDetail) { + return true; + } + } else if (ccNameDetail?.reason == "fathom") { + if (ccNumberDetail || ccExpiryDetail) { + return true; + } + } + + // Condition C. + let highConfidenceThreshold = + FormAutofillUtils.ccFathomHighConfidenceThreshold; + let highConfidenceField; + if (ccNumberDetail?.confidence > highConfidenceThreshold) { + highConfidenceField = ccNumberDetail; + } else if (ccNameDetail?.confidence > highConfidenceThreshold) { + highConfidenceField = ccNameDetail; + } + if (highConfidenceField) { + // Temporarily add an addtional "the field is the only visible input" constraint + // when determining whether a form has only a high-confidence cc-* field a valid + // credit card section. We can remove this restriction once we are confident + // about only using fathom. + const element = highConfidenceField.element; + const root = element.form || element.ownerDocument; + const inputs = root.querySelectorAll("input:not([type=hidden])"); + if (inputs.length == 1 && inputs[0] == element) { + return true; + } + } + + return false; + } + + isEnabled() { + return FormAutofill.isAutofillCreditCardsEnabled; + } + + isRecordCreatable(record) { + return ( + record["cc-number"] && FormAutofillUtils.isCCNumber(record["cc-number"]) + ); + } + + /** + * Handles credit card expiry date transformation when + * the expiry date exists in a cc-exp field. + * + * @param {object} profile + * @memberof FormAutofillCreditCardSection + */ + creditCardExpiryDateTransformer(profile) { + if (!profile["cc-exp"]) { + return; + } + + const element = this.getFieldDetailByName("cc-exp")?.element; + if (!element) { + return; + } + + function updateExpiry(_string, _month, _year) { + // Bug 1687681: This is a short term fix to other locales having + // different characters to represent year. + // - FR locales may use "A" to represent year. + // - DE locales may use "J" to represent year. + // - PL locales may use "R" to represent year. + // This approach will not scale well and should be investigated in a follow up bug. + const monthChars = "m"; + const yearChars = "yy|aa|jj|rr"; + const expiryDateFormatRegex = (firstChars, secondChars) => + new RegExp( + "(?:\\b|^)((?:[" + + firstChars + + "]{2}){1,2})\\s*([\\-/])\\s*((?:[" + + secondChars + + "]{2}){1,2})(?:\\b|$)", + "i" + ); + + // If the month first check finds a result, where placeholder is "mm - yyyy", + // the result will be structured as such: ["mm - yyyy", "mm", "-", "yyyy"] + let result = expiryDateFormatRegex(monthChars, yearChars).exec(_string); + if (result) { + return ( + _month.padStart(result[1].length, "0") + + result[2] + + _year.substr(-1 * result[3].length) + ); + } + + // If the year first check finds a result, where placeholder is "yyyy mm", + // the result will be structured as such: ["yyyy mm", "yyyy", " ", "mm"] + result = expiryDateFormatRegex(yearChars, monthChars).exec(_string); + if (result) { + return ( + _year.substr(-1 * result[1].length) + + result[2] + + _month.padStart(result[3].length, "0") + ); + } + return null; + } + + let newExpiryString = null; + const month = profile["cc-exp-month"].toString(); + const year = profile["cc-exp-year"].toString(); + if (element.tagName == "INPUT") { + // Use the placeholder or label to determine the expiry string format. + const possibleExpiryStrings = []; + if (element.placeholder) { + possibleExpiryStrings.push(element.placeholder); + } + const labels = lazy.LabelUtils.findLabelElements(element); + if (labels) { + // Not consider multiple lable for now. + possibleExpiryStrings.push(element.labels[0]?.textContent); + } + if (element.previousElementSibling?.tagName == "LABEL") { + possibleExpiryStrings.push(element.previousElementSibling.textContent); + } + + possibleExpiryStrings.some(string => { + newExpiryString = updateExpiry(string, month, year); + return !!newExpiryString; + }); + } + + // Bug 1688576: Change YYYY-MM to MM/YYYY since MM/YYYY is the + // preferred presentation format for credit card expiry dates. + profile["cc-exp"] = newExpiryString ?? `${month.padStart(2, "0")}/${year}`; + } + + /** + * Handles credit card expiry date transformation when the expiry date exists in + * the separate cc-exp-month and cc-exp-year fields + * + * @param {object} profile + * @memberof FormAutofillCreditCardSection + */ + creditCardExpMonthAndYearTransformer(profile) { + const getInputElementByField = (field, self) => { + if (!field) { + return null; + } + let detail = self.getFieldDetailByName(field); + if (!detail) { + return null; + } + let element = detail.element; + return element.tagName === "INPUT" ? element : null; + }; + let month = getInputElementByField("cc-exp-month", this); + if (month) { + // Transform the expiry month to MM since this is a common format needed for filling. + profile["cc-exp-month-formatted"] = profile["cc-exp-month"] + ?.toString() + .padStart(2, "0"); + } + let year = getInputElementByField("cc-exp-year", this); + // If the expiration year element is an input, + // then we examine any placeholder to see if we should format the expiration year + // as a zero padded string in order to autofill correctly. + if (year) { + let placeholder = year.placeholder; + + // Checks for 'YY'|'AA'|'JJ'|'RR' placeholder and converts the year to a two digit string using the last two digits. + let result = /\b(yy|aa|jj|rr)\b/i.test(placeholder); + if (result) { + profile["cc-exp-year-formatted"] = profile["cc-exp-year"] + .toString() + .substring(2); + } + } + } + + /** + * Handles credit card name transformation when the name exists in + * the separate cc-given-name, cc-middle-name, and cc-family name fields + * + * @param {object} profile + * @memberof FormAutofillCreditCardSection + */ + creditCardNameTransformer(profile) { + const name = profile["cc-name"]; + if (!name) { + return; + } + + const given = this.getFieldDetailByName("cc-given-name"); + const middle = this.getFieldDetailByName("cc-middle-name"); + const family = this.getFieldDetailByName("cc-family-name"); + if (given || middle || family) { + const nameParts = lazy.FormAutofillNameUtils.splitName(name); + if (given && nameParts.given) { + profile["cc-given-name"] = nameParts.given; + } + if (middle && nameParts.middle) { + profile["cc-middle-name"] = nameParts.middle; + } + if (family && nameParts.family) { + profile["cc-family-name"] = nameParts.family; + } + } + } + + async _decrypt(cipherText, reauth) { + // Get the window for the form field. + let window; + for (let fieldDetail of this.fieldDetails) { + let element = fieldDetail.element; + if (element) { + window = element.ownerGlobal; + break; + } + } + if (!window) { + return null; + } + + let actor = window.windowGlobalChild.getActor("FormAutofill"); + return actor.sendQuery("FormAutofill:GetDecryptedString", { + cipherText, + reauth, + }); + } + + /* + * Apply all credit card related transformers. + * + * @param {Object} profile + * A profile for adjusting credit card related value. + * @override + */ + applyTransformers(profile) { + // The matchSelectOptions transformer must be placed after the expiry transformers. + // This ensures that the expiry value that is cached in the matchSelectOptions + // matches the expiry value that is stored in the profile ensuring that autofill works + // correctly when dealing with option elements. + this.creditCardExpiryDateTransformer(profile); + this.creditCardExpMonthAndYearTransformer(profile); + this.creditCardNameTransformer(profile); + this.matchSelectOptions(profile); + this.adaptFieldMaxLength(profile); + } + + getFilledValueFromProfile(fieldDetail, profile) { + const value = super.getFilledValueFromProfile(fieldDetail, profile); + if (fieldDetail.fieldName == "cc-number" && fieldDetail.part != null) { + const part = fieldDetail.part; + return value.slice((part - 1) * 4, part * 4); + } + return value; + } + + computeFillingValue(value, fieldDetail, element) { + if ( + fieldDetail.fieldName != "cc-type" || + !HTMLSelectElement.isInstance(element) + ) { + return value; + } + + if (lazy.CreditCard.isValidNetwork(value)) { + return value; + } + + // Don't save the record when the option value is empty *OR* there + // are multiple options being selected. The empty option is usually + // assumed to be default along with a meaningless text to users. + if (value && element.selectedOptions.length == 1) { + let selectedOption = element.selectedOptions[0]; + let networkType = + lazy.CreditCard.getNetworkFromName(selectedOption.text) ?? + lazy.CreditCard.getNetworkFromName(selectedOption.value); + if (networkType) { + return networkType; + } + } + // If we couldn't match the value to any network, we'll + // strip this field when submitting. + return value; + } + + /** + * Customize for previewing profile + * + * @param {object} profile + * A profile for pre-processing before previewing values. + * @override + */ + preparePreviewProfile(profile) { + // Always show the decrypted credit card number when Master Password is + // disabled. + if (profile["cc-number-decrypted"]) { + profile["cc-number"] = profile["cc-number-decrypted"]; + } else if (!profile["cc-number"].startsWith("****")) { + // Show the previewed credit card as "**** 4444" which is + // needed when a credit card number field has a maxlength of four. + profile["cc-number"] = "****" + profile["cc-number"]; + } + } + + /** + * Customize for filling profile + * + * @param {object} profile + * A profile for pre-processing before filling values. + * @returns {boolean} Whether the profile should be filled. + * @override + */ + async prepareFillingProfile(profile) { + // Prompt the OS login dialog to get the decrypted credit card number. + if (profile["cc-number-encrypted"]) { + const promptMessage = FormAutofillUtils.reauthOSPromptMessage( + "autofill-use-payment-method-os-prompt-macos", + "autofill-use-payment-method-os-prompt-windows", + "autofill-use-payment-method-os-prompt-other" + ); + let decrypted = await this._decrypt( + profile["cc-number-encrypted"], + promptMessage + ); + + if (!decrypted) { + // Early return if the decrypted is empty or undefined + return false; + } + + profile["cc-number"] = decrypted; + } + return true; + } +} diff --git a/toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs b/toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs new file mode 100644 index 0000000000..ce10c71ce1 --- /dev/null +++ b/toolkit/components/formautofill/shared/FormAutofillUtils.sys.mjs @@ -0,0 +1,1129 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", + FormAutofillNameUtils: + "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs", + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", + AddressMetaDataLoader: + "resource://gre/modules/shared/AddressMetaDataLoader.sys.mjs", +}); + +ChromeUtils.defineLazyGetter( + lazy, + "l10n", + () => + new Localization( + ["toolkit/formautofill/formAutofill.ftl", "branding/brand.ftl"], + true + ) +); + +export let FormAutofillUtils; + +const ADDRESSES_COLLECTION_NAME = "addresses"; +const CREDITCARDS_COLLECTION_NAME = "creditCards"; +const MANAGE_ADDRESSES_L10N_IDS = [ + "autofill-add-address-title", + "autofill-manage-addresses-title", +]; +const EDIT_ADDRESS_L10N_IDS = [ + "autofill-address-given-name", + "autofill-address-additional-name", + "autofill-address-family-name", + "autofill-address-organization", + "autofill-address-street", + "autofill-address-state", + "autofill-address-province", + "autofill-address-city", + "autofill-address-country", + "autofill-address-zip", + "autofill-address-postal-code", + "autofill-address-email", + "autofill-address-tel", +]; +const MANAGE_CREDITCARDS_L10N_IDS = [ + "autofill-add-card-title", + "autofill-manage-payment-methods-title", +]; +const EDIT_CREDITCARD_L10N_IDS = [ + "autofill-card-number", + "autofill-card-name-on-card", + "autofill-card-expires-month", + "autofill-card-expires-year", + "autofill-card-network", +]; +const FIELD_STATES = { + NORMAL: "NORMAL", + AUTO_FILLED: "AUTO_FILLED", + PREVIEW: "PREVIEW", +}; +const FORM_SUBMISSION_REASON = { + FORM_SUBMIT_EVENT: "form-submit-event", + FORM_REMOVAL_AFTER_FETCH: "form-removal-after-fetch", + IFRAME_PAGEHIDE: "iframe-pagehide", + PAGE_NAVIGATION: "page-navigation", +}; + +const ELIGIBLE_INPUT_TYPES = ["text", "email", "tel", "number", "month"]; + +// The maximum length of data to be saved in a single field for preventing DoS +// attacks that fill the user's hard drive(s). +const MAX_FIELD_VALUE_LENGTH = 200; + +FormAutofillUtils = { + get AUTOFILL_FIELDS_THRESHOLD() { + return 3; + }, + + ADDRESSES_COLLECTION_NAME, + CREDITCARDS_COLLECTION_NAME, + MANAGE_ADDRESSES_L10N_IDS, + EDIT_ADDRESS_L10N_IDS, + MANAGE_CREDITCARDS_L10N_IDS, + EDIT_CREDITCARD_L10N_IDS, + MAX_FIELD_VALUE_LENGTH, + FIELD_STATES, + FORM_SUBMISSION_REASON, + + _fieldNameInfo: { + name: "name", + "given-name": "name", + "additional-name": "name", + "family-name": "name", + organization: "organization", + "street-address": "address", + "address-line1": "address", + "address-line2": "address", + "address-line3": "address", + "address-level1": "address", + "address-level2": "address", + "postal-code": "address", + country: "address", + "country-name": "address", + tel: "tel", + "tel-country-code": "tel", + "tel-national": "tel", + "tel-area-code": "tel", + "tel-local": "tel", + "tel-local-prefix": "tel", + "tel-local-suffix": "tel", + "tel-extension": "tel", + email: "email", + "cc-name": "creditCard", + "cc-given-name": "creditCard", + "cc-additional-name": "creditCard", + "cc-family-name": "creditCard", + "cc-number": "creditCard", + "cc-exp-month": "creditCard", + "cc-exp-year": "creditCard", + "cc-exp": "creditCard", + "cc-type": "creditCard", + "cc-csc": "creditCard", + }, + + _collators: {}, + _reAlternativeCountryNames: {}, + + isAddressField(fieldName) { + return ( + !!this._fieldNameInfo[fieldName] && !this.isCreditCardField(fieldName) + ); + }, + + isCreditCardField(fieldName) { + return this._fieldNameInfo?.[fieldName] == "creditCard"; + }, + + isCCNumber(ccNumber) { + return ccNumber && lazy.CreditCard.isValidNumber(ccNumber); + }, + + ensureLoggedIn(promptMessage) { + return lazy.OSKeyStore.ensureLoggedIn( + this._reauthEnabledByUser && promptMessage ? promptMessage : false + ); + }, + + /** + * Get the array of credit card network ids ("types") we expect and offer as valid choices + * + * @returns {Array} + */ + getCreditCardNetworks() { + return lazy.CreditCard.getSupportedNetworks(); + }, + + getCategoryFromFieldName(fieldName) { + return this._fieldNameInfo[fieldName]; + }, + + getCategoriesFromFieldNames(fieldNames) { + let categories = new Set(); + for (let fieldName of fieldNames) { + let info = this.getCategoryFromFieldName(fieldName); + if (info) { + categories.add(info); + } + } + return Array.from(categories); + }, + + getAddressSeparator() { + // The separator should be based on the L10N address format, and using a + // white space is a temporary solution. + return " "; + }, + + /** + * Get address display label. It should display information separated + * by a comma. + * + * @param {object} address + * @returns {string} + */ + getAddressLabel(address) { + // TODO: Implement a smarter way for deciding what to display + // as option text. Possibly improve the algorithm in + // ProfileAutoCompleteResult.jsm and reuse it here. + let fieldOrder = [ + "name", + "-moz-street-address-one-line", // Street address + "address-level3", // Townland / Neighborhood / Village + "address-level2", // City/Town + "organization", // Company or organization name + "address-level1", // Province/State (Standardized code if possible) + "country", // Country name + "postal-code", // Postal code + "tel", // Phone number + "email", // Email address + ]; + + address = { ...address }; + let parts = []; + if (address["street-address"]) { + address["-moz-street-address-one-line"] = this.toOneLineAddress( + address["street-address"] + ); + } + + if (!("name" in address)) { + address.name = lazy.FormAutofillNameUtils.joinNameParts({ + given: address["given-name"], + middle: address["additional-name"], + family: address["family-name"], + }); + } + + for (const fieldName of fieldOrder) { + let string = address[fieldName]; + if (string) { + parts.push(string); + } + } + return parts.join(", "); + }, + + /** + * Internal method to split an address to multiple parts per the provided delimiter, + * removing blank parts. + * + * @param {string} address The address the split + * @param {string} [delimiter] The separator that is used between lines in the address + * @returns {string[]} + */ + _toStreetAddressParts(address, delimiter = "\n") { + let array = typeof address == "string" ? address.split(delimiter) : address; + + if (!Array.isArray(array)) { + return []; + } + return array.map(s => (s ? s.trim() : "")).filter(s => s); + }, + + /** + * Converts a street address to a single line, removing linebreaks marked by the delimiter + * + * @param {string} address The address the convert + * @param {string} [delimiter] The separator that is used between lines in the address + * @returns {string} + */ + toOneLineAddress(address, delimiter = "\n") { + let addressParts = this._toStreetAddressParts(address, delimiter); + return addressParts.join(this.getAddressSeparator()); + }, + + /** + * In-place concatenate tel-related components into a single "tel" field and + * delete unnecessary fields. + * + * @param {object} address An address record. + */ + compressTel(address) { + let telCountryCode = address["tel-country-code"] || ""; + let telAreaCode = address["tel-area-code"] || ""; + + if (!address.tel) { + if (address["tel-national"]) { + address.tel = telCountryCode + address["tel-national"]; + } else if (address["tel-local"]) { + address.tel = telCountryCode + telAreaCode + address["tel-local"]; + } else if (address["tel-local-prefix"] && address["tel-local-suffix"]) { + address.tel = + telCountryCode + + telAreaCode + + address["tel-local-prefix"] + + address["tel-local-suffix"]; + } + } + + for (let field in address) { + if (field != "tel" && this.getCategoryFromFieldName(field) == "tel") { + delete address[field]; + } + } + }, + + /** + * Determines if an element can be autofilled or not. + * + * @param {HTMLElement} element + * @returns {boolean} true if the element can be autofilled + */ + isFieldAutofillable(element) { + return element && !element.readOnly && !element.disabled; + }, + + /** + * Determines if an element is focusable + * and accessible via keyboard navigation or not. + * + * @param {HTMLElement} element + * + * @returns {bool} true if the element is focusable and accessible + */ + isFieldFocusable(element) { + return ( + // The Services.focus.elementIsFocusable API considers elements with + // tabIndex="-1" set as focusable. But since they are not accessible + // via keyboard navigation we treat them as non-interactive + Services.focus.elementIsFocusable(element, 0) && element.tabIndex != "-1" + ); + }, + + /** + * Determines if an element is eligible to be used by credit card or address autofill. + * + * @param {HTMLElement} element + * @returns {boolean} true if element can be used by credit card or address autofill + */ + isCreditCardOrAddressFieldType(element) { + if (!element) { + return false; + } + + if (HTMLInputElement.isInstance(element)) { + // `element.type` can be recognized as `text`, if it's missing or invalid. + return ELIGIBLE_INPUT_TYPES.includes(element.type); + } + + return HTMLSelectElement.isInstance(element); + }, + + loadDataFromScript(url, sandbox = {}) { + Services.scriptloader.loadSubScript(url, sandbox); + return sandbox; + }, + + /** + * Get country address data and fallback to US if not found. + * See AddressMetaDataLoader.#loadData for more details of addressData structure. + * + * @param {string} [country=FormAutofill.DEFAULT_REGION] + * The country code for requesting specific country's metadata. It'll be + * default region if parameter is not set. + * @param {string} [level1=null] + * Return address level 1/level 2 metadata if parameter is set. + * @returns {object|null} + * Return metadata of specific region with default locale and other supported + * locales. We need to return a default country metadata for layout format + * and collator, but for sub-region metadata we'll just return null if not found. + */ + getCountryAddressRawData( + country = FormAutofill.DEFAULT_REGION, + level1 = null + ) { + let metadata = lazy.AddressMetaDataLoader.getData(country, level1); + if (!metadata) { + if (level1) { + return null; + } + // Fallback to default region if we couldn't get data from given country. + if (country != FormAutofill.DEFAULT_REGION) { + metadata = lazy.AddressMetaDataLoader.getData( + FormAutofill.DEFAULT_REGION + ); + } + } + + // TODO: Now we fallback to US if we couldn't get data from default region, + // but it could be removed in bug 1423464 if it's not necessary. + if (!metadata) { + metadata = lazy.AddressMetaDataLoader.getData("US"); + } + return metadata; + }, + + /** + * Get country address data with default locale. + * + * @param {string} country + * @param {string} level1 + * @returns {object|null} Return metadata of specific region with default locale. + * NOTE: The returned data may be for a default region if the + * specified one cannot be found. Callers who only want the specific + * region should check the returned country code. + */ + getCountryAddressData(country, level1) { + let metadata = this.getCountryAddressRawData(country, level1); + return metadata && metadata.defaultLocale; + }, + + /** + * Get country address data with all locales. + * + * @param {string} country + * @param {string} level1 + * @returns {Array<object> | null} + * Return metadata of specific region with all the locales. + * NOTE: The returned data may be for a default region if the + * specified one cannot be found. Callers who only want the specific + * region should check the returned country code. + */ + getCountryAddressDataWithLocales(country, level1) { + let metadata = this.getCountryAddressRawData(country, level1); + return metadata && [metadata.defaultLocale, ...metadata.locales]; + }, + + /** + * Get the collators based on the specified country. + * + * @param {string} country The specified country. + * @param {object} [options = {}] a list of options for this method + * @param {boolean} [options.ignorePunctuation = true] Whether punctuation should be ignored. + * @param {string} [options.sensitivity = 'base'] Which differences in the strings should lead to non-zero result values + * @param {string} [options.usage = 'search'] Whether the comparison is for sorting or for searching for matching strings + * @returns {Array} An array containing several collator objects. + */ + getSearchCollators( + country, + { ignorePunctuation = true, sensitivity = "base", usage = "search" } = {} + ) { + // TODO: Only one language should be used at a time per country. The locale + // of the page should be taken into account to do this properly. + // We are going to support more countries in bug 1370193 and this + // should be addressed when we start to implement that bug. + + if (!this._collators[country]) { + let dataset = this.getCountryAddressData(country); + let languages = dataset.languages || [dataset.lang]; + let options = { + ignorePunctuation, + sensitivity, + usage, + }; + this._collators[country] = languages.map( + lang => new Intl.Collator(lang, options) + ); + } + return this._collators[country]; + }, + + // Based on the list of fields abbreviations in + // https://github.com/googlei18n/libaddressinput/wiki/AddressValidationMetadata + FIELDS_LOOKUP: { + N: "name", + O: "organization", + A: "street-address", + S: "address-level1", + C: "address-level2", + D: "address-level3", + Z: "postal-code", + n: "newLine", + }, + + /** + * Parse a country address format string and outputs an array of fields. + * Spaces, commas, and other literals are ignored in this implementation. + * For example, format string "%A%n%C, %S" should return: + * [ + * {fieldId: "street-address", newLine: true}, + * {fieldId: "address-level2"}, + * {fieldId: "address-level1"}, + * ] + * + * @param {string} fmt Country address format string + * @returns {Array<object>} List of fields + */ + parseAddressFormat(fmt) { + if (!fmt) { + throw new Error("fmt string is missing."); + } + + return fmt.match(/%[^%]/g).reduce((parsed, part) => { + // Take the first letter of each segment and try to identify it + let fieldId = this.FIELDS_LOOKUP[part[1]]; + // Early return if cannot identify part. + if (!fieldId) { + return parsed; + } + // If a new line is detected, add an attribute to the previous field. + if (fieldId == "newLine") { + let size = parsed.length; + if (size) { + parsed[size - 1].newLine = true; + } + return parsed; + } + return parsed.concat({ fieldId }); + }, []); + }, + + /** + * Used to populate dropdowns in the UI (e.g. FormAutofill preferences). + * Use findAddressSelectOption for matching a value to a region. + * + * @param {string[]} subKeys An array of regionCode strings + * @param {string[]} subIsoids An array of ISO ID strings, if provided will be preferred over the key + * @param {string[]} subNames An array of regionName strings + * @param {string[]} subLnames An array of latinised regionName strings + * @returns {Map?} Returns null if subKeys or subNames are not truthy. + * Otherwise, a Map will be returned mapping keys -> names. + */ + buildRegionMapIfAvailable(subKeys, subIsoids, subNames, subLnames) { + // Not all regions have sub_keys. e.g. DE + if ( + !subKeys || + !subKeys.length || + (!subNames && !subLnames) || + (subNames && subKeys.length != subNames.length) || + (subLnames && subKeys.length != subLnames.length) + ) { + return null; + } + + // Overwrite subKeys with subIsoids, when available + if (subIsoids && subIsoids.length && subIsoids.length == subKeys.length) { + for (let i = 0; i < subIsoids.length; i++) { + if (subIsoids[i]) { + subKeys[i] = subIsoids[i]; + } + } + } + + // Apply sub_lnames if sub_names does not exist + let names = subNames || subLnames; + return new Map(subKeys.map((key, index) => [key, names[index]])); + }, + + /** + * Parse a require string and outputs an array of fields. + * Spaces, commas, and other literals are ignored in this implementation. + * For example, a require string "ACS" should return: + * ["street-address", "address-level2", "address-level1"] + * + * @param {string} requireString Country address require string + * @returns {Array<string>} List of fields + */ + parseRequireString(requireString) { + if (!requireString) { + throw new Error("requireString string is missing."); + } + + return requireString.split("").map(fieldId => this.FIELDS_LOOKUP[fieldId]); + }, + + /** + * Use address data and alternative country name list to identify a country code from a + * specified country name. + * + * @param {string} countryName A country name to be identified + * @param {string} [countrySpecified] A country code indicating that we only + * search its alternative names if specified. + * @returns {string} The matching country code. + */ + identifyCountryCode(countryName, countrySpecified) { + if (!countryName) { + return null; + } + + if (lazy.AddressMetaDataLoader.getData(countryName)) { + return countryName; + } + + const countries = countrySpecified + ? [countrySpecified] + : [...FormAutofill.countries.keys()]; + + for (const country of countries) { + let collators = this.getSearchCollators(country); + let metadata = this.getCountryAddressData(country); + if (country != metadata.key) { + // We hit the fallback logic in getCountryAddressRawData so ignore it as + // it's not related to `country` and use the name from l10n instead. + metadata = { + id: `data/${country}`, + key: country, + name: FormAutofill.countries.get(country), + }; + } + let alternativeCountryNames = metadata.alternative_names || [ + metadata.name, + ]; + let reAlternativeCountryNames = this._reAlternativeCountryNames[country]; + if (!reAlternativeCountryNames) { + reAlternativeCountryNames = this._reAlternativeCountryNames[country] = + []; + } + + if (countryName.length == 3) { + if (this.strCompare(metadata.alpha_3_code, countryName, collators)) { + return country; + } + } + + for (let i = 0; i < alternativeCountryNames.length; i++) { + let name = alternativeCountryNames[i]; + let reName = reAlternativeCountryNames[i]; + if (!reName) { + reName = reAlternativeCountryNames[i] = new RegExp( + "\\b" + this.escapeRegExp(name) + "\\b", + "i" + ); + } + + if ( + this.strCompare(name, countryName, collators) || + reName.test(countryName) + ) { + return country; + } + } + } + + return null; + }, + + findSelectOption(selectEl, record, fieldName) { + if (this.isAddressField(fieldName)) { + return this.findAddressSelectOption(selectEl, record, fieldName); + } + if (this.isCreditCardField(fieldName)) { + return this.findCreditCardSelectOption(selectEl, record, fieldName); + } + return null; + }, + + /** + * Try to find the abbreviation of the given sub-region name + * + * @param {string[]} subregionValues A list of inferable sub-region values. + * @param {string} [country] A country name to be identified. + * @returns {string} The matching sub-region abbreviation. + */ + getAbbreviatedSubregionName(subregionValues, country) { + let values = Array.isArray(subregionValues) + ? subregionValues + : [subregionValues]; + + let collators = this.getSearchCollators(country); + for (let metadata of this.getCountryAddressDataWithLocales(country)) { + let { + sub_keys: subKeys, + sub_names: subNames, + sub_lnames: subLnames, + } = metadata; + if (!subKeys) { + // Not all regions have sub_keys. e.g. DE + continue; + } + // Apply sub_lnames if sub_names does not exist + subNames = subNames || subLnames; + + let speculatedSubIndexes = []; + for (const val of values) { + let identifiedValue = this.identifyValue( + subKeys, + subNames, + val, + collators + ); + if (identifiedValue) { + return identifiedValue; + } + + // Predict the possible state by partial-matching if no exact match. + [subKeys, subNames].forEach(sub => { + speculatedSubIndexes.push( + sub.findIndex(token => { + let pattern = new RegExp( + "\\b" + this.escapeRegExp(token) + "\\b" + ); + + return pattern.test(val); + }) + ); + }); + } + let subKey = subKeys[speculatedSubIndexes.find(i => !!~i)]; + if (subKey) { + return subKey; + } + } + return null; + }, + + /** + * Find the option element from select element. + * 1. Try to find the locale using the country from address. + * 2. First pass try to find exact match. + * 3. Second pass try to identify values from address value and options, + * and look for a match. + * + * @param {DOMElement} selectEl + * @param {object} address + * @param {string} fieldName + * @returns {DOMElement} + */ + findAddressSelectOption(selectEl, address, fieldName) { + if (selectEl.options.length > 512) { + // Allow enough space for all countries (roughly 300 distinct values) and all + // timezones (roughly 400 distinct values), plus some extra wiggle room. + return null; + } + let value = address[fieldName]; + if (!value) { + return null; + } + + let collators = this.getSearchCollators(address.country); + + for (let option of selectEl.options) { + if ( + this.strCompare(value, option.value, collators) || + this.strCompare(value, option.text, collators) + ) { + return option; + } + } + + switch (fieldName) { + case "address-level1": { + let { country } = address; + let identifiedValue = this.getAbbreviatedSubregionName( + [value], + country + ); + // No point going any further if we cannot identify value from address level 1 + if (!identifiedValue) { + return null; + } + for (let dataset of this.getCountryAddressDataWithLocales(country)) { + let keys = dataset.sub_keys; + if (!keys) { + // Not all regions have sub_keys. e.g. DE + continue; + } + // Apply sub_lnames if sub_names does not exist + let names = dataset.sub_names || dataset.sub_lnames; + + // Go through options one by one to find a match. + // Also check if any option contain the address-level1 key. + let pattern = new RegExp( + "\\b" + this.escapeRegExp(identifiedValue) + "\\b", + "i" + ); + for (let option of selectEl.options) { + let optionValue = this.identifyValue( + keys, + names, + option.value, + collators + ); + let optionText = this.identifyValue( + keys, + names, + option.text, + collators + ); + if ( + identifiedValue === optionValue || + identifiedValue === optionText || + pattern.test(option.value) + ) { + return option; + } + } + } + break; + } + case "country": { + if (this.getCountryAddressData(value)) { + for (let option of selectEl.options) { + if ( + this.identifyCountryCode(option.text, value) || + this.identifyCountryCode(option.value, value) + ) { + return option; + } + } + } + break; + } + } + + return null; + }, + + findCreditCardSelectOption(selectEl, creditCard, fieldName) { + let oneDigitMonth = creditCard["cc-exp-month"] + ? creditCard["cc-exp-month"].toString() + : null; + let twoDigitsMonth = oneDigitMonth ? oneDigitMonth.padStart(2, "0") : null; + let fourDigitsYear = creditCard["cc-exp-year"] + ? creditCard["cc-exp-year"].toString() + : null; + let twoDigitsYear = fourDigitsYear ? fourDigitsYear.substr(2, 2) : null; + let options = Array.from(selectEl.options); + + switch (fieldName) { + case "cc-exp-month": { + if (!oneDigitMonth) { + return null; + } + for (let option of options) { + if ( + [option.text, option.label, option.value].some(s => { + let result = /[1-9]\d*/.exec(s); + return result && result[0] == oneDigitMonth; + }) + ) { + return option; + } + } + break; + } + case "cc-exp-year": { + if (!fourDigitsYear) { + return null; + } + for (let option of options) { + if ( + [option.text, option.label, option.value].some( + s => s == twoDigitsYear || s == fourDigitsYear + ) + ) { + return option; + } + } + break; + } + case "cc-exp": { + if (!oneDigitMonth || !fourDigitsYear) { + return null; + } + let patterns = [ + oneDigitMonth + "/" + twoDigitsYear, // 8/22 + oneDigitMonth + "/" + fourDigitsYear, // 8/2022 + twoDigitsMonth + "/" + twoDigitsYear, // 08/22 + twoDigitsMonth + "/" + fourDigitsYear, // 08/2022 + oneDigitMonth + "-" + twoDigitsYear, // 8-22 + oneDigitMonth + "-" + fourDigitsYear, // 8-2022 + twoDigitsMonth + "-" + twoDigitsYear, // 08-22 + twoDigitsMonth + "-" + fourDigitsYear, // 08-2022 + twoDigitsYear + "-" + twoDigitsMonth, // 22-08 + fourDigitsYear + "-" + twoDigitsMonth, // 2022-08 + fourDigitsYear + "/" + oneDigitMonth, // 2022/8 + twoDigitsMonth + twoDigitsYear, // 0822 + twoDigitsYear + twoDigitsMonth, // 2208 + ]; + + for (let option of options) { + if ( + [option.text, option.label, option.value].some(str => + patterns.some(pattern => str.includes(pattern)) + ) + ) { + return option; + } + } + break; + } + case "cc-type": { + let network = creditCard["cc-type"] || ""; + for (let option of options) { + if ( + [option.text, option.label, option.value].some( + s => lazy.CreditCard.getNetworkFromName(s) == network + ) + ) { + return option; + } + } + break; + } + } + + return null; + }, + + /** + * Try to match value with keys and names, but always return the key. + * + * @param {Array<string>} keys + * @param {Array<string>} names + * @param {string} value + * @param {Array} collators + * @returns {string} + */ + identifyValue(keys, names, value, collators) { + let resultKey = keys.find(key => this.strCompare(value, key, collators)); + if (resultKey) { + return resultKey; + } + + let index = names.findIndex(name => + this.strCompare(value, name, collators) + ); + if (index !== -1) { + return keys[index]; + } + + return null; + }, + + /** + * Compare if two strings are the same. + * + * @param {string} a + * @param {string} b + * @param {Array} collators + * @returns {boolean} + */ + strCompare(a = "", b = "", collators) { + return collators.some(collator => !collator.compare(a, b)); + }, + + /** + * Determine whether one string(b) may be found within another string(a) + * + * @param {string} a + * @param {string} b + * @param {Array} collators + * @returns {boolean} True if the string is found + */ + strInclude(a = "", b = "", collators) { + const len = a.length - b.length; + for (let i = 0; i <= len; i++) { + if (this.strCompare(a.substring(i, i + b.length), b, collators)) { + return true; + } + } + return false; + }, + + /** + * Escaping user input to be treated as a literal string within a regular + * expression. + * + * @param {string} string + * @returns {string} + */ + escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + }, + + /** + * Get formatting information of a given country + * + * @param {string} country + * @returns {object} + * { + * {string} addressLevel3L10nId + * {string} addressLevel2L10nId + * {string} addressLevel1L10nId + * {string} postalCodeL10nId + * {object} fieldsOrder + * {string} postalCodePattern + * } + */ + getFormFormat(country) { + let dataset = this.getCountryAddressData(country); + // We hit a country fallback in `getCountryAddressRawData` but it's not relevant here. + if (country != dataset.key) { + // Use a sparse object so the below default values take effect. + dataset = { + /** + * Even though data/ZZ only has address-level2, include the other levels + * in case they are needed for unknown countries. Users can leave the + * unnecessary fields blank which is better than forcing users to enter + * the data in incorrect fields. + */ + fmt: "%N%n%O%n%A%n%C %S %Z", + }; + } + return { + // When particular values are missing for a country, the + // data/ZZ value should be used instead: + // https://chromium-i18n.appspot.com/ssl-aggregate-address/data/ZZ + addressLevel3L10nId: this.getAddressFieldL10nId( + dataset.sublocality_name_type || "suburb" + ), + addressLevel2L10nId: this.getAddressFieldL10nId( + dataset.locality_name_type || "city" + ), + addressLevel1L10nId: this.getAddressFieldL10nId( + dataset.state_name_type || "province" + ), + addressLevel1Options: this.buildRegionMapIfAvailable( + dataset.sub_keys, + dataset.sub_isoids, + dataset.sub_names, + dataset.sub_lnames + ), + countryRequiredFields: this.parseRequireString(dataset.require || "AC"), + fieldsOrder: this.parseAddressFormat(dataset.fmt || "%N%n%O%n%A%n%C"), + postalCodeL10nId: this.getAddressFieldL10nId( + dataset.zip_name_type || "postal-code" + ), + postalCodePattern: dataset.zip, + }; + }, + + getAddressFieldL10nId(type) { + return "autofill-address-" + type.replace(/_/g, "-"); + }, + + CC_FATHOM_NONE: 0, + CC_FATHOM_JS: 1, + CC_FATHOM_NATIVE: 2, + isFathomCreditCardsEnabled() { + return this.ccHeuristicsMode != this.CC_FATHOM_NONE; + }, + + /** + * Transform the key in FormAutofillConfidences (defined in ChromeUtils.webidl) + * to fathom recognized field type. + * + * @param {string} key key from FormAutofillConfidences dictionary + * @returns {string} fathom field type + */ + formAutofillConfidencesKeyToCCFieldType(key) { + const MAP = { + ccNumber: "cc-number", + ccName: "cc-name", + ccType: "cc-type", + ccExp: "cc-exp", + ccExpMonth: "cc-exp-month", + ccExpYear: "cc-exp-year", + }; + return MAP[key]; + }, + /** + * Generates the localized os dialog message that + * prompts the user to reauthenticate + * + * @param {string} msgMac fluent message id for macos clients + * @param {string} msgWin fluent message id for windows clients + * @param {string} msgOther fluent message id for other clients + * @param {string} msgLin (optional) fluent message id for linux clients + * @returns {string} localized os prompt message + */ + reauthOSPromptMessage(msgMac, msgWin, msgOther, msgLin = null) { + const platform = AppConstants.platform; + let messageID; + + switch (platform) { + case "win": + messageID = msgWin; + break; + case "macosx": + messageID = msgMac; + break; + case "linux": + messageID = msgLin ?? msgOther; + break; + default: + messageID = msgOther; + } + return lazy.l10n.formatValueSync(messageID); + }, +}; + +ChromeUtils.defineLazyGetter(FormAutofillUtils, "stringBundle", function () { + return Services.strings.createBundle( + "chrome://formautofill/locale/formautofill.properties" + ); +}); + +ChromeUtils.defineLazyGetter(FormAutofillUtils, "brandBundle", function () { + return Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "_reauthEnabledByUser", + "extensions.formautofill.reauth.enabled", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "ccHeuristicsMode", + "extensions.formautofill.creditCards.heuristics.mode", + 0 +); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "ccFathomConfidenceThreshold", + "extensions.formautofill.creditCards.heuristics.fathom.confidenceThreshold", + null, + null, + pref => parseFloat(pref) +); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "ccFathomHighConfidenceThreshold", + "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold", + null, + null, + pref => parseFloat(pref) +); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "ccFathomTestConfidence", + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence", + null, + null, + pref => parseFloat(pref) +); + +// This is only used in iOS +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "focusOnAutofill", + "extensions.formautofill.focusOnAutofill", + true +); diff --git a/toolkit/components/formautofill/shared/FormStateManager.sys.mjs b/toolkit/components/formautofill/shared/FormStateManager.sys.mjs new file mode 100644 index 0000000000..064b4e5356 --- /dev/null +++ b/toolkit/components/formautofill/shared/FormStateManager.sys.mjs @@ -0,0 +1,157 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs", + FormAutofillHandler: + "resource://gre/modules/shared/FormAutofillHandler.sys.mjs", +}); + +export class FormStateManager { + constructor(onSubmit, onAutofillCallback) { + /** + * @type {WeakMap} mapping FormLike root HTML elements to FormAutofillHandler objects. + */ + this._formsDetails = new WeakMap(); + /** + * @type {object} The object where to store the active items, e.g. element, + * handler, section, and field detail. + */ + this._activeItems = {}; + + this.onSubmit = onSubmit; + + this.onAutofillCallback = onAutofillCallback; + } + + /** + * Get the active input's information from cache which is created after page + * identified. + * + * @returns {object | null} + * Return the active input's information that cloned from content cache + * (or return null if the information is not found in the cache). + */ + get activeFieldDetail() { + if (!this._activeItems.fieldDetail) { + let formDetails = this.activeFormDetails; + if (!formDetails) { + return null; + } + for (let detail of formDetails) { + let detailElement = detail.element; + if (detailElement && this.activeInput == detailElement) { + this._activeItems.fieldDetail = detail; + break; + } + } + } + return this._activeItems.fieldDetail; + } + + /** + * Get the active form's information from cache which is created after page + * identified. + * + * @returns {Array<object> | null} + * Return target form's information from content cache + * (or return null if the information is not found in the cache). + * + */ + get activeFormDetails() { + let formHandler = this.activeHandler; + return formHandler ? formHandler.fieldDetails : null; + } + + get activeInput() { + return this._activeItems.elementWeakRef?.deref(); + } + + get activeHandler() { + const activeInput = this.activeInput; + if (!activeInput) { + return null; + } + + // XXX: We are recomputing the activeHandler every time to avoid keeping a + // reference on the active element. This might be called quite frequently + // so if _getFormHandler/findRootForField become more costly, we should + // look into caching this result (eg by adding a weakmap). + let handler = this._getFormHandler(activeInput); + if (handler) { + handler.focusedInput = activeInput; + } + return handler; + } + + get activeSection() { + let formHandler = this.activeHandler; + return formHandler ? formHandler.activeSection : null; + } + + /** + * Get the form's handler from cache which is created after page identified. + * + * @param {HTMLInputElement} element Focused input which triggered profile searching + * @returns {Array<object> | null} + * Return target form's handler from content cache + * (or return null if the information is not found in the cache). + * + */ + _getFormHandler(element) { + if (!element) { + return null; + } + let rootElement = lazy.FormLikeFactory.findRootForField(element); + return this._formsDetails.get(rootElement); + } + + identifyAutofillFields(element) { + let formHandler = this._getFormHandler(element); + if (!formHandler) { + let formLike = lazy.FormLikeFactory.createFromField(element); + formHandler = new lazy.FormAutofillHandler( + formLike, + this.onSubmit, + this.onAutofillCallback + ); + } else if (!formHandler.updateFormIfNeeded(element)) { + return formHandler.fieldDetails; + } + this._formsDetails.set(formHandler.form.rootElement, formHandler); + return formHandler.collectFormFields(); + } + + updateActiveInput(element) { + if (!element) { + this._activeItems = {}; + return; + } + this._activeItems = { + elementWeakRef: new WeakRef(element), + fieldDetail: null, + }; + } + + getRecords(formElement, handler) { + handler = handler || this._formsDetails.get(formElement); + const records = handler?.createRecords(); + + if ( + !handler || + !records || + !Object.values(records).some(typeRecords => typeRecords.length) + ) { + return null; + } + return records; + } + + didDestroy() { + this._activeItems = null; + } +} + +export default FormStateManager; diff --git a/toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs b/toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs new file mode 100644 index 0000000000..c4141628f8 --- /dev/null +++ b/toolkit/components/formautofill/shared/HeuristicsRegExp.sys.mjs @@ -0,0 +1,687 @@ +/* eslint-disable no-useless-concat */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// prettier-ignore +export const HeuristicsRegExp = { + RULES: { + email: undefined, + tel: undefined, + organization: undefined, + "street-address": undefined, + "address-line1": undefined, + "address-line2": undefined, + "address-line3": undefined, + "address-level2": undefined, + "address-level1": undefined, + "postal-code": undefined, + country: undefined, + // Note: We place the `cc-name` field for Credit Card first, because + // it is more specific than the `name` field below and we want to check + // for it before we catch the more generic one. + "cc-name": undefined, + name: undefined, + "given-name": undefined, + "additional-name": undefined, + "family-name": undefined, + "cc-csc": undefined, + "cc-number": undefined, + "cc-exp-month": undefined, + "cc-exp-year": undefined, + "cc-exp": undefined, + "cc-type": undefined, + }, + + // regular expressions that only apply to label + LABEL_RULES: { + "address-line1": undefined, + "address-line2": undefined, + }, + + RULE_SETS: [ + //========================================================================= + // Firefox-specific rules + { + "address-line1": "addrline1|address_1|addl1", + "address-line2": "addrline2|address_2|addl2", + "address-line3": "addrline3|address_3|addl3", + "address-level1": "land", // de-DE + "additional-name": "apellido.?materno|lastlastname", + "cc-name": + "accountholdername" + + "|titulaire", // fr-FR + "cc-number": + "(cc|kk)nr", // de-DE + "cc-exp": + "ważna.*do" + // pl-PL + "|data.*ważności" + // pl-PL + "|mm\\s*[\\-\\/]\\s*yy" + // en-US + "|mm\\s*[\\-\\/]\\s*aa" + // es-ES + "|mm\\s*[\\-\\/]\\s*jj" + // de-AT + "|vervaldatum", // nl-NL + "cc-exp-month": + "month" + + "|(cc|kk)month" + // de-DE + "|miesiąc" + // pl-PL + "|mes" + // es-ES + "|maand", // nl-NL + "cc-exp-year": + "year" + + "|(cc|kk)year" + // de-DE + "|rok" + // pl-PL + "|(anno|año)" + // es-ES + "|jaar", // nl-NL + "cc-type": + "type" + + "|kartenmarke" + // de-DE + "|typ.*karty", // pl-PL + "cc-csc": + "(\\bcvn\\b|\\bcvv\\b|\\bcvc\\b|\\bcsc\\b|\\bcvd\\b|\\bcid\\b|\\bccv\\b)", + }, + + //========================================================================= + // These are the rules used by Bitwarden [0], converted into RegExp form. + // [0] https://github.com/bitwarden/browser/blob/c2b8802201fac5e292d55d5caf3f1f78088d823c/src/services/autofill.service.ts#L436 + { + email: "(^e-?mail$)|(^email-?address$)", + + tel: + "(^phone$)" + + "|(^mobile$)" + + "|(^mobile-?phone$)" + + "|(^tel$)" + + "|(^telephone$)" + + "|(^phone-?number$)", + + organization: + "(^company$)" + + "|(^company-?name$)" + + "|(^organization$)" + + "|(^organization-?name$)", + + "street-address": + "(^address$)" + + "|(^street-?address$)" + + "|(^addr$)" + + "|(^street$)" + + "|(^mailing-?addr(ess)?$)" + // Modified to not grab lines, below + "|(^billing-?addr(ess)?$)" + // Modified to not grab lines, below + "|(^mail-?addr(ess)?$)" + // Modified to not grab lines, below + "|(^bill-?addr(ess)?$)", // Modified to not grab lines, below + + "address-line1": + "(^address-?1$)" + + "|(^address-?line-?1$)" + + "|(^addr-?1$)" + + "|(^street-?1$)", + + "address-line2": + "(^address-?2$)" + + "|(^address-?line-?2$)" + + "|(^addr-?2$)" + + "|(^street-?2$)", + + "address-line3": + "(^address-?3$)" + + "|(^address-?line-?3$)" + + "|(^addr-?3$)" + + "|(^street-?3$)", + + "address-level2": + "(^city$)" + + "|(^town$)" + + "|(^address-?level-?2$)" + + "|(^address-?city$)" + + "|(^address-?town$)", + + "address-level1": + "(^state$)" + + "|(^province$)" + + "|(^provence$)" + + "|(^address-?level-?1$)" + + "|(^address-?state$)" + + "|(^address-?province$)", + + "postal-code": + "(^postal$)" + + "|(^zip$)" + + "|(^zip2$)" + + "|(^zip-?code$)" + + "|(^postal-?code$)" + + "|(^post-?code$)" + + "|(^address-?zip$)" + + "|(^address-?postal$)" + + "|(^address-?code$)" + + "|(^address-?postal-?code$)" + + "|(^address-?zip-?code$)", + + country: + "(^country$)" + + "|(^country-?code$)" + + "|(^country-?name$)" + + "|(^address-?country$)" + + "|(^address-?country-?name$)" + + "|(^address-?country-?code$)", + + name: "(^name$)|full-?name|your-?name", + + "given-name": + "(^f-?name$)" + + "|(^first-?name$)" + + "|(^given-?name$)" + + "|(^first-?n$)", + + "additional-name": + "(^m-?name$)" + + "|(^middle-?name$)" + + "|(^additional-?name$)" + + "|(^middle-?initial$)" + + "|(^middle-?n$)" + + "|(^middle-?i$)", + + "family-name": + "(^l-?name$)" + + "|(^last-?name$)" + + "|(^s-?name$)" + + "|(^surname$)" + + "|(^family-?name$)" + + "|(^family-?n$)" + + "|(^last-?n$)", + + "cc-name": + "cc-?name" + + "|card-?name" + + "|cardholder-?name" + + "|cardholder" + + // "|(^name$)" + // Removed to avoid overwriting "name", above. + "|(^nom$)", + + "cc-number": + "cc-?number" + + "|cc-?num" + + "|card-?number" + + "|card-?num" + + "|(^number$)" + + "|(^cc$)" + + "|cc-?no" + + "|card-?no" + + "|(^credit-?card$)" + + "|numero-?carte" + + "|(^carte$)" + + "|(^carte-?credit$)" + + "|num-?carte" + + "|cb-?num", + + "cc-exp": + "(^cc-?exp$)" + + "|(^card-?exp$)" + + "|(^cc-?expiration$)" + + "|(^card-?expiration$)" + + "|(^cc-?ex$)" + + "|(^card-?ex$)" + + "|(^card-?expire$)" + + "|(^card-?expiry$)" + + "|(^validite$)" + + "|(^expiration$)" + + "|(^expiry$)" + + "|mm-?yy" + + "|mm-?yyyy" + + "|yy-?mm" + + "|yyyy-?mm" + + "|expiration-?date" + + "|payment-?card-?expiration" + + "|(^payment-?cc-?date$)", + + "cc-exp-month": + "(^exp-?month$)" + + "|(^cc-?exp-?month$)" + + "|(^cc-?month$)" + + "|(^card-?month$)" + + "|(^cc-?mo$)" + + "|(^card-?mo$)" + + "|(^exp-?mo$)" + + "|(^card-?exp-?mo$)" + + "|(^cc-?exp-?mo$)" + + "|(^card-?expiration-?month$)" + + "|(^expiration-?month$)" + + "|(^cc-?mm$)" + + "|(^cc-?m$)" + + "|(^card-?mm$)" + + "|(^card-?m$)" + + "|(^card-?exp-?mm$)" + + "|(^cc-?exp-?mm$)" + + "|(^exp-?mm$)" + + "|(^exp-?m$)" + + "|(^expire-?month$)" + + "|(^expire-?mo$)" + + "|(^expiry-?month$)" + + "|(^expiry-?mo$)" + + "|(^card-?expire-?month$)" + + "|(^card-?expire-?mo$)" + + "|(^card-?expiry-?month$)" + + "|(^card-?expiry-?mo$)" + + "|(^mois-?validite$)" + + "|(^mois-?expiration$)" + + "|(^m-?validite$)" + + "|(^m-?expiration$)" + + "|(^expiry-?date-?field-?month$)" + + "|(^expiration-?date-?month$)" + + "|(^expiration-?date-?mm$)" + + "|(^exp-?mon$)" + + "|(^validity-?mo$)" + + "|(^exp-?date-?mo$)" + + "|(^cb-?date-?mois$)" + + "|(^date-?m$)", + + "cc-exp-year": + "(^exp-?year$)" + + "|(^cc-?exp-?year$)" + + "|(^cc-?year$)" + + "|(^card-?year$)" + + "|(^cc-?yr$)" + + "|(^card-?yr$)" + + "|(^exp-?yr$)" + + "|(^card-?exp-?yr$)" + + "|(^cc-?exp-?yr$)" + + "|(^card-?expiration-?year$)" + + "|(^expiration-?year$)" + + "|(^cc-?yy$)" + + "|(^cc-?y$)" + + "|(^card-?yy$)" + + "|(^card-?y$)" + + "|(^card-?exp-?yy$)" + + "|(^cc-?exp-?yy$)" + + "|(^exp-?yy$)" + + "|(^exp-?y$)" + + "|(^cc-?yyyy$)" + + "|(^card-?yyyy$)" + + "|(^card-?exp-?yyyy$)" + + "|(^cc-?exp-?yyyy$)" + + "|(^expire-?year$)" + + "|(^expire-?yr$)" + + "|(^expiry-?year$)" + + "|(^expiry-?yr$)" + + "|(^card-?expire-?year$)" + + "|(^card-?expire-?yr$)" + + "|(^card-?expiry-?year$)" + + "|(^card-?expiry-?yr$)" + + "|(^an-?validite$)" + + "|(^an-?expiration$)" + + "|(^annee-?validite$)" + + "|(^annee-?expiration$)" + + "|(^expiry-?date-?field-?year$)" + + "|(^expiration-?date-?year$)" + + "|(^cb-?date-?ann$)" + + "|(^expiration-?date-?yy$)" + + "|(^expiration-?date-?yyyy$)" + + "|(^validity-?year$)" + + "|(^exp-?date-?year$)" + + "|(^date-?y$)", + + "cc-type": + "(^cc-?type$)" + + "|(^card-?type$)" + + "|(^card-?brand$)" + + "|(^cc-?brand$)" + + "|(^cb-?type$)", + }, + + //========================================================================= + // These rules are from Chromium source codes [1]. Most of them + // converted to JS format have the same meaning with the original ones except + // the first line of "address-level1". + // [1] https://source.chromium.org/chromium/chromium/src/+/master:components/autofill/core/common/autofill_regex_constants.cc + { + // ==== Email ==== + email: + "e.?mail" + + "|courriel" + // fr + "|correo.*electr(o|ó)nico" + // es-ES + "|メールアドレス" + // ja-JP + "|Электронной.?Почты" + // ru + "|邮件|邮箱" + // zh-CN + "|電郵地址" + // zh-TW + "|ഇ-മെയില്|ഇലക്ട്രോണിക്.?" + + "മെയിൽ" + // ml + "|ایمیل|پست.*الکترونیک" + // fa + "|ईमेल|इलॅक्ट्रॉनिक.?मेल" + // hi + "|(\\b|_)eposta(\\b|_)" + // tr + "|(?:이메일|전자.?우편|[Ee]-?mail)(.?주소)?", // ko-KR + + // ==== Telephone ==== + tel: + "phone|mobile|contact.?number" + + "|telefonnummer" + // de-DE + "|telefono|teléfono" + // es + "|telfixe" + // fr-FR + "|電話" + // ja-JP + "|telefone|telemovel" + // pt-BR, pt-PT + "|телефон" + // ru + "|मोबाइल" + // hi for mobile + "|(\\b|_|\\*)telefon(\\b|_|\\*)" + // tr + "|电话" + // zh-CN + "|മൊബൈല്" + // ml for mobile + "|(?:전화|핸드폰|휴대폰|휴대전화)(?:.?번호)?", // ko-KR + + // ==== Address Fields ==== + organization: + "company|business|organization|organisation" + + // In order to support webkit we convert all negative lookbehinds to a capture group + // (?<!not)word -> (?<neg>notword)|word + // TODO: Bug 1829583 + "|(?<neg>confirma)" + + "|firma|firmenname" + // de-DE + "|empresa" + // es + "|societe|société" + // fr-FR + "|ragione.?sociale" + // it-IT + "|会社" + // ja-JP + "|название.?компании" + // ru + "|单位|公司" + // zh-CN + "|شرکت" + // fa + "|회사|직장", // ko-KR + + "street-address": "streetaddress|street-address", + "address-line1": + "^address$|address[_-]?line(one)?|address1|addr1|street" + + "|(?:shipping|billing)address$" + + "|strasse|straße|hausnummer|housenumber" + // de-DE + "|house.?name" + // en-GB + "|direccion|dirección" + // es + "|adresse" + // fr-FR + "|indirizzo" + // it-IT + "|^住所$|住所1" + // ja-JP + "|morada" + // pt-BR, pt-PT + // In order to support webkit we convert all negative lookbehinds to a capture group + // (?<!not)word -> (?<neg>notword)|word + // TODO: Bug 1829583 + "|(?<neg>identificação do endereço)" + + "|(endereço)" + // pt-BR, pt-PT + "|Адрес" + // ru + "|地址" + // zh-CN + "|(\\b|_)adres(?! (başlığı(nız)?|tarifi))(\\b|_)" + // tr + "|^주소.?$|주소.?1", // ko-KR + + "address-line2": + "address[_-]?line(2|two)|address2|addr2|street|suite|unit(?!e)" + // Firefox adds `(?!e)` to unit to skip `United State` + "|adresszusatz|ergänzende.?angaben" + // de-DE + "|direccion2|colonia|adicional" + // es + "|addresssuppl|complementnom|appartement" + // fr-FR + "|indirizzo2" + // it-IT + "|住所2" + // ja-JP + "|complemento|addrcomplement" + // pt-BR, pt-PT + "|Улица" + // ru + "|地址2" + // zh-CN + "|주소.?2", // ko-KR + + "address-line3": + "address[_-]?line(3|three)|address3|addr3|street|suite|unit(?!e)" + // Firefox adds `(?!e)` to unit to skip `United State` + "|adresszusatz|ergänzende.?angaben" + // de-DE + "|direccion3|colonia|adicional" + // es + "|addresssuppl|complementnom|appartement" + // fr-FR + "|indirizzo3" + // it-IT + "|住所3" + // ja-JP + "|complemento|addrcomplement" + // pt-BR, pt-PT + "|Улица" + // ru + "|地址3" + // zh-CN + "|주소.?3", // ko-KR + + "address-level2": + "city|town" + + "|\\bort\\b|stadt" + // de-DE + "|suburb" + // en-AU + "|ciudad|provincia|localidad|poblacion" + // es + "|ville|commune" + // fr-FR + "|localita" + // it-IT + "|市区町村" + // ja-JP + "|cidade" + // pt-BR, pt-PT + "|Город" + // ru + "|市" + // zh-CN + "|分區" + // zh-TW + "|شهر" + // fa + "|शहर" + // hi for city + "|ग्राम|गाँव" + // hi for village + "|നഗരം|ഗ്രാമം" + // ml for town|village + "|((\\b|_|\\*)([İii̇]l[cç]e(miz|niz)?)(\\b|_|\\*))" + // tr + "|^시[^도·・]|시[·・]?군[·・]?구", // ko-KR + + "address-level1": + // In order to support webkit we convert all negative lookbehinds to a capture group + // (?<!not)word -> (?<neg>notword)|word + // TODO: Bug 1829583 + "(?<neg>united?.state|hist?.state|history?.state)" + + "|state|county|region|province" + + "|principality" + // en-UK + "|都道府県" + // ja-JP + "|estado|provincia" + // pt-BR, pt-PT + "|область" + // ru + "|省" + // zh-CN + "|地區" + // zh-TW + "|സംസ്ഥാനം" + // ml + "|استان" + // fa + "|राज्य" + // hi + "|((\\b|_|\\*)(eyalet|[şs]ehir|[İii̇]l(imiz)?|kent)(\\b|_|\\*))" + // tr + "|^시[·・]?도", // ko-KR + + "postal-code": + "zip|postal|post.*code|pcode" + + "|pin.?code" + // en-IN + "|postleitzahl" + // de-DE + "|\\bcp\\b" + // es + "|\\bcdp\\b" + // fr-FR + "|\\bcap\\b" + // it-IT + "|郵便番号" + // ja-JP + "|codigo|codpos|\\bcep\\b" + // pt-BR, pt-PT + "|Почтовый.?Индекс" + // ru + "|पिन.?कोड" + // hi + "|പിന്കോഡ്" + // ml + "|邮政编码|邮编" + // zh-CN + "|郵遞區號" + // zh-TW + "|(\\b|_)posta kodu(\\b|_)" + // tr + "|우편.?번호", // ko-KR + + country: + "country|countries" + + "|país|pais" + // es + "|(\\b|_)land(\\b|_)(?!.*(mark.*))" + // de-DE landmark is a type in india. + // In order to support webkit we convert all negative lookbehinds to a capture group + // (?<!not)word -> (?<neg>notword)|word + // TODO: Bug 1829583 + "|(?<neg>入国|出国)" + + "|国" + // ja-JP + "|国家" + // zh-CN + "|국가|나라" + // ko-KR + "|(\\b|_)(ülke|ulce|ulke)(\\b|_)" + // tr + "|کشور", // fa + + // ==== Name Fields ==== + "cc-name": + "card.?(?:holder|owner)|name.*(\\b)?on(\\b)?.*card" + + "|^(credit[-\\s]?card|card).*name|cc.?full.?name" + + "|karteninhaber" + // de-DE + "|nombre.*tarjeta" + // es + "|nom.*carte" + // fr-FR + "|nome.*cart" + // it-IT + "|名前" + // ja-JP + "|Имя.*карты" + // ru + "|信用卡开户名|开户名|持卡人姓名" + // zh-CN + "|持卡人姓名", // zh-TW + + name: + "^name|full.?name|your.?name|customer.?name|bill.?name|ship.?name" + + "|name.*first.*last|firstandlastname" + + "|nombre.*y.*apellidos" + // es + "|^nom(?!bre)" + // fr-FR + "|お名前|氏名" + // ja-JP + "|^nome" + // pt-BR, pt-PT + "|نام.*نام.*خانوادگی" + // fa + "|姓名" + // zh-CN + "|(\\b|_|\\*)ad[ı]? soyad[ı]?(\\b|_|\\*)" + // tr + "|성명", // ko-KR + + "given-name": + "first.*name|initials|fname|first$|given.*name" + + "|vorname" + // de-DE + "|nombre" + // es + "|forename|prénom|prenom" + // fr-FR + "|名" + // ja-JP + "|nome" + // pt-BR, pt-PT + "|Имя" + // ru + "|نام" + // fa + "|이름" + // ko-KR + "|പേര്" + // ml + "|(\\b|_|\\*)(isim|ad|ad(i|ı|iniz|ınız)?)(\\b|_|\\*)" + // tr + "|नाम", // hi + + "additional-name": + "middle.*name|mname|middle$|middle.*initial|m\\.i\\.|mi$|\\bmi\\b", + + "family-name": + "last.*name|lname|surname|last$|secondname|family.*name" + + "|nachname" + // de-DE + "|apellidos?" + // es + "|famille|^nom(?!bre)" + // fr-FR + "|cognome" + // it-IT + "|姓" + // ja-JP + "|apelidos|surename|sobrenome" + // pt-BR, pt-PT + "|Фамилия" + // ru + "|نام.*خانوادگی" + // fa + "|उपनाम" + // hi + "|മറുപേര്" + // ml + "|(\\b|_|\\*)(soyisim|soyad(i|ı|iniz|ınız)?)(\\b|_|\\*)" + // tr + "|\\b성(?:[^명]|\\b)", // ko-KR + + // ==== Credit Card Fields ==== + // Note: `cc-name` expression has been moved up, above `name`, in + // order to handle specialization through ordering. + "cc-number": + "(add)?(?:card|cc|acct).?(?:number|#|no|num|field)" + + // In order to support webkit we convert all negative lookbehinds to a capture group + // (?<!not)word -> (?<neg>notword)|word + // TODO: Bug 1829583 + "|(?<neg>telefonnummer|hausnummer|personnummer|fødselsnummer)" + // de-DE, sv-SE, no + "|nummer" + + "|カード番号" + // ja-JP + "|Номер.*карты" + // ru + "|信用卡号|信用卡号码" + // zh-CN + "|信用卡卡號" + // zh-TW + "|카드" + // ko-KR + // es/pt/fr + "|(numero|número|numéro)(?!.*(document|fono|phone|réservation))", + + "cc-exp-month": + "expir|exp.*mo|exp.*date|ccmonth|cardmonth|addmonth" + + "|gueltig|gültig|monat" + // de-DE + "|fecha" + // es + "|date.*exp" + // fr-FR + "|scadenza" + // it-IT + "|有効期限" + // ja-JP + "|validade" + // pt-BR, pt-PT + "|Срок действия карты" + // ru + "|月", // zh-CN + + "cc-exp-year": + "exp|^/|(add)?year" + + "|ablaufdatum|gueltig|gültig|jahr" + // de-DE + "|fecha" + // es + "|scadenza" + // it-IT + "|有効期限" + // ja-JP + "|validade" + // pt-BR, pt-PT + "|Срок действия карты" + // ru + "|年|有效期", // zh-CN + + "cc-exp": + "expir|exp.*date|^expfield$" + + "|gueltig|gültig" + // de-DE + "|fecha" + // es + "|date.*exp" + // fr-FR + "|scadenza" + // it-IT + "|有効期限" + // ja-JP + "|validade" + // pt-BR, pt-PT + "|Срок действия карты", // ru + + "cc-csc": + "verification|card.?identification|security.?code|card.?code" + + "|security.?value" + + "|security.?number|card.?pin|c-v-v" + + // We omit this regexp in favor of being less generic. + // See "Firefox-specific" rules for cc-csc + // "|(cvn|cvv|cvc|csc|cvd|cid|ccv)(field)?" + + "|\\bcid\\b", + }, + ], + + LABEL_RULE_SETS: [ + { + "address-line1": + "(^\\W*address)" + + "|(address\\W*$)" + + "|(?:shipping|billing|mailing|pick.?up|drop.?off|delivery|sender|postal|" + + "recipient|home|work|office|school|business|mail)[\\s\\-]+address" + + "|address\\s+(of|for|to|from)" + + "|adresse" + // fr-FR + "|indirizzo" + // it-IT + "|住所" + // ja-JP + "|地址" + // zh-CN + "|(\\b|_)adres(?! tarifi)(\\b|_)" + // tr + "|주소" + // ko-KR + "|^alamat" + // id + // Should contain street and any other address component, in any order + "|street.*(house|building|apartment|floor)" + // en + "|(house|building|apartment|floor).*street" + + "|(sokak|cadde).*(apartman|bina|daire|mahalle)" + // tr + "|(apartman|bina|daire|mahalle).*(sokak|cadde)" + + "|улиц.*(дом|корпус|квартир|этаж)|(дом|корпус|квартир|этаж).*улиц", // ru + }, + { + "address-line2": + "address|line" + + "|adresse" + // fr-FR + "|indirizzo" + // it-IT + "|地址" + // zh-CN + "|주소", // ko-KR + }, + ], + + _getRules(rules, rulesets) { + function computeRule(name) { + let regexps = []; + rulesets.forEach(set => { + if (set[name]) { + // Add the rule. + // We make the regex lower case so that we can match it against the + // lower-cased field name and get a rough equivalent of a case-insensitive + // match. This avoids a performance cliff with the "iu" flag on regular + // expressions. + regexps.push(`(${set[name].toLowerCase()})`.normalize("NFKC")); + } + }); + + const value = new RegExp(regexps.join("|"), "gu"); + + Object.defineProperty(rules, name, { get: undefined }); + Object.defineProperty(rules, name, { value }); + return value; + } + + Object.keys(rules).forEach(field => + Object.defineProperty(rules, field, { + get() { + return computeRule(field); + }, + }) + ); + + return rules; + }, + + getLabelRules() { + return this._getRules(this.LABEL_RULES, this.LABEL_RULE_SETS); + }, + + getRules() { + return this._getRules(this.RULES, this.RULE_SETS); + }, +}; + +export default HeuristicsRegExp; diff --git a/toolkit/components/formautofill/shared/LabelUtils.sys.mjs b/toolkit/components/formautofill/shared/LabelUtils.sys.mjs new file mode 100644 index 0000000000..9bfedee105 --- /dev/null +++ b/toolkit/components/formautofill/shared/LabelUtils.sys.mjs @@ -0,0 +1,120 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This is a utility object to work with HTML labels in web pages, + * including finding label elements and label text extraction. + */ +export const LabelUtils = { + // The tag name list is from Chromium except for "STYLE": + // eslint-disable-next-line max-len + // https://cs.chromium.org/chromium/src/components/autofill/content/renderer/form_autofill_util.cc?l=216&rcl=d33a171b7c308a64dc3372fac3da2179c63b419e + EXCLUDED_TAGS: ["SCRIPT", "NOSCRIPT", "OPTION", "STYLE"], + + // A map object, whose keys are the id's of form fields and each value is an + // array consisting of label elements correponding to the id. + // @type {Map<string, array>} + _mappedLabels: null, + + // An array consisting of label elements whose correponding form field doesn't + // have an id attribute. + // @type {Array<[HTMLLabelElement, HTMLElement]>} + _unmappedLabelControls: null, + + // A weak map consisting of label element and extracted strings pairs. + // @type {WeakMap<HTMLLabelElement, array>} + _labelStrings: null, + + /** + * Extract all strings of an element's children to an array. + * "element.textContent" is a string which is merged of all children nodes, + * and this function provides an array of the strings contains in an element. + * + * @param {object} element + * A DOM element to be extracted. + * @returns {Array} + * All strings in an element. + */ + extractLabelStrings(element) { + if (this._labelStrings.has(element)) { + return this._labelStrings.get(element); + } + let strings = []; + let _extractLabelStrings = el => { + if (this.EXCLUDED_TAGS.includes(el.tagName)) { + return; + } + + if (el.nodeType == el.TEXT_NODE || !el.childNodes.length) { + let trimmedText = el.textContent.trim(); + if (trimmedText) { + strings.push(trimmedText); + } + return; + } + + for (let node of el.childNodes) { + let nodeType = node.nodeType; + if (nodeType != node.ELEMENT_NODE && nodeType != node.TEXT_NODE) { + continue; + } + _extractLabelStrings(node); + } + }; + _extractLabelStrings(element); + this._labelStrings.set(element, strings); + return strings; + }, + + generateLabelMap(doc) { + this._mappedLabels = new Map(); + this._unmappedLabelControls = []; + this._labelStrings = new WeakMap(); + + for (let label of doc.querySelectorAll("label")) { + let id = label.htmlFor; + let control; + if (!id) { + control = label.control; + if (!control) { + continue; + } + id = control.id; + } + if (id) { + let labels = this._mappedLabels.get(id); + if (labels) { + labels.push(label); + } else { + this._mappedLabels.set(id, [label]); + } + } else { + // control must be non-empty here + this._unmappedLabelControls.push({ label, control }); + } + } + }, + + clearLabelMap() { + this._mappedLabels = null; + this._unmappedLabelControls = null; + this._labelStrings = null; + }, + + findLabelElements(element) { + if (!this._mappedLabels) { + this.generateLabelMap(element.ownerDocument); + } + + let id = element.id; + if (!id) { + return this._unmappedLabelControls + .filter(lc => lc.control == element) + .map(lc => lc.label); + } + return this._mappedLabels.get(id) || []; + }, +}; + +export default LabelUtils; |