1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5"use strict";
6
7var EXPORTED_SYMBOLS = ["FormAutofillUtils", "AddressDataLoader"];
8
9const ADDRESS_METADATA_PATH = "resource://autofill/addressmetadata/";
10const ADDRESS_REFERENCES = "addressReferences.js";
11const ADDRESS_REFERENCES_EXT = "addressReferencesExt.js";
12
13const ADDRESSES_COLLECTION_NAME = "addresses";
14const CREDITCARDS_COLLECTION_NAME = "creditCards";
15const MANAGE_ADDRESSES_KEYWORDS = [
16  "manageAddressesTitle",
17  "addNewAddressTitle",
18];
19const EDIT_ADDRESS_KEYWORDS = [
20  "givenName",
21  "additionalName",
22  "familyName",
23  "organization2",
24  "streetAddress",
25  "state",
26  "province",
27  "city",
28  "country",
29  "zip",
30  "postalCode",
31  "email",
32  "tel",
33];
34const MANAGE_CREDITCARDS_KEYWORDS = [
35  "manageCreditCardsTitle",
36  "addNewCreditCardTitle",
37];
38const EDIT_CREDITCARD_KEYWORDS = [
39  "cardNumber",
40  "nameOnCard",
41  "cardExpiresMonth",
42  "cardExpiresYear",
43  "cardNetwork",
44];
45const FIELD_STATES = {
46  NORMAL: "NORMAL",
47  AUTO_FILLED: "AUTO_FILLED",
48  PREVIEW: "PREVIEW",
49};
50const SECTION_TYPES = {
51  ADDRESS: "address",
52  CREDIT_CARD: "creditCard",
53};
54
55// The maximum length of data to be saved in a single field for preventing DoS
56// attacks that fill the user's hard drive(s).
57const MAX_FIELD_VALUE_LENGTH = 200;
58
59const { XPCOMUtils } = ChromeUtils.import(
60  "resource://gre/modules/XPCOMUtils.jsm"
61);
62const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
63const { FormAutofill } = ChromeUtils.import(
64  "resource://autofill/FormAutofill.jsm"
65);
66XPCOMUtils.defineLazyModuleGetters(this, {
67  CreditCard: "resource://gre/modules/CreditCard.jsm",
68  OSKeyStore: "resource://gre/modules/OSKeyStore.jsm",
69});
70
71let AddressDataLoader = {
72  // Status of address data loading. We'll load all the countries with basic level 1
73  // information while requesting conutry information, and set country to true.
74  // Level 1 Set is for recording which country's level 1/level 2 data is loaded,
75  // since we only load this when getCountryAddressData called with level 1 parameter.
76  _dataLoaded: {
77    country: false,
78    level1: new Set(),
79  },
80
81  /**
82   * Load address data and extension script into a sandbox from different paths.
83   * @param   {string} path
84   *          The path for address data and extension script. It could be root of the address
85   *          metadata folder(addressmetadata/) or under specific country(addressmetadata/TW/).
86   * @returns {object}
87   *          A sandbox that contains address data object with properties from extension.
88   */
89  _loadScripts(path) {
90    let sandbox = {};
91    let extSandbox = {};
92
93    try {
94      sandbox = FormAutofillUtils.loadDataFromScript(path + ADDRESS_REFERENCES);
95      extSandbox = FormAutofillUtils.loadDataFromScript(
96        path + ADDRESS_REFERENCES_EXT
97      );
98    } catch (e) {
99      // Will return only address references if extension loading failed or empty sandbox if
100      // address references loading failed.
101      return sandbox;
102    }
103
104    if (extSandbox.addressDataExt) {
105      for (let key in extSandbox.addressDataExt) {
106        let addressDataForKey = sandbox.addressData[key];
107        if (!addressDataForKey) {
108          addressDataForKey = sandbox.addressData[key] = {};
109        }
110
111        Object.assign(addressDataForKey, extSandbox.addressDataExt[key]);
112      }
113    }
114    return sandbox;
115  },
116
117  /**
118   * Convert certain properties' string value into array. We should make sure
119   * the cached data is parsed.
120   * @param   {object} data Original metadata from addressReferences.
121   * @returns {object} parsed metadata with property value that converts to array.
122   */
123  _parse(data) {
124    if (!data) {
125      return null;
126    }
127
128    const properties = [
129      "languages",
130      "sub_keys",
131      "sub_isoids",
132      "sub_names",
133      "sub_lnames",
134    ];
135    for (let key of properties) {
136      if (!data[key]) {
137        continue;
138      }
139      // No need to normalize data if the value is array already.
140      if (Array.isArray(data[key])) {
141        return data;
142      }
143
144      data[key] = data[key].split("~");
145    }
146    return data;
147  },
148
149  /**
150   * We'll cache addressData in the loader once the data loaded from scripts.
151   * It'll become the example below after loading addressReferences with extension:
152   * addressData: {
153   *               "data/US": {"lang": ["en"], ...// Data defined in libaddressinput metadata
154   *                           "alternative_names": ... // Data defined in extension }
155   *               "data/CA": {} // Other supported country metadata
156   *               "data/TW": {} // Other supported country metadata
157   *               "data/TW/台北市": {} // Other supported country level 1 metadata
158   *              }
159   * @param   {string} country
160   * @param   {string?} level1
161   * @returns {object} Default locale metadata
162   */
163  _loadData(country, level1 = null) {
164    // Load the addressData if needed
165    if (!this._dataLoaded.country) {
166      this._addressData = this._loadScripts(ADDRESS_METADATA_PATH).addressData;
167      this._dataLoaded.country = true;
168    }
169    if (!level1) {
170      return this._parse(this._addressData[`data/${country}`]);
171    }
172    // If level1 is set, load addressReferences under country folder with specific
173    // country/level 1 for level 2 information.
174    if (!this._dataLoaded.level1.has(country)) {
175      Object.assign(
176        this._addressData,
177        this._loadScripts(`${ADDRESS_METADATA_PATH}${country}/`).addressData
178      );
179      this._dataLoaded.level1.add(country);
180    }
181    return this._parse(this._addressData[`data/${country}/${level1}`]);
182  },
183
184  /**
185   * Return the region metadata with default locale and other locales (if exists).
186   * @param   {string} country
187   * @param   {string?} level1
188   * @returns {object} Return default locale and other locales metadata.
189   */
190  getData(country, level1 = null) {
191    let defaultLocale = this._loadData(country, level1);
192    if (!defaultLocale) {
193      return null;
194    }
195
196    let countryData = this._parse(this._addressData[`data/${country}`]);
197    let locales = [];
198    // TODO: Should be able to support multi-locale level 1/ level 2 metadata query
199    //      in Bug 1421886
200    if (countryData.languages) {
201      let list = countryData.languages.filter(key => key !== countryData.lang);
202      locales = list.map(key =>
203        this._parse(this._addressData[`${defaultLocale.id}--${key}`])
204      );
205    }
206    return { defaultLocale, locales };
207  },
208};
209
210this.FormAutofillUtils = {
211  get AUTOFILL_FIELDS_THRESHOLD() {
212    return 3;
213  },
214
215  ADDRESSES_COLLECTION_NAME,
216  CREDITCARDS_COLLECTION_NAME,
217  MANAGE_ADDRESSES_KEYWORDS,
218  EDIT_ADDRESS_KEYWORDS,
219  MANAGE_CREDITCARDS_KEYWORDS,
220  EDIT_CREDITCARD_KEYWORDS,
221  MAX_FIELD_VALUE_LENGTH,
222  FIELD_STATES,
223  SECTION_TYPES,
224
225  _fieldNameInfo: {
226    name: "name",
227    "given-name": "name",
228    "additional-name": "name",
229    "family-name": "name",
230    organization: "organization",
231    "street-address": "address",
232    "address-line1": "address",
233    "address-line2": "address",
234    "address-line3": "address",
235    "address-level1": "address",
236    "address-level2": "address",
237    "postal-code": "address",
238    country: "address",
239    "country-name": "address",
240    tel: "tel",
241    "tel-country-code": "tel",
242    "tel-national": "tel",
243    "tel-area-code": "tel",
244    "tel-local": "tel",
245    "tel-local-prefix": "tel",
246    "tel-local-suffix": "tel",
247    "tel-extension": "tel",
248    email: "email",
249    "cc-name": "creditCard",
250    "cc-given-name": "creditCard",
251    "cc-additional-name": "creditCard",
252    "cc-family-name": "creditCard",
253    "cc-number": "creditCard",
254    "cc-exp-month": "creditCard",
255    "cc-exp-year": "creditCard",
256    "cc-exp": "creditCard",
257    "cc-type": "creditCard",
258  },
259
260  _collators: {},
261  _reAlternativeCountryNames: {},
262
263  isAddressField(fieldName) {
264    return (
265      !!this._fieldNameInfo[fieldName] && !this.isCreditCardField(fieldName)
266    );
267  },
268
269  isCreditCardField(fieldName) {
270    return this._fieldNameInfo[fieldName] == "creditCard";
271  },
272
273  isCCNumber(ccNumber) {
274    return CreditCard.isValidNumber(ccNumber);
275  },
276
277  ensureLoggedIn(promptMessage) {
278    return OSKeyStore.ensureLoggedIn(
279      this._reauthEnabledByUser && promptMessage ? promptMessage : false
280    );
281  },
282
283  /**
284   * Get the array of credit card network ids ("types") we expect and offer as valid choices
285   *
286   * @returns {Array}
287   */
288  getCreditCardNetworks() {
289    return CreditCard.SUPPORTED_NETWORKS;
290  },
291
292  getCategoryFromFieldName(fieldName) {
293    return this._fieldNameInfo[fieldName];
294  },
295
296  getCategoriesFromFieldNames(fieldNames) {
297    let categories = new Set();
298    for (let fieldName of fieldNames) {
299      let info = this.getCategoryFromFieldName(fieldName);
300      if (info) {
301        categories.add(info);
302      }
303    }
304    return Array.from(categories);
305  },
306
307  getAddressSeparator() {
308    // The separator should be based on the L10N address format, and using a
309    // white space is a temporary solution.
310    return " ";
311  },
312
313  /**
314   * Get address display label. It should display information separated
315   * by a comma.
316   *
317   * @param  {object} address
318   * @param  {string?} addressFields Override the fields which can be displayed, but not the order.
319   * @returns {string}
320   */
321  getAddressLabel(address, addressFields = null) {
322    // TODO: Implement a smarter way for deciding what to display
323    //       as option text. Possibly improve the algorithm in
324    //       ProfileAutoCompleteResult.jsm and reuse it here.
325    let fieldOrder = [
326      "name",
327      "-moz-street-address-one-line", // Street address
328      "address-level3", // Townland / Neighborhood / Village
329      "address-level2", // City/Town
330      "organization", // Company or organization name
331      "address-level1", // Province/State (Standardized code if possible)
332      "country-name", // Country name
333      "postal-code", // Postal code
334      "tel", // Phone number
335      "email", // Email address
336    ];
337
338    address = { ...address };
339    let parts = [];
340    if (addressFields) {
341      let requiredFields = addressFields.trim().split(/\s+/);
342      fieldOrder = fieldOrder.filter(name => requiredFields.includes(name));
343    }
344    if (address["street-address"]) {
345      address["-moz-street-address-one-line"] = this.toOneLineAddress(
346        address["street-address"]
347      );
348    }
349    for (const fieldName of fieldOrder) {
350      let string = address[fieldName];
351      if (string) {
352        parts.push(string);
353      }
354      if (parts.length == 2 && !addressFields) {
355        break;
356      }
357    }
358    return parts.join(", ");
359  },
360
361  /**
362   * Internal method to split an address to multiple parts per the provided delimiter,
363   * removing blank parts.
364   * @param {string} address The address the split
365   * @param {string} [delimiter] The separator that is used between lines in the address
366   * @returns {string[]}
367   */
368  _toStreetAddressParts(address, delimiter = "\n") {
369    let array = typeof address == "string" ? address.split(delimiter) : address;
370
371    if (!Array.isArray(array)) {
372      return [];
373    }
374    return array.map(s => (s ? s.trim() : "")).filter(s => s);
375  },
376
377  /**
378   * Converts a street address to a single line, removing linebreaks marked by the delimiter
379   * @param {string} address The address the convert
380   * @param {string} [delimiter] The separator that is used between lines in the address
381   * @returns {string}
382   */
383  toOneLineAddress(address, delimiter = "\n") {
384    let addressParts = this._toStreetAddressParts(address, delimiter);
385    return addressParts.join(this.getAddressSeparator());
386  },
387
388  /**
389   * Compares two addresses, removing internal whitespace
390   * @param {string} a The first address to compare
391   * @param {string} b The second address to compare
392   * @param {array} collators Search collators that will be used for comparison
393   * @param {string} [delimiter="\n"] The separator that is used between lines in the address
394   * @returns {boolean} True if the addresses are equal, false otherwise
395   */
396  compareStreetAddress(a, b, collators, delimiter = "\n") {
397    let oneLineA = this._toStreetAddressParts(a, delimiter)
398      .map(p => p.replace(/\s/g, ""))
399      .join("");
400    let oneLineB = this._toStreetAddressParts(b, delimiter)
401      .map(p => p.replace(/\s/g, ""))
402      .join("");
403    return this.strCompare(oneLineA, oneLineB, collators);
404  },
405
406  /**
407   * In-place concatenate tel-related components into a single "tel" field and
408   * delete unnecessary fields.
409   * @param {object} address An address record.
410   */
411  compressTel(address) {
412    let telCountryCode = address["tel-country-code"] || "";
413    let telAreaCode = address["tel-area-code"] || "";
414
415    if (!address.tel) {
416      if (address["tel-national"]) {
417        address.tel = telCountryCode + address["tel-national"];
418      } else if (address["tel-local"]) {
419        address.tel = telCountryCode + telAreaCode + address["tel-local"];
420      } else if (address["tel-local-prefix"] && address["tel-local-suffix"]) {
421        address.tel =
422          telCountryCode +
423          telAreaCode +
424          address["tel-local-prefix"] +
425          address["tel-local-suffix"];
426      }
427    }
428
429    for (let field in address) {
430      if (field != "tel" && this.getCategoryFromFieldName(field) == "tel") {
431        delete address[field];
432      }
433    }
434  },
435
436  autofillFieldSelector(doc) {
437    return doc.querySelectorAll("input, select");
438  },
439
440  /**
441   *  Determines if an element is visually hidden or not.
442   *
443   * NOTE: this does not encompass every possible way of hiding an element.
444   * Instead, we check some of the more common methods of hiding for performance reasons.
445   * See Bug 1727832 for follow up.
446   * @param {HTMLElement} element
447   * @returns {boolean}
448   */
449  isFieldVisible(element) {
450    if (element.hidden) {
451      return false;
452    }
453    if (element.style.display == "none") {
454      return false;
455    }
456    return true;
457  },
458
459  ALLOWED_TYPES: ["text", "email", "tel", "number", "month"],
460  isFieldEligibleForAutofill(element) {
461    let tagName = element.tagName;
462    if (tagName == "INPUT") {
463      // `element.type` can be recognized as `text`, if it's missing or invalid.
464      if (!this.ALLOWED_TYPES.includes(element.type)) {
465        return false;
466      }
467      // If the field is visually invisible, we do not want to autofill into it.
468      if (!this.isFieldVisible(element)) {
469        return false;
470      }
471    } else if (tagName != "SELECT") {
472      return false;
473    }
474
475    return true;
476  },
477
478  loadDataFromScript(url, sandbox = {}) {
479    Services.scriptloader.loadSubScript(url, sandbox);
480    return sandbox;
481  },
482
483  /**
484   * Get country address data and fallback to US if not found.
485   * See AddressDataLoader._loadData for more details of addressData structure.
486   * @param {string} [country=FormAutofill.DEFAULT_REGION]
487   *        The country code for requesting specific country's metadata. It'll be
488   *        default region if parameter is not set.
489   * @param {string} [level1=null]
490   *        Return address level 1/level 2 metadata if parameter is set.
491   * @returns {object|null}
492   *          Return metadata of specific region with default locale and other supported
493   *          locales. We need to return a default country metadata for layout format
494   *          and collator, but for sub-region metadata we'll just return null if not found.
495   */
496  getCountryAddressRawData(
497    country = FormAutofill.DEFAULT_REGION,
498    level1 = null
499  ) {
500    let metadata = AddressDataLoader.getData(country, level1);
501    if (!metadata) {
502      if (level1) {
503        return null;
504      }
505      // Fallback to default region if we couldn't get data from given country.
506      if (country != FormAutofill.DEFAULT_REGION) {
507        metadata = AddressDataLoader.getData(FormAutofill.DEFAULT_REGION);
508      }
509    }
510
511    // TODO: Now we fallback to US if we couldn't get data from default region,
512    //       but it could be removed in bug 1423464 if it's not necessary.
513    if (!metadata) {
514      metadata = AddressDataLoader.getData("US");
515    }
516    return metadata;
517  },
518
519  /**
520   * Get country address data with default locale.
521   * @param {string} country
522   * @param {string} level1
523   * @returns {object|null} Return metadata of specific region with default locale.
524   *          NOTE: The returned data may be for a default region if the
525   *          specified one cannot be found. Callers who only want the specific
526   *          region should check the returned country code.
527   */
528  getCountryAddressData(country, level1) {
529    let metadata = this.getCountryAddressRawData(country, level1);
530    return metadata && metadata.defaultLocale;
531  },
532
533  /**
534   * Get country address data with all locales.
535   * @param {string} country
536   * @param {string} level1
537   * @returns {array<object>|null}
538   *          Return metadata of specific region with all the locales.
539   *          NOTE: The returned data may be for a default region if the
540   *          specified one cannot be found. Callers who only want the specific
541   *          region should check the returned country code.
542   */
543  getCountryAddressDataWithLocales(country, level1) {
544    let metadata = this.getCountryAddressRawData(country, level1);
545    return metadata && [metadata.defaultLocale, ...metadata.locales];
546  },
547
548  /**
549   * Get the collators based on the specified country.
550   * @param   {string} country The specified country.
551   * @returns {array} An array containing several collator objects.
552   */
553  getSearchCollators(country) {
554    // TODO: Only one language should be used at a time per country. The locale
555    //       of the page should be taken into account to do this properly.
556    //       We are going to support more countries in bug 1370193 and this
557    //       should be addressed when we start to implement that bug.
558
559    if (!this._collators[country]) {
560      let dataset = this.getCountryAddressData(country);
561      let languages = dataset.languages || [dataset.lang];
562      let options = {
563        ignorePunctuation: true,
564        sensitivity: "base",
565        usage: "search",
566      };
567      this._collators[country] = languages.map(
568        lang => new Intl.Collator(lang, options)
569      );
570    }
571    return this._collators[country];
572  },
573
574  // Based on the list of fields abbreviations in
575  // https://github.com/googlei18n/libaddressinput/wiki/AddressValidationMetadata
576  FIELDS_LOOKUP: {
577    N: "name",
578    O: "organization",
579    A: "street-address",
580    S: "address-level1",
581    C: "address-level2",
582    D: "address-level3",
583    Z: "postal-code",
584    n: "newLine",
585  },
586
587  /**
588   * Parse a country address format string and outputs an array of fields.
589   * Spaces, commas, and other literals are ignored in this implementation.
590   * For example, format string "%A%n%C, %S" should return:
591   * [
592   *   {fieldId: "street-address", newLine: true},
593   *   {fieldId: "address-level2"},
594   *   {fieldId: "address-level1"},
595   * ]
596   *
597   * @param   {string} fmt Country address format string
598   * @returns {array<object>} List of fields
599   */
600  parseAddressFormat(fmt) {
601    if (!fmt) {
602      throw new Error("fmt string is missing.");
603    }
604
605    return fmt.match(/%[^%]/g).reduce((parsed, part) => {
606      // Take the first letter of each segment and try to identify it
607      let fieldId = this.FIELDS_LOOKUP[part[1]];
608      // Early return if cannot identify part.
609      if (!fieldId) {
610        return parsed;
611      }
612      // If a new line is detected, add an attribute to the previous field.
613      if (fieldId == "newLine") {
614        let size = parsed.length;
615        if (size) {
616          parsed[size - 1].newLine = true;
617        }
618        return parsed;
619      }
620      return parsed.concat({ fieldId });
621    }, []);
622  },
623
624  /**
625   * Used to populate dropdowns in the UI (e.g. FormAutofill preferences).
626   * Use findAddressSelectOption for matching a value to a region.
627   *
628   * @param {string[]} subKeys An array of regionCode strings
629   * @param {string[]} subIsoids An array of ISO ID strings, if provided will be preferred over the key
630   * @param {string[]} subNames An array of regionName strings
631   * @param {string[]} subLnames An array of latinised regionName strings
632   * @returns {Map?} Returns null if subKeys or subNames are not truthy.
633   *                   Otherwise, a Map will be returned mapping keys -> names.
634   */
635  buildRegionMapIfAvailable(subKeys, subIsoids, subNames, subLnames) {
636    // Not all regions have sub_keys. e.g. DE
637    if (
638      !subKeys ||
639      !subKeys.length ||
640      (!subNames && !subLnames) ||
641      (subNames && subKeys.length != subNames.length) ||
642      (subLnames && subKeys.length != subLnames.length)
643    ) {
644      return null;
645    }
646
647    // Overwrite subKeys with subIsoids, when available
648    if (subIsoids && subIsoids.length && subIsoids.length == subKeys.length) {
649      for (let i = 0; i < subIsoids.length; i++) {
650        if (subIsoids[i]) {
651          subKeys[i] = subIsoids[i];
652        }
653      }
654    }
655
656    // Apply sub_lnames if sub_names does not exist
657    let names = subNames || subLnames;
658    return new Map(subKeys.map((key, index) => [key, names[index]]));
659  },
660
661  /**
662   * Parse a require string and outputs an array of fields.
663   * Spaces, commas, and other literals are ignored in this implementation.
664   * For example, a require string "ACS" should return:
665   * ["street-address", "address-level2", "address-level1"]
666   *
667   * @param   {string} requireString Country address require string
668   * @returns {array<string>} List of fields
669   */
670  parseRequireString(requireString) {
671    if (!requireString) {
672      throw new Error("requireString string is missing.");
673    }
674
675    return requireString.split("").map(fieldId => this.FIELDS_LOOKUP[fieldId]);
676  },
677
678  /**
679   * Use alternative country name list to identify a country code from a
680   * specified country name.
681   * @param   {string} countryName A country name to be identified
682   * @param   {string} [countrySpecified] A country code indicating that we only
683   *                                      search its alternative names if specified.
684   * @returns {string} The matching country code.
685   */
686  identifyCountryCode(countryName, countrySpecified) {
687    let countries = countrySpecified
688      ? [countrySpecified]
689      : [...FormAutofill.countries.keys()];
690
691    for (let country of countries) {
692      let collators = this.getSearchCollators(country);
693      let metadata = this.getCountryAddressData(country);
694      if (country != metadata.key) {
695        // We hit the fallback logic in getCountryAddressRawData so ignore it as
696        // it's not related to `country` and use the name from l10n instead.
697        metadata = {
698          id: `data/${country}`,
699          key: country,
700          name: FormAutofill.countries.get(country),
701        };
702      }
703      let alternativeCountryNames = metadata.alternative_names || [
704        metadata.name,
705      ];
706      let reAlternativeCountryNames = this._reAlternativeCountryNames[country];
707      if (!reAlternativeCountryNames) {
708        reAlternativeCountryNames = this._reAlternativeCountryNames[
709          country
710        ] = [];
711      }
712
713      for (let i = 0; i < alternativeCountryNames.length; i++) {
714        let name = alternativeCountryNames[i];
715        let reName = reAlternativeCountryNames[i];
716        if (!reName) {
717          reName = reAlternativeCountryNames[i] = new RegExp(
718            "\\b" + this.escapeRegExp(name) + "\\b",
719            "i"
720          );
721        }
722
723        if (
724          this.strCompare(name, countryName, collators) ||
725          reName.test(countryName)
726        ) {
727          return country;
728        }
729      }
730    }
731
732    return null;
733  },
734
735  findSelectOption(selectEl, record, fieldName) {
736    if (this.isAddressField(fieldName)) {
737      return this.findAddressSelectOption(selectEl, record, fieldName);
738    }
739    if (this.isCreditCardField(fieldName)) {
740      return this.findCreditCardSelectOption(selectEl, record, fieldName);
741    }
742    return null;
743  },
744
745  /**
746   * Try to find the abbreviation of the given sub-region name
747   * @param   {string[]} subregionValues A list of inferable sub-region values.
748   * @param   {string} [country] A country name to be identified.
749   * @returns {string} The matching sub-region abbreviation.
750   */
751  getAbbreviatedSubregionName(subregionValues, country) {
752    let values = Array.isArray(subregionValues)
753      ? subregionValues
754      : [subregionValues];
755
756    let collators = this.getSearchCollators(country);
757    for (let metadata of this.getCountryAddressDataWithLocales(country)) {
758      let {
759        sub_keys: subKeys,
760        sub_names: subNames,
761        sub_lnames: subLnames,
762      } = metadata;
763      if (!subKeys) {
764        // Not all regions have sub_keys. e.g. DE
765        continue;
766      }
767      // Apply sub_lnames if sub_names does not exist
768      subNames = subNames || subLnames;
769
770      let speculatedSubIndexes = [];
771      for (const val of values) {
772        let identifiedValue = this.identifyValue(
773          subKeys,
774          subNames,
775          val,
776          collators
777        );
778        if (identifiedValue) {
779          return identifiedValue;
780        }
781
782        // Predict the possible state by partial-matching if no exact match.
783        [subKeys, subNames].forEach(sub => {
784          speculatedSubIndexes.push(
785            sub.findIndex(token => {
786              let pattern = new RegExp(
787                "\\b" + this.escapeRegExp(token) + "\\b"
788              );
789
790              return pattern.test(val);
791            })
792          );
793        });
794      }
795      let subKey = subKeys[speculatedSubIndexes.find(i => !!~i)];
796      if (subKey) {
797        return subKey;
798      }
799    }
800    return null;
801  },
802
803  /**
804   * Find the option element from select element.
805   * 1. Try to find the locale using the country from address.
806   * 2. First pass try to find exact match.
807   * 3. Second pass try to identify values from address value and options,
808   *    and look for a match.
809   * @param   {DOMElement} selectEl
810   * @param   {object} address
811   * @param   {string} fieldName
812   * @returns {DOMElement}
813   */
814  findAddressSelectOption(selectEl, address, fieldName) {
815    if (selectEl.options.length > 512) {
816      // Allow enough space for all countries (roughly 300 distinct values) and all
817      // timezones (roughly 400 distinct values), plus some extra wiggle room.
818      return null;
819    }
820    let value = address[fieldName];
821    if (!value) {
822      return null;
823    }
824
825    let collators = this.getSearchCollators(address.country);
826
827    for (let option of selectEl.options) {
828      if (
829        this.strCompare(value, option.value, collators) ||
830        this.strCompare(value, option.text, collators)
831      ) {
832        return option;
833      }
834    }
835
836    switch (fieldName) {
837      case "address-level1": {
838        let { country } = address;
839        let identifiedValue = this.getAbbreviatedSubregionName(
840          [value],
841          country
842        );
843        // No point going any further if we cannot identify value from address level 1
844        if (!identifiedValue) {
845          return null;
846        }
847        for (let dataset of this.getCountryAddressDataWithLocales(country)) {
848          let keys = dataset.sub_keys;
849          if (!keys) {
850            // Not all regions have sub_keys. e.g. DE
851            continue;
852          }
853          // Apply sub_lnames if sub_names does not exist
854          let names = dataset.sub_names || dataset.sub_lnames;
855
856          // Go through options one by one to find a match.
857          // Also check if any option contain the address-level1 key.
858          let pattern = new RegExp(
859            "\\b" + this.escapeRegExp(identifiedValue) + "\\b",
860            "i"
861          );
862          for (let option of selectEl.options) {
863            let optionValue = this.identifyValue(
864              keys,
865              names,
866              option.value,
867              collators
868            );
869            let optionText = this.identifyValue(
870              keys,
871              names,
872              option.text,
873              collators
874            );
875            if (
876              identifiedValue === optionValue ||
877              identifiedValue === optionText ||
878              pattern.test(option.value)
879            ) {
880              return option;
881            }
882          }
883        }
884        break;
885      }
886      case "country": {
887        if (this.getCountryAddressData(value).alternative_names) {
888          for (let option of selectEl.options) {
889            if (
890              this.identifyCountryCode(option.text, value) ||
891              this.identifyCountryCode(option.value, value)
892            ) {
893              return option;
894            }
895          }
896        }
897        break;
898      }
899    }
900
901    return null;
902  },
903
904  findCreditCardSelectOption(selectEl, creditCard, fieldName) {
905    let oneDigitMonth = creditCard["cc-exp-month"]
906      ? creditCard["cc-exp-month"].toString()
907      : null;
908    let twoDigitsMonth = oneDigitMonth ? oneDigitMonth.padStart(2, "0") : null;
909    let fourDigitsYear = creditCard["cc-exp-year"]
910      ? creditCard["cc-exp-year"].toString()
911      : null;
912    let twoDigitsYear = fourDigitsYear ? fourDigitsYear.substr(2, 2) : null;
913    let options = Array.from(selectEl.options);
914
915    switch (fieldName) {
916      case "cc-exp-month": {
917        if (!oneDigitMonth) {
918          return null;
919        }
920        for (let option of options) {
921          if (
922            [option.text, option.label, option.value].some(s => {
923              let result = /[1-9]\d*/.exec(s);
924              return result && result[0] == oneDigitMonth;
925            })
926          ) {
927            return option;
928          }
929        }
930        break;
931      }
932      case "cc-exp-year": {
933        if (!fourDigitsYear) {
934          return null;
935        }
936        for (let option of options) {
937          if (
938            [option.text, option.label, option.value].some(
939              s => s == twoDigitsYear || s == fourDigitsYear
940            )
941          ) {
942            return option;
943          }
944        }
945        break;
946      }
947      case "cc-exp": {
948        if (!oneDigitMonth || !fourDigitsYear) {
949          return null;
950        }
951        let patterns = [
952          oneDigitMonth + "/" + twoDigitsYear, // 8/22
953          oneDigitMonth + "/" + fourDigitsYear, // 8/2022
954          twoDigitsMonth + "/" + twoDigitsYear, // 08/22
955          twoDigitsMonth + "/" + fourDigitsYear, // 08/2022
956          oneDigitMonth + "-" + twoDigitsYear, // 8-22
957          oneDigitMonth + "-" + fourDigitsYear, // 8-2022
958          twoDigitsMonth + "-" + twoDigitsYear, // 08-22
959          twoDigitsMonth + "-" + fourDigitsYear, // 08-2022
960          twoDigitsYear + "-" + twoDigitsMonth, // 22-08
961          fourDigitsYear + "-" + twoDigitsMonth, // 2022-08
962          fourDigitsYear + "/" + oneDigitMonth, // 2022/8
963          twoDigitsMonth + twoDigitsYear, // 0822
964          twoDigitsYear + twoDigitsMonth, // 2208
965        ];
966
967        for (let option of options) {
968          if (
969            [option.text, option.label, option.value].some(str =>
970              patterns.some(pattern => str.includes(pattern))
971            )
972          ) {
973            return option;
974          }
975        }
976        break;
977      }
978      case "cc-type": {
979        let network = creditCard["cc-type"] || "";
980        for (let option of options) {
981          if (
982            [option.text, option.label, option.value].some(
983              s => CreditCard.getNetworkFromName(s) == network
984            )
985          ) {
986            return option;
987          }
988        }
989        break;
990      }
991    }
992
993    return null;
994  },
995
996  /**
997   * Try to match value with keys and names, but always return the key.
998   * @param   {array<string>} keys
999   * @param   {array<string>} names
1000   * @param   {string} value
1001   * @param   {array} collators
1002   * @returns {string}
1003   */
1004  identifyValue(keys, names, value, collators) {
1005    let resultKey = keys.find(key => this.strCompare(value, key, collators));
1006    if (resultKey) {
1007      return resultKey;
1008    }
1009
1010    let index = names.findIndex(name =>
1011      this.strCompare(value, name, collators)
1012    );
1013    if (index !== -1) {
1014      return keys[index];
1015    }
1016
1017    return null;
1018  },
1019
1020  /**
1021   * Compare if two strings are the same.
1022   * @param   {string} a
1023   * @param   {string} b
1024   * @param   {array} collators
1025   * @returns {boolean}
1026   */
1027  strCompare(a = "", b = "", collators) {
1028    return collators.some(collator => !collator.compare(a, b));
1029  },
1030
1031  /**
1032   * Escaping user input to be treated as a literal string within a regular
1033   * expression.
1034   * @param   {string} string
1035   * @returns {string}
1036   */
1037  escapeRegExp(string) {
1038    return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1039  },
1040
1041  /**
1042   * Get formatting information of a given country
1043   * @param   {string} country
1044   * @returns {object}
1045   *         {
1046   *           {string} addressLevel3Label
1047   *           {string} addressLevel2Label
1048   *           {string} addressLevel1Label
1049   *           {string} postalCodeLabel
1050   *           {object} fieldsOrder
1051   *           {string} postalCodePattern
1052   *         }
1053   */
1054  getFormFormat(country) {
1055    let dataset = this.getCountryAddressData(country);
1056    // We hit a country fallback in `getCountryAddressRawData` but it's not relevant here.
1057    if (country != dataset.key) {
1058      // Use a sparse object so the below default values take effect.
1059      dataset = {
1060        /**
1061         * Even though data/ZZ only has address-level2, include the other levels
1062         * in case they are needed for unknown countries. Users can leave the
1063         * unnecessary fields blank which is better than forcing users to enter
1064         * the data in incorrect fields.
1065         */
1066        fmt: "%N%n%O%n%A%n%C %S %Z",
1067      };
1068    }
1069    return {
1070      // When particular values are missing for a country, the
1071      // data/ZZ value should be used instead:
1072      // https://chromium-i18n.appspot.com/ssl-aggregate-address/data/ZZ
1073      addressLevel3Label: dataset.sublocality_name_type || "suburb",
1074      addressLevel2Label: dataset.locality_name_type || "city",
1075      addressLevel1Label: dataset.state_name_type || "province",
1076      addressLevel1Options: this.buildRegionMapIfAvailable(
1077        dataset.sub_keys,
1078        dataset.sub_isoids,
1079        dataset.sub_names,
1080        dataset.sub_lnames
1081      ),
1082      countryRequiredFields: this.parseRequireString(dataset.require || "AC"),
1083      fieldsOrder: this.parseAddressFormat(dataset.fmt || "%N%n%O%n%A%n%C"),
1084      postalCodeLabel: dataset.zip_name_type || "postalCode",
1085      postalCodePattern: dataset.zip,
1086    };
1087  },
1088
1089  /**
1090   * Localize "data-localization" or "data-localization-region" attributes.
1091   * @param {Element} element
1092   * @param {string} attributeName
1093   */
1094  localizeAttributeForElement(element, attributeName) {
1095    switch (attributeName) {
1096      case "data-localization": {
1097        element.textContent = this.stringBundle.GetStringFromName(
1098          element.getAttribute(attributeName)
1099        );
1100        element.removeAttribute(attributeName);
1101        break;
1102      }
1103      case "data-localization-region": {
1104        let regionCode = element.getAttribute(attributeName);
1105        element.textContent = Services.intl.getRegionDisplayNames(undefined, [
1106          regionCode,
1107        ]);
1108        element.removeAttribute(attributeName);
1109        return;
1110      }
1111      default:
1112        throw new Error("Unexpected attributeName");
1113    }
1114  },
1115
1116  /**
1117   * Localize elements with "data-localization" or "data-localization-region" attributes.
1118   * @param {Element} root
1119   */
1120  localizeMarkup(root) {
1121    let elements = root.querySelectorAll("[data-localization]");
1122    for (let element of elements) {
1123      this.localizeAttributeForElement(element, "data-localization");
1124    }
1125
1126    elements = root.querySelectorAll("[data-localization-region]");
1127    for (let element of elements) {
1128      this.localizeAttributeForElement(element, "data-localization-region");
1129    }
1130  },
1131};
1132
1133this.log = null;
1134FormAutofill.defineLazyLogGetter(this, EXPORTED_SYMBOLS[0]);
1135
1136XPCOMUtils.defineLazyGetter(FormAutofillUtils, "stringBundle", function() {
1137  return Services.strings.createBundle(
1138    "chrome://formautofill/locale/formautofill.properties"
1139  );
1140});
1141
1142XPCOMUtils.defineLazyGetter(FormAutofillUtils, "brandBundle", function() {
1143  return Services.strings.createBundle(
1144    "chrome://branding/locale/brand.properties"
1145  );
1146});
1147
1148XPCOMUtils.defineLazyPreferenceGetter(
1149  FormAutofillUtils,
1150  "_reauthEnabledByUser",
1151  "extensions.formautofill.reauth.enabled",
1152  false
1153);
1154