@yext/phonenumber-util 0.3.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +37 -0
- package/.github/workflows/pull-request.yml +3 -0
- package/.nvmrc +1 -1
- package/CODEOWNERS +1 -1
- package/README.md +79 -0
- package/package.json +16 -9
- package/src/__tests__/base.test.js +68 -0
- package/src/__tests__/geo.test.js +230 -1
- package/src/areaCodeList.js +2 -2
- package/src/base.d.ts +15 -13
- package/src/base.js +6 -5
- package/src/geo.d.ts +64 -18
- package/src/geo.js +25 -7
- package/src/phoneFormats.js +13 -12
- package/src/timezones.js +3 -3
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
name: Publish to NPM
|
|
2
|
+
|
|
3
|
+
permissions:
|
|
4
|
+
contents: read
|
|
5
|
+
id-token: write
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
publish:
|
|
12
|
+
environment: Release
|
|
13
|
+
name: Publish to NPM
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
|
|
16
|
+
steps:
|
|
17
|
+
- name: Checkout code
|
|
18
|
+
uses: actions/checkout@v4
|
|
19
|
+
|
|
20
|
+
- name: Setup Node.js
|
|
21
|
+
uses: actions/setup-node@v4
|
|
22
|
+
with:
|
|
23
|
+
node-version: '24'
|
|
24
|
+
cache: 'npm'
|
|
25
|
+
registry-url: 'https://registry.npmjs.org'
|
|
26
|
+
|
|
27
|
+
- name: Install dependencies
|
|
28
|
+
run: npm ci
|
|
29
|
+
|
|
30
|
+
- name: Run tests
|
|
31
|
+
run: npm test
|
|
32
|
+
|
|
33
|
+
- name: Run linting
|
|
34
|
+
run: npm run lint
|
|
35
|
+
|
|
36
|
+
- name: Publish to NPM
|
|
37
|
+
run: npm publish --provenance --access public
|
package/.nvmrc
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
24
|
package/CODEOWNERS
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# The following aliases have approval permissions in this repo:
|
|
2
|
-
* @imbrianj @mi4l @DerekWW @jperezhearsay @Bearbar00008
|
|
2
|
+
* @imbrianj @mi4l @DerekWW @jperezhearsay @Bearbar00008 @jocruz-yext
|
package/README.md
CHANGED
|
@@ -39,6 +39,85 @@ npm test
|
|
|
39
39
|
npx vitest run --coverage
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
+
#### Release Process
|
|
43
|
+
|
|
44
|
+
This package is automatically published to NPM via GitHub Actions when a new version tag is pushed.
|
|
45
|
+
|
|
46
|
+
To publish a new version:
|
|
47
|
+
|
|
48
|
+
1. **Bump the version** using one of these commands:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npm run version:patch # For bug fixes (0.4.0 → 0.4.1)
|
|
52
|
+
npm run version:minor # For new features (0.4.0 → 0.5.0)
|
|
53
|
+
npm run version:major # For breaking changes (0.4.0 → 1.0.0)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
2. **Push the changes and tags**:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm run release
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This will run tests, linting, and push both commits and tags to GitHub.
|
|
63
|
+
|
|
64
|
+
3. **GitHub Actions will automatically**:
|
|
65
|
+
- Run tests and linting
|
|
66
|
+
- Publish to NPM with provenance
|
|
67
|
+
- Use OIDC authentication for secure publishing
|
|
68
|
+
|
|
69
|
+
### ⚠️ **SECURITY WARNING**
|
|
70
|
+
|
|
71
|
+
**The `rawNumber` field contains UNSANITIZED user input and poses XSS risks if displayed in web applications.**
|
|
72
|
+
|
|
73
|
+
#### 🚨 **NEVER do this:**
|
|
74
|
+
|
|
75
|
+
```javascript
|
|
76
|
+
// DANGEROUS - Direct HTML insertion
|
|
77
|
+
document.getElementById('phone').innerHTML = result.rawNumber;
|
|
78
|
+
|
|
79
|
+
// DANGEROUS - Template literal insertion
|
|
80
|
+
element.innerHTML = `Call ${result.rawNumber}`;
|
|
81
|
+
|
|
82
|
+
// DANGEROUS - React without escaping
|
|
83
|
+
return <div>{result.rawNumber}</div>;
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
#### ✅ **Safe approaches:**
|
|
87
|
+
|
|
88
|
+
```javascript
|
|
89
|
+
// SAFE - Use formatted version for display
|
|
90
|
+
document.getElementById('phone').textContent = result.formattedNumber;
|
|
91
|
+
|
|
92
|
+
// SAFE - Use rawNumber for string replacement/processing
|
|
93
|
+
const updatedText = originalText.replace(
|
|
94
|
+
result.rawNumber,
|
|
95
|
+
result.formattedNumber,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// SAFE - Escape before HTML insertion
|
|
99
|
+
document.getElementById('phone').innerHTML = escapeHtml(result.rawNumber);
|
|
100
|
+
|
|
101
|
+
// SAFE - React with proper text content
|
|
102
|
+
return <div>{result.formattedNumber}</div>;
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
#### **Why `rawNumber` exists:**
|
|
106
|
+
|
|
107
|
+
The `rawNumber` field enables powerful text replacement functionality:
|
|
108
|
+
|
|
109
|
+
```javascript
|
|
110
|
+
const text = 'Call me at (310) 555-1234 anytime!';
|
|
111
|
+
const numbers = findNumbersInString(text);
|
|
112
|
+
let updatedText = text;
|
|
113
|
+
|
|
114
|
+
numbers.forEach((phone) => {
|
|
115
|
+
// Replace original with formatted version
|
|
116
|
+
updatedText = updatedText.replace(phone.rawNumber, phone.formattedNumber);
|
|
117
|
+
});
|
|
118
|
+
// Result: "Call me at (310) 555-1234 anytime!" → formatted consistently
|
|
119
|
+
```
|
|
120
|
+
|
|
42
121
|
### Warning
|
|
43
122
|
|
|
44
123
|
The returned object will include a `rawNumber` value. This value is the return of the exact value passed to the function. No sanitization occurs with this value. If you reference this number, ensure you sanitize it _BEFORE_ passing to this function.
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yext/phonenumber-util",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"author": "bajohnson@hearsaycorp.com",
|
|
5
5
|
"license": "BSD-3-Clause",
|
|
6
6
|
"description": "Utility for extracting and validating phone numbers",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
7
10
|
"type": "module",
|
|
8
11
|
"main": "src",
|
|
9
12
|
"exports": {
|
|
@@ -22,19 +25,23 @@
|
|
|
22
25
|
"test": "TZ=America/Los_Angeles vitest",
|
|
23
26
|
"build": "node -e \"console.log('No build script for vanilla JS')\"",
|
|
24
27
|
"prepare": "husky",
|
|
25
|
-
"make-badges": "istanbul-badges-readme"
|
|
28
|
+
"make-badges": "istanbul-badges-readme",
|
|
29
|
+
"version:patch": "npm version patch",
|
|
30
|
+
"version:minor": "npm version minor",
|
|
31
|
+
"version:major": "npm version major",
|
|
32
|
+
"release": "npm run test && npm run lint && git push && git push --tags"
|
|
26
33
|
},
|
|
27
34
|
"devDependencies": {
|
|
28
|
-
"@eslint/js": "^9.
|
|
29
|
-
"@vitest/coverage-v8": "^
|
|
30
|
-
"eslint": "^9.
|
|
31
|
-
"generate-license-file": "4.
|
|
32
|
-
"globals": "^16.
|
|
35
|
+
"@eslint/js": "^9.38.0",
|
|
36
|
+
"@vitest/coverage-v8": "^4.0.2",
|
|
37
|
+
"eslint": "^9.38.0",
|
|
38
|
+
"generate-license-file": "^4.1.0",
|
|
39
|
+
"globals": "^16.4.0",
|
|
33
40
|
"husky": "^9.1.7",
|
|
34
41
|
"istanbul-badges-readme": "^1.9.0",
|
|
35
|
-
"lint-staged": "^16.
|
|
42
|
+
"lint-staged": "^16.2.6",
|
|
36
43
|
"prettier": "^3.6.2",
|
|
37
|
-
"vitest": "^
|
|
44
|
+
"vitest": "^4.0.2"
|
|
38
45
|
},
|
|
39
46
|
"eslintConfig": {},
|
|
40
47
|
"lint-staged": {
|
|
@@ -433,6 +433,11 @@ describe('Phone number pretty formatting', () => {
|
|
|
433
433
|
regionCode: '47',
|
|
434
434
|
format: findPhoneFormat({ regionCode: '47', e164: '+471740876543' }),
|
|
435
435
|
},
|
|
436
|
+
egyptStringFormat: {
|
|
437
|
+
e164: '+201012345678',
|
|
438
|
+
regionCode: '20',
|
|
439
|
+
format: findPhoneFormat({ regionCode: '20', e164: '+201012345678' }),
|
|
440
|
+
},
|
|
436
441
|
};
|
|
437
442
|
|
|
438
443
|
expect(formatPhoneNumber(testNumbers.nullCase)).toBe(null);
|
|
@@ -443,5 +448,68 @@ describe('Phone number pretty formatting', () => {
|
|
|
443
448
|
expect(formatPhoneNumber(testNumbers.norwayUnexpected)).toBe(
|
|
444
449
|
'+47174087654',
|
|
445
450
|
);
|
|
451
|
+
// Egypt has a string format (not array), this tests the else if (formatRaw)
|
|
452
|
+
expect(formatPhoneNumber(testNumbers.egyptStringFormat)).toBe(
|
|
453
|
+
'+20 101 234 5678',
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
describe('E.164 formatting edge cases', () => {
|
|
459
|
+
describe('formatPhoneNumberForE164 with missing required fields', () => {
|
|
460
|
+
it('should format US numbers with separate area code correctly', () => {
|
|
461
|
+
expect(
|
|
462
|
+
formatPhoneNumberForE164({
|
|
463
|
+
regionCode: '1',
|
|
464
|
+
areaCode: '310',
|
|
465
|
+
localNumber: '3496200',
|
|
466
|
+
}),
|
|
467
|
+
).toBe('+13103496200');
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('should format international numbers without area code correctly', () => {
|
|
471
|
+
expect(
|
|
472
|
+
formatPhoneNumberForE164({
|
|
473
|
+
regionCode: '44',
|
|
474
|
+
areaCode: null,
|
|
475
|
+
localNumber: '2079460958',
|
|
476
|
+
}),
|
|
477
|
+
).toBe('+442079460958');
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
describe('International number parsing with unknown region codes', () => {
|
|
482
|
+
it('should handle international numbers that do not match any known region code in the loop', () => {
|
|
483
|
+
// Test a number with + prefix and valid length but an unrecognized region code
|
|
484
|
+
// The parser should try all region code lengths (1, 2, 3 digits) but find no match in PHONE_FORMATS
|
|
485
|
+
const phoneParts = getPhoneParts('+00012345678');
|
|
486
|
+
// Should not have regionCode since +000, +00, or +0 are not valid region codes
|
|
487
|
+
expect(phoneParts.regionCode).toBeNull();
|
|
488
|
+
expect(phoneParts.localNumber).toBeNull();
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('should handle numbers that skip the international parsing else-if block entirely', () => {
|
|
492
|
+
// Test numbers with + prefix that exceed the maximum length for international number parsing
|
|
493
|
+
// The parser checks strippedPhoneNumber.length <= 14, so 16 chars (15 digits + plus sign) is too long
|
|
494
|
+
// This ensures the else-if branch is not entered, leaving regionCode and localNumber as null
|
|
495
|
+
const longNumber = getPhoneParts('+123456789012345'); // 15 digits, 16 chars total
|
|
496
|
+
expect(longNumber.regionCode).toBeNull();
|
|
497
|
+
expect(longNumber.localNumber).toBeNull();
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
describe('findNumbersInString with multiple phone numbers', () => {
|
|
502
|
+
it('should extract multiple valid phone numbers from a single string', () => {
|
|
503
|
+
const text = 'Call me at +1 (310) 349-6200 or +44 20 7946 0958.';
|
|
504
|
+
const results = findNumbersInString(text);
|
|
505
|
+
expect(results.length).toBe(2);
|
|
506
|
+
expect(results[0].e164).toBe('+13103496200');
|
|
507
|
+
expect(results[1].e164).toBe('+442079460958');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('should return an empty array when no valid phone numbers are present', () => {
|
|
511
|
+
const text = 'No phone numbers here!';
|
|
512
|
+
expect(findNumbersInString(text)).toEqual([]);
|
|
513
|
+
});
|
|
446
514
|
});
|
|
447
515
|
});
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
findTimeDetails,
|
|
6
6
|
findTimeFromAreaCode,
|
|
7
7
|
findRegionFromRegionCode,
|
|
8
|
+
findAllNumbersInfoInString,
|
|
8
9
|
} from '../geo.js';
|
|
9
10
|
import { AREA_CODE_LIST } from '../areaCodeList.js';
|
|
10
11
|
import { AREA_CODES, REGION_CODES } from '../phoneCodes.js';
|
|
@@ -188,6 +189,200 @@ const canadianPhone = {
|
|
|
188
189
|
},
|
|
189
190
|
};
|
|
190
191
|
|
|
192
|
+
const longStringWithNumbers = `
|
|
193
|
+
It was a cold, rainy evening when Sophie stumbled upon the weathered journal in the attic of her late grandfather's home. The journal's leather cover was cracked, its pages yellowed with time. Inside, however, was a web of mystery that would unravel Sophie's life over the coming weeks. The most intriguing part? Each entry ended with a phone number-a mix of US, Canadian, and international numbers-written in her grandfather's meticulous hand.
|
|
194
|
+
The first number, 617-555-0134, was scribbled below a cryptic entry about "the Boston job." Sophie's heart raced as she dialed the Massachusetts number. A gruff voice answered on the third ring. "This is Detective Harris. Who's calling?" Sophie's voice trembled as she explained the journal. Harris's tone softened. "If that journal belonged to Robert Fields, you need to be careful. He was involved in some deep, dangerous stuff."
|
|
195
|
+
Sophie wasn't deterred. Instead, her curiosity grew. The next number was Canadian: +1-416-555-2468, written beneath an entry about a maple leaf emblem. The line connected to a cafe in Toronto. A barista named Elena answered and, after a moment of hesitation, said, "I remember Mr. Fields. He came in every Tuesday for a year. Always ordered the same thing-a double espresso-and left me an envelope every time."
|
|
196
|
+
Sophie's pulse quickened as Elena offered to send a picture of one of the envelopes. Within an hour, Sophie received an email with an image showing a wax seal bearing an intricate insignia. Below it, another phone number was scrawled: +44 20 7946 0958, a UK number. Without hesitation, Sophie dialed.
|
|
197
|
+
"Briggs Antiquities, London," a posh voice answered. Sophie explained her discovery, and the receptionist transferred her to a man named Charles. "Ah, Robert Fields," Charles said wistfully. "He was a loyal patron, always seeking artifacts tied to the Knights Templar. If you have his journal, you might find yourself part of a larger puzzle."
|
|
198
|
+
The puzzle, it seemed, spanned the globe. Another entry mentioned "the desert winds" and was linked to a number in Dubai: +971 4 555 1234. The number connected her to a man named Tariq, who revealed that Robert had been searching for a rare relic-a golden compass said to point to treasure. Tariq's description matched the insignia Sophie had seen on the envelope.
|
|
199
|
+
"But why so many numbers?" Sophie muttered to herself. Each call led to another layer of intrigue. A South African number, +27 21 555 6789, took her to Cape Town, where a historian named Amara spoke of Robert's fascination with lost languages. "He believed there was a cipher hidden in ancient texts. If he left you his journal, you're meant to decode it."
|
|
200
|
+
Another number, +55-11-5555-1234 in Brazil, led her to a tech entrepreneur in Sao Paulo who had once helped Robert crack encrypted files. "I thought he was crazy," the entrepreneur said. "But now? Maybe he was onto something."
|
|
201
|
+
Sophie pieced together her grandfather's movements over decades. Each phone number represented a person who had played a role in his quest. An Australian number, +61 2 5551 2345, introduced her to a diver named Liam who had helped Robert retrieve artifacts from a shipwreck. "He was fearless," Liam recalled. "And determined."
|
|
202
|
+
Back in the US, a Seattle number, 206-555-7890, connected Sophie to a lawyer who held a key to Robert's safety deposit box. Inside was a single piece of parchment and yet another number: +49 30 555 4321, this time in Germany. The number belonged to a librarian in Berlin who recognized the parchment as part of a centuries-old map.
|
|
203
|
+
The map, Sophie realized, was the heart of the mystery. Each phone number had been a breadcrumb leading her closer to understanding her grandfather's obsession. The final number, scribbled in bold at the back of the journal, was unlike the others. It was an Argentinian number: +54 11 5555 6789. Sophie hesitated, her finger hovering over the dial button. She took a deep breath and called.
|
|
204
|
+
"Sophie Fields," a voice answered before she could speak. "We've been expecting you."
|
|
205
|
+
"Who is this?" she demanded, her voice trembling.
|
|
206
|
+
"A friend of your grandfather," the voice replied. "He knew you would finish what he started."
|
|
207
|
+
The call ended abruptly, leaving Sophie with more questions than answers. But as she stared at the journal, now marked with notes and connections, she felt a sense of purpose. Each phone number was a clue, each person a piece of a puzzle her grandfather had trusted her to solve.
|
|
208
|
+
And Sophie was determined to finish what Robert Fields had begun.`;
|
|
209
|
+
const longStringOutput = [
|
|
210
|
+
{
|
|
211
|
+
index: 464,
|
|
212
|
+
lastIndex: 476,
|
|
213
|
+
areaCode: '617',
|
|
214
|
+
e164: '+16175550134',
|
|
215
|
+
format: '(xxx) xxx-xxxx',
|
|
216
|
+
formattedNumber: '(617) 555-0134',
|
|
217
|
+
href: 'tel:+16175550134',
|
|
218
|
+
localNumber: '5550134',
|
|
219
|
+
rawNumber: '617-555-0134',
|
|
220
|
+
regionCode: '1',
|
|
221
|
+
timezoneOffset: '-04:00',
|
|
222
|
+
stateHasMultipleTimezones: false,
|
|
223
|
+
areaCodeHasMultipleTimezones: false,
|
|
224
|
+
daylightSavings: true,
|
|
225
|
+
estimatedTime: false,
|
|
226
|
+
state: { name: 'Massachusetts', code: 'MA' },
|
|
227
|
+
region: { name: 'United States', code: 'US', flag: '🇺🇸' },
|
|
228
|
+
localTimeReadable: '11:00:00 AM',
|
|
229
|
+
localTime24Hour: '11:00:00',
|
|
230
|
+
isTCPAQuietHours: false,
|
|
231
|
+
isQuietHours: false,
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
index: 961,
|
|
235
|
+
lastIndex: 976,
|
|
236
|
+
areaCode: '416',
|
|
237
|
+
e164: '+14165552468',
|
|
238
|
+
format: '(xxx) xxx-xxxx',
|
|
239
|
+
formattedNumber: '(416) 555-2468',
|
|
240
|
+
href: 'tel:+14165552468',
|
|
241
|
+
localNumber: '5552468',
|
|
242
|
+
rawNumber: '+1-416-555-2468',
|
|
243
|
+
regionCode: '1',
|
|
244
|
+
timezoneOffset: '-04:00',
|
|
245
|
+
stateHasMultipleTimezones: true,
|
|
246
|
+
areaCodeHasMultipleTimezones: false,
|
|
247
|
+
daylightSavings: true,
|
|
248
|
+
estimatedTime: false,
|
|
249
|
+
state: { name: 'Ontario', code: 'ON' },
|
|
250
|
+
region: { name: 'Canada', code: 'CA', flag: '🇨🇦' },
|
|
251
|
+
localTimeReadable: '11:00:00 AM',
|
|
252
|
+
localTime24Hour: '11:00:00',
|
|
253
|
+
isCRTCQuietHours: false,
|
|
254
|
+
isQuietHours: false,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
index: 1524,
|
|
258
|
+
lastIndex: 1540,
|
|
259
|
+
areaCode: null,
|
|
260
|
+
e164: '+442079460958',
|
|
261
|
+
format: '+xx xxxx xxxxxx',
|
|
262
|
+
formattedNumber: '+44 2079 460958',
|
|
263
|
+
href: 'tel:+442079460958',
|
|
264
|
+
localNumber: '2079460958',
|
|
265
|
+
rawNumber: '+44 20 7946 0958',
|
|
266
|
+
regionCode: '44',
|
|
267
|
+
name: 'United Kingdom',
|
|
268
|
+
code: 'GB',
|
|
269
|
+
flag: '🇬🇧',
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
index: 2056,
|
|
273
|
+
lastIndex: 2071,
|
|
274
|
+
areaCode: null,
|
|
275
|
+
e164: '+97145551234',
|
|
276
|
+
format: '+xxx xx xxx xxxx',
|
|
277
|
+
formattedNumber: '+97145551234',
|
|
278
|
+
href: 'tel:+97145551234',
|
|
279
|
+
localNumber: '45551234',
|
|
280
|
+
rawNumber: '+971 4 555 1234',
|
|
281
|
+
regionCode: '971',
|
|
282
|
+
name: 'United Arab Emirates',
|
|
283
|
+
code: 'AE',
|
|
284
|
+
flag: '🇦🇪',
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
index: 2422,
|
|
288
|
+
lastIndex: 2437,
|
|
289
|
+
areaCode: null,
|
|
290
|
+
e164: '+27215556789',
|
|
291
|
+
format: '+xx xx xxx xxxx',
|
|
292
|
+
formattedNumber: '+27 21 555 6789',
|
|
293
|
+
href: 'tel:+27215556789',
|
|
294
|
+
localNumber: '215556789',
|
|
295
|
+
rawNumber: '+27 21 555 6789',
|
|
296
|
+
regionCode: '27',
|
|
297
|
+
name: 'South Africa',
|
|
298
|
+
code: 'ZA',
|
|
299
|
+
flag: '🇿🇦',
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
index: 2672,
|
|
303
|
+
lastIndex: 2688,
|
|
304
|
+
areaCode: null,
|
|
305
|
+
e164: '+551155551234',
|
|
306
|
+
format: '+xx xx xxxx-xxxx',
|
|
307
|
+
formattedNumber: '+55 11 5555-1234',
|
|
308
|
+
href: 'tel:+551155551234',
|
|
309
|
+
localNumber: '1155551234',
|
|
310
|
+
rawNumber: '+55-11-5555-1234',
|
|
311
|
+
regionCode: '55',
|
|
312
|
+
name: 'Brazil',
|
|
313
|
+
code: 'BR',
|
|
314
|
+
flag: '🇧🇷',
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
index: 3045,
|
|
318
|
+
lastIndex: 3060,
|
|
319
|
+
areaCode: null,
|
|
320
|
+
e164: '+61255512345',
|
|
321
|
+
format: '+xx xxx xxx xxx',
|
|
322
|
+
formattedNumber: '+61 255 512 345',
|
|
323
|
+
href: 'tel:+61255512345',
|
|
324
|
+
localNumber: '255512345',
|
|
325
|
+
rawNumber: '+61 2 5551 2345',
|
|
326
|
+
regionCode: '61',
|
|
327
|
+
name: 'Australia',
|
|
328
|
+
code: 'AU',
|
|
329
|
+
flag: '🇦🇺',
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
index: 3244,
|
|
333
|
+
lastIndex: 3256,
|
|
334
|
+
areaCode: '206',
|
|
335
|
+
e164: '+12065557890',
|
|
336
|
+
format: '(xxx) xxx-xxxx',
|
|
337
|
+
formattedNumber: '(206) 555-7890',
|
|
338
|
+
href: 'tel:+12065557890',
|
|
339
|
+
localNumber: '5557890',
|
|
340
|
+
rawNumber: '206-555-7890',
|
|
341
|
+
regionCode: '1',
|
|
342
|
+
timezoneOffset: '-07:00',
|
|
343
|
+
stateHasMultipleTimezones: false,
|
|
344
|
+
areaCodeHasMultipleTimezones: false,
|
|
345
|
+
daylightSavings: true,
|
|
346
|
+
estimatedTime: false,
|
|
347
|
+
state: { name: 'Washington', code: 'WA' },
|
|
348
|
+
region: { name: 'United States', code: 'US', flag: '🇺🇸' },
|
|
349
|
+
localTimeReadable: '8:00:00 AM',
|
|
350
|
+
localTime24Hour: '08:00:00',
|
|
351
|
+
isTCPAQuietHours: false,
|
|
352
|
+
isQuietHours: false,
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
index: 3397,
|
|
356
|
+
lastIndex: 3412,
|
|
357
|
+
areaCode: null,
|
|
358
|
+
e164: '+49305554321',
|
|
359
|
+
format: '+xx xx xxxxxxx',
|
|
360
|
+
formattedNumber: '+49 30 5554321',
|
|
361
|
+
href: 'tel:+49305554321',
|
|
362
|
+
localNumber: '305554321',
|
|
363
|
+
rawNumber: '+49 30 555 4321',
|
|
364
|
+
regionCode: '49',
|
|
365
|
+
name: 'Germany',
|
|
366
|
+
code: 'DE',
|
|
367
|
+
flag: '🇩🇪',
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
index: 3820,
|
|
371
|
+
lastIndex: 3836,
|
|
372
|
+
areaCode: null,
|
|
373
|
+
e164: '+541155556789',
|
|
374
|
+
format: '+xx xxx-xxx-xxxx',
|
|
375
|
+
formattedNumber: '+54 115-555-6789',
|
|
376
|
+
href: 'tel:+541155556789',
|
|
377
|
+
localNumber: '1155556789',
|
|
378
|
+
rawNumber: '+54 11 5555 6789',
|
|
379
|
+
regionCode: '54',
|
|
380
|
+
name: 'Argentina',
|
|
381
|
+
code: 'AR',
|
|
382
|
+
flag: '🇦🇷',
|
|
383
|
+
},
|
|
384
|
+
];
|
|
385
|
+
|
|
191
386
|
describe('Validate that every allow-list area code has matching geo and time info', () => {
|
|
192
387
|
it('should ensure every area code in AREA_CODE_LIST has a matching region code in AREA_CODES', () => {
|
|
193
388
|
AREA_CODE_LIST.forEach((areaCode) => {
|
|
@@ -204,7 +399,7 @@ describe('Validate that every allow-list area code has matching geo and time inf
|
|
|
204
399
|
});
|
|
205
400
|
|
|
206
401
|
Object.keys(AREA_CODES).forEach((areaCode) => {
|
|
207
|
-
const exists = AREA_CODE_LIST.
|
|
402
|
+
const exists = AREA_CODE_LIST.has(areaCode);
|
|
208
403
|
|
|
209
404
|
if (!exists) {
|
|
210
405
|
console.warn(
|
|
@@ -380,3 +575,37 @@ describe('Provides region name for a given region code', () => {
|
|
|
380
575
|
expect(findRegionFromRegionCode(33).name).toEqual('France');
|
|
381
576
|
});
|
|
382
577
|
});
|
|
578
|
+
|
|
579
|
+
describe('Extracts all useful phone info from a long string of text', () => {
|
|
580
|
+
it('Returns all phone numbers and geo data', () => {
|
|
581
|
+
const numbers = findAllNumbersInfoInString(
|
|
582
|
+
longStringWithNumbers,
|
|
583
|
+
new Date('2024-07-20T08:00:00'),
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
expect(numbers).toEqual(longStringOutput);
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
describe('Area code region information mapping', () => {
|
|
591
|
+
it('should include region details when area code has region information', () => {
|
|
592
|
+
const result = findTimeFromAreaCode('206', new Date('2024-07-15T08:00:00'));
|
|
593
|
+
|
|
594
|
+
expect(result.region).toBeDefined();
|
|
595
|
+
expect(result.region.name).toBe('United States');
|
|
596
|
+
expect(result.region.code).toBe('US');
|
|
597
|
+
expect(result.state).toBeDefined();
|
|
598
|
+
expect(result.state.name).toBe('Washington');
|
|
599
|
+
expect(result.state.code).toBe('WA');
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('should include region flag for area codes with region property', () => {
|
|
603
|
+
// Test with Puerto Rico which has a region property with flag
|
|
604
|
+
const result = findTimeFromAreaCode('787', new Date('2024-07-15T08:00:00'));
|
|
605
|
+
|
|
606
|
+
expect(result.region).toBeDefined();
|
|
607
|
+
expect(result.region.flag).toBeDefined();
|
|
608
|
+
expect(result.region.name).toBe('Puerto Rico');
|
|
609
|
+
expect(result.region.code).toBe('PR');
|
|
610
|
+
});
|
|
611
|
+
});
|
package/src/areaCodeList.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// If a new area code is added to this array, it must also be added to the AREA_CODES object in phoneCodes.js.
|
|
3
3
|
// If a new area code is added and covers a region that has multiple timezones, it will need to be added to the STATES_WITH_MULTIPLE_TIMEZONES object in timezones.js.
|
|
4
4
|
// If a new area code is added and covers a region that has portions that do and portions that do not adhere to daylight savings time, it will need to be added to the AREA_CODES_WITH_MULTIPLE_DAYLIGHT_SAVINGS object in daylightSavings.js.
|
|
5
|
-
export const AREA_CODE_LIST = [
|
|
5
|
+
export const AREA_CODE_LIST = new Set([
|
|
6
6
|
'201',
|
|
7
7
|
'202',
|
|
8
8
|
'203',
|
|
@@ -504,4 +504,4 @@ export const AREA_CODE_LIST = [
|
|
|
504
504
|
'985',
|
|
505
505
|
'986',
|
|
506
506
|
'989',
|
|
507
|
-
];
|
|
507
|
+
]);
|
package/src/base.d.ts
CHANGED
|
@@ -14,6 +14,11 @@ export interface PhoneValidationResult {
|
|
|
14
14
|
isValid: boolean;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export interface PhoneNumberInText extends PhoneParts {
|
|
18
|
+
index: number;
|
|
19
|
+
lastIndex: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
export function formatPhoneNumberForE164(
|
|
18
23
|
phoneParts: Pick<PhoneParts, 'regionCode' | 'areaCode' | 'localNumber'>
|
|
19
24
|
): string | null;
|
|
@@ -22,26 +27,23 @@ export function formatPhoneNumberLink(
|
|
|
22
27
|
phoneParts: Pick<PhoneParts, 'regionCode' | 'areaCode' | 'localNumber'>
|
|
23
28
|
): string | null;
|
|
24
29
|
|
|
25
|
-
export function isValidPhoneNumber(phoneNumber
|
|
30
|
+
export function isValidPhoneNumber(phoneNumber?: string | null | undefined): boolean;
|
|
26
31
|
|
|
27
|
-
export function isValidPhoneNumberWithDescription(phoneNumber
|
|
32
|
+
export function isValidPhoneNumberWithDescription(phoneNumber?: string | null | undefined): PhoneValidationResult;
|
|
28
33
|
|
|
29
|
-
export function getPhoneParts(phoneNumber?: string | null): PhoneParts;
|
|
34
|
+
export function getPhoneParts(phoneNumber?: string | null | undefined): PhoneParts;
|
|
30
35
|
|
|
31
|
-
export function sanitizeRawNumber(phoneNumber
|
|
36
|
+
export function sanitizeRawNumber(phoneNumber?: string | null | undefined): string;
|
|
32
37
|
|
|
33
|
-
export function findNumbersInString(text
|
|
34
|
-
index: number;
|
|
35
|
-
lastIndex: number;
|
|
36
|
-
} & PhoneParts>;
|
|
38
|
+
export function findNumbersInString(text?: string | null | undefined): PhoneNumberInText[];
|
|
37
39
|
|
|
38
40
|
export function findPhoneFormat(params: {
|
|
39
|
-
regionCode
|
|
40
|
-
e164
|
|
41
|
+
regionCode?: string | null;
|
|
42
|
+
e164?: string | null;
|
|
41
43
|
}): string;
|
|
42
44
|
|
|
43
45
|
export function formatPhoneNumber(params: {
|
|
44
|
-
format
|
|
45
|
-
e164
|
|
46
|
-
regionCode
|
|
46
|
+
format?: string | null;
|
|
47
|
+
e164?: string | null;
|
|
48
|
+
regionCode?: string | null;
|
|
47
49
|
}): string | null;
|
package/src/base.js
CHANGED
|
@@ -225,7 +225,7 @@ export const getPhoneParts = (phoneNumber) => {
|
|
|
225
225
|
// If no region code is provided, assume US with the format 3109309000 after being stripped of non-numeric values.
|
|
226
226
|
// We'll try and derive the area code by looking it up against the known area codes.
|
|
227
227
|
else if (strippedPhoneNumber.length === US_PHONE_LENGTH) {
|
|
228
|
-
if (AREA_CODE_LIST.
|
|
228
|
+
if (AREA_CODE_LIST.has(strippedPhoneNumber.substring(0, 3))) {
|
|
229
229
|
phoneParts.regionCode = '1';
|
|
230
230
|
phoneParts.areaCode = strippedPhoneNumber.substring(0, 3);
|
|
231
231
|
phoneParts.localNumber = strippedPhoneNumber.substring(3);
|
|
@@ -264,7 +264,7 @@ export const getPhoneParts = (phoneNumber) => {
|
|
|
264
264
|
if (
|
|
265
265
|
phoneParts.regionCode === '1' &&
|
|
266
266
|
phoneParts.areaCode &&
|
|
267
|
-
AREA_CODE_LIST.
|
|
267
|
+
!AREA_CODE_LIST.has(phoneParts.areaCode)
|
|
268
268
|
) {
|
|
269
269
|
phoneParts.areaCode = null;
|
|
270
270
|
}
|
|
@@ -362,15 +362,16 @@ export const findPhoneFormat = ({ regionCode, e164 }) => {
|
|
|
362
362
|
if (formatRaw && numberLength) {
|
|
363
363
|
// The PHONE_FORMATS will have arrays for regions with inconsistent number lengths / formats.
|
|
364
364
|
if (Array.isArray(formatRaw)) {
|
|
365
|
-
|
|
365
|
+
for (const value of formatRaw) {
|
|
366
366
|
const templateLength = value.split('x').length - 1;
|
|
367
367
|
if (numberLength === templateLength) {
|
|
368
368
|
format = value;
|
|
369
|
+
break;
|
|
369
370
|
}
|
|
370
|
-
}
|
|
371
|
+
}
|
|
371
372
|
}
|
|
372
373
|
// Some region (such as the US) will have a consistent format, so we expect a string.
|
|
373
|
-
else
|
|
374
|
+
else {
|
|
374
375
|
format = formatRaw;
|
|
375
376
|
}
|
|
376
377
|
}
|
package/src/geo.d.ts
CHANGED
|
@@ -1,40 +1,86 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
export interface RegionInfo {
|
|
2
|
+
name: string;
|
|
3
|
+
code: string;
|
|
4
|
+
flag: string;
|
|
5
|
+
}
|
|
4
6
|
|
|
5
|
-
export
|
|
7
|
+
export interface StateInfo {
|
|
8
|
+
name: string;
|
|
9
|
+
code: string;
|
|
10
|
+
}
|
|
6
11
|
|
|
7
|
-
export
|
|
8
|
-
offset: string,
|
|
9
|
-
date: Date,
|
|
10
|
-
state: string
|
|
11
|
-
): {
|
|
12
|
+
export interface TimeDetails {
|
|
12
13
|
localTimeReadable: string;
|
|
13
14
|
localTime24Hour: string;
|
|
14
15
|
isTCPAQuietHours?: boolean;
|
|
15
16
|
isCRTCQuietHours?: boolean;
|
|
16
17
|
isQuietHours: boolean;
|
|
17
|
-
}
|
|
18
|
+
}
|
|
18
19
|
|
|
19
|
-
export
|
|
20
|
-
areaCode: string,
|
|
21
|
-
date?: Date
|
|
22
|
-
): {
|
|
20
|
+
export interface AreaCodeTimeInfo {
|
|
23
21
|
timezoneOffset: string | null;
|
|
24
22
|
daylightSavings: boolean | null;
|
|
25
23
|
stateHasMultipleTimezones: boolean | null;
|
|
26
|
-
state: { name: string; code: string } | null;
|
|
27
|
-
region?: { name: string; code: string; flag: string };
|
|
28
24
|
areaCodeHasMultipleTimezones: boolean | null;
|
|
29
25
|
estimatedTime: boolean;
|
|
26
|
+
state?: StateInfo;
|
|
27
|
+
region?: RegionInfo;
|
|
28
|
+
localTimeReadable?: string;
|
|
30
29
|
localTime24Hour?: string;
|
|
30
|
+
isTCPAQuietHours?: boolean;
|
|
31
|
+
isCRTCQuietHours?: boolean;
|
|
32
|
+
isQuietHours?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PhoneNumberWithGeoInfo {
|
|
36
|
+
index: number;
|
|
37
|
+
lastIndex: number;
|
|
38
|
+
areaCode: string | null;
|
|
39
|
+
e164: string | null;
|
|
40
|
+
format: string | null;
|
|
41
|
+
formattedNumber: string | null;
|
|
42
|
+
href: string | null;
|
|
43
|
+
localNumber: string | null;
|
|
44
|
+
rawNumber: string | undefined; // Can be undefined, matches base PhoneParts
|
|
45
|
+
regionCode: string | null;
|
|
46
|
+
// Geo-specific fields (all present in actual return)
|
|
47
|
+
timezoneOffset?: string | null;
|
|
48
|
+
daylightSavings?: boolean | null;
|
|
49
|
+
stateHasMultipleTimezones?: boolean | null;
|
|
50
|
+
areaCodeHasMultipleTimezones?: boolean | null;
|
|
51
|
+
estimatedTime?: boolean;
|
|
52
|
+
state?: StateInfo | null;
|
|
53
|
+
region?: RegionInfo | null;
|
|
31
54
|
localTimeReadable?: string;
|
|
55
|
+
localTime24Hour?: string;
|
|
32
56
|
isTCPAQuietHours?: boolean;
|
|
33
57
|
isCRTCQuietHours?: boolean;
|
|
34
58
|
isQuietHours?: boolean;
|
|
35
|
-
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isDaylightSavingTime(date?: Date): boolean;
|
|
62
|
+
|
|
63
|
+
export function formatTimeOffset(offset: string): string;
|
|
64
|
+
|
|
65
|
+
export function offsetTieBreaker(timezones: string[], date: Date): string;
|
|
66
|
+
|
|
67
|
+
export function findTimeDetails(
|
|
68
|
+
offset: string,
|
|
69
|
+
date: Date,
|
|
70
|
+
stateName: string
|
|
71
|
+
): TimeDetails;
|
|
72
|
+
|
|
73
|
+
export function findTimeFromAreaCode(
|
|
74
|
+
areaCode: string,
|
|
75
|
+
date?: Date
|
|
76
|
+
): AreaCodeTimeInfo;
|
|
36
77
|
|
|
37
78
|
export function findRegionFromRegionCode(
|
|
38
79
|
regionCode: string | number,
|
|
39
80
|
areaCode?: string
|
|
40
|
-
):
|
|
81
|
+
): RegionInfo | null;
|
|
82
|
+
|
|
83
|
+
export function findAllNumbersInfoInString(
|
|
84
|
+
text: string,
|
|
85
|
+
date?: Date
|
|
86
|
+
): PhoneNumberWithGeoInfo[];
|
package/src/geo.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
STATES_THAT_DONT_HAVE_DAYLIGHT_SAVINGS,
|
|
13
13
|
AREA_CODES_WITH_MULTIPLE_DAYLIGHT_SAVINGS,
|
|
14
14
|
} from './daylightSavings.js';
|
|
15
|
+
import { findNumbersInString } from './base.js';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Determines whether the given date is within daylight saving time for the local time zone.
|
|
@@ -154,13 +155,11 @@ export function findTimeFromAreaCode(areaCode, date = new Date()) {
|
|
|
154
155
|
code: AREA_CODES[areaCode].code,
|
|
155
156
|
};
|
|
156
157
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
};
|
|
163
|
-
}
|
|
158
|
+
returnTime.region = {
|
|
159
|
+
name: AREA_CODES[areaCode].region.name,
|
|
160
|
+
code: AREA_CODES[areaCode].region.code,
|
|
161
|
+
flag: AREA_CODES[areaCode].region.flag,
|
|
162
|
+
};
|
|
164
163
|
}
|
|
165
164
|
|
|
166
165
|
if (!stateName || !STATE_TIMEZONES[stateName]) {
|
|
@@ -256,3 +255,22 @@ export function findRegionFromRegionCode(regionCode, areaCode) {
|
|
|
256
255
|
|
|
257
256
|
return regionInfo;
|
|
258
257
|
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Finds all phone numbers in a string and adds in geographical and/or time zone information to that object.
|
|
261
|
+
*
|
|
262
|
+
* @param {string} text - The text to search for phone numbers.
|
|
263
|
+
* @param {Date} [date=new Date()] - The date to use for determining time zone information. Defaults to the current date.
|
|
264
|
+
* @returns {Array<object>} An array of objects, where each object represents a found phone number
|
|
265
|
+
* and includes details from `findNumbersInString` as well as geographical and/or time zone information.
|
|
266
|
+
*/
|
|
267
|
+
export function findAllNumbersInfoInString(text, date = new Date()) {
|
|
268
|
+
const numbers = findNumbersInString(text);
|
|
269
|
+
|
|
270
|
+
return numbers.map((item) => {
|
|
271
|
+
const geo = item.areaCode
|
|
272
|
+
? findTimeFromAreaCode(item.areaCode, date)
|
|
273
|
+
: findRegionFromRegionCode(item.regionCode);
|
|
274
|
+
return { ...item, ...geo };
|
|
275
|
+
});
|
|
276
|
+
}
|
package/src/phoneFormats.js
CHANGED
|
@@ -15,34 +15,35 @@ export const PHONE_FORMATS = {
|
|
|
15
15
|
40: '+xx xxx xxx xxx', // Romania
|
|
16
16
|
41: '+xx xxx xxx xx xx', // Switzerland
|
|
17
17
|
43: [
|
|
18
|
-
'+xx xxxx
|
|
19
|
-
'+xx xxxx
|
|
18
|
+
'+xx xxxx xxxx', // 10 digits (landline)
|
|
19
|
+
'+xx xxxx xxxx xx', // 12 digits (mobile)
|
|
20
20
|
], // Austria
|
|
21
21
|
44: [
|
|
22
|
-
'+xx xxxx xxxxxx', //
|
|
23
|
-
'+xx xxxxx xxxxxx', //
|
|
22
|
+
'+xx xxxx xxxxxx', // 12 digits (landline/mobile)
|
|
23
|
+
'+xx xxxxx xxxxxx', // 13 digits (some mobiles)
|
|
24
24
|
], // United Kingdom
|
|
25
25
|
45: '+xx xx xx xx xx', // Denmark
|
|
26
26
|
46: '+xx xx-xxx xx xx', // Sweden
|
|
27
27
|
47: '+xx xxx xx xxx', // Norway
|
|
28
28
|
48: '+xx xxx xxx xxx', // Poland
|
|
29
29
|
49: [
|
|
30
|
-
'+xx
|
|
31
|
-
'+xx
|
|
30
|
+
'+xx xx xxxxxxx', // 11 digits (landline: +49 + 2-digit area code + 7 digits)
|
|
31
|
+
'+xx xxx xxxxxxx', // 12 digits (landline: +49 + 3-digit area code + 7 digits)
|
|
32
|
+
'+xx xxx xxxxxxxx', // 12 digits (mobile: +49 + 3-digit prefix + 8 digits)
|
|
32
33
|
], // Germany
|
|
33
34
|
51: '+xx xxx xxx xxx', // Peru
|
|
34
35
|
52: [
|
|
35
|
-
'+xx xxx xxx xxxx', //
|
|
36
|
-
'+xx
|
|
36
|
+
'+xx xxx xxx xxxx', // 12 digits (landline)
|
|
37
|
+
'+xx xxx xxx xxxx', // 13 digits (mobile with 1)
|
|
37
38
|
], // Mexico
|
|
38
39
|
53: '+xx x xxx xxxx', // Cuba
|
|
39
40
|
54: [
|
|
40
|
-
'+xx xxx-xxx-xxxx', //
|
|
41
|
-
'+xx xxxx-
|
|
41
|
+
'+xx xxx-xxx-xxxx', // 12 digits (landline)
|
|
42
|
+
'+xx xxxx-xxx-xxxx', // 13 digits (mobile with 9 prefix)
|
|
42
43
|
], // Argentina
|
|
43
44
|
55: [
|
|
44
|
-
'+xx xx xxxx-xxxx', //
|
|
45
|
-
'+xx xx xxxxx-xxxx', //
|
|
45
|
+
'+xx xx xxxx-xxxx', // 12 digits (landline)
|
|
46
|
+
'+xx xx xxxxx-xxxx', // 13 digits (mobile)
|
|
46
47
|
], // Brazil
|
|
47
48
|
56: '+xx x xxxx xxxx', // Chile
|
|
48
49
|
57: '+xx xxx xxx xxxx', // Colombia
|
package/src/timezones.js
CHANGED
|
@@ -147,9 +147,9 @@ export const STATES_WITH_MULTIPLE_TIMEZONES = {
|
|
|
147
147
|
807: ['-05:00', '-06:00'],
|
|
148
148
|
},
|
|
149
149
|
Quebec: {
|
|
150
|
-
367:
|
|
151
|
-
418:
|
|
152
|
-
581:
|
|
150
|
+
367: '-04:00',
|
|
151
|
+
418: '-04:00',
|
|
152
|
+
581: '-04:00',
|
|
153
153
|
},
|
|
154
154
|
'Newfoundland and Labrador': {
|
|
155
155
|
709: ['-03:30', '-04:00'],
|