--- a/browser/extensions/formautofill/FormAutofillUtils.jsm
+++ b/browser/extensions/formautofill/FormAutofillUtils.jsm
@@ -5,16 +5,22 @@
"use strict";
this.EXPORTED_SYMBOLS = ["FormAutofillUtils"];
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
const ADDRESS_REFERENCES = "chrome://formautofill/content/addressReferences.js";
+// TODO: We only support US in MVP. We are going to support more countries in
+// bug 1370193.
+const ALTERNATIVE_COUNTRY_NAMES = {
+ "US": ["US", "United States of America", "United States", "America", "U.S.", "USA", "U.S.A.", "U.S.A"],
+};
+
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
this.FormAutofillUtils = {
get AUTOFILL_FIELDS_THRESHOLD() { return 3; },
_fieldNameInfo: {
"name": "name",
@@ -41,16 +47,18 @@ this.FormAutofillUtils = {
"tel-extension": "tel",
"email": "email",
"cc-name": "creditCard",
"cc-number": "creditCard",
"cc-exp-month": "creditCard",
"cc-exp-year": "creditCard",
},
_addressDataLoaded: false,
+ _collators: {},
+ _reAlternativeCountryNames: {},
isAddressField(fieldName) {
return !!this._fieldNameInfo[fieldName] && !this.isCreditCardField(fieldName);
},
isCreditCardField(fieldName) {
return this._fieldNameInfo[fieldName] == "creditCard";
},
@@ -213,108 +221,183 @@ this.FormAutofillUtils = {
if (!this._addressDataLoaded) {
Object.assign(this, this.loadDataFromScript(ADDRESS_REFERENCES));
this._addressDataLoaded = true;
}
return this.addressData[`data/${country}`] || this.addressData["data/US"];
},
/**
+ * Get the collators based on the specified country.
+ * @param {string} country The specified country.
+ * @returns {array} An array containing several collator objects.
+ */
+ getCollators(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.languages.split("~") : [dataset.lang];
+ this._collators[country] = languages.map(lang => new Intl.Collator(lang, {sensitivity: "base", ignorePunctuation: true}));
+ }
+ return this._collators[country];
+ },
+
+ /**
+ * 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] : Object.keys(ALTERNATIVE_COUNTRY_NAMES);
+
+ for (let country of countries) {
+ let collators = this.getCollators(country);
+
+ let alternativeCountryNames = ALTERNATIVE_COUNTRY_NAMES[country];
+ 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;
+ },
+
+ /**
* 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}
*/
findSelectOption(selectEl, address, fieldName) {
let value = address[fieldName];
if (!value) {
return null;
}
- let dataset = this.getCountryAddressData(address.country);
- let collator = new Intl.Collator(dataset.lang, {sensitivity: "base", ignorePunctuation: true});
+ let country = address.country || this.DEFAULT_COUNTRY_CODE;
+ let dataset = this.getCountryAddressData(country);
+ let collators = this.getCollators(country);
for (let option of selectEl.options) {
- if (this.strCompare(value, option.value, collator) ||
- this.strCompare(value, option.text, collator)) {
+ if (this.strCompare(value, option.value, collators) ||
+ this.strCompare(value, option.text, collators)) {
return option;
}
}
- if (fieldName === "address-level1") {
- if (!Array.isArray(dataset.sub_keys)) {
- dataset.sub_keys = dataset.sub_keys.split("~");
- }
- if (!Array.isArray(dataset.sub_names)) {
- dataset.sub_names = dataset.sub_names.split("~");
- }
- let keys = dataset.sub_keys;
- let names = dataset.sub_names;
- let identifiedValue = this.identifyValue(keys, names, value, collator);
+ switch (fieldName) {
+ case "address-level1": {
+ if (!Array.isArray(dataset.sub_keys)) {
+ dataset.sub_keys = dataset.sub_keys.split("~");
+ }
+ if (!Array.isArray(dataset.sub_names)) {
+ dataset.sub_names = dataset.sub_names.split("~");
+ }
+ let keys = dataset.sub_keys;
+ let names = dataset.sub_names;
+ let identifiedValue = this.identifyValue(keys, names, value, collators);
- // No point going any further if we cannot identify value from address
- if (identifiedValue === undefined) {
- return null;
+ // No point going any further if we cannot identify value from address
+ if (!identifiedValue) {
+ return null;
+ }
+
+ // 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;
}
-
- // 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${identifiedValue}\\b`, "i");
- for (let option of selectEl.options) {
- let optionValue = this.identifyValue(keys, names, option.value, collator);
- let optionText = this.identifyValue(keys, names, option.text, collator);
- if (identifiedValue === optionValue || identifiedValue === optionText || pattern.test(option.value)) {
- return option;
+ case "country": {
+ if (ALTERNATIVE_COUNTRY_NAMES[value]) {
+ for (let option of selectEl.options) {
+ if (this.identifyCountryCode(option.text, value) || this.identifyCountryCode(option.value, value)) {
+ return option;
+ }
+ }
}
+ break;
}
}
- if (fieldName === "country") {
- // TODO: Support matching countries (Bug 1375382)
- }
-
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 {object} collator
+ * @param {array} collators
* @returns {string}
*/
- identifyValue(keys, names, value, collator) {
- let resultKey = keys.find(key => this.strCompare(value, key, collator));
+ 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, collator));
+ 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 {object} collator
+ * @param {array} collators
* @returns {boolean}
*/
- strCompare(a = "", b = "", collator) {
- return !collator.compare(a, b);
+ 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} addressLevel1Label
@@ -339,10 +422,14 @@ this.FormAutofillUtils = {
let elements = root.querySelectorAll("[data-localization]");
for (let element of elements) {
element.textContent = bundle.GetStringFromName(element.getAttribute("data-localization"));
element.removeAttribute("data-localization");
}
},
};
+XPCOMUtils.defineLazyGetter(this.FormAutofillUtils, "DEFAULT_COUNTRY_CODE", () => {
+ return Services.prefs.getCharPref("browser.search.countryCode", "US");
+});
+
this.log = null;
this.FormAutofillUtils.defineLazyLogGetter(this, this.EXPORTED_SYMBOLS[0]);
--- a/browser/extensions/formautofill/ProfileStorage.jsm
+++ b/browser/extensions/formautofill/ProfileStorage.jsm
@@ -1211,19 +1211,17 @@ class Addresses extends AutofillRecords
address["country-name"] = "";
}
hasNewComputedFields = true;
}
// Compute tel
if (!("tel-national" in address)) {
if (address.tel) {
- // Set "US" as the default region as we only support "en-US" for now.
- let browserCountryCode = Services.prefs.getCharPref("browser.search.countryCode", "US");
- let tel = PhoneNumber.Parse(address.tel, address.country || browserCountryCode);
+ let tel = PhoneNumber.Parse(address.tel, address.country || FormAutofillUtils.DEFAULT_COUNTRY_CODE);
if (tel) {
if (tel.countryCode) {
address["tel-country-code"] = tel.countryCode;
}
if (tel.nationalNumber) {
address["tel-national"] = tel.nationalNumber;
}
@@ -1296,43 +1294,40 @@ class Addresses extends AutofillRecords
if (!address["street-address"]) {
address["street-address"] = STREET_ADDRESS_COMPONENTS.map(c => address[c]).join("\n");
}
STREET_ADDRESS_COMPONENTS.forEach(c => delete address[c]);
}
_normalizeCountry(address) {
+ let country;
+
if (address.country) {
- let country = address.country.toUpperCase();
- // Only values included in the region list will be saved.
- if (REGION_NAMES[country]) {
- address.country = country;
- } else {
- delete address.country;
- }
+ country = address.country.toUpperCase();
} else if (address["country-name"]) {
- for (let region in REGION_NAMES) {
- if (REGION_NAMES[region].toLowerCase() == address["country-name"].toLowerCase()) {
- address.country = region;
- break;
- }
- }
+ country = FormAutofillUtils.identifyCountryCode(address["country-name"]);
}
+
+ // Only values included in the region list will be saved.
+ if (country && REGION_NAMES[country]) {
+ address.country = country;
+ } else {
+ delete address.country;
+ }
+
delete address["country-name"];
}
_normalizeTel(address) {
if (!address.tel && TEL_COMPONENTS.every(c => !address[c])) {
return;
}
- // Set "US" as the default region as we only support "en-US" for now.
- let browserCountryCode = Services.prefs.getCharPref("browser.search.countryCode", "US");
- let region = address["tel-country-code"] || address.country || browserCountryCode;
+ let region = address["tel-country-code"] || address.country || FormAutofillUtils.DEFAULT_COUNTRY_CODE;
let number;
if (address.tel) {
number = address.tel;
} else if (address["tel-national"]) {
number = address["tel-national"];
} else if (address["tel-local"]) {
number = (address["tel-area-code"] || "") + address["tel-local"];
--- a/browser/extensions/formautofill/test/unit/test_autofillFormFields.js
+++ b/browser/extensions/formautofill/test/unit/test_autofillFormFields.js
@@ -247,36 +247,55 @@ const TESTCASES_INPUT_UNCHANGED = [
},
expectedResult: {
"country": "US",
"state": "",
},
},
];
-const TESTCASES_US_STATES = [
+const TESTCASES_FILL_SELECT = [
+ // US States
{
- description: "Form with US states select elements; with lower case state key",
+ description: "Form with US states select elements",
document: `<form><select id="state" autocomplete="shipping address-level1">
<option value=""></option>
<option value="CA">California</option>
</select></form>`,
addressFieldDetails: [
{"section": "", "addressType": "shipping", "contactType": "", "fieldName": "address-level1", "element": {}},
],
profileData: {
"guid": "123",
"country": "US",
- "address-level1": "ca",
+ "address-level1": "CA",
},
expectedResult: {
"state": "CA",
},
},
{
+ description: "Form with US states select elements; with lower case state key",
+ document: `<form><select id="state" autocomplete="shipping address-level1">
+ <option value=""></option>
+ <option value="ca">ca</option>
+ </select></form>`,
+ addressFieldDetails: [
+ {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "address-level1", "element": {}},
+ ],
+ profileData: {
+ "guid": "123",
+ "country": "US",
+ "address-level1": "CA",
+ },
+ expectedResult: {
+ "state": "ca",
+ },
+ },
+ {
description: "Form with US states select elements; with state name and extra spaces",
document: `<form><select id="state" autocomplete="shipping address-level1">
<option value=""></option>
<option value="CA">CA</option>
</select></form>`,
addressFieldDetails: [
{"section": "", "addressType": "shipping", "contactType": "", "fieldName": "address-level1", "element": {}},
],
@@ -302,16 +321,103 @@ const TESTCASES_US_STATES = [
"guid": "123",
"country": "US",
"address-level1": "WA",
},
expectedResult: {
"state": "US-WA",
},
},
+
+ // Country
+ {
+ description: "Form with country select elements",
+ document: `<form><select id="country" autocomplete="country">
+ <option value=""></option>
+ <option value="US">United States</option>
+ </select></form>`,
+ addressFieldDetails: [
+ {"section": "", "addressType": "", "contactType": "", "fieldName": "country", "element": {}},
+ ],
+ profileData: {
+ "guid": "123",
+ "country": "US",
+ },
+ expectedResult: {
+ "country": "US",
+ },
+ },
+ {
+ description: "Form with country select elements; with lower case key",
+ document: `<form><select id="country" autocomplete="country">
+ <option value=""></option>
+ <option value="us">us</option>
+ </select></form>`,
+ addressFieldDetails: [
+ {"section": "", "addressType": "", "contactType": "", "fieldName": "country", "element": {}},
+ ],
+ profileData: {
+ "guid": "123",
+ "country": "US",
+ },
+ expectedResult: {
+ "country": "us",
+ },
+ },
+ {
+ description: "Form with country select elements; with alternative name 1",
+ document: `<form><select id="country" autocomplete="country">
+ <option value=""></option>
+ <option value="XX">United States</option>
+ </select></form>`,
+ addressFieldDetails: [
+ {"section": "", "addressType": "", "contactType": "", "fieldName": "country", "element": {}},
+ ],
+ profileData: {
+ "guid": "123",
+ "country": "US",
+ },
+ expectedResult: {
+ "country": "XX",
+ },
+ },
+ {
+ description: "Form with country select elements; with alternative name 2",
+ document: `<form><select id="country" autocomplete="country">
+ <option value=""></option>
+ <option value="XX">America</option>
+ </select></form>`,
+ addressFieldDetails: [
+ {"section": "", "addressType": "", "contactType": "", "fieldName": "country", "element": {}},
+ ],
+ profileData: {
+ "guid": "123",
+ "country": "US",
+ },
+ expectedResult: {
+ "country": "XX",
+ },
+ },
+ {
+ description: "Form with country select elements; with partial matching value",
+ document: `<form><select id="country" autocomplete="country">
+ <option value=""></option>
+ <option value="XX">Ship to America</option>
+ </select></form>`,
+ addressFieldDetails: [
+ {"section": "", "addressType": "", "contactType": "", "fieldName": "country", "element": {}},
+ ],
+ profileData: {
+ "guid": "123",
+ "country": "US",
+ },
+ expectedResult: {
+ "country": "XX",
+ },
+ },
];
function do_test(testcases, testFn) {
for (let tc of testcases) {
(function() {
let testcase = tc;
add_task(async function() {
do_print("Starting testcase: " + testcase.description);
@@ -383,17 +489,17 @@ do_test(TESTCASES_INPUT_UNCHANGED, (test
reject(`${event.type} event should not fire`);
};
element.addEventListener("change", cleaner);
element.addEventListener("input", cleaner);
}),
];
});
-do_test(TESTCASES_US_STATES, (testcase, element) => {
+do_test(TESTCASES_FILL_SELECT, (testcase, element) => {
let id = element.id;
return [
new Promise(resolve => {
element.addEventListener("input", () => {
Assert.equal(element.value, testcase.expectedResult[id],
"Check the " + id + " field was filled with correct data");
resolve();
}, {once: true});
--- a/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js
+++ b/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js
@@ -112,118 +112,141 @@ const TESTCASES = [
},
{
description: "Form with exact matching options in select",
document: `<form>
<select autocomplete="address-level1">
<option id="option-address-level1-XX" value="XX">Dummy</option>
<option id="option-address-level1-CA" value="CA">California</option>
</select>
+ <select autocomplete="country">
+ <option id="option-country-XX" value="XX">Dummy</option>
+ <option id="option-country-US" value="US">United States</option>
+ </select>
</form>`,
profileData: [Object.assign({}, DEFAULT_PROFILE)],
expectedResult: [{
"guid": "123",
"street-address": "2 Harrison St\nline2\nline3",
"-moz-street-address-one-line": "2 Harrison St line2 line3",
"address-line1": "2 Harrison St",
"address-line2": "line2",
"address-line3": "line3",
"address-level1": "CA",
"country": "US",
}],
expectedOptionElements: [{
"address-level1": "option-address-level1-CA",
+ "country": "option-country-US",
}],
},
{
description: "Form with inexact matching options in select",
document: `<form>
<select autocomplete="address-level1">
<option id="option-address-level1-XX" value="XX">Dummy</option>
<option id="option-address-level1-OO" value="OO">California</option>
</select>
+ <select autocomplete="country">
+ <option id="option-country-XX" value="XX">Dummy</option>
+ <option id="option-country-OO" value="OO">United States</option>
+ </select>
</form>`,
profileData: [Object.assign({}, DEFAULT_PROFILE)],
expectedResult: [{
"guid": "123",
"street-address": "2 Harrison St\nline2\nline3",
"-moz-street-address-one-line": "2 Harrison St line2 line3",
"address-line1": "2 Harrison St",
"address-line2": "line2",
"address-line3": "line3",
"address-level1": "CA",
"country": "US",
}],
expectedOptionElements: [{
"address-level1": "option-address-level1-OO",
+ "country": "option-country-OO",
}],
},
{
description: "Form with value-omitted options in select",
document: `<form>
<select autocomplete="address-level1">
<option id="option-address-level1-1" value="">Dummy</option>
<option id="option-address-level1-2" value="">California</option>
</select>
+ <select autocomplete="country">
+ <option id="option-country-1" value="">Dummy</option>
+ <option id="option-country-2" value="">United States</option>
+ </select>
</form>`,
profileData: [Object.assign({}, DEFAULT_PROFILE)],
expectedResult: [{
"guid": "123",
"street-address": "2 Harrison St\nline2\nline3",
"-moz-street-address-one-line": "2 Harrison St line2 line3",
"address-line1": "2 Harrison St",
"address-line2": "line2",
"address-line3": "line3",
"address-level1": "CA",
"country": "US",
}],
expectedOptionElements: [{
"address-level1": "option-address-level1-2",
+ "country": "option-country-2",
}],
},
{
description: "Form with options with the same value in select",
document: `<form>
<select autocomplete="address-level1">
<option id="option-address-level1-same1" value="same">Dummy</option>
<option id="option-address-level1-same2" value="same">California</option>
</select>
+ <select autocomplete="country">
+ <option id="option-country-same1" value="sametoo">Dummy</option>
+ <option id="option-country-same2" value="sametoo">United States</option>
+ </select>
</form>`,
profileData: [Object.assign({}, DEFAULT_PROFILE)],
expectedResult: [{
"guid": "123",
"street-address": "2 Harrison St\nline2\nline3",
"-moz-street-address-one-line": "2 Harrison St line2 line3",
"address-line1": "2 Harrison St",
"address-line2": "line2",
"address-line3": "line3",
"address-level1": "CA",
"country": "US",
}],
expectedOptionElements: [{
"address-level1": "option-address-level1-same2",
+ "country": "option-country-same2",
}],
},
{
description: "Form without matching options in select",
document: `<form>
<select autocomplete="address-level1">
<option id="option-address-level1-dummy1" value="">Dummy</option>
<option id="option-address-level1-dummy2" value="">Dummy 2</option>
</select>
+ <select autocomplete="country">
+ <option id="option-country-dummy1" value="">Dummy</option>
+ <option id="option-country-dummy2" value="">Dummy 2</option>
+ </select>
</form>`,
profileData: [Object.assign({}, DEFAULT_PROFILE)],
expectedResult: [{
"guid": "123",
"street-address": "2 Harrison St\nline2\nline3",
"-moz-street-address-one-line": "2 Harrison St line2 line3",
"address-line1": "2 Harrison St",
"address-line2": "line2",
"address-line3": "line3",
- "country": "US",
}],
},
];
for (let testcase of TESTCASES) {
add_task(async function() {
do_print("Starting testcase: " + testcase.description);
--- a/browser/extensions/formautofill/test/unit/test_transformFields.js
+++ b/browser/extensions/formautofill/test/unit/test_transformFields.js
@@ -305,16 +305,46 @@ const ADDRESS_NORMALIZE_TESTCASES = [
"country-name": "united states",
},
expectedResult: {
"country": "US",
"country-name": "United States",
},
},
{
+ description: "Has alternative \"country-name\"",
+ address: {
+ "country-name": "america",
+ },
+ expectedResult: {
+ "country": "US",
+ "country-name": "United States",
+ },
+ },
+ {
+ description: "Has \"country-name\" as a substring",
+ address: {
+ "country-name": "test america test",
+ },
+ expectedResult: {
+ "country": "US",
+ "country-name": "United States",
+ },
+ },
+ {
+ description: "Has \"country-name\" as part of a word",
+ address: {
+ "country-name": "TRUST",
+ },
+ expectedResult: {
+ "country": undefined,
+ "country-name": "",
+ },
+ },
+ {
description: "Has unknown \"country-name\"",
address: {
"country-name": "unknown country name",
},
expectedResult: {
"country": undefined,
"country-name": "",
},