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.
@@ -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';
@@ -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[];
@@ -4,5 +4,7 @@ export interface GeoEntry {
4
4
  name: string;
5
5
  city: string;
6
6
  country: string;
7
+ countryName: string;
8
+ continent: string;
7
9
  }
8
10
  export declare const geo: Record<string, GeoEntry>;
@@ -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.28",
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.0",
47
- "@semantic-release/npm": "^13.0.0",
48
- "@semantic-release/release-notes-generator": "^14.0.3",
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.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
- async function generateMapping() {
7
- const url =
8
- 'https://raw.githubusercontent.com/opentraveldata/opentraveldata/master/opentraveldata/optd_por_public.csv';
9
- const res = await fetch(url);
10
- if (!res.ok) throw new Error(`Fetch failed: ${res.statusText}`);
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
- country: header.indexOf('country_code')
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<string, {
30
- latitude: number;
31
- longitude: number;
32
- name: string;
33
- city: string;
34
- country: string;
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) timezonesMap[code] = 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].split(',')[0].trim();
78
+ const city = pickCity(cols[idx.city]);
48
79
  const country = cols[idx.country];
49
- if (!isNaN(lat) && !isNaN(lon) && name && city && country) {
50
- geoMap[code] = { latitude: lat, longitude: lon, name, city, country };
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(path.dirname(import.meta.url.replace('file://', '')), '../src/mapping');
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
- fs.writeFileSync(path.join(dir, 'timezones.ts'), tzTs + '\n');
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
- 'export const geo: Record<string, GeoEntry> = {',
84
- geoEntries,
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
- fs.writeFileSync(path.join(dir, 'geo.ts'), geoTs + '\n');
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(`✅ Mappings: ${sortedCodes.length} timezones, ${Object.keys(sortedGeo).length} geo entries`);
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
- function parseLocalIso(
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
- * Convert a local ISO‐8601 string at an airport (IATA) into a UTC ISO string.
30
- * Always emits "YYYY-MM-DDTHH:mm:ssZ" (no milliseconds).
31
- */
32
- export function convertToUTC(localIso: string, iata: string): string {
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
- // Quick semantic check
37
- const base = parseISO(localIso);
38
- if (isNaN(base.getTime())) throw new InvalidTimestampError(localIso);
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
- const [year, month, day, hour, minute, second] = parseLocalIso(localIso);
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
- let zoned: TZDate;
43
- try {
44
- zoned = TZDate.tz(tz, year, month, day, hour, minute, second);
45
- } catch {
46
- throw new InvalidTimestampError(localIso);
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
- // Strip ".000" from the ISO string
51
- return new Date(zoned.getTime()).toISOString().replace('.000Z', 'Z');
38
+ return [year, month, day, hour, minute, second];
52
39
  }
53
40
 
54
- /**
55
- * Convert a local ISO‐8601 string in any IANA timezone into a UTC ISO string.
56
- * Always emits "YYYY-MM-DDTHH:mm:ssZ" (no milliseconds).
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
- // Quick semantic check
70
- const base = parseISO(localIso);
71
- if (isNaN(base.getTime())) throw new InvalidTimestampError(localIso);
72
-
73
- const [year, month, day, hour, minute, second] = parseLocalIso(localIso);
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 new UnknownTimezoneError(timeZone);
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
@@ -15,4 +15,4 @@ export class UnknownTimezoneError extends Error {
15
15
  super(`Unknown timezone: ${tz}`);
16
16
  this.name = 'UnknownTimezoneError';
17
17
  }
18
- }
18
+ }
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, GeoEntry } from './mapping/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
+ }