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  ALLOWED_TYPES: ["text", "email", "tel", "number", "month"],
441  isFieldEligibleForAutofill(element) {
442    let tagName = element.tagName;
443    if (tagName == "INPUT") {
444      // `element.type` can be recognized as `text`, if it's missing or invalid.
445      if (!this.ALLOWED_TYPES.includes(element.type)) {
446        return false;
447      }
448    } else if (tagName != "SELECT") {
449      return false;
450    }
451
452    return true;
453  },
454
455  loadDataFromScript(url, sandbox = {}) {
456    Services.scriptloader.loadSubScript(url, sandbox);
457    return sandbox;
458  },
459
460  /**
461   * Get country address data and fallback to US if not found.
462   * See AddressDataLoader._loadData for more details of addressData structure.
463   * @param {string} [country=FormAutofill.DEFAULT_REGION]
464   *        The country code for requesting specific country's metadata. It'll be
465   *        default region if parameter is not set.
466   * @param {string} [level1=null]
467   *        Return address level 1/level 2 metadata if parameter is set.
468   * @returns {object|null}
469   *          Return metadata of specific region with default locale and other supported
470   *          locales. We need to return a default country metadata for layout format
471   *          and collator, but for sub-region metadata we'll just return null if not found.
472   */
473  getCountryAddressRawData(
474    country = FormAutofill.DEFAULT_REGION,
475    level1 = null
476  ) {
477    let metadata = AddressDataLoader.getData(country, level1);
478    if (!metadata) {
479      if (level1) {
480        return null;
481      }
482      // Fallback to default region if we couldn't get data from given country.
483      if (country != FormAutofill.DEFAULT_REGION) {
484        metadata = AddressDataLoader.getData(FormAutofill.DEFAULT_REGION);
485      }
486    }
487
488    // TODO: Now we fallback to US if we couldn't get data from default region,
489    //       but it could be removed in bug 1423464 if it's not necessary.
490    if (!metadata) {
491      metadata = AddressDataLoader.getData("US");
492    }
493    return metadata;
494  },
495
496  /**
497   * Get country address data with default locale.
498   * @param {string} country
499   * @param {string} level1
500   * @returns {object|null} Return metadata of specific region with default locale.
501   *          NOTE: The returned data may be for a default region if the
502   *          specified one cannot be found. Callers who only want the specific
503   *          region should check the returned country code.
504   */
505  getCountryAddressData(country, level1) {
506    let metadata = this.getCountryAddressRawData(country, level1);
507    return metadata && metadata.defaultLocale;
508  },
509
510  /**
511   * Get country address data with all locales.
512   * @param {string} country
513   * @param {string} level1
514   * @returns {array<object>|null}
515   *          Return metadata of specific region with all the locales.
516   *          NOTE: The returned data may be for a default region if the
517   *          specified one cannot be found. Callers who only want the specific
518   *          region should check the returned country code.
519   */
520  getCountryAddressDataWithLocales(country, level1) {
521    let metadata = this.getCountryAddressRawData(country, level1);
522    return metadata && [metadata.defaultLocale, ...metadata.locales];
523  },
524
525  /**
526   * Get the collators based on the specified country.
527   * @param   {string} country The specified country.
528   * @returns {array} An array containing several collator objects.
529   */
530  getSearchCollators(country) {
531    // TODO: Only one language should be used at a time per country. The locale
532    //       of the page should be taken into account to do this properly.
533    //       We are going to support more countries in bug 1370193 and this
534    //       should be addressed when we start to implement that bug.
535
536    if (!this._collators[country]) {
537      let dataset = this.getCountryAddressData(country);
538      let languages = dataset.languages || [dataset.lang];
539      let options = {
540        ignorePunctuation: true,
541        sensitivity: "base",
542        usage: "search",
543      };
544      this._collators[country] = languages.map(
545        lang => new Intl.Collator(lang, options)
546      );
547    }
548    return this._collators[country];
549  },
550
551  // Based on the list of fields abbreviations in
552  // https://github.com/googlei18n/libaddressinput/wiki/AddressValidationMetadata
553  FIELDS_LOOKUP: {
554    N: "name",
555    O: "organization",
556    A: "street-address",
557    S: "address-level1",
558    C: "address-level2",
559    D: "address-level3",
560    Z: "postal-code",
561    n: "newLine",
562  },
563
564  /**
565   * Parse a country address format string and outputs an array of fields.
566   * Spaces, commas, and other literals are ignored in this implementation.
567   * For example, format string "%A%n%C, %S" should return:
568   * [
569   *   {fieldId: "street-address", newLine: true},
570   *   {fieldId: "address-level2"},
571   *   {fieldId: "address-level1"},
572   * ]
573   *
574   * @param   {string} fmt Country address format string
575   * @returns {array<object>} List of fields
576   */
577  parseAddressFormat(fmt) {
578    if (!fmt) {
579      throw new Error("fmt string is missing.");
580    }
581
582    return fmt.match(/%[^%]/g).reduce((parsed, part) => {
583      // Take the first letter of each segment and try to identify it
584      let fieldId = this.FIELDS_LOOKUP[part[1]];
585      // Early return if cannot identify part.
586      if (!fieldId) {
587        return parsed;
588      }
589      // If a new line is detected, add an attribute to the previous field.
590      if (fieldId == "newLine") {
591        let size = parsed.length;
592        if (size) {
593          parsed[size - 1].newLine = true;
594        }
595        return parsed;
596      }
597      return parsed.concat({ fieldId });
598    }, []);
599  },
600
601  /**
602   * Used to populate dropdowns in the UI (e.g. FormAutofill preferences, Web Payments).
603   * Use findAddressSelectOption for matching a value to a region.
604   *
605   * @param {string[]} subKeys An array of regionCode strings
606   * @param {string[]} subIsoids An array of ISO ID strings, if provided will be preferred over the key
607   * @param {string[]} subNames An array of regionName strings
608   * @param {string[]} subLnames An array of latinised regionName strings
609   * @returns {Map?} Returns null if subKeys or subNames are not truthy.
610   *                   Otherwise, a Map will be returned mapping keys -> names.
611   */
612  buildRegionMapIfAvailable(subKeys, subIsoids, subNames, subLnames) {
613    // Not all regions have sub_keys. e.g. DE
614    if (
615      !subKeys ||
616      !subKeys.length ||
617      (!subNames && !subLnames) ||
618      (subNames && subKeys.length != subNames.length) ||
619      (subLnames && subKeys.length != subLnames.length)
620    ) {
621      return null;
622    }
623
624    // Overwrite subKeys with subIsoids, when available
625    if (subIsoids && subIsoids.length && subIsoids.length == subKeys.length) {
626      for (let i = 0; i < subIsoids.length; i++) {
627        if (subIsoids[i]) {
628          subKeys[i] = subIsoids[i];
629        }
630      }
631    }
632
633    // Apply sub_lnames if sub_names does not exist
634    let names = subNames || subLnames;
635    return new Map(subKeys.map((key, index) => [key, names[index]]));
636  },
637
638  /**
639   * Parse a require string and outputs an array of fields.
640   * Spaces, commas, and other literals are ignored in this implementation.
641   * For example, a require string "ACS" should return:
642   * ["street-address", "address-level2", "address-level1"]
643   *
644   * @param   {string} requireString Country address require string
645   * @returns {array<string>} List of fields
646   */
647  parseRequireString(requireString) {
648    if (!requireString) {
649      throw new Error("requireString string is missing.");
650    }
651
652    return requireString.split("").map(fieldId => this.FIELDS_LOOKUP[fieldId]);
653  },
654
655  /**
656   * Use alternative country name list to identify a country code from a
657   * specified country name.
658   * @param   {string} countryName A country name to be identified
659   * @param   {string} [countrySpecified] A country code indicating that we only
660   *                                      search its alternative names if specified.
661   * @returns {string} The matching country code.
662   */
663  identifyCountryCode(countryName, countrySpecified) {
664    let countries = countrySpecified
665      ? [countrySpecified]
666      : [...FormAutofill.countries.keys()];
667
668    for (let country of countries) {
669      let collators = this.getSearchCollators(country);
670      let metadata = this.getCountryAddressData(country);
671      if (country != metadata.key) {
672        // We hit the fallback logic in getCountryAddressRawData so ignore it as
673        // it's not related to `country` and use the name from l10n instead.
674        metadata = {
675          id: `data/${country}`,
676          key: country,
677          name: FormAutofill.countries.get(country),
678        };
679      }
680      let alternativeCountryNames = metadata.alternative_names || [
681        metadata.name,
682      ];
683      let reAlternativeCountryNames = this._reAlternativeCountryNames[country];
684      if (!reAlternativeCountryNames) {
685        reAlternativeCountryNames = this._reAlternativeCountryNames[
686          country
687        ] = [];
688      }
689
690      for (let i = 0; i < alternativeCountryNames.length; i++) {
691        let name = alternativeCountryNames[i];
692        let reName = reAlternativeCountryNames[i];
693        if (!reName) {
694          reName = reAlternativeCountryNames[i] = new RegExp(
695            "\\b" + this.escapeRegExp(name) + "\\b",
696            "i"
697          );
698        }
699
700        if (
701          this.strCompare(name, countryName, collators) ||
702          reName.test(countryName)
703        ) {
704          return country;
705        }
706      }
707    }
708
709    return null;
710  },
711
712  findSelectOption(selectEl, record, fieldName) {
713    if (this.isAddressField(fieldName)) {
714      return this.findAddressSelectOption(selectEl, record, fieldName);
715    }
716    if (this.isCreditCardField(fieldName)) {
717      return this.findCreditCardSelectOption(selectEl, record, fieldName);
718    }
719    return null;
720  },
721
722  /**
723   * Try to find the abbreviation of the given sub-region name
724   * @param   {string[]} subregionValues A list of inferable sub-region values.
725   * @param   {string} [country] A country name to be identified.
726   * @returns {string} The matching sub-region abbreviation.
727   */
728  getAbbreviatedSubregionName(subregionValues, country) {
729    let values = Array.isArray(subregionValues)
730      ? subregionValues
731      : [subregionValues];
732
733    let collators = this.getSearchCollators(country);
734    for (let metadata of this.getCountryAddressDataWithLocales(country)) {
735      let {
736        sub_keys: subKeys,
737        sub_names: subNames,
738        sub_lnames: subLnames,
739      } = metadata;
740      if (!subKeys) {
741        // Not all regions have sub_keys. e.g. DE
742        continue;
743      }
744      // Apply sub_lnames if sub_names does not exist
745      subNames = subNames || subLnames;
746
747      let speculatedSubIndexes = [];
748      for (const val of values) {
749        let identifiedValue = this.identifyValue(
750          subKeys,
751          subNames,
752          val,
753          collators
754        );
755        if (identifiedValue) {
756          return identifiedValue;
757        }
758
759        // Predict the possible state by partial-matching if no exact match.
760        [subKeys, subNames].forEach(sub => {
761          speculatedSubIndexes.push(
762            sub.findIndex(token => {
763              let pattern = new RegExp(
764                "\\b" + this.escapeRegExp(token) + "\\b"
765              );
766
767              return pattern.test(val);
768            })
769          );
770        });
771      }
772      let subKey = subKeys[speculatedSubIndexes.find(i => !!~i)];
773      if (subKey) {
774        return subKey;
775      }
776    }
777    return null;
778  },
779
780  /**
781   * Find the option element from select element.
782   * 1. Try to find the locale using the country from address.
783   * 2. First pass try to find exact match.
784   * 3. Second pass try to identify values from address value and options,
785   *    and look for a match.
786   * @param   {DOMElement} selectEl
787   * @param   {object} address
788   * @param   {string} fieldName
789   * @returns {DOMElement}
790   */
791  findAddressSelectOption(selectEl, address, fieldName) {
792    let value = address[fieldName];
793    if (!value) {
794      return null;
795    }
796
797    let collators = this.getSearchCollators(address.country);
798
799    for (let option of selectEl.options) {
800      if (
801        this.strCompare(value, option.value, collators) ||
802        this.strCompare(value, option.text, collators)
803      ) {
804        return option;
805      }
806    }
807
808    switch (fieldName) {
809      case "address-level1": {
810        let { country } = address;
811        let identifiedValue = this.getAbbreviatedSubregionName(
812          [value],
813          country
814        );
815        // No point going any further if we cannot identify value from address level 1
816        if (!identifiedValue) {
817          return null;
818        }
819        for (let dataset of this.getCountryAddressDataWithLocales(country)) {
820          let keys = dataset.sub_keys;
821          if (!keys) {
822            // Not all regions have sub_keys. e.g. DE
823            continue;
824          }
825          // Apply sub_lnames if sub_names does not exist
826          let names = dataset.sub_names || dataset.sub_lnames;
827
828          // Go through options one by one to find a match.
829          // Also check if any option contain the address-level1 key.
830          let pattern = new RegExp(
831            "\\b" + this.escapeRegExp(identifiedValue) + "\\b",
832            "i"
833          );
834          for (let option of selectEl.options) {
835            let optionValue = this.identifyValue(
836              keys,
837              names,
838              option.value,
839              collators
840            );
841            let optionText = this.identifyValue(
842              keys,
843              names,
844              option.text,
845              collators
846            );
847            if (
848              identifiedValue === optionValue ||
849              identifiedValue === optionText ||
850              pattern.test(option.value)
851            ) {
852              return option;
853            }
854          }
855        }
856        break;
857      }
858      case "country": {
859        if (this.getCountryAddressData(value).alternative_names) {
860          for (let option of selectEl.options) {
861            if (
862              this.identifyCountryCode(option.text, value) ||
863              this.identifyCountryCode(option.value, value)
864            ) {
865              return option;
866            }
867          }
868        }
869        break;
870      }
871    }
872
873    return null;
874  },
875
876  findCreditCardSelectOption(selectEl, creditCard, fieldName) {
877    let oneDigitMonth = creditCard["cc-exp-month"]
878      ? creditCard["cc-exp-month"].toString()
879      : null;
880    let twoDigitsMonth = oneDigitMonth ? oneDigitMonth.padStart(2, "0") : null;
881    let fourDigitsYear = creditCard["cc-exp-year"]
882      ? creditCard["cc-exp-year"].toString()
883      : null;
884    let twoDigitsYear = fourDigitsYear ? fourDigitsYear.substr(2, 2) : null;
885    let options = Array.from(selectEl.options);
886
887    switch (fieldName) {
888      case "cc-exp-month": {
889        if (!oneDigitMonth) {
890          return null;
891        }
892        for (let option of options) {
893          if (
894            [option.text, option.label, option.value].some(s => {
895              let result = /[1-9]\d*/.exec(s);
896              return result && result[0] == oneDigitMonth;
897            })
898          ) {
899            return option;
900          }
901        }
902        break;
903      }
904      case "cc-exp-year": {
905        if (!fourDigitsYear) {
906          return null;
907        }
908        for (let option of options) {
909          if (
910            [option.text, option.label, option.value].some(
911              s => s == twoDigitsYear || s == fourDigitsYear
912            )
913          ) {
914            return option;
915          }
916        }
917        break;
918      }
919      case "cc-exp": {
920        if (!oneDigitMonth || !fourDigitsYear) {
921          return null;
922        }
923        let patterns = [
924          oneDigitMonth + "/" + twoDigitsYear, // 8/22
925          oneDigitMonth + "/" + fourDigitsYear, // 8/2022
926          twoDigitsMonth + "/" + twoDigitsYear, // 08/22
927          twoDigitsMonth + "/" + fourDigitsYear, // 08/2022
928          oneDigitMonth + "-" + twoDigitsYear, // 8-22
929          oneDigitMonth + "-" + fourDigitsYear, // 8-2022
930          twoDigitsMonth + "-" + twoDigitsYear, // 08-22
931          twoDigitsMonth + "-" + fourDigitsYear, // 08-2022
932          twoDigitsYear + "-" + twoDigitsMonth, // 22-08
933          fourDigitsYear + "-" + twoDigitsMonth, // 2022-08
934          fourDigitsYear + "/" + oneDigitMonth, // 2022/8
935          twoDigitsMonth + twoDigitsYear, // 0822
936          twoDigitsYear + twoDigitsMonth, // 2208
937        ];
938
939        for (let option of options) {
940          if (
941            [option.text, option.label, option.value].some(str =>
942              patterns.some(pattern => str.includes(pattern))
943            )
944          ) {
945            return option;
946          }
947        }
948        break;
949      }
950      case "cc-type": {
951        let network = creditCard["cc-type"] || "";
952        for (let option of options) {
953          if (
954            [option.text, option.label, option.value].some(
955              s => s.trim().toLowerCase() == network
956            )
957          ) {
958            return option;
959          }
960        }
961        break;
962      }
963    }
964
965    return null;
966  },
967
968  /**
969   * Try to match value with keys and names, but always return the key.
970   * @param   {array<string>} keys
971   * @param   {array<string>} names
972   * @param   {string} value
973   * @param   {array} collators
974   * @returns {string}
975   */
976  identifyValue(keys, names, value, collators) {
977    let resultKey = keys.find(key => this.strCompare(value, key, collators));
978    if (resultKey) {
979      return resultKey;
980    }
981
982    let index = names.findIndex(name =>
983      this.strCompare(value, name, collators)
984    );
985    if (index !== -1) {
986      return keys[index];
987    }
988
989    return null;
990  },
991
992  /**
993   * Compare if two strings are the same.
994   * @param   {string} a
995   * @param   {string} b
996   * @param   {array} collators
997   * @returns {boolean}
998   */
999  strCompare(a = "", b = "", collators) {
1000    return collators.some(collator => !collator.compare(a, b));
1001  },
1002
1003  /**
1004   * Escaping user input to be treated as a literal string within a regular
1005   * expression.
1006   * @param   {string} string
1007   * @returns {string}
1008   */
1009  escapeRegExp(string) {
1010    return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1011  },
1012
1013  /**
1014   * Get formatting information of a given country
1015   * @param   {string} country
1016   * @returns {object}
1017   *         {
1018   *           {string} addressLevel3Label
1019   *           {string} addressLevel2Label
1020   *           {string} addressLevel1Label
1021   *           {string} postalCodeLabel
1022   *           {object} fieldsOrder
1023   *           {string} postalCodePattern
1024   *         }
1025   */
1026  getFormFormat(country) {
1027    let dataset = this.getCountryAddressData(country);
1028    // We hit a country fallback in `getCountryAddressRawData` but it's not relevant here.
1029    if (country != dataset.key) {
1030      // Use a sparse object so the below default values take effect.
1031      dataset = {
1032        /**
1033         * Even though data/ZZ only has address-level2, include the other levels
1034         * in case they are needed for unknown countries. Users can leave the
1035         * unnecessary fields blank which is better than forcing users to enter
1036         * the data in incorrect fields.
1037         */
1038        fmt: "%N%n%O%n%A%n%C %S %Z",
1039      };
1040    }
1041    return {
1042      // When particular values are missing for a country, the
1043      // data/ZZ value should be used instead:
1044      // https://chromium-i18n.appspot.com/ssl-aggregate-address/data/ZZ
1045      addressLevel3Label: dataset.sublocality_name_type || "suburb",
1046      addressLevel2Label: dataset.locality_name_type || "city",
1047      addressLevel1Label: dataset.state_name_type || "province",
1048      addressLevel1Options: this.buildRegionMapIfAvailable(
1049        dataset.sub_keys,
1050        dataset.sub_isoids,
1051        dataset.sub_names,
1052        dataset.sub_lnames
1053      ),
1054      countryRequiredFields: this.parseRequireString(dataset.require || "AC"),
1055      fieldsOrder: this.parseAddressFormat(dataset.fmt || "%N%n%O%n%A%n%C"),
1056      postalCodeLabel: dataset.zip_name_type || "postalCode",
1057      postalCodePattern: dataset.zip,
1058    };
1059  },
1060
1061  /**
1062   * Localize "data-localization" or "data-localization-region" attributes.
1063   * @param {Element} element
1064   * @param {string} attributeName
1065   */
1066  localizeAttributeForElement(element, attributeName) {
1067    switch (attributeName) {
1068      case "data-localization": {
1069        element.textContent = this.stringBundle.GetStringFromName(
1070          element.getAttribute(attributeName)
1071        );
1072        element.removeAttribute(attributeName);
1073        break;
1074      }
1075      case "data-localization-region": {
1076        let regionCode = element.getAttribute(attributeName);
1077        element.textContent = Services.intl.getRegionDisplayNames(undefined, [
1078          regionCode,
1079        ]);
1080        element.removeAttribute(attributeName);
1081        return;
1082      }
1083      default:
1084        throw new Error("Unexpected attributeName");
1085    }
1086  },
1087
1088  /**
1089   * Localize elements with "data-localization" or "data-localization-region" attributes.
1090   * @param {Element} root
1091   */
1092  localizeMarkup(root) {
1093    let elements = root.querySelectorAll("[data-localization]");
1094    for (let element of elements) {
1095      this.localizeAttributeForElement(element, "data-localization");
1096    }
1097
1098    elements = root.querySelectorAll("[data-localization-region]");
1099    for (let element of elements) {
1100      this.localizeAttributeForElement(element, "data-localization-region");
1101    }
1102  },
1103};
1104
1105this.log = null;
1106FormAutofill.defineLazyLogGetter(this, EXPORTED_SYMBOLS[0]);
1107
1108XPCOMUtils.defineLazyGetter(FormAutofillUtils, "stringBundle", function() {
1109  return Services.strings.createBundle(
1110    "chrome://formautofill/locale/formautofill.properties"
1111  );
1112});
1113
1114XPCOMUtils.defineLazyGetter(FormAutofillUtils, "brandBundle", function() {
1115  return Services.strings.createBundle(
1116    "chrome://branding/locale/brand.properties"
1117  );
1118});
1119
1120XPCOMUtils.defineLazyPreferenceGetter(
1121  FormAutofillUtils,
1122  "_reauthEnabledByUser",
1123  "extensions.formautofill.reauth.enabled",
1124  false
1125);
1126