Source

PhoneGeoLocator.js

/*
 * PhoneGeoLocator.js - Represent a phone number geolocator object.
 *
 * Copyright © 2014-2015, 2018, JEDLSoft
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

// !data iddarea area extarea extstates phoneres

var ilib = require("../index.js");
var Utils = require("./Utils.js");
var JSUtils = require("./JSUtils.js");

var PhoneNumber = require("./PhoneNumber.js");
var NumberingPlan = require("./NumberingPlan.js");
var PhoneLocale = require("./PhoneLocale.js");
var ResBundle = require("./ResBundle.js");

/**
 * @class
 * Create an instance that can geographically locate a phone number.<p>
 *
 * The location of the number is calculated according to the following rules:
 *
 * <ol>
 * <li>If the areaCode property is undefined or empty, or if the number specifies a
 * country code for which we do not have information, then the area property may be
 * missing from the returned object. In this case, only the country object will be returned.
 *
 * <li>If there is no area code, but there is a mobile prefix, service code, or emergency
 * code, then a fixed string indicating the type of number will be returned.
 *
 * <li>The country object is filled out according to the countryCode property of the phone
 * number.
 *
 * <li>If the phone number does not have an explicit country code, the MCC will be used if
 * it is available. The country code can be gleaned directly from the MCC. If the MCC
 * of the carrier to which the phone is currently connected is available, it should be
 * passed in so that local phone numbers will look correct.
 *
 * <li>If the country's dialling plan mandates a fixed length for phone numbers, and a
 * particular number exceeds that length, then the area code will not be given on the
 * assumption that the number has problems in the first place and we cannot guess
 * correctly.
 * </ol>
 *
 * The returned area property varies in specificity according
 * to the locale. In North America, the area is no finer than large parts of states
 * or provinces. In Germany and the UK, the area can be as fine as small towns.<p>
 *
 * If the number passed in is invalid, no geolocation will be performed. If the location
 * information about the country where the phone number is located is not available,
 * then the area information will be missing and only the country will be available.<p>
 *
 * The options parameter can contain any one of the following properties:
 *
 * <ul>
 * <li><i>locale</i> The locale parameter is used to load translations of the names of regions and
 * areas if available. For example, if the locale property is given as "en-US" (English for USA),
 * but the phone number being geolocated is in Germany, then this class would return the the names
 * of the country (Germany) and region inside of Germany in English instead of German. That is, a
 * phone number in Munich and return the country "Germany" and the area code "Munich"
 * instead of "Deutschland" and "München". The default display locale is the current ilib locale.
 * If translations are not available, the region and area names are given in English, which should
 * always be available.
 * <li><i>mcc</i> The mcc of the current mobile carrier, if known.
 *
 * <li><i>onLoad</i> - a callback function to call when the data for the
 * locale is fully loaded. When the onLoad option is given, this object
 * will attempt to load any missing locale data using the ilib loader callback.
 * When the constructor is done (even if the data is already preassembled), the
 * onLoad function is called with the current instance as a parameter, so this
 * callback can be used with preassembled or dynamic loading or a mix of the two.
 *
 * <li><i>sync</i> - tell whether to load any missing locale data synchronously or
 * asynchronously. If this option is given as "false", then the "onLoad"
 * callback must be given, as the instance returned from this constructor will
 * not be usable for a while.
 *
 * <li><i>loadParams</i> - an object containing parameters to pass to the
 * loader callback function when locale data is missing. The parameters are not
 * interpretted or modified in any way. They are simply passed along. The object
 * may contain any property/value pairs as long as the calling code is in
 * agreement with the loader callback function as to what those parameters mean.
 * </ul>
 *
 * @constructor
 * @param {Object} options parameters controlling the geolocation of the phone number.
 */
var PhoneGeoLocator = function(options) {
    var sync = true,
        loadParams = {},
        locale = ilib.getLocale();

    if (options) {
        if (options.locale) {
            locale = options.locale;
        }

        if (typeof(options.sync) === 'boolean') {
            sync = options.sync;
        }

        if (options.loadParams) {
            loadParams = options.loadParams;
        }
    }

    new PhoneLocale({
        locale: locale,
        mcc: options && options.mcc,
        countryCode: options && options.countryCode,
        sync: sync,
        loadParams: loadParams,
        onLoad: ilib.bind(this, function (loc) {
            this.locale = loc;
            new NumberingPlan({
                locale: this.locale,
                sync: sync,
                loadParams: loadParams,
                onLoad: ilib.bind(this, function (plan) {
                    this.plan = plan;

                    new ResBundle({
                        locale: this.locale,
                        name: "phoneres",
                        sync: sync,
                        loadParams: loadParams,
                        onLoad: ilib.bind(this, function (rb) {
                            this.rb = rb;

                            Utils.loadData({
                                name: "iddarea.json",
                                object: "PhoneGeoLocator",
                                nonlocale: true,
                                sync: sync,
                                loadParams: loadParams,
                                callback: ilib.bind(this, function (data) {
                                    this.regiondata = data;
                                    Utils.loadData({
                                        name: "area.json",
                                        object: "PhoneGeoLocator",
                                        locale: this.locale,
                                        sync: sync,
                                        loadParams: JSUtils.merge(loadParams, {
                                            returnOne: true
                                        }),
                                        callback: ilib.bind(this, function (areadata) {
                                            this.areadata = areadata;

                                            if (options && typeof(options.onLoad) === 'function') {
                                                options.onLoad(this);
                                            }
                                        })
                                    });
                                })
                            });
                        })
                    });
                })
            });
        })
    });
};

PhoneGeoLocator.prototype = {
    /**
     * Used for locales where the area code is very general, and you need to add in
     * the initial digits of the subscriber number in order to get the area
     * @private
     * @param {string} number
     * @param {Object} stateTable
     */
    _parseAreaAndSubscriber: function (number, stateTable) {
        var ch,
            i,
            handlerMethod,
            newState,
            consumed,
            lastLeaf,
            currentState,
            dot = 14;    // special transition which matches all characters. See AreaCodeTableMaker.java

        if (!number || !stateTable) {
            // can't parse anything
            return undefined;
        }

        //console.log("GeoLocator._parseAreaAndSubscriber: parsing number " + number);

        currentState = stateTable;
        i = 0;
        while (i < number.length) {
            ch = PhoneNumber._getCharacterCode(number.charAt(i));
            if (ch >= 0) {
                // newState = stateData.states[state][ch];
                newState = currentState.s && currentState.s[ch];

                if (!newState && currentState.s && currentState.s[dot]) {
                    newState = currentState.s[dot];
                }

                if (typeof(newState) === 'object') {
                    if (typeof(newState.l) !== 'undefined') {
                        // save for latter if needed
                        lastLeaf = newState;
                        consumed = i;
                    }
                    // console.info("recognized digit " + ch + " continuing...");
                    // recognized digit, so continue parsing
                    currentState = newState;
                    i++;
                } else {
                    if (typeof(newState) === 'undefined' || newState === 0) {
                        // this is possibly a look-ahead and it didn't work...
                        // so fall back to the last leaf and use that as the
                        // final state
                        newState = lastLeaf;
                        i = consumed;
                    }

                    if ((typeof(newState) === 'number' && newState) ||
                        (typeof(newState) === 'object' && typeof(newState.l) !== 'undefined')) {
                        // final state
                        var stateNumber = typeof(newState) === 'number' ? newState : newState.l;
                        handlerMethod = PhoneNumber._states[stateNumber];

                        //console.info("reached final state " + newState + " handler method is " + handlerMethod + " and i is " + i);

                        return (handlerMethod === "area") ? number.substring(0, i+1) : undefined;
                    } else {
                        // failed parse. Either no last leaf to fall back to, or there was an explicit
                        // zero in the table
                        break;
                    }
                }
            } else if (ch === -1) {
                // non-transition character, continue parsing in the same state
                i++;
            } else {
                // should not happen
                // console.info("skipping character " + ch);
                // not a digit, plus, pound, or star, so this is probably a formatting char. Skip it.
                i++;
            }
        }
        return undefined;
    },
    /**
     * @private
     * @param prefix
     * @param table
     */
    _matchPrefix: function(prefix, table)  {
        var i, matchedDot, matchesWithDots = [];

        if (table[prefix]) {
            return table[prefix];
        }
        for (var entry in table) {
            if (entry && typeof(entry) === 'string') {
                i = 0;
                matchedDot = false;
                while (i < entry.length && (entry.charAt(i) === prefix.charAt(i) || entry.charAt(i) === '.')) {
                    if (entry.charAt(i) === '.') {
                        matchedDot = true;
                    }
                    i++;
                }
                if (i >= entry.length) {
                    if (matchedDot) {
                        matchesWithDots.push(entry);
                    } else {
                        return table[entry];
                    }
                }
            }
        }

        // match entries with dots last, so sort the matches so that the entry with the
        // most dots sorts last. The entry that ends up at the beginning of the list is
        // the best match because it has the fewest dots
        if (matchesWithDots.length > 0) {
            matchesWithDots.sort(function (left, right) {
                return (right < left) ? -1 : ((left < right) ? 1 : 0);
            });
            return table[matchesWithDots[0]];
        }

        return undefined;
    },
    /**
     * @private
     * @param number
     * @param data
     * @param locale
     * @param plan
     * @param options
     * @returns {Object}
     */
    _getAreaInfo: function(number, data, locale, plan, options) {
        var sync = true,
            ret = {},
            countryCode,
            areaInfo,
            temp,
            areaCode,
            geoTable,
            tempNumber,
            prefix;

        if (options && typeof(options.sync) === 'boolean') {
            sync = options.sync;
        }

        prefix = number.areaCode || number.serviceCode;
        geoTable = data;

        if (prefix !== undefined) {
            if (plan.getExtendedAreaCode()) {
                // for countries where the area code is very general and large, and you need a few initial
                // digits of the subscriber number in order find the actual area
                tempNumber = prefix + number.subscriberNumber;
                tempNumber = tempNumber.replace(/[wWpPtT\+#\*]/g, '');    // fix for NOV-108200

                Utils.loadData({
                    name: "extarea.json",
                    object: "PhoneGeoLocator",
                    locale: locale,
                    sync: sync,
                    loadParams: JSUtils.merge((options && options.loadParams) || {}, {returnOne: true}),
                    callback: ilib.bind(this, function (data) {
                        this.extarea = data;
                        Utils.loadData({
                            name: "extstates.json",
                            object: "PhoneGeoLocator",
                            locale: locale,
                            sync: sync,
                            loadParams: JSUtils.merge((options && options.loadParams) || {}, {returnOne: true}),
                            callback: ilib.bind(this, function (data) {
                                this.extstates = data;
                                geoTable = this.extarea;
                                if (this.extarea && this.extstates) {
                                    prefix = this._parseAreaAndSubscriber(tempNumber, this.extstates);
                                }

                                if (!prefix) {
                                    // not a recognized prefix, so now try the general table
                                    geoTable = this.areadata;
                                    prefix = number.areaCode || number.serviceCode;
                                }

                                if ((!plan.fieldLengths ||
                                  plan.getFieldLength('maxLocalLength') === undefined ||
                                  !number.subscriberNumber ||
                                     number.subscriberNumber.length <= plan.fieldLengths('maxLocalLength'))) {
                                      areaInfo = this._matchPrefix(prefix, geoTable);
                                    if (areaInfo && areaInfo.sn && areaInfo.ln) {
                                        //console.log("Found areaInfo " + JSON.stringify(areaInfo));
                                        ret.area = {
                                            sn: this.rb.getString(areaInfo.sn).toString(),
                                            ln: this.rb.getString(areaInfo.ln).toString()
                                        };
                                    }
                                }
                            })
                        });
                    })
                });

            } else if (!plan ||
                    plan.getFieldLength('maxLocalLength') === undefined ||
                    !number.subscriberNumber ||
                    number.subscriberNumber.length <= plan.getFieldLength('maxLocalLength')) {
                if (geoTable) {
                    areaCode = prefix.replace(/[wWpPtT\+#\*]/g, '');
                    areaInfo = this._matchPrefix(areaCode, geoTable);

                    if (areaInfo && areaInfo.sn && areaInfo.ln) {
                        ret.area = {
                            sn: this.rb.getString(areaInfo.sn).toString(),
                            ln: this.rb.getString(areaInfo.ln).toString()
                        };
                    } else if (number.serviceCode) {
                        ret.area = {
                            sn: this.rb.getString("Service Number").toString(),
                            ln: this.rb.getString("Service Number").toString()
                        };
                    }
                } else {
                    countryCode = number.locale._mapRegiontoCC(this.locale.getRegion());
                    if (countryCode !== "0" && this.regiondata) {
                        temp = this.regiondata[countryCode];
                        if (temp && temp.sn) {
                            ret.country = {
                                sn: this.rb.getString(temp.sn).toString(),
                                ln: this.rb.getString(temp.ln).toString(),
                                code: this.locale.getRegion()
                            };
                        }
                    }
                }
            } else {
                countryCode = number.locale._mapRegiontoCC(this.locale.getRegion());
                if (countryCode !== "0" && this.regiondata) {
                    temp = this.regiondata[countryCode];
                    if (temp && temp.sn) {
                        ret.country = {
                            sn: this.rb.getString(temp.sn).toString(),
                            ln: this.rb.getString(temp.ln).toString(),
                            code: this.locale.getRegion()
                        };
                    }
                }
            }

        } else if (number.mobilePrefix) {
            ret.area = {
                sn: this.rb.getString("Mobile Number").toString(),
                ln: this.rb.getString("Mobile Number").toString()
            };
        } else if (number.emergency) {
            ret.area = {
                sn: this.rb.getString("Emergency Services Number").toString(),
                ln: this.rb.getString("Emergency Services Number").toString()
            };
        }

        return ret;
    },
    /**
     * Returns a the location of the given phone number, if known.
     * The returned object has 2 properties, each of which has an sn (short name)
     * and an ln (long name) string. Additionally, the country code, if given,
     * includes the 2 letter ISO code for the recognized country.
     *         {
     *            "country": {
     *                "sn": "North America",
     *                "ln": "North America and the Caribbean Islands",
     *                "code": "us"
     *              },
     *              "area": {
     *               "sn": "California",
     *               "ln": "Central California: San Jose, Los Gatos, Milpitas, Sunnyvale, Cupertino, Gilroy"
     *              }
     *         }
     *
     * The location name is subject to the following rules:
     *
     * If the areaCode property is undefined or empty, or if the number specifies a
     * country code for which we do not have information, then the area property may be
     * missing from the returned object. In this case, only the country object will be returned.
     *
     * If there is no area code, but there is a mobile prefix, service code, or emergency
     * code, then a fixed string indicating the type of number will be returned.
     *
     * The country object is filled out according to the countryCode property of the phone
     * number.
     *
     * If the phone number does not have an explicit country code, the MCC will be used if
     * it is available. The country code can be gleaned directly from the MCC. If the MCC
     * of the carrier to which the phone is currently connected is available, it should be
     * passed in so that local phone numbers will look correct.
     *
     * If the country's dialling plan mandates a fixed length for phone numbers, and a
     * particular number exceeds that length, then the area code will not be given on the
     * assumption that the number has problems in the first place and we cannot guess
     * correctly.
     *
     * The returned area property varies in specificity according
     * to the locale. In North America, the area is no finer than large parts of states
     * or provinces. In Germany and the UK, the area can be as fine as small towns.
     *
     * The strings returned from this function are already localized to the
     * given locale, and thus are ready for display to the user.
     *
     * If the number passed in is invalid, an empty object is returned. If the location
     * information about the country where the phone number is located is not available,
     * then the area information will be missing and only the country will be returned.
     *
     * The options parameter can contain any one of the following properties:
      *
      * <ul>
      * <li><i>locale</i> The locale parameter is used to load translations of the names of regions and
      * areas if available. For example, if the locale property is given as "en-US" (English for USA),
      * but the phone number being geolocated is in Germany, then this class would return the the names
      * of the country (Germany) and region inside of Germany in English instead of German. That is, a
      * phone number in Munich and return the country "Germany" and the area code "Munich"
      * instead of "Deutschland" and "München". The default display locale is the current ilib locale.
      * If translations are not available, the region and area names are given in English, which should
      * always be available.
      * <li><i>mcc</i> The mcc of the current mobile carrier, if known.
      *
      * <li><i>onLoad</i> - a callback function to call when the data for the
      * locale is fully loaded. When the onLoad option is given, this object
      * will attempt to load any missing locale data using the ilib loader callback.
      * When the constructor is done (even if the data is already preassembled), the
      * onLoad function is called with the current instance as a parameter, so this
      * callback can be used with preassembled or dynamic loading or a mix of the two.
      *
      * <li><i>sync</i> - tell whether to load any missing locale data synchronously or
      * asynchronously. If this option is given as "false", then the "onLoad"
      * callback must be given, as the instance returned from this constructor will
      * not be usable for a while.
      *
      * <li><i>loadParams</i> - an object containing parameters to pass to the
      * loader callback function when locale data is missing. The parameters are not
      * interpretted or modified in any way. They are simply passed along. The object
      * may contain any property/value pairs as long as the calling code is in
      * agreement with the loader callback function as to what those parameters mean.
      * </ul>
     *
     * @param {PhoneNumber} number phone number to locate
     * @param {Object} options options governing the way this ares is loaded
     * @return {Object} an object
     * that describes the country and the area in that country corresponding to this
     * phone number. Each of the country and area contain a short name (sn) and long
     * name (ln) that describes the location.
     */
    locate: function(number, options) {
        var loadParams = {},
            ret = {},
            region,
            countryCode,
            temp,
            plan,
            areaResult,
            phoneLoc = this.locale,
            sync = true;

        if (number === undefined || typeof(number) !== 'object' || !(number instanceof PhoneNumber)) {
            return ret;
        }

        if (options) {
            if (typeof(options.sync) !== 'undefined') {
                sync = !!options.sync;
            }

            if (options.loadParams) {
                loadParams = options.loadParams;
            }
        }

        // console.log("GeoLocator.locate: looking for geo for number " + JSON.stringify(number));
        region = this.locale.getRegion();
        if (number.countryCode !== undefined && this.regiondata) {
            countryCode = number.countryCode.replace(/[wWpPtT\+#\*]/g, '');
            temp = this.regiondata[countryCode];
            phoneLoc = number.destinationLocale;
            plan = number.destinationPlan;
            ret.country = {
                sn: this.rb.getString(temp.sn).toString(),
                ln: this.rb.getString(temp.ln).toString(),
                code: phoneLoc.getRegion()
            };
        }

        if (!plan) {
            plan = this.plan;
        }

        Utils.loadData({
            name: "area.json",
            object: "PhoneGeoLocator",
            locale: phoneLoc,
            sync: sync,
            loadParams: JSUtils.merge(loadParams, {
                returnOne: true
            }),
            callback: ilib.bind(this, function (areadata) {
                if (areadata) {
                    this.areadata = areadata;
                }
                areaResult = this._getAreaInfo(number, this.areadata, phoneLoc, plan, options);
                ret = JSUtils.merge(ret, areaResult);

                if (ret.country === undefined) {
                    countryCode = number.locale._mapRegiontoCC(region);

                    if (countryCode !== "0" && this.regiondata) {
                        temp = this.regiondata[countryCode];
                        if (temp && temp.sn) {
                            ret.country = {
                                sn: this.rb.getString(temp.sn).toString(),
                                ln: this.rb.getString(temp.ln).toString(),
                                code: this.locale.getRegion()
                            };
                        }
                    }
                }
            })
        });

        return ret;
    },

    /**
     * Returns a string that describes the ISO-3166-2 country code of the given phone
     * number.<p>
     *
     * If the phone number is a local phone number and does not contain
     * any country information, this routine will return the region for the current
     * formatter instance.
     *
     * @param {PhoneNumber} number An PhoneNumber instance
     * @return {string}
     */
    country: function(number) {
        var countryCode,
            region,
            phoneLoc;

        if (!number || !(number instanceof PhoneNumber)) {
            return "";
        }

        phoneLoc = number.locale;

        region = (number.countryCode && phoneLoc._mapCCtoRegion(number.countryCode)) ||
            (number.locale && number.locale.region) ||
            phoneLoc.locale.getRegion() ||
            this.locale.getRegion();

        countryCode = number.countryCode || phoneLoc._mapRegiontoCC(region);

        if (number.areaCode) {
            region = phoneLoc._mapAreatoRegion(countryCode, number.areaCode);
        } else if (countryCode === "33" && number.serviceCode) {
            // french departments are in the service code, not the area code
            region = phoneLoc._mapAreatoRegion(countryCode, number.serviceCode);
        }
        return region;
    }
};

module.exports = PhoneGeoLocator;