diff options
Diffstat (limited to 'toolkit/components/formautofill/phonenumberutils/PhoneNumber.jsm')
-rw-r--r-- | toolkit/components/formautofill/phonenumberutils/PhoneNumber.jsm | 483 |
1 files changed, 483 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/phonenumberutils/PhoneNumber.jsm b/toolkit/components/formautofill/phonenumberutils/PhoneNumber.jsm new file mode 100644 index 0000000000..b773e35f77 --- /dev/null +++ b/toolkit/components/formautofill/phonenumberutils/PhoneNumber.jsm @@ -0,0 +1,483 @@ +/* This Source Code Form is subject to the terms of the Apache License, Version + * 2.0. If a copy of the Apache License was not distributed with this file, You + * can obtain one at https://www.apache.org/licenses/LICENSE-2.0 */ + +// This library came from https://github.com/andreasgal/PhoneNumber.js but will +// be further maintained by our own in Form Autofill codebase. + +"use strict"; + +var EXPORTED_SYMBOLS = ["PhoneNumber"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const { PHONE_NUMBER_META_DATA } = ChromeUtils.import( + "resource://autofill/phonenumberutils/PhoneNumberMetaData.jsm" +); + +const lazy = {}; + +XPCOMUtils.defineLazyModuleGetters(lazy, { + PhoneNumberNormalizer: + "resource://autofill/phonenumberutils/PhoneNumberNormalizer.jsm", +}); + +var PhoneNumber = (function(dataBase) { + const MAX_PHONE_NUMBER_LENGTH = 50; + const NON_ALPHA_CHARS = /[^a-zA-Z]/g; + const NON_DIALABLE_CHARS = /[^,#+\*\d]/g; + const NON_DIALABLE_CHARS_ONCE = new RegExp(NON_DIALABLE_CHARS.source); + const SPLIT_FIRST_GROUP = /^(\d+)(.*)$/; + const LEADING_PLUS_CHARS_PATTERN = /^[+\uFF0B]+/g; + + // Format of the string encoded meta data. If the name contains "^" or "$" + // we will generate a regular expression from the value, with those special + // characters as prefix/suffix. + const META_DATA_ENCODING = [ + "region", + "^(?:internationalPrefix)", + "nationalPrefix", + "^(?:nationalPrefixForParsing)", + "nationalPrefixTransformRule", + "nationalPrefixFormattingRule", + "^possiblePattern$", + "^nationalPattern$", + "formats", + ]; + + const FORMAT_ENCODING = [ + "^pattern$", + "nationalFormat", + "^leadingDigits", + "nationalPrefixFormattingRule", + "internationalFormat", + ]; + + let regionCache = Object.create(null); + + // Parse an array of strings into a convenient object. We store meta + // data as arrays since thats much more compact than JSON. + function ParseArray(array, encoding, obj) { + for (let n = 0; n < encoding.length; ++n) { + let value = array[n]; + if (!value) { + continue; + } + let field = encoding[n]; + let fieldAlpha = field.replace(NON_ALPHA_CHARS, ""); + if (field != fieldAlpha) { + value = new RegExp(field.replace(fieldAlpha, value)); + } + obj[fieldAlpha] = value; + } + return obj; + } + + // Parse string encoded meta data into a convenient object + // representation. + function ParseMetaData(countryCode, md) { + let array = JSON.parse(md); + md = ParseArray(array, META_DATA_ENCODING, { countryCode }); + regionCache[md.region] = md; + return md; + } + + // Parse string encoded format data into a convenient object + // representation. + function ParseFormat(md) { + let formats = md.formats; + if (!formats) { + return; + } + // Bail if we already parsed the format definitions. + if (!Array.isArray(formats[0])) { + return; + } + for (let n = 0; n < formats.length; ++n) { + formats[n] = ParseArray(formats[n], FORMAT_ENCODING, {}); + } + } + + // Search for the meta data associated with a region identifier ("US") in + // our database, which is indexed by country code ("1"). Since we have + // to walk the entire database for this, we cache the result of the lookup + // for future reference. + function FindMetaDataForRegion(region) { + // Check in the region cache first. This will find all entries we have + // already resolved (parsed from a string encoding). + let md = regionCache[region]; + if (md) { + return md; + } + for (let countryCode in dataBase) { + let entry = dataBase[countryCode]; + // Each entry is a string encoded object of the form '["US..', or + // an array of strings. We don't want to parse the string here + // to save memory, so we just substring the region identifier + // and compare it. For arrays, we compare against all region + // identifiers with that country code. We skip entries that are + // of type object, because they were already resolved (parsed into + // an object), and their country code should have been in the cache. + if (Array.isArray(entry)) { + for (let n = 0; n < entry.length; n++) { + if (typeof entry[n] == "string" && entry[n].substr(2, 2) == region) { + if (n > 0) { + // Only the first entry has the formats field set. + // Parse the main country if we haven't already and use + // the formats field from the main country. + if (typeof entry[0] == "string") { + entry[0] = ParseMetaData(countryCode, entry[0]); + } + let formats = entry[0].formats; + let current = ParseMetaData(countryCode, entry[n]); + current.formats = formats; + entry[n] = current; + return entry[n]; + } + + entry[n] = ParseMetaData(countryCode, entry[n]); + return entry[n]; + } + } + continue; + } + if (typeof entry == "string" && entry.substr(2, 2) == region) { + dataBase[countryCode] = ParseMetaData(countryCode, entry); + return dataBase[countryCode]; + } + } + } + + // Format a national number for a given region. The boolean flag "intl" + // indicates whether we want the national or international format. + function FormatNumber(regionMetaData, number, intl) { + // We lazily parse the format description in the meta data for the region, + // so make sure to parse it now if we haven't already done so. + ParseFormat(regionMetaData); + let formats = regionMetaData.formats; + if (!formats) { + return null; + } + for (let n = 0; n < formats.length; ++n) { + let format = formats[n]; + // The leading digits field is optional. If we don't have it, just + // use the matching pattern to qualify numbers. + if (format.leadingDigits && !format.leadingDigits.test(number)) { + continue; + } + if (!format.pattern.test(number)) { + continue; + } + if (intl) { + // If there is no international format, just fall back to the national + // format. + let internationalFormat = format.internationalFormat; + if (!internationalFormat) { + internationalFormat = format.nationalFormat; + } + // Some regions have numbers that can't be dialed from outside the + // country, indicated by "NA" for the international format of that + // number format pattern. + if (internationalFormat == "NA") { + return null; + } + // Prepend "+" and the country code. + number = + "+" + + regionMetaData.countryCode + + " " + + number.replace(format.pattern, internationalFormat); + } else { + number = number.replace(format.pattern, format.nationalFormat); + // The region has a national prefix formatting rule, and it can be overwritten + // by each actual number format rule. + let nationalPrefixFormattingRule = + regionMetaData.nationalPrefixFormattingRule; + if (format.nationalPrefixFormattingRule) { + nationalPrefixFormattingRule = format.nationalPrefixFormattingRule; + } + if (nationalPrefixFormattingRule) { + // The prefix formatting rule contains two magic markers, "$NP" and "$FG". + // "$NP" will be replaced by the national prefix, and "$FG" with the + // first group of numbers. + let match = number.match(SPLIT_FIRST_GROUP); + if (match) { + let firstGroup = match[1]; + let rest = match[2]; + let prefix = nationalPrefixFormattingRule; + prefix = prefix.replace("$NP", regionMetaData.nationalPrefix); + prefix = prefix.replace("$FG", firstGroup); + number = prefix + rest; + } + } + } + return number == "NA" ? null : number; + } + return null; + } + + function NationalNumber(regionMetaData, number) { + this.region = regionMetaData.region; + this.regionMetaData = regionMetaData; + this.number = number; + } + + // NationalNumber represents the result of parsing a phone number. We have + // three getters on the prototype that format the number in national and + // international format. Once called, the getters put a direct property + // onto the object, caching the result. + NationalNumber.prototype = { + // +1 949-726-2896 + get internationalFormat() { + let value = FormatNumber(this.regionMetaData, this.number, true); + Object.defineProperty(this, "internationalFormat", { + value, + enumerable: true, + }); + return value; + }, + // (949) 726-2896 + get nationalFormat() { + let value = FormatNumber(this.regionMetaData, this.number, false); + Object.defineProperty(this, "nationalFormat", { + value, + enumerable: true, + }); + return value; + }, + // +19497262896 + get internationalNumber() { + let value = this.internationalFormat + ? this.internationalFormat.replace(NON_DIALABLE_CHARS, "") + : null; + Object.defineProperty(this, "internationalNumber", { + value, + enumerable: true, + }); + return value; + }, + // 9497262896 + get nationalNumber() { + let value = this.nationalFormat + ? this.nationalFormat.replace(NON_DIALABLE_CHARS, "") + : null; + Object.defineProperty(this, "nationalNumber", { + value, + enumerable: true, + }); + return value; + }, + // country name 'US' + get countryName() { + let value = this.region ? this.region : null; + Object.defineProperty(this, "countryName", { value, enumerable: true }); + return value; + }, + // country code '+1' + get countryCode() { + let value = this.regionMetaData.countryCode + ? "+" + this.regionMetaData.countryCode + : null; + Object.defineProperty(this, "countryCode", { value, enumerable: true }); + return value; + }, + }; + + // Check whether the number is valid for the given region. + function IsValidNumber(number, md) { + return md.possiblePattern.test(number); + } + + // Check whether the number is a valid national number for the given region. + /* eslint-disable no-unused-vars */ + function IsNationalNumber(number, md) { + return IsValidNumber(number, md) && md.nationalPattern.test(number); + } + + // Determine the country code a number starts with, or return null if + // its not a valid country code. + function ParseCountryCode(number) { + for (let n = 1; n <= 3; ++n) { + let cc = number.substr(0, n); + if (dataBase[cc]) { + return cc; + } + } + return null; + } + + // Parse a national number for a specific region. Return null if the + // number is not a valid national number (it might still be a possible + // number for parts of that region). + function ParseNationalNumber(number, md) { + if (!md.possiblePattern.test(number) || !md.nationalPattern.test(number)) { + return null; + } + // Success. + return new NationalNumber(md, number); + } + + function ParseNationalNumberAndCheckNationalPrefix(number, md) { + let ret; + + // This is not an international number. See if its a national one for + // the current region. National numbers can start with the national + // prefix, or without. + if (md.nationalPrefixForParsing) { + // Some regions have specific national prefix parse rules. Apply those. + let withoutPrefix = number.replace( + md.nationalPrefixForParsing, + md.nationalPrefixTransformRule || "" + ); + ret = ParseNationalNumber(withoutPrefix, md); + if (ret) { + return ret; + } + } else { + // If there is no specific national prefix rule, just strip off the + // national prefix from the beginning of the number (if there is one). + let nationalPrefix = md.nationalPrefix; + if ( + nationalPrefix && + number.indexOf(nationalPrefix) == 0 && + (ret = ParseNationalNumber(number.substr(nationalPrefix.length), md)) + ) { + return ret; + } + } + ret = ParseNationalNumber(number, md); + if (ret) { + return ret; + } + } + + function ParseNumberByCountryCode(number, countryCode) { + let ret; + + // Lookup the meta data for the region (or regions) and if the rest of + // the number parses for that region, return the parsed number. + let entry = dataBase[countryCode]; + if (Array.isArray(entry)) { + for (let n = 0; n < entry.length; ++n) { + if (typeof entry[n] == "string") { + entry[n] = ParseMetaData(countryCode, entry[n]); + } + if (n > 0) { + entry[n].formats = entry[0].formats; + } + ret = ParseNationalNumberAndCheckNationalPrefix(number, entry[n]); + if (ret) { + return ret; + } + } + return null; + } + if (typeof entry == "string") { + entry = dataBase[countryCode] = ParseMetaData(countryCode, entry); + } + return ParseNationalNumberAndCheckNationalPrefix(number, entry); + } + + // Parse an international number that starts with the country code. Return + // null if the number is not a valid international number. + function ParseInternationalNumber(number) { + // Parse and strip the country code. + let countryCode = ParseCountryCode(number); + if (!countryCode) { + return null; + } + number = number.substr(countryCode.length); + + return ParseNumberByCountryCode(number, countryCode); + } + + // Parse a number and transform it into the national format, removing any + // international dial prefixes and country codes. + function ParseNumber(number, defaultRegion) { + let ret; + + // Remove formating characters and whitespace. + number = lazy.PhoneNumberNormalizer.Normalize(number); + + // If there is no defaultRegion or the defaultRegion is the global region, + // we can't parse international access codes. + if ((!defaultRegion || defaultRegion === "001") && number[0] !== "+") { + return null; + } + + // Detect and strip leading '+'. + if (number[0] === "+") { + return ParseInternationalNumber( + number.replace(LEADING_PLUS_CHARS_PATTERN, "") + ); + } + + // If "defaultRegion" is a country code, use it to parse the number directly. + let matches = String(defaultRegion).match(/^\+?(\d+)/); + if (matches) { + let countryCode = ParseCountryCode(matches[1]); + if (!countryCode) { + return null; + } + return ParseNumberByCountryCode(number, countryCode); + } + + // Lookup the meta data for the given region. + let md = FindMetaDataForRegion(defaultRegion.toUpperCase()); + + if (!md) { + dump("Couldn't find Meta Data for region: " + defaultRegion + "\n"); + return null; + } + + // See if the number starts with an international prefix, and if the + // number resulting from stripping the code is valid, then remove the + // prefix and flag the number as international. + if (md.internationalPrefix.test(number)) { + let possibleNumber = number.replace(md.internationalPrefix, ""); + ret = ParseInternationalNumber(possibleNumber); + if (ret) { + return ret; + } + } + + ret = ParseNationalNumberAndCheckNationalPrefix(number, md); + if (ret) { + return ret; + } + + // Now lets see if maybe its an international number after all, but + // without '+' or the international prefix. + ret = ParseInternationalNumber(number); + if (ret) { + return ret; + } + + // If the number matches the possible numbers of the current region, + // return it as a possible number. + if (md.possiblePattern.test(number)) { + return new NationalNumber(md, number); + } + + // We couldn't parse the number at all. + return null; + } + + function IsPlainPhoneNumber(number) { + if (typeof number !== "string") { + return false; + } + + let length = number.length; + let isTooLong = length > MAX_PHONE_NUMBER_LENGTH; + let isEmpty = length === 0; + return !(isTooLong || isEmpty || NON_DIALABLE_CHARS_ONCE.test(number)); + } + + return { + IsPlain: IsPlainPhoneNumber, + Parse: ParseNumber, + }; +})(PHONE_NUMBER_META_DATA); |