diff options
Diffstat (limited to '')
-rw-r--r-- | toolkit/components/formautofill/FormAutofillUtils.jsm | 1335 |
1 files changed, 1335 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/FormAutofillUtils.jsm b/toolkit/components/formautofill/FormAutofillUtils.jsm new file mode 100644 index 0000000000..a06746944b --- /dev/null +++ b/toolkit/components/formautofill/FormAutofillUtils.jsm @@ -0,0 +1,1335 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const EXPORTED_SYMBOLS = [ + "FormAutofillUtils", + "AddressDataLoader", + "LabelUtils", +]; +let FormAutofillUtils; + +const ADDRESS_METADATA_PATH = "resource://autofill/addressmetadata/"; +const ADDRESS_REFERENCES = "addressReferences.js"; +const ADDRESS_REFERENCES_EXT = "addressReferencesExt.js"; + +const ADDRESSES_COLLECTION_NAME = "addresses"; +const CREDITCARDS_COLLECTION_NAME = "creditCards"; +const MANAGE_ADDRESSES_L10N_IDS = [ + "autofill-add-new-address-title", + "autofill-manage-addresses-title", +]; +const EDIT_ADDRESS_L10N_IDS = [ + "autofill-address-given-name", + "autofill-address-additional-name", + "autofill-address-family-name", + "autofill-address-organization", + "autofill-address-street", + "autofill-address-state", + "autofill-address-province", + "autofill-address-city", + "autofill-address-country", + "autofill-address-zip", + "autofill-address-postal-code", + "autofill-address-email", + "autofill-address-tel", +]; +const MANAGE_CREDITCARDS_L10N_IDS = [ + "autofill-add-new-card-title", + "autofill-manage-credit-cards-title", +]; +const EDIT_CREDITCARD_L10N_IDS = [ + "autofill-card-number", + "autofill-card-name-on-card", + "autofill-card-expires-month", + "autofill-card-expires-year", + "autofill-card-network", +]; +const FIELD_STATES = { + NORMAL: "NORMAL", + AUTO_FILLED: "AUTO_FILLED", + PREVIEW: "PREVIEW", +}; +const SECTION_TYPES = { + ADDRESS: "address", + CREDIT_CARD: "creditCard", +}; + +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; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { FormAutofill } = ChromeUtils.import( + "resource://autofill/FormAutofill.jsm" +); +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CreditCard: "resource://gre/modules/CreditCard.sys.mjs", + OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", +}); + +let AddressDataLoader = { + // Status of address data loading. We'll load all the countries with basic level 1 + // information while requesting conutry information, and set country to true. + // Level 1 Set is for recording which country's level 1/level 2 data is loaded, + // since we only load this when getCountryAddressData called with level 1 parameter. + _dataLoaded: { + country: false, + level1: new Set(), + }, + + /** + * Load address data and extension script into a sandbox from different paths. + * + * @param {string} path + * The path for address data and extension script. It could be root of the address + * metadata folder(addressmetadata/) or under specific country(addressmetadata/TW/). + * @returns {object} + * A sandbox that contains address data object with properties from extension. + */ + _loadScripts(path) { + let sandbox = {}; + let extSandbox = {}; + + try { + sandbox = FormAutofillUtils.loadDataFromScript(path + ADDRESS_REFERENCES); + extSandbox = FormAutofillUtils.loadDataFromScript( + path + ADDRESS_REFERENCES_EXT + ); + } catch (e) { + // Will return only address references if extension loading failed or empty sandbox if + // address references loading failed. + return sandbox; + } + + if (extSandbox.addressDataExt) { + for (let key in extSandbox.addressDataExt) { + let addressDataForKey = sandbox.addressData[key]; + if (!addressDataForKey) { + addressDataForKey = sandbox.addressData[key] = {}; + } + + Object.assign(addressDataForKey, extSandbox.addressDataExt[key]); + } + } + return sandbox; + }, + + /** + * Convert certain properties' string value into array. We should make sure + * the cached data is parsed. + * + * @param {object} data Original metadata from addressReferences. + * @returns {object} parsed metadata with property value that converts to array. + */ + _parse(data) { + if (!data) { + return null; + } + + const properties = [ + "languages", + "sub_keys", + "sub_isoids", + "sub_names", + "sub_lnames", + ]; + for (let key of properties) { + if (!data[key]) { + continue; + } + // No need to normalize data if the value is array already. + if (Array.isArray(data[key])) { + return data; + } + + data[key] = data[key].split("~"); + } + return data; + }, + + /** + * We'll cache addressData in the loader once the data loaded from scripts. + * It'll become the example below after loading addressReferences with extension: + * addressData: { + * "data/US": {"lang": ["en"], ...// Data defined in libaddressinput metadata + * "alternative_names": ... // Data defined in extension } + * "data/CA": {} // Other supported country metadata + * "data/TW": {} // Other supported country metadata + * "data/TW/台北市": {} // Other supported country level 1 metadata + * } + * + * @param {string} country + * @param {string?} level1 + * @returns {object} Default locale metadata + */ + _loadData(country, level1 = null) { + // Load the addressData if needed + if (!this._dataLoaded.country) { + this._addressData = this._loadScripts(ADDRESS_METADATA_PATH).addressData; + this._dataLoaded.country = true; + } + if (!level1) { + return this._parse(this._addressData[`data/${country}`]); + } + // If level1 is set, load addressReferences under country folder with specific + // country/level 1 for level 2 information. + if (!this._dataLoaded.level1.has(country)) { + Object.assign( + this._addressData, + this._loadScripts(`${ADDRESS_METADATA_PATH}${country}/`).addressData + ); + this._dataLoaded.level1.add(country); + } + return this._parse(this._addressData[`data/${country}/${level1}`]); + }, + + /** + * Return the region metadata with default locale and other locales (if exists). + * + * @param {string} country + * @param {string?} level1 + * @returns {object} Return default locale and other locales metadata. + */ + getData(country, level1 = null) { + let defaultLocale = this._loadData(country, level1); + if (!defaultLocale) { + return null; + } + + let countryData = this._parse(this._addressData[`data/${country}`]); + let locales = []; + // TODO: Should be able to support multi-locale level 1/ level 2 metadata query + // in Bug 1421886 + if (countryData.languages) { + let list = countryData.languages.filter(key => key !== countryData.lang); + locales = list.map(key => + this._parse(this._addressData[`${defaultLocale.id}--${key}`]) + ); + } + return { defaultLocale, locales }; + }, +}; + +FormAutofillUtils = { + get AUTOFILL_FIELDS_THRESHOLD() { + return 3; + }, + + ADDRESSES_COLLECTION_NAME, + CREDITCARDS_COLLECTION_NAME, + MANAGE_ADDRESSES_L10N_IDS, + EDIT_ADDRESS_L10N_IDS, + MANAGE_CREDITCARDS_L10N_IDS, + EDIT_CREDITCARD_L10N_IDS, + MAX_FIELD_VALUE_LENGTH, + FIELD_STATES, + SECTION_TYPES, + + _fieldNameInfo: { + name: "name", + "given-name": "name", + "additional-name": "name", + "family-name": "name", + organization: "organization", + "street-address": "address", + "address-line1": "address", + "address-line2": "address", + "address-line3": "address", + "address-level1": "address", + "address-level2": "address", + "postal-code": "address", + country: "address", + "country-name": "address", + tel: "tel", + "tel-country-code": "tel", + "tel-national": "tel", + "tel-area-code": "tel", + "tel-local": "tel", + "tel-local-prefix": "tel", + "tel-local-suffix": "tel", + "tel-extension": "tel", + email: "email", + "cc-name": "creditCard", + "cc-given-name": "creditCard", + "cc-additional-name": "creditCard", + "cc-family-name": "creditCard", + "cc-number": "creditCard", + "cc-exp-month": "creditCard", + "cc-exp-year": "creditCard", + "cc-exp": "creditCard", + "cc-type": "creditCard", + }, + + _collators: {}, + _reAlternativeCountryNames: {}, + + isAddressField(fieldName) { + return ( + !!this._fieldNameInfo[fieldName] && !this.isCreditCardField(fieldName) + ); + }, + + isCreditCardField(fieldName) { + return this._fieldNameInfo[fieldName] == "creditCard"; + }, + + isCCNumber(ccNumber) { + return lazy.CreditCard.isValidNumber(ccNumber); + }, + + ensureLoggedIn(promptMessage) { + return lazy.OSKeyStore.ensureLoggedIn( + this._reauthEnabledByUser && promptMessage ? promptMessage : false + ); + }, + + /** + * Get the array of credit card network ids ("types") we expect and offer as valid choices + * + * @returns {Array} + */ + getCreditCardNetworks() { + return lazy.CreditCard.getSupportedNetworks(); + }, + + getCategoryFromFieldName(fieldName) { + return this._fieldNameInfo[fieldName]; + }, + + getCategoriesFromFieldNames(fieldNames) { + let categories = new Set(); + for (let fieldName of fieldNames) { + let info = this.getCategoryFromFieldName(fieldName); + if (info) { + categories.add(info); + } + } + return Array.from(categories); + }, + + getAddressSeparator() { + // The separator should be based on the L10N address format, and using a + // white space is a temporary solution. + return " "; + }, + + /** + * Get address display label. It should display information separated + * by a comma. + * + * @param {object} address + * @param {string?} addressFields Override the fields which can be displayed, but not the order. + * @returns {string} + */ + getAddressLabel(address, addressFields = null) { + // TODO: Implement a smarter way for deciding what to display + // as option text. Possibly improve the algorithm in + // ProfileAutoCompleteResult.jsm and reuse it here. + let fieldOrder = [ + "name", + "-moz-street-address-one-line", // Street address + "address-level3", // Townland / Neighborhood / Village + "address-level2", // City/Town + "organization", // Company or organization name + "address-level1", // Province/State (Standardized code if possible) + "country-name", // Country name + "postal-code", // Postal code + "tel", // Phone number + "email", // Email address + ]; + + address = { ...address }; + let parts = []; + if (addressFields) { + let requiredFields = addressFields.trim().split(/\s+/); + fieldOrder = fieldOrder.filter(name => requiredFields.includes(name)); + } + if (address["street-address"]) { + address["-moz-street-address-one-line"] = this.toOneLineAddress( + address["street-address"] + ); + } + for (const fieldName of fieldOrder) { + let string = address[fieldName]; + if (string) { + parts.push(string); + } + if (parts.length == 2 && !addressFields) { + break; + } + } + return parts.join(", "); + }, + + /** + * Internal method to split an address to multiple parts per the provided delimiter, + * removing blank parts. + * + * @param {string} address The address the split + * @param {string} [delimiter] The separator that is used between lines in the address + * @returns {string[]} + */ + _toStreetAddressParts(address, delimiter = "\n") { + let array = typeof address == "string" ? address.split(delimiter) : address; + + if (!Array.isArray(array)) { + return []; + } + return array.map(s => (s ? s.trim() : "")).filter(s => s); + }, + + /** + * Converts a street address to a single line, removing linebreaks marked by the delimiter + * + * @param {string} address The address the convert + * @param {string} [delimiter] The separator that is used between lines in the address + * @returns {string} + */ + toOneLineAddress(address, delimiter = "\n") { + let addressParts = this._toStreetAddressParts(address, delimiter); + return addressParts.join(this.getAddressSeparator()); + }, + + /** + * Compares two addresses, removing internal whitespace + * + * @param {string} a The first address to compare + * @param {string} b The second address to compare + * @param {Array} collators Search collators that will be used for comparison + * @param {string} [delimiter="\n"] The separator that is used between lines in the address + * @returns {boolean} True if the addresses are equal, false otherwise + */ + compareStreetAddress(a, b, collators, delimiter = "\n") { + let oneLineA = this._toStreetAddressParts(a, delimiter) + .map(p => p.replace(/\s/g, "")) + .join(""); + let oneLineB = this._toStreetAddressParts(b, delimiter) + .map(p => p.replace(/\s/g, "")) + .join(""); + return this.strCompare(oneLineA, oneLineB, collators); + }, + + /** + * In-place concatenate tel-related components into a single "tel" field and + * delete unnecessary fields. + * + * @param {object} address An address record. + */ + compressTel(address) { + let telCountryCode = address["tel-country-code"] || ""; + let telAreaCode = address["tel-area-code"] || ""; + + if (!address.tel) { + if (address["tel-national"]) { + address.tel = telCountryCode + address["tel-national"]; + } else if (address["tel-local"]) { + address.tel = telCountryCode + telAreaCode + address["tel-local"]; + } else if (address["tel-local-prefix"] && address["tel-local-suffix"]) { + address.tel = + telCountryCode + + telAreaCode + + address["tel-local-prefix"] + + address["tel-local-suffix"]; + } + } + + for (let field in address) { + if (field != "tel" && this.getCategoryFromFieldName(field) == "tel") { + delete address[field]; + } + } + }, + + autofillFieldSelector(doc) { + return doc.querySelectorAll("input, select"); + }, + + /** + * Determines if an element can be autofilled or not. + * + * @param {HTMLElement} element + * @returns {boolean} true if the element can be autofilled + */ + isFieldAutofillable(element) { + return element && !element.readOnly && !element.disabled; + }, + + /** + * Determines if an element is visually hidden or not. + * + * NOTE: this does not encompass every possible way of hiding an element. + * Instead, we check some of the more common methods of hiding for performance reasons. + * See Bug 1727832 for follow up. + * + * @param {HTMLElement} element + * @returns {boolean} true if the element is visible + */ + isFieldVisible(element) { + if (element.hidden) { + return false; + } + if (element.style.display == "none") { + return false; + } + return true; + }, + + /** + * 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. + if (!ELIGIBLE_INPUT_TYPES.includes(element.type)) { + return false; + } + // If the field is visually invisible, we do not want to autofill into it. + if (!this.isFieldVisible(element)) { + return false; + } + } else if (!HTMLSelectElement.isInstance(element)) { + return false; + } + + return true; + }, + + loadDataFromScript(url, sandbox = {}) { + Services.scriptloader.loadSubScript(url, sandbox); + return sandbox; + }, + + /** + * Get country address data and fallback to US if not found. + * See AddressDataLoader._loadData for more details of addressData structure. + * + * @param {string} [country=FormAutofill.DEFAULT_REGION] + * The country code for requesting specific country's metadata. It'll be + * default region if parameter is not set. + * @param {string} [level1=null] + * Return address level 1/level 2 metadata if parameter is set. + * @returns {object|null} + * Return metadata of specific region with default locale and other supported + * locales. We need to return a default country metadata for layout format + * and collator, but for sub-region metadata we'll just return null if not found. + */ + getCountryAddressRawData( + country = FormAutofill.DEFAULT_REGION, + level1 = null + ) { + let metadata = AddressDataLoader.getData(country, level1); + if (!metadata) { + if (level1) { + return null; + } + // Fallback to default region if we couldn't get data from given country. + if (country != FormAutofill.DEFAULT_REGION) { + metadata = AddressDataLoader.getData(FormAutofill.DEFAULT_REGION); + } + } + + // TODO: Now we fallback to US if we couldn't get data from default region, + // but it could be removed in bug 1423464 if it's not necessary. + if (!metadata) { + metadata = AddressDataLoader.getData("US"); + } + return metadata; + }, + + /** + * Get country address data with default locale. + * + * @param {string} country + * @param {string} level1 + * @returns {object|null} Return metadata of specific region with default locale. + * NOTE: The returned data may be for a default region if the + * specified one cannot be found. Callers who only want the specific + * region should check the returned country code. + */ + getCountryAddressData(country, level1) { + let metadata = this.getCountryAddressRawData(country, level1); + return metadata && metadata.defaultLocale; + }, + + /** + * Get country address data with all locales. + * + * @param {string} country + * @param {string} level1 + * @returns {Array<object> | null} + * Return metadata of specific region with all the locales. + * NOTE: The returned data may be for a default region if the + * specified one cannot be found. Callers who only want the specific + * region should check the returned country code. + */ + getCountryAddressDataWithLocales(country, level1) { + let metadata = this.getCountryAddressRawData(country, level1); + return metadata && [metadata.defaultLocale, ...metadata.locales]; + }, + + /** + * Get the collators based on the specified country. + * + * @param {string} country The specified country. + * @returns {Array} An array containing several collator objects. + */ + getSearchCollators(country) { + // 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: true, + sensitivity: "base", + usage: "search", + }; + 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 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) { + let countries = countrySpecified + ? [countrySpecified] + : [...FormAutofill.countries.keys()]; + + for (let 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 + ] = []; + } + + 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).alternative_names) { + 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)); + }, + + /** + * 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]; + }, +}; + +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) || []; + }, +}; + +XPCOMUtils.defineLazyGetter(FormAutofillUtils, "stringBundle", function() { + return Services.strings.createBundle( + "chrome://formautofill/locale/formautofill.properties" + ); +}); + +XPCOMUtils.defineLazyGetter(FormAutofillUtils, "brandBundle", function() { + return Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "_reauthEnabledByUser", + "extensions.formautofill.reauth.enabled", + false +); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "ccHeuristicsMode", + "extensions.formautofill.creditCards.heuristics.mode", + 0 +); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "ccFathomConfidenceThreshold", + "extensions.formautofill.creditCards.heuristics.fathom.confidenceThreshold", + null, + null, + pref => parseFloat(pref) +); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "ccFathomHighConfidenceThreshold", + "extensions.formautofill.creditCards.heuristics.fathom.highConfidenceThreshold", + null, + null, + pref => parseFloat(pref) +); + +XPCOMUtils.defineLazyPreferenceGetter( + FormAutofillUtils, + "ccFathomTestConfidence", + "extensions.formautofill.creditCards.heuristics.fathom.testConfidence", + null, + null, + pref => parseFloat(pref) +); |