airport-utils 1.0.28 → 1.3.0
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/.editorconfig +12 -0
- package/.github/workflows/ci.yml +5 -3
- package/.github/workflows/publish.yml +8 -1
- package/.github/workflows/update-mapping.yml +1 -1
- package/.prettierrc.json +6 -0
- package/README.md +23 -2
- package/dist/cjs/converter.js +58 -45
- package/dist/cjs/index.js +1 -0
- package/dist/cjs/info.js +12 -0
- package/dist/cjs/mapping/geo.js +75681 -11998
- package/dist/cjs/mapping/timezones.js +8409 -12011
- package/dist/esm/converter.js +58 -45
- package/dist/esm/index.js +1 -1
- package/dist/esm/info.js +12 -1
- package/dist/esm/mapping/geo.js +75681 -11998
- package/dist/esm/mapping/timezones.js +8409 -12011
- package/dist/types/index.d.ts +1 -1
- package/dist/types/info.d.ts +6 -0
- package/dist/types/mapping/geo.d.ts +2 -0
- package/eslint.config.cjs +84 -0
- package/jest.config.js +5 -1
- package/package.json +15 -8
- package/scripts/generateMapping.ts +77 -40
- package/src/converter.ts +64 -57
- package/src/errors.ts +1 -1
- package/src/index.ts +2 -2
- package/src/info.ts +19 -2
- package/src/mapping/geo.ts +75683 -11998
- package/src/mapping/timezones.ts +8410 -12013
- package/tests/built.test.ts +59 -42
- package/tests/converter.test.ts +80 -65
- package/tests/generateMapping.test.ts +233 -0
- package/tests/helpers.ts +16 -0
- package/tests/info.mocks.test.ts +36 -0
- package/tests/info.test.ts +13 -18
package/dist/types/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { convertToUTC, convertLocalToUTCByZone } from './converter';
|
|
2
|
-
export { getAirportInfo, AirportInfo } from './info';
|
|
2
|
+
export { getAirportInfo, getAllAirports, AirportInfo, Airport } from './info';
|
|
3
3
|
export { UnknownAirportError, InvalidTimestampError, UnknownTimezoneError } from './errors';
|
package/dist/types/info.d.ts
CHANGED
|
@@ -5,6 +5,12 @@ export interface AirportInfo {
|
|
|
5
5
|
name: string;
|
|
6
6
|
city: string;
|
|
7
7
|
country: string;
|
|
8
|
+
countryName: string;
|
|
9
|
+
continent: string;
|
|
8
10
|
}
|
|
9
11
|
/** @throws UnknownAirportError */
|
|
10
12
|
export declare function getAirportInfo(iata: string): AirportInfo;
|
|
13
|
+
export interface Airport extends AirportInfo {
|
|
14
|
+
iata: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function getAllAirports(): Airport[];
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const tsParser = require('@typescript-eslint/parser');
|
|
2
|
+
const tsPlugin = require('@typescript-eslint/eslint-plugin');
|
|
3
|
+
const js = require('@eslint/js');
|
|
4
|
+
|
|
5
|
+
const nodeGlobals = {
|
|
6
|
+
__dirname: 'readonly',
|
|
7
|
+
module: 'readonly',
|
|
8
|
+
require: 'readonly',
|
|
9
|
+
process: 'readonly',
|
|
10
|
+
console: 'readonly',
|
|
11
|
+
Buffer: 'readonly',
|
|
12
|
+
setTimeout: 'readonly',
|
|
13
|
+
clearTimeout: 'readonly'
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const jestGlobals = {
|
|
17
|
+
describe: 'readonly',
|
|
18
|
+
it: 'readonly',
|
|
19
|
+
test: 'readonly',
|
|
20
|
+
expect: 'readonly',
|
|
21
|
+
beforeAll: 'readonly',
|
|
22
|
+
afterAll: 'readonly',
|
|
23
|
+
beforeEach: 'readonly',
|
|
24
|
+
afterEach: 'readonly',
|
|
25
|
+
jest: 'readonly'
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
module.exports = [
|
|
29
|
+
{
|
|
30
|
+
ignores: ['dist/**', 'coverage/**', 'node_modules/**']
|
|
31
|
+
},
|
|
32
|
+
js.configs.recommended,
|
|
33
|
+
{
|
|
34
|
+
files: ['src/**/*.ts'],
|
|
35
|
+
languageOptions: {
|
|
36
|
+
parser: tsParser,
|
|
37
|
+
parserOptions: {
|
|
38
|
+
ecmaVersion: 'latest',
|
|
39
|
+
sourceType: 'module'
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
plugins: {
|
|
43
|
+
'@typescript-eslint': tsPlugin
|
|
44
|
+
},
|
|
45
|
+
rules: {
|
|
46
|
+
...tsPlugin.configs.recommended.rules
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
files: ['scripts/**/*.ts'],
|
|
51
|
+
languageOptions: {
|
|
52
|
+
parser: tsParser,
|
|
53
|
+
parserOptions: {
|
|
54
|
+
ecmaVersion: 'latest',
|
|
55
|
+
sourceType: 'module'
|
|
56
|
+
},
|
|
57
|
+
globals: nodeGlobals
|
|
58
|
+
},
|
|
59
|
+
plugins: {
|
|
60
|
+
'@typescript-eslint': tsPlugin
|
|
61
|
+
},
|
|
62
|
+
rules: {
|
|
63
|
+
...tsPlugin.configs.recommended.rules
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
files: ['tests/**/*.ts'],
|
|
68
|
+
languageOptions: {
|
|
69
|
+
parser: tsParser,
|
|
70
|
+
parserOptions: {
|
|
71
|
+
ecmaVersion: 'latest',
|
|
72
|
+
sourceType: 'module'
|
|
73
|
+
},
|
|
74
|
+
globals: { ...nodeGlobals, ...jestGlobals }
|
|
75
|
+
},
|
|
76
|
+
plugins: {
|
|
77
|
+
'@typescript-eslint': tsPlugin
|
|
78
|
+
},
|
|
79
|
+
rules: {
|
|
80
|
+
...tsPlugin.configs.recommended.rules,
|
|
81
|
+
'@typescript-eslint/no-require-imports': 'off'
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
];
|
package/jest.config.js
CHANGED
|
@@ -3,6 +3,10 @@ export default {
|
|
|
3
3
|
testEnvironment: 'node',
|
|
4
4
|
roots: ['<rootDir>/tests'],
|
|
5
5
|
collectCoverage: true,
|
|
6
|
+
collectCoverageFrom: [
|
|
7
|
+
'<rootDir>/src/**/*.ts',
|
|
8
|
+
'<rootDir>/scripts/**/*.ts'
|
|
9
|
+
],
|
|
6
10
|
coverageThreshold: {
|
|
7
11
|
global: { branches: 100, functions: 100, lines: 100, statements: 100 }
|
|
8
12
|
},
|
|
@@ -12,4 +16,4 @@ export default {
|
|
|
12
16
|
'/node_modules/',
|
|
13
17
|
'/dist/'
|
|
14
18
|
]
|
|
15
|
-
};
|
|
19
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "airport-utils",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Convert local ISO 8601 timestamps to UTC using airport IATA codes, with airport geo-data",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -21,9 +21,12 @@
|
|
|
21
21
|
"build:types": "tsc --emitDeclarationOnly --declaration --outDir dist/types",
|
|
22
22
|
"build:js": "rollup -c",
|
|
23
23
|
"build": "npm run build:types && npm run build:js",
|
|
24
|
+
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\"",
|
|
25
|
+
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\"",
|
|
26
|
+
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\"",
|
|
24
27
|
"test": "jest --coverage",
|
|
25
28
|
"prepublishOnly": "npm run build",
|
|
26
|
-
"update:mapping": "node --loader ts-node/esm scripts/generateMapping.ts"
|
|
29
|
+
"update:mapping": "node --loader ts-node/esm --input-type=module -e \"import { generateMapping } from './scripts/generateMapping.ts'; await generateMapping();\""
|
|
27
30
|
},
|
|
28
31
|
"keywords": [
|
|
29
32
|
"amadeus",
|
|
@@ -36,23 +39,27 @@
|
|
|
36
39
|
],
|
|
37
40
|
"license": "MIT",
|
|
38
41
|
"dependencies": {
|
|
39
|
-
"@date-fns/tz": "^1.2.0"
|
|
40
|
-
"date-fns": "^4.1.0"
|
|
42
|
+
"@date-fns/tz": "^1.2.0"
|
|
41
43
|
},
|
|
42
44
|
"devDependencies": {
|
|
43
45
|
"@rollup/plugin-node-resolve": "^16.0.1",
|
|
44
46
|
"@rollup/plugin-typescript": "^12.1.2",
|
|
45
47
|
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
46
|
-
"@semantic-release/github": "^12.0.
|
|
47
|
-
"@semantic-release/npm": "^13.
|
|
48
|
-
"@semantic-release/release-notes-generator": "^14.0
|
|
48
|
+
"@semantic-release/github": "^12.0.2",
|
|
49
|
+
"@semantic-release/npm": "^13.1.3",
|
|
50
|
+
"@semantic-release/release-notes-generator": "^14.1.0",
|
|
49
51
|
"@types/jest": "^30.0.0",
|
|
50
52
|
"@types/node-fetch": "^2.6.12",
|
|
53
|
+
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
|
54
|
+
"@typescript-eslint/parser": "^8.53.1",
|
|
51
55
|
"conventional-changelog-conventionalcommits": "^9.1.0",
|
|
56
|
+
"eslint": "^9.39.2",
|
|
57
|
+
"eslint-config-prettier": "^10.1.8",
|
|
52
58
|
"jest": "^30.0.5",
|
|
53
59
|
"node-fetch": "^3.3.2",
|
|
60
|
+
"prettier": "^3.8.0",
|
|
54
61
|
"rollup": "^4.40.2",
|
|
55
|
-
"semantic-release": "^25.0.
|
|
62
|
+
"semantic-release": "^25.0.2",
|
|
56
63
|
"ts-jest": "^29.0.0",
|
|
57
64
|
"ts-node": "^10.9.1",
|
|
58
65
|
"tslib": "^2.8.1",
|
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import fetch from 'node-fetch';
|
|
3
2
|
import fs from 'fs';
|
|
4
3
|
import path from 'path';
|
|
4
|
+
import prettier from 'prettier';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
const DEFAULT_SOURCE_URL =
|
|
7
|
+
'https://raw.githubusercontent.com/opentraveldata/opentraveldata/master/opentraveldata/optd_por_public.csv';
|
|
8
|
+
|
|
9
|
+
export async function generateMapping() {
|
|
10
|
+
const resolvedFetch = globalThis.fetch;
|
|
11
|
+
if (!resolvedFetch) {
|
|
12
|
+
throw new Error('Global fetch is not available.');
|
|
13
|
+
}
|
|
14
|
+
const cwd = process.cwd();
|
|
15
|
+
|
|
16
|
+
const prettierConfig =
|
|
17
|
+
(await prettier.resolveConfig(path.join(cwd, 'package.json'), {
|
|
18
|
+
editorconfig: true
|
|
19
|
+
})) ?? {};
|
|
20
|
+
|
|
21
|
+
const res = await resolvedFetch(DEFAULT_SOURCE_URL);
|
|
22
|
+
if (!res.ok) throw new Error(`Fetch failed: ${res.statusText ?? 'Unknown error'}`);
|
|
11
23
|
const text = await res.text();
|
|
12
24
|
|
|
13
|
-
const lines = text.split('\n').filter(l => l.trim());
|
|
25
|
+
const lines = text.split('\n').filter((l) => l.trim());
|
|
14
26
|
const header = lines[0].split('^');
|
|
15
27
|
const idx = {
|
|
16
28
|
iata: header.indexOf('iata_code'),
|
|
@@ -19,77 +31,102 @@ async function generateMapping() {
|
|
|
19
31
|
lon: header.indexOf('longitude'),
|
|
20
32
|
name: header.indexOf('name'),
|
|
21
33
|
city: header.indexOf('city_name_list'),
|
|
22
|
-
|
|
34
|
+
locationType: header.indexOf('location_type'),
|
|
35
|
+
country: header.indexOf('country_code'),
|
|
36
|
+
countryName: header.indexOf('country_name'),
|
|
37
|
+
continent: header.indexOf('continent_name')
|
|
23
38
|
};
|
|
24
|
-
if (Object.values(idx).some(i => i < 0)) {
|
|
39
|
+
if (Object.values(idx).some((i) => i < 0)) {
|
|
25
40
|
throw new Error('Missing required OPTD columns');
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
const timezonesMap: Record<string, string> = {};
|
|
29
|
-
const geoMap: Record<
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
const geoMap: Record<
|
|
45
|
+
string,
|
|
46
|
+
{
|
|
47
|
+
latitude: number;
|
|
48
|
+
longitude: number;
|
|
49
|
+
name: string;
|
|
50
|
+
city: string;
|
|
51
|
+
country: string;
|
|
52
|
+
countryName: string;
|
|
53
|
+
continent: string;
|
|
54
|
+
}
|
|
55
|
+
> = {};
|
|
56
|
+
const pickCity = (cityNameList: string) => {
|
|
57
|
+
const parts = cityNameList
|
|
58
|
+
.split(/[=,]/)
|
|
59
|
+
.map((part) => part.trim())
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
return parts.at(-1) ?? '';
|
|
62
|
+
};
|
|
36
63
|
|
|
37
64
|
for (let i = 1; i < lines.length; i++) {
|
|
38
65
|
const cols = lines[i].split('^');
|
|
39
66
|
const code = cols[idx.iata];
|
|
40
67
|
if (!code || code.length !== 3) continue;
|
|
68
|
+
const locationType = cols[idx.locationType] ?? '';
|
|
69
|
+
if (locationType !== 'A') continue;
|
|
41
70
|
const tz = cols[idx.tz];
|
|
42
|
-
if (tz)
|
|
71
|
+
if (tz) {
|
|
72
|
+
timezonesMap[code] = tz;
|
|
73
|
+
}
|
|
43
74
|
|
|
44
75
|
const lat = parseFloat(cols[idx.lat]);
|
|
45
76
|
const lon = parseFloat(cols[idx.lon]);
|
|
46
77
|
const name = cols[idx.name];
|
|
47
|
-
const city = cols[idx.city]
|
|
78
|
+
const city = pickCity(cols[idx.city]);
|
|
48
79
|
const country = cols[idx.country];
|
|
49
|
-
|
|
50
|
-
|
|
80
|
+
const countryName = cols[idx.countryName];
|
|
81
|
+
const continent = cols[idx.continent];
|
|
82
|
+
if (!isNaN(lat) && !isNaN(lon) && name && city && country && countryName && continent) {
|
|
83
|
+
geoMap[code] = { latitude: lat, longitude: lon, name, city, country, countryName, continent };
|
|
51
84
|
}
|
|
52
85
|
}
|
|
53
86
|
|
|
54
87
|
const sortedCodes = Object.keys(timezonesMap).sort();
|
|
55
|
-
const sortedTz = Object.fromEntries(sortedCodes.map(c => [c, timezonesMap[c]]));
|
|
88
|
+
const sortedTz = Object.fromEntries(sortedCodes.map((c) => [c, timezonesMap[c]]));
|
|
56
89
|
const sortedGeo = Object.fromEntries(
|
|
57
|
-
sortedCodes.filter(c => geoMap[c]).map(c => [c, geoMap[c]])
|
|
90
|
+
sortedCodes.filter((c) => geoMap[c]).map((c) => [c, geoMap[c]])
|
|
58
91
|
);
|
|
59
92
|
|
|
60
|
-
const dir = path.resolve(
|
|
93
|
+
const dir = path.resolve(cwd, 'src/mapping');
|
|
61
94
|
fs.mkdirSync(dir, { recursive: true });
|
|
62
95
|
|
|
63
96
|
// Write TypeScript modules
|
|
64
97
|
const tzTs = [
|
|
65
98
|
'// generated — do not edit',
|
|
66
99
|
'export const timezones: Record<string, string> = ',
|
|
67
|
-
JSON.stringify(sortedTz, null, 2) + ';'
|
|
100
|
+
JSON.stringify(sortedTz, null, 2) + ';'
|
|
68
101
|
].join('\n');
|
|
69
|
-
|
|
102
|
+
const formattedTz = await prettier.format(tzTs, {
|
|
103
|
+
...prettierConfig,
|
|
104
|
+
parser: 'typescript'
|
|
105
|
+
});
|
|
106
|
+
fs.writeFileSync(path.join(dir, 'timezones.ts'), formattedTz);
|
|
70
107
|
|
|
71
|
-
const geoEntries = Object.entries(sortedGeo)
|
|
72
|
-
.map(([code, g]) => ` "${code}": ${JSON.stringify(g)}`)
|
|
73
|
-
.join(',\n');
|
|
74
108
|
const geoTs = [
|
|
75
109
|
'// generated — do not edit',
|
|
76
110
|
'export interface GeoEntry {',
|
|
77
111
|
' latitude: number;',
|
|
78
112
|
' longitude: number;',
|
|
79
|
-
' name: string;',
|
|
113
|
+
' name: string;',
|
|
80
114
|
' city: string;',
|
|
81
115
|
' country: string;',
|
|
82
|
-
'
|
|
83
|
-
'
|
|
84
|
-
|
|
85
|
-
'
|
|
116
|
+
' countryName: string;',
|
|
117
|
+
' continent: string;',
|
|
118
|
+
'}',
|
|
119
|
+
'',
|
|
120
|
+
'export const geo: Record<string, GeoEntry> = ',
|
|
121
|
+
JSON.stringify(sortedGeo, null, 2) + ';'
|
|
86
122
|
].join('\n');
|
|
87
|
-
|
|
123
|
+
const formattedGeo = await prettier.format(geoTs, {
|
|
124
|
+
...prettierConfig,
|
|
125
|
+
parser: 'typescript'
|
|
126
|
+
});
|
|
127
|
+
fs.writeFileSync(path.join(dir, 'geo.ts'), formattedGeo);
|
|
88
128
|
|
|
89
|
-
console.log(
|
|
129
|
+
console.log(
|
|
130
|
+
`✅ Mappings: ${sortedCodes.length} timezones, ${Object.keys(sortedGeo).length} geo entries`
|
|
131
|
+
);
|
|
90
132
|
}
|
|
91
|
-
|
|
92
|
-
generateMapping().catch(err => {
|
|
93
|
-
console.error(err);
|
|
94
|
-
process.exit(1);
|
|
95
|
-
});
|
package/src/converter.ts
CHANGED
|
@@ -1,84 +1,91 @@
|
|
|
1
|
-
import { parseISO } from 'date-fns';
|
|
2
1
|
import { TZDate } from '@date-fns/tz';
|
|
3
2
|
import { timezones } from './mapping/timezones';
|
|
4
|
-
import {
|
|
5
|
-
UnknownAirportError,
|
|
6
|
-
InvalidTimestampError,
|
|
7
|
-
UnknownTimezoneError
|
|
8
|
-
} from './errors';
|
|
3
|
+
import { UnknownAirportError, InvalidTimestampError, UnknownTimezoneError } from './errors';
|
|
9
4
|
|
|
10
5
|
const ISO_LOCAL_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
|
|
11
6
|
|
|
12
|
-
|
|
13
|
-
localIso: string
|
|
14
|
-
): [number, number, number, number, number, number] {
|
|
15
|
-
const m = ISO_LOCAL_RE.exec(localIso);
|
|
16
|
-
if (!m) throw new InvalidTimestampError(localIso);
|
|
17
|
-
const [, Y, Mo, D, h, mi, s] = m;
|
|
18
|
-
return [
|
|
19
|
-
Number(Y),
|
|
20
|
-
Number(Mo) - 1,
|
|
21
|
-
Number(D),
|
|
22
|
-
Number(h),
|
|
23
|
-
Number(mi),
|
|
24
|
-
s ? Number(s) : 0
|
|
25
|
-
];
|
|
26
|
-
}
|
|
7
|
+
type LocalDateTimeParts = [number, number, number, number, number, number];
|
|
27
8
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const tz = timezones[iata];
|
|
34
|
-
if (!tz) throw new UnknownAirportError(iata);
|
|
9
|
+
const VALID_TIMEZONE_CACHE = new Map<string, boolean>();
|
|
10
|
+
|
|
11
|
+
function isLeapYear(year: number): boolean {
|
|
12
|
+
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
13
|
+
}
|
|
35
14
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
15
|
+
function daysInMonth(year: number, month: number): number {
|
|
16
|
+
if (month === 1) return isLeapYear(year) ? 29 : 28;
|
|
17
|
+
if (month === 3 || month === 5 || month === 8 || month === 10) return 30;
|
|
18
|
+
return 31;
|
|
19
|
+
}
|
|
39
20
|
|
|
40
|
-
|
|
21
|
+
function parseLocalIsoStrict(localIso: string): LocalDateTimeParts {
|
|
22
|
+
const m = ISO_LOCAL_RE.exec(localIso);
|
|
23
|
+
if (!m) throw new InvalidTimestampError(localIso);
|
|
24
|
+
const [, Y, Mo, D, h, mi, s] = m;
|
|
25
|
+
const year = Number(Y);
|
|
26
|
+
const month = Number(Mo) - 1;
|
|
27
|
+
const day = Number(D);
|
|
28
|
+
const hour = Number(h);
|
|
29
|
+
const minute = Number(mi);
|
|
30
|
+
const second = s ? Number(s) : 0;
|
|
41
31
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
if (isNaN(zoned.getTime())) throw new InvalidTimestampError(localIso);
|
|
32
|
+
if (month < 0 || month > 11) throw new InvalidTimestampError(localIso);
|
|
33
|
+
if (day < 1 || day > daysInMonth(year, month)) throw new InvalidTimestampError(localIso);
|
|
34
|
+
if (hour < 0 || hour > 23) throw new InvalidTimestampError(localIso);
|
|
35
|
+
if (minute < 0 || minute > 59) throw new InvalidTimestampError(localIso);
|
|
36
|
+
if (second < 0 || second > 59) throw new InvalidTimestampError(localIso);
|
|
49
37
|
|
|
50
|
-
|
|
51
|
-
return new Date(zoned.getTime()).toISOString().replace('.000Z', 'Z');
|
|
38
|
+
return [year, month, day, hour, minute, second];
|
|
52
39
|
}
|
|
53
40
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
export function convertLocalToUTCByZone(
|
|
59
|
-
localIso: string,
|
|
60
|
-
timeZone: string
|
|
61
|
-
): string {
|
|
62
|
-
// Validate timezone
|
|
41
|
+
function assertValidTimezone(timeZone: string): void {
|
|
42
|
+
const cached = VALID_TIMEZONE_CACHE.get(timeZone);
|
|
43
|
+
if (cached === true) return;
|
|
44
|
+
if (cached === false) throw new UnknownTimezoneError(timeZone);
|
|
63
45
|
try {
|
|
64
46
|
new Intl.DateTimeFormat('en-US', { timeZone }).format();
|
|
47
|
+
VALID_TIMEZONE_CACHE.set(timeZone, true);
|
|
65
48
|
} catch {
|
|
49
|
+
VALID_TIMEZONE_CACHE.set(timeZone, false);
|
|
66
50
|
throw new UnknownTimezoneError(timeZone);
|
|
67
51
|
}
|
|
52
|
+
}
|
|
68
53
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
54
|
+
function toUtcIso(
|
|
55
|
+
localIso: string,
|
|
56
|
+
timeZone: string,
|
|
57
|
+
onTimeZoneError: (err: unknown) => Error
|
|
58
|
+
): string {
|
|
59
|
+
const [year, month, day, hour, minute, second] = parseLocalIsoStrict(localIso);
|
|
74
60
|
|
|
75
61
|
let zoned: TZDate;
|
|
76
62
|
try {
|
|
77
63
|
zoned = TZDate.tz(timeZone, year, month, day, hour, minute, second);
|
|
78
|
-
} catch {
|
|
79
|
-
throw
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw onTimeZoneError(err);
|
|
80
66
|
}
|
|
81
67
|
if (isNaN(zoned.getTime())) throw new InvalidTimestampError(localIso);
|
|
82
68
|
|
|
69
|
+
// Strip ".000" from the ISO string
|
|
83
70
|
return new Date(zoned.getTime()).toISOString().replace('.000Z', 'Z');
|
|
84
71
|
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Convert a local ISO‐8601 string at an airport (IATA) into a UTC ISO string.
|
|
75
|
+
* Always emits "YYYY-MM-DDTHH:mm:ssZ" (no milliseconds).
|
|
76
|
+
*/
|
|
77
|
+
export function convertToUTC(localIso: string, iata: string): string {
|
|
78
|
+
const tz = timezones[iata];
|
|
79
|
+
if (!tz) throw new UnknownAirportError(iata);
|
|
80
|
+
|
|
81
|
+
return toUtcIso(localIso, tz, () => new InvalidTimestampError(localIso));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Convert a local ISO‐8601 string in any IANA timezone into a UTC ISO string.
|
|
86
|
+
* Always emits "YYYY-MM-DDTHH:mm:ssZ" (no milliseconds).
|
|
87
|
+
*/
|
|
88
|
+
export function convertLocalToUTCByZone(localIso: string, timeZone: string): string {
|
|
89
|
+
assertValidTimezone(timeZone);
|
|
90
|
+
return toUtcIso(localIso, timeZone, () => new UnknownTimezoneError(timeZone));
|
|
91
|
+
}
|
package/src/errors.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { convertToUTC, convertLocalToUTCByZone } from './converter';
|
|
2
|
-
export { getAirportInfo, AirportInfo } from './info';
|
|
3
|
-
export { UnknownAirportError, InvalidTimestampError, UnknownTimezoneError } from './errors';
|
|
2
|
+
export { getAirportInfo, getAllAirports, AirportInfo, Airport } from './info';
|
|
3
|
+
export { UnknownAirportError, InvalidTimestampError, UnknownTimezoneError } from './errors';
|
package/src/info.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { timezones } from './mapping/timezones';
|
|
2
|
-
import { geo
|
|
2
|
+
import { geo } from './mapping/geo';
|
|
3
3
|
import { UnknownAirportError } from './errors';
|
|
4
4
|
|
|
5
5
|
export interface AirportInfo {
|
|
@@ -9,6 +9,8 @@ export interface AirportInfo {
|
|
|
9
9
|
name: string;
|
|
10
10
|
city: string;
|
|
11
11
|
country: string;
|
|
12
|
+
countryName: string;
|
|
13
|
+
continent: string;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
/** @throws UnknownAirportError */
|
|
@@ -17,4 +19,19 @@ export function getAirportInfo(iata: string): AirportInfo {
|
|
|
17
19
|
const g = geo[iata];
|
|
18
20
|
if (!tz || !g) throw new UnknownAirportError(iata);
|
|
19
21
|
return { timezone: tz, ...g };
|
|
20
|
-
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface Airport extends AirportInfo {
|
|
25
|
+
iata: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getAllAirports(): Airport[] {
|
|
29
|
+
const all: Airport[] = [];
|
|
30
|
+
for (const iata of Object.keys(geo)) {
|
|
31
|
+
const tz = timezones[iata];
|
|
32
|
+
const g = geo[iata];
|
|
33
|
+
if (!tz || !g) continue;
|
|
34
|
+
all.push({ iata, timezone: tz, ...g });
|
|
35
|
+
}
|
|
36
|
+
return all;
|
|
37
|
+
}
|