1 /*
  2  * PhoneGeoLocator.js - Represent a phone number geolocator object.
  3  *
  4  * Copyright © 2014-2015, 2018, JEDLSoft
  5  *
  6  * Licensed under the Apache License, Version 2.0 (the "License");
  7  * you may not use this file except in compliance with the License.
  8  * You may obtain a copy of the License at
  9  *
 10  *     http://www.apache.org/licenses/LICENSE-2.0
 11  *
 12  * Unless required by applicable law or agreed to in writing, software
 13  * distributed under the License is distributed on an "AS IS" BASIS,
 14  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 15  *
 16  * See the License for the specific language governing permissions and
 17  * limitations under the License.
 18  */
 19 
 20 // !data iddarea area extarea extstates phoneres
 21 
 22 var ilib = require("../index.js");
 23 var Utils = require("./Utils.js");
 24 var JSUtils = require("./JSUtils.js");
 25 
 26 var PhoneNumber = require("./PhoneNumber.js");
 27 var NumberingPlan = require("./NumberingPlan.js");
 28 var PhoneLocale = require("./PhoneLocale.js");
 29 var ResBundle = require("./ResBundle.js");
 30 
 31 /**
 32  * @class
 33  * Create an instance that can geographically locate a phone number.<p>
 34  *
 35  * The location of the number is calculated according to the following rules:
 36  *
 37  * <ol>
 38  * <li>If the areaCode property is undefined or empty, or if the number specifies a
 39  * country code for which we do not have information, then the area property may be
 40  * missing from the returned object. In this case, only the country object will be returned.
 41  *
 42  * <li>If there is no area code, but there is a mobile prefix, service code, or emergency
 43  * code, then a fixed string indicating the type of number will be returned.
 44  *
 45  * <li>The country object is filled out according to the countryCode property of the phone
 46  * number.
 47  *
 48  * <li>If the phone number does not have an explicit country code, the MCC will be used if
 49  * it is available. The country code can be gleaned directly from the MCC. If the MCC
 50  * of the carrier to which the phone is currently connected is available, it should be
 51  * passed in so that local phone numbers will look correct.
 52  *
 53  * <li>If the country's dialling plan mandates a fixed length for phone numbers, and a
 54  * particular number exceeds that length, then the area code will not be given on the
 55  * assumption that the number has problems in the first place and we cannot guess
 56  * correctly.
 57  * </ol>
 58  *
 59  * The returned area property varies in specificity according
 60  * to the locale. In North America, the area is no finer than large parts of states
 61  * or provinces. In Germany and the UK, the area can be as fine as small towns.<p>
 62  *
 63  * If the number passed in is invalid, no geolocation will be performed. If the location
 64  * information about the country where the phone number is located is not available,
 65  * then the area information will be missing and only the country will be available.<p>
 66  *
 67  * The options parameter can contain any one of the following properties:
 68  *
 69  * <ul>
 70  * <li><i>locale</i> The locale parameter is used to load translations of the names of regions and
 71  * areas if available. For example, if the locale property is given as "en-US" (English for USA),
 72  * but the phone number being geolocated is in Germany, then this class would return the the names
 73  * of the country (Germany) and region inside of Germany in English instead of German. That is, a
 74  * phone number in Munich and return the country "Germany" and the area code "Munich"
 75  * instead of "Deutschland" and "München". The default display locale is the current ilib locale.
 76  * If translations are not available, the region and area names are given in English, which should
 77  * always be available.
 78  * <li><i>mcc</i> The mcc of the current mobile carrier, if known.
 79  *
 80  * <li><i>onLoad</i> - a callback function to call when the data for the
 81  * locale is fully loaded. When the onLoad option is given, this object
 82  * will attempt to load any missing locale data using the ilib loader callback.
 83  * When the constructor is done (even if the data is already preassembled), the
 84  * onLoad function is called with the current instance as a parameter, so this
 85  * callback can be used with preassembled or dynamic loading or a mix of the two.
 86  *
 87  * <li><i>sync</i> - tell whether to load any missing locale data synchronously or
 88  * asynchronously. If this option is given as "false", then the "onLoad"
 89  * callback must be given, as the instance returned from this constructor will
 90  * not be usable for a while.
 91  *
 92  * <li><i>loadParams</i> - an object containing parameters to pass to the
 93  * loader callback function when locale data is missing. The parameters are not
 94  * interpretted or modified in any way. They are simply passed along. The object
 95  * may contain any property/value pairs as long as the calling code is in
 96  * agreement with the loader callback function as to what those parameters mean.
 97  * </ul>
 98  *
 99  * @constructor
100  * @param {Object} options parameters controlling the geolocation of the phone number.
101  */
102 var PhoneGeoLocator = function(options) {
103     var sync = true,
104         loadParams = {},
105         locale = ilib.getLocale();
106 
107     if (options) {
108         if (options.locale) {
109             locale = options.locale;
110         }
111 
112         if (typeof(options.sync) === 'boolean') {
113             sync = options.sync;
114         }
115 
116         if (options.loadParams) {
117             loadParams = options.loadParams;
118         }
119     }
120 
121     new PhoneLocale({
122         locale: locale,
123         mcc: options && options.mcc,
124         countryCode: options && options.countryCode,
125         sync: sync,
126         loadParams: loadParams,
127         onLoad: ilib.bind(this, function (loc) {
128             this.locale = loc;
129             new NumberingPlan({
130                 locale: this.locale,
131                 sync: sync,
132                 loadParams: loadParams,
133                 onLoad: ilib.bind(this, function (plan) {
134                     this.plan = plan;
135 
136                     new ResBundle({
137                         locale: this.locale,
138                         name: "phoneres",
139                         sync: sync,
140                         loadParams: loadParams,
141                         onLoad: ilib.bind(this, function (rb) {
142                             this.rb = rb;
143 
144                             Utils.loadData({
145                                 name: "iddarea.json",
146                                 object: "PhoneGeoLocator",
147                                 nonlocale: true,
148                                 sync: sync,
149                                 loadParams: loadParams,
150                                 callback: ilib.bind(this, function (data) {
151                                     this.regiondata = data;
152                                     Utils.loadData({
153                                         name: "area.json",
154                                         object: "PhoneGeoLocator",
155                                         locale: this.locale,
156                                         sync: sync,
157                                         loadParams: JSUtils.merge(loadParams, {
158                                             returnOne: true
159                                         }),
160                                         callback: ilib.bind(this, function (areadata) {
161                                             this.areadata = areadata;
162 
163                                             if (options && typeof(options.onLoad) === 'function') {
164                                                 options.onLoad(this);
165                                             }
166                                         })
167                                     });
168                                 })
169                             });
170                         })
171                     });
172                 })
173             });
174         })
175     });
176 };
177 
178 PhoneGeoLocator.prototype = {
179     /**
180      * @private
181      *
182      * Used for locales where the area code is very general, and you need to add in
183      * the initial digits of the subscriber number in order to get the area
184      *
185      * @param {string} number
186      * @param {Object} stateTable
187      */
188     _parseAreaAndSubscriber: function (number, stateTable) {
189         var ch,
190             i,
191             handlerMethod,
192             newState,
193             consumed,
194             lastLeaf,
195             currentState,
196             dot = 14;    // special transition which matches all characters. See AreaCodeTableMaker.java
197 
198         if (!number || !stateTable) {
199             // can't parse anything
200             return undefined;
201         }
202 
203         //console.log("GeoLocator._parseAreaAndSubscriber: parsing number " + number);
204 
205         currentState = stateTable;
206         i = 0;
207         while (i < number.length) {
208             ch = PhoneNumber._getCharacterCode(number.charAt(i));
209             if (ch >= 0) {
210                 // newState = stateData.states[state][ch];
211                 newState = currentState.s && currentState.s[ch];
212 
213                 if (!newState && currentState.s && currentState.s[dot]) {
214                     newState = currentState.s[dot];
215                 }
216 
217                 if (typeof(newState) === 'object') {
218                     if (typeof(newState.l) !== 'undefined') {
219                         // save for latter if needed
220                         lastLeaf = newState;
221                         consumed = i;
222                     }
223                     // console.info("recognized digit " + ch + " continuing...");
224                     // recognized digit, so continue parsing
225                     currentState = newState;
226                     i++;
227                 } else {
228                     if (typeof(newState) === 'undefined' || newState === 0) {
229                         // this is possibly a look-ahead and it didn't work...
230                         // so fall back to the last leaf and use that as the
231                         // final state
232                         newState = lastLeaf;
233                         i = consumed;
234                     }
235 
236                     if ((typeof(newState) === 'number' && newState) ||
237                         (typeof(newState) === 'object' && typeof(newState.l) !== 'undefined')) {
238                         // final state
239                         var stateNumber = typeof(newState) === 'number' ? newState : newState.l;
240                         handlerMethod = PhoneNumber._states[stateNumber];
241 
242                         //console.info("reached final state " + newState + " handler method is " + handlerMethod + " and i is " + i);
243 
244                         return (handlerMethod === "area") ? number.substring(0, i+1) : undefined;
245                     } else {
246                         // failed parse. Either no last leaf to fall back to, or there was an explicit
247                         // zero in the table
248                         break;
249                     }
250                 }
251             } else if (ch === -1) {
252                 // non-transition character, continue parsing in the same state
253                 i++;
254             } else {
255                 // should not happen
256                 // console.info("skipping character " + ch);
257                 // not a digit, plus, pound, or star, so this is probably a formatting char. Skip it.
258                 i++;
259             }
260         }
261         return undefined;
262     },
263     /**
264      * @private
265      * @param prefix
266      * @param table
267      * @returns
268      */
269     _matchPrefix: function(prefix, table)  {
270         var i, matchedDot, matchesWithDots = [];
271 
272         if (table[prefix]) {
273             return table[prefix];
274         }
275         for (var entry in table) {
276             if (entry && typeof(entry) === 'string') {
277                 i = 0;
278                 matchedDot = false;
279                 while (i < entry.length && (entry.charAt(i) === prefix.charAt(i) || entry.charAt(i) === '.')) {
280                     if (entry.charAt(i) === '.') {
281                         matchedDot = true;
282                     }
283                     i++;
284                 }
285                 if (i >= entry.length) {
286                     if (matchedDot) {
287                         matchesWithDots.push(entry);
288                     } else {
289                         return table[entry];
290                     }
291                 }
292             }
293         }
294 
295         // match entries with dots last, so sort the matches so that the entry with the
296         // most dots sorts last. The entry that ends up at the beginning of the list is
297         // the best match because it has the fewest dots
298         if (matchesWithDots.length > 0) {
299             matchesWithDots.sort(function (left, right) {
300                 return (right < left) ? -1 : ((left < right) ? 1 : 0);
301             });
302             return table[matchesWithDots[0]];
303         }
304 
305         return undefined;
306     },
307     /**
308      * @private
309      * @param number
310      * @param data
311      * @param locale
312      * @param plan
313      * @param options
314      * @returns {Object}
315      */
316     _getAreaInfo: function(number, data, locale, plan, options) {
317         var sync = true,
318             ret = {},
319             countryCode,
320             areaInfo,
321             temp,
322             areaCode,
323             geoTable,
324             tempNumber,
325             prefix;
326 
327         if (options && typeof(options.sync) === 'boolean') {
328             sync = options.sync;
329         }
330 
331         prefix = number.areaCode || number.serviceCode;
332         geoTable = data;
333 
334         if (prefix !== undefined) {
335             if (plan.getExtendedAreaCode()) {
336                 // for countries where the area code is very general and large, and you need a few initial
337                 // digits of the subscriber number in order find the actual area
338                 tempNumber = prefix + number.subscriberNumber;
339                 tempNumber = tempNumber.replace(/[wWpPtT\+#\*]/g, '');    // fix for NOV-108200
340 
341                 Utils.loadData({
342                     name: "extarea.json",
343                     object: "PhoneGeoLocator",
344                     locale: locale,
345                     sync: sync,
346                     loadParams: JSUtils.merge((options && options.loadParams) || {}, {returnOne: true}),
347                     callback: ilib.bind(this, function (data) {
348                         this.extarea = data;
349                         Utils.loadData({
350                             name: "extstates.json",
351                             object: "PhoneGeoLocator",
352                             locale: locale,
353                             sync: sync,
354                             loadParams: JSUtils.merge((options && options.loadParams) || {}, {returnOne: true}),
355                             callback: ilib.bind(this, function (data) {
356                                 this.extstates = data;
357                                 geoTable = this.extarea;
358                                 if (this.extarea && this.extstates) {
359                                     prefix = this._parseAreaAndSubscriber(tempNumber, this.extstates);
360                                 }
361 
362                                 if (!prefix) {
363                                     // not a recognized prefix, so now try the general table
364                                     geoTable = this.areadata;
365                                     prefix = number.areaCode || number.serviceCode;
366                                 }
367 
368                                 if ((!plan.fieldLengths ||
369                                   plan.getFieldLength('maxLocalLength') === undefined ||
370                                   !number.subscriberNumber ||
371                                      number.subscriberNumber.length <= plan.fieldLengths('maxLocalLength'))) {
372                                       areaInfo = this._matchPrefix(prefix, geoTable);
373                                     if (areaInfo && areaInfo.sn && areaInfo.ln) {
374                                         //console.log("Found areaInfo " + JSON.stringify(areaInfo));
375                                         ret.area = {
376                                             sn: this.rb.getString(areaInfo.sn).toString(),
377                                             ln: this.rb.getString(areaInfo.ln).toString()
378                                         };
379                                     }
380                                 }
381                             })
382                         });
383                     })
384                 });
385 
386             } else if (!plan ||
387                     plan.getFieldLength('maxLocalLength') === undefined ||
388                     !number.subscriberNumber ||
389                     number.subscriberNumber.length <= plan.getFieldLength('maxLocalLength')) {
390                 if (geoTable) {
391                     areaCode = prefix.replace(/[wWpPtT\+#\*]/g, '');
392                     areaInfo = this._matchPrefix(areaCode, geoTable);
393 
394                     if (areaInfo && areaInfo.sn && areaInfo.ln) {
395                         ret.area = {
396                             sn: this.rb.getString(areaInfo.sn).toString(),
397                             ln: this.rb.getString(areaInfo.ln).toString()
398                         };
399                     } else if (number.serviceCode) {
400                         ret.area = {
401                             sn: this.rb.getString("Service Number").toString(),
402                             ln: this.rb.getString("Service Number").toString()
403                         };
404                     }
405                 } else {
406                     countryCode = number.locale._mapRegiontoCC(this.locale.getRegion());
407                     if (countryCode !== "0" && this.regiondata) {
408                         temp = this.regiondata[countryCode];
409                         if (temp && temp.sn) {
410                             ret.country = {
411                                 sn: this.rb.getString(temp.sn).toString(),
412                                 ln: this.rb.getString(temp.ln).toString(),
413                                 code: this.locale.getRegion()
414                             };
415                         }
416                     }
417                 }
418             } else {
419                 countryCode = number.locale._mapRegiontoCC(this.locale.getRegion());
420                 if (countryCode !== "0" && this.regiondata) {
421                     temp = this.regiondata[countryCode];
422                     if (temp && temp.sn) {
423                         ret.country = {
424                             sn: this.rb.getString(temp.sn).toString(),
425                             ln: this.rb.getString(temp.ln).toString(),
426                             code: this.locale.getRegion()
427                         };
428                     }
429                 }
430             }
431 
432         } else if (number.mobilePrefix) {
433             ret.area = {
434                 sn: this.rb.getString("Mobile Number").toString(),
435                 ln: this.rb.getString("Mobile Number").toString()
436             };
437         } else if (number.emergency) {
438             ret.area = {
439                 sn: this.rb.getString("Emergency Services Number").toString(),
440                 ln: this.rb.getString("Emergency Services Number").toString()
441             };
442         }
443 
444         return ret;
445     },
446     /**
447      * Returns a the location of the given phone number, if known.
448      * The returned object has 2 properties, each of which has an sn (short name)
449      * and an ln (long name) string. Additionally, the country code, if given,
450      * includes the 2 letter ISO code for the recognized country.
451      *         {
452      *            "country": {
453      *                "sn": "North America",
454      *                "ln": "North America and the Caribbean Islands",
455      *                "code": "us"
456      *              },
457      *              "area": {
458      *               "sn": "California",
459      *               "ln": "Central California: San Jose, Los Gatos, Milpitas, Sunnyvale, Cupertino, Gilroy"
460      *              }
461      *         }
462      *
463      * The location name is subject to the following rules:
464      *
465      * If the areaCode property is undefined or empty, or if the number specifies a
466      * country code for which we do not have information, then the area property may be
467      * missing from the returned object. In this case, only the country object will be returned.
468      *
469      * If there is no area code, but there is a mobile prefix, service code, or emergency
470      * code, then a fixed string indicating the type of number will be returned.
471      *
472      * The country object is filled out according to the countryCode property of the phone
473      * number.
474      *
475      * If the phone number does not have an explicit country code, the MCC will be used if
476      * it is available. The country code can be gleaned directly from the MCC. If the MCC
477      * of the carrier to which the phone is currently connected is available, it should be
478      * passed in so that local phone numbers will look correct.
479      *
480      * If the country's dialling plan mandates a fixed length for phone numbers, and a
481      * particular number exceeds that length, then the area code will not be given on the
482      * assumption that the number has problems in the first place and we cannot guess
483      * correctly.
484      *
485      * The returned area property varies in specificity according
486      * to the locale. In North America, the area is no finer than large parts of states
487      * or provinces. In Germany and the UK, the area can be as fine as small towns.
488      *
489      * The strings returned from this function are already localized to the
490      * given locale, and thus are ready for display to the user.
491      *
492      * If the number passed in is invalid, an empty object is returned. If the location
493      * information about the country where the phone number is located is not available,
494      * then the area information will be missing and only the country will be returned.
495      *
496      * The options parameter can contain any one of the following properties:
497       *
498       * <ul>
499       * <li><i>locale</i> The locale parameter is used to load translations of the names of regions and
500       * areas if available. For example, if the locale property is given as "en-US" (English for USA),
501       * but the phone number being geolocated is in Germany, then this class would return the the names
502       * of the country (Germany) and region inside of Germany in English instead of German. That is, a
503       * phone number in Munich and return the country "Germany" and the area code "Munich"
504       * instead of "Deutschland" and "München". The default display locale is the current ilib locale.
505       * If translations are not available, the region and area names are given in English, which should
506       * always be available.
507       * <li><i>mcc</i> The mcc of the current mobile carrier, if known.
508       *
509       * <li><i>onLoad</i> - a callback function to call when the data for the
510       * locale is fully loaded. When the onLoad option is given, this object
511       * will attempt to load any missing locale data using the ilib loader callback.
512       * When the constructor is done (even if the data is already preassembled), the
513       * onLoad function is called with the current instance as a parameter, so this
514       * callback can be used with preassembled or dynamic loading or a mix of the two.
515       *
516       * <li><i>sync</i> - tell whether to load any missing locale data synchronously or
517       * asynchronously. If this option is given as "false", then the "onLoad"
518       * callback must be given, as the instance returned from this constructor will
519       * not be usable for a while.
520       *
521       * <li><i>loadParams</i> - an object containing parameters to pass to the
522       * loader callback function when locale data is missing. The parameters are not
523       * interpretted or modified in any way. They are simply passed along. The object
524       * may contain any property/value pairs as long as the calling code is in
525       * agreement with the loader callback function as to what those parameters mean.
526       * </ul>
527      *
528      * @param {PhoneNumber} number phone number to locate
529      * @param {Object} options options governing the way this ares is loaded
530      * @return {Object} an object
531      * that describes the country and the area in that country corresponding to this
532      * phone number. Each of the country and area contain a short name (sn) and long
533      * name (ln) that describes the location.
534      */
535     locate: function(number, options) {
536         var loadParams = {},
537             ret = {},
538             region,
539             countryCode,
540             temp,
541             plan,
542             areaResult,
543             phoneLoc = this.locale,
544             sync = true;
545 
546         if (number === undefined || typeof(number) !== 'object' || !(number instanceof PhoneNumber)) {
547             return ret;
548         }
549 
550         if (options) {
551             if (typeof(options.sync) !== 'undefined') {
552                 sync = !!options.sync;
553             }
554 
555             if (options.loadParams) {
556                 loadParams = options.loadParams;
557             }
558         }
559 
560         // console.log("GeoLocator.locate: looking for geo for number " + JSON.stringify(number));
561         region = this.locale.getRegion();
562         if (number.countryCode !== undefined && this.regiondata) {
563             countryCode = number.countryCode.replace(/[wWpPtT\+#\*]/g, '');
564             temp = this.regiondata[countryCode];
565             phoneLoc = number.destinationLocale;
566             plan = number.destinationPlan;
567             ret.country = {
568                 sn: this.rb.getString(temp.sn).toString(),
569                 ln: this.rb.getString(temp.ln).toString(),
570                 code: phoneLoc.getRegion()
571             };
572         }
573 
574         if (!plan) {
575             plan = this.plan;
576         }
577 
578         Utils.loadData({
579             name: "area.json",
580             object: "PhoneGeoLocator",
581             locale: phoneLoc,
582             sync: sync,
583             loadParams: JSUtils.merge(loadParams, {
584                 returnOne: true
585             }),
586             callback: ilib.bind(this, function (areadata) {
587                 if (areadata) {
588                     this.areadata = areadata;
589                 }
590                 areaResult = this._getAreaInfo(number, this.areadata, phoneLoc, plan, options);
591                 ret = JSUtils.merge(ret, areaResult);
592 
593                 if (ret.country === undefined) {
594                     countryCode = number.locale._mapRegiontoCC(region);
595 
596                     if (countryCode !== "0" && this.regiondata) {
597                         temp = this.regiondata[countryCode];
598                         if (temp && temp.sn) {
599                             ret.country = {
600                                 sn: this.rb.getString(temp.sn).toString(),
601                                 ln: this.rb.getString(temp.ln).toString(),
602                                 code: this.locale.getRegion()
603                             };
604                         }
605                     }
606                 }
607             })
608         });
609 
610         return ret;
611     },
612 
613     /**
614      * Returns a string that describes the ISO-3166-2 country code of the given phone
615      * number.<p>
616      *
617      * If the phone number is a local phone number and does not contain
618      * any country information, this routine will return the region for the current
619      * formatter instance.
620      *
621      * @param {PhoneNumber} number An PhoneNumber instance
622      * @return {string}
623      */
624     country: function(number) {
625         var countryCode,
626             region,
627             phoneLoc;
628 
629         if (!number || !(number instanceof PhoneNumber)) {
630             return "";
631         }
632 
633         phoneLoc = number.locale;
634 
635         region = (number.countryCode && phoneLoc._mapCCtoRegion(number.countryCode)) ||
636             (number.locale && number.locale.region) ||
637             phoneLoc.locale.getRegion() ||
638             this.locale.getRegion();
639 
640         countryCode = number.countryCode || phoneLoc._mapRegiontoCC(region);
641 
642         if (number.areaCode) {
643             region = phoneLoc._mapAreatoRegion(countryCode, number.areaCode);
644         } else if (countryCode === "33" && number.serviceCode) {
645             // french departments are in the service code, not the area code
646             region = phoneLoc._mapAreatoRegion(countryCode, number.serviceCode);
647         }
648         return region;
649     }
650 };
651 
652 module.exports = PhoneGeoLocator;
653