mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: add an airport time zone table for flight cards
This commit is contained in:
@@ -55,3 +55,11 @@ Vendored test tool (scripts/a11y-guard/axe.min.js): axe-core 4.10.3,
|
||||
Copyright (c) 2015-2025 Deque Systems, Inc., licensed under the Mozilla
|
||||
Public License 2.0 (https://mozilla.org/MPL/2.0/). It runs only in the
|
||||
accessibility check in CI and is not part of the app.
|
||||
|
||||
Third-party data:
|
||||
|
||||
- The airport time zone table (lib/structured-mail/data/airport-timezones.json,
|
||||
generated by scripts/generate-airport-timezones.mjs) is derived from
|
||||
mwgg/Airports (https://github.com/mwgg/Airports), Copyright (c) 2014 mwgg,
|
||||
licensed under the MIT License, whose text is the same as the one quoted
|
||||
above.
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import table from '../data/airport-timezones.json';
|
||||
import {
|
||||
countryZone,
|
||||
fixedOffsetZone,
|
||||
isValidTimeZone,
|
||||
loadAirportZones,
|
||||
resolveZone,
|
||||
wallClockAt,
|
||||
zonedTimeToUtc,
|
||||
zoneOffsetAt,
|
||||
} from '../time-zones';
|
||||
|
||||
const utc = (iso: string) => Date.parse(iso);
|
||||
|
||||
describe('airport time zone table', () => {
|
||||
it('maps IATA codes to their IANA zones', async () => {
|
||||
const zoneOf = await loadAirportZones();
|
||||
expect(zoneOf('FRA')).toBe('Europe/Berlin');
|
||||
expect(zoneOf('JFK')).toBe('America/New_York');
|
||||
expect(zoneOf('SFO')).toBe('America/Los_Angeles');
|
||||
expect(zoneOf('NRT')).toBe('Asia/Tokyo');
|
||||
expect(zoneOf('HNL')).toBe('Pacific/Honolulu');
|
||||
expect(zoneOf('syd')).toBe('Australia/Sydney');
|
||||
});
|
||||
|
||||
it('knows nothing about codes it does not have', async () => {
|
||||
const zoneOf = await loadAirportZones();
|
||||
expect(zoneOf('QQQ')).toBeUndefined();
|
||||
expect(zoneOf('FRAX')).toBeUndefined();
|
||||
expect(zoneOf(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('holds only valid zone names and indexes inside the zone list', () => {
|
||||
expect(Object.keys(table.airports).length).toBeGreaterThan(7000);
|
||||
for (const zone of table.zones) expect(isValidTimeZone(zone), zone).toBe(true);
|
||||
for (const index of Object.values(table.airports)) {
|
||||
expect(index).toBeGreaterThanOrEqual(0);
|
||||
expect(index).toBeLessThan(table.zones.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixedOffsetZone', () => {
|
||||
it('inverts the sign for whole hours', () => {
|
||||
expect(fixedOffsetZone(-8 * 60)).toBe('Etc/GMT+8');
|
||||
expect(fixedOffsetZone(5 * 60)).toBe('Etc/GMT-5');
|
||||
expect(fixedOffsetZone(14 * 60)).toBe('Etc/GMT-14');
|
||||
expect(fixedOffsetZone(-12 * 60)).toBe('Etc/GMT+12');
|
||||
});
|
||||
|
||||
it('keeps the UTC instant for half hours and offsets without an Etc zone', () => {
|
||||
expect(fixedOffsetZone(0)).toBe('Etc/UTC');
|
||||
expect(fixedOffsetZone(330)).toBe('Etc/UTC');
|
||||
expect(fixedOffsetZone(345)).toBe('Etc/UTC');
|
||||
expect(fixedOffsetZone(-13 * 60)).toBe('Etc/UTC');
|
||||
});
|
||||
|
||||
it('produces zones whose offset is the one asked for', () => {
|
||||
for (const hours of [-12, -8, -1, 1, 5, 9, 14]) {
|
||||
const zone = fixedOffsetZone(hours * 60);
|
||||
expect(zoneOffsetAt(zone, utc('2026-07-01T00:00:00Z'))).toBe(hours * 60);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoneOffsetAt and wall clocks', () => {
|
||||
it('follows DST', () => {
|
||||
expect(zoneOffsetAt('Europe/Berlin', utc('2026-01-15T12:00:00Z'))).toBe(60);
|
||||
expect(zoneOffsetAt('Europe/Berlin', utc('2026-07-15T12:00:00Z'))).toBe(120);
|
||||
expect(zoneOffsetAt('America/Los_Angeles', utc('2027-03-05T04:15:00Z'))).toBe(-480);
|
||||
expect(zoneOffsetAt('Asia/Kathmandu', utc('2026-07-15T12:00:00Z'))).toBe(345);
|
||||
});
|
||||
|
||||
it('turns an instant into the local date and time across the date line', () => {
|
||||
expect(wallClockAt(utc('2027-03-05T04:15:00Z'), 'America/Los_Angeles')).toEqual({ date: '2027-03-04', time: '20:15:00' });
|
||||
expect(wallClockAt(utc('2026-12-01T00:30:00Z'), 'Asia/Tokyo')).toEqual({ date: '2026-12-01', time: '09:30:00' });
|
||||
expect(wallClockAt(utc('2026-12-01T00:30:00Z'), 'Pacific/Honolulu')).toEqual({ date: '2026-11-30', time: '14:30:00' });
|
||||
});
|
||||
|
||||
it('turns a local time back into the instant, and refuses DST gaps and overlaps', () => {
|
||||
expect(zonedTimeToUtc('2027-03-04', '20:15', 'America/Los_Angeles')).toBe(utc('2027-03-05T04:15:00Z'));
|
||||
expect(zonedTimeToUtc('2026-12-01', '09:30:00', 'Asia/Tokyo')).toBe(utc('2026-12-01T00:30:00Z'));
|
||||
// 02:30 on 29 March 2026 doesn't exist in Berlin; 02:30 on 25 October happens twice.
|
||||
expect(zonedTimeToUtc('2026-03-29', '02:30', 'Europe/Berlin')).toBeNull();
|
||||
expect(zonedTimeToUtc('2026-10-25', '02:30', 'Europe/Berlin')).toBeNull();
|
||||
expect(zonedTimeToUtc('2026-13-01', '10:00', 'Europe/Berlin')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('countryZone', () => {
|
||||
it('names the zone of single-zone countries only', () => {
|
||||
expect(countryZone('DE')).toBe('Europe/Berlin');
|
||||
expect(countryZone(' de ')).toBe('Europe/Berlin');
|
||||
expect(countryZone('US')).toBeUndefined();
|
||||
expect(countryZone('ES')).toBeUndefined();
|
||||
expect(countryZone('Germany')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not trust France for a time without an offset', () => {
|
||||
expect(countryZone('FR')).toBe('Europe/Paris');
|
||||
expect(countryZone('FR', { floating: true })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveZone', () => {
|
||||
const at = utc('2026-06-04T09:44:00Z');
|
||||
|
||||
it('accepts a named zone that agrees with the offset', () => {
|
||||
expect(resolveZone({ named: { zone: 'Europe/Berlin', source: 'country' }, offsetMinutes: 120, utcMs: at }))
|
||||
.toEqual({ zone: 'Europe/Berlin', source: 'country' });
|
||||
});
|
||||
|
||||
it('reports a mismatch when table and mail disagree', () => {
|
||||
expect(resolveZone({ named: { zone: 'Europe/Berlin', source: 'airport' }, offsetMinutes: 60, utcMs: at })).toBe('mismatch');
|
||||
});
|
||||
|
||||
it('falls back to a fixed offset, then to UTC, without a named zone', () => {
|
||||
expect(resolveZone({ offsetMinutes: -480, utcMs: at })).toEqual({ zone: 'Etc/GMT+8', source: 'offset' });
|
||||
expect(resolveZone({ offsetMinutes: 330, utcMs: at })).toEqual({ zone: 'Etc/UTC', source: 'utc' });
|
||||
});
|
||||
|
||||
it('reads a time without offset in the named zone, or leaves it floating', () => {
|
||||
expect(resolveZone({ named: { zone: 'Asia/Tokyo', source: 'airport' } })).toEqual({ zone: 'Asia/Tokyo', source: 'airport' });
|
||||
expect(resolveZone({})).toEqual({ zone: null, source: 'floating' });
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Time zones for booking cards. Markup gives local times with a UTC offset
|
||||
* and never an IANA zone; the card shows the place's local time and the
|
||||
* calendar wants a zone that stays right across DST changes. Plain
|
||||
* TypeScript on Intl, no React, so the native app can reuse it.
|
||||
*/
|
||||
|
||||
const MINUTE = 60_000;
|
||||
|
||||
/**
|
||||
* Countries that use one IANA zone throughout, so an address in them names
|
||||
* the zone. Countries whose territory spans zones (US, CA, RU, BR, AU, MX,
|
||||
* ID, ES with the Canaries, PT with the Azores, ...) are left out on purpose.
|
||||
*/
|
||||
const SINGLE_ZONE_COUNTRIES: Record<string, string> = {
|
||||
AD: 'Europe/Andorra', AE: 'Asia/Dubai', AL: 'Europe/Tirane', AR: 'America/Argentina/Buenos_Aires',
|
||||
AT: 'Europe/Vienna', BA: 'Europe/Sarajevo', BE: 'Europe/Brussels', BG: 'Europe/Sofia',
|
||||
BY: 'Europe/Minsk', CH: 'Europe/Zurich', CN: 'Asia/Shanghai', CO: 'America/Bogota',
|
||||
CR: 'America/Costa_Rica', CU: 'America/Havana', CY: 'Asia/Nicosia', CZ: 'Europe/Prague',
|
||||
DE: 'Europe/Berlin', DK: 'Europe/Copenhagen', DO: 'America/Santo_Domingo', EE: 'Europe/Tallinn',
|
||||
EG: 'Africa/Cairo', FI: 'Europe/Helsinki', FR: 'Europe/Paris', GB: 'Europe/London',
|
||||
GR: 'Europe/Athens', HK: 'Asia/Hong_Kong', HR: 'Europe/Zagreb', HU: 'Europe/Budapest',
|
||||
IE: 'Europe/Dublin', IL: 'Asia/Jerusalem', IN: 'Asia/Kolkata', IS: 'Atlantic/Reykjavik',
|
||||
IT: 'Europe/Rome', JM: 'America/Jamaica', JP: 'Asia/Tokyo', KE: 'Africa/Nairobi',
|
||||
KR: 'Asia/Seoul', LI: 'Europe/Vaduz', LK: 'Asia/Colombo', LT: 'Europe/Vilnius',
|
||||
LU: 'Europe/Luxembourg', LV: 'Europe/Riga', MA: 'Africa/Casablanca', MC: 'Europe/Monaco',
|
||||
MD: 'Europe/Chisinau', ME: 'Europe/Podgorica', MK: 'Europe/Skopje', MT: 'Europe/Malta',
|
||||
MY: 'Asia/Kuala_Lumpur', NL: 'Europe/Amsterdam', NO: 'Europe/Oslo', NP: 'Asia/Kathmandu',
|
||||
NZ: 'Pacific/Auckland', PA: 'America/Panama', PE: 'America/Lima', PH: 'Asia/Manila',
|
||||
PL: 'Europe/Warsaw', QA: 'Asia/Qatar', RO: 'Europe/Bucharest', RS: 'Europe/Belgrade',
|
||||
SE: 'Europe/Stockholm', SG: 'Asia/Singapore', SI: 'Europe/Ljubljana', SK: 'Europe/Bratislava',
|
||||
SM: 'Europe/San_Marino', TH: 'Asia/Bangkok', TN: 'Africa/Tunis', TR: 'Europe/Istanbul',
|
||||
TW: 'Asia/Taipei', UY: 'America/Montevideo', VA: 'Europe/Vatican', VN: 'Asia/Ho_Chi_Minh',
|
||||
ZA: 'Africa/Johannesburg',
|
||||
};
|
||||
|
||||
/**
|
||||
* Countries in the table whose postal addresses also cover overseas places
|
||||
* in other zones (a hotel on Réunion often writes "France"). Good enough
|
||||
* when the mail's offset confirms the zone, not for a time without one.
|
||||
*/
|
||||
const NOT_FOR_FLOATING_TIMES = new Set(['FR']);
|
||||
|
||||
/** The single IANA zone of a country (ISO 3166-1 alpha-2), if it has one. */
|
||||
export function countryZone(country: string | undefined, opts: { floating?: boolean } = {}): string | undefined {
|
||||
const code = country?.trim().toUpperCase();
|
||||
if (!code || !/^[A-Z]{2}$/.test(code)) return undefined;
|
||||
if (opts.floating && NOT_FOR_FLOATING_TIMES.has(code)) return undefined;
|
||||
return SINGLE_ZONE_COUNTRIES[code];
|
||||
}
|
||||
|
||||
const offsetFormatters = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function partsFormatter(zone: string): Intl.DateTimeFormat {
|
||||
let formatter = offsetFormatters.get(zone);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
offsetFormatters.set(zone, formatter);
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
export function isValidTimeZone(zone: string): boolean {
|
||||
try {
|
||||
partsFormatter(zone);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface WallClock { year: number; month: number; day: number; hour: number; minute: number; second: number }
|
||||
|
||||
function wallClockParts(utcMs: number, zone: string): WallClock {
|
||||
const parts: Record<string, number> = {};
|
||||
for (const part of partsFormatter(zone).formatToParts(new Date(utcMs))) {
|
||||
if (part.type !== 'literal') parts[part.type] = Number(part.value);
|
||||
}
|
||||
return { year: parts.year, month: parts.month, day: parts.day, hour: parts.hour % 24, minute: parts.minute, second: parts.second };
|
||||
}
|
||||
|
||||
/** UTC offset of `zone` at `utcMs`, in minutes east of UTC (Berlin in winter: 60). */
|
||||
export function zoneOffsetAt(zone: string, utcMs: number): number {
|
||||
const w = wallClockParts(utcMs, zone);
|
||||
const asUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second);
|
||||
return Math.round((asUtc - Math.floor(utcMs / 1000) * 1000) / MINUTE);
|
||||
}
|
||||
|
||||
const pad = (n: number, width = 2) => String(n).padStart(width, '0');
|
||||
|
||||
/** Local date and time in `zone` at `utcMs`: `{ date: 'YYYY-MM-DD', time: 'HH:mm:ss' }`. */
|
||||
export function wallClockAt(utcMs: number, zone: string): { date: string; time: string } {
|
||||
const w = wallClockParts(utcMs, zone);
|
||||
return {
|
||||
date: `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}`,
|
||||
time: `${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The UTC instant of a local wall-clock time in `zone`. A time that falls in
|
||||
* a DST gap or overlap has no single answer; returns null then, so a caller
|
||||
* never shows a guessed instant.
|
||||
*/
|
||||
export function zonedTimeToUtc(date: string, time: string, zone: string): number | null {
|
||||
const d = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
||||
const t = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(time);
|
||||
if (!d || !t) return null;
|
||||
const naive = Date.UTC(+d[1], +d[2] - 1, +d[3], +t[1], +t[2], +(t[3] ?? 0));
|
||||
const check = new Date(naive);
|
||||
if (check.getUTCMonth() !== +d[2] - 1 || check.getUTCDate() !== +d[3] || check.getUTCHours() !== +t[1] || +t[2] > 59 || +(t[3] ?? 0) > 59) {
|
||||
return null;
|
||||
}
|
||||
const candidates = new Set<number>();
|
||||
for (const probe of [naive - 14 * 60 * MINUTE, naive, naive + 14 * 60 * MINUTE]) {
|
||||
const offset = zoneOffsetAt(zone, probe);
|
||||
const utc = naive - offset * MINUTE;
|
||||
if (zoneOffsetAt(zone, utc) === offset) candidates.add(utc);
|
||||
}
|
||||
return candidates.size === 1 ? [...candidates][0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A zone that has a fixed UTC offset. Whole hours map to `Etc/GMT∓N`, whose
|
||||
* sign is inverted (UTC−8 is `Etc/GMT+8`). Anything else, a half-hour offset
|
||||
* or one outside `Etc/GMT-14` … `Etc/GMT+12`, has no Etc zone: the caller
|
||||
* keeps the UTC instant and gets `Etc/UTC`.
|
||||
*/
|
||||
export function fixedOffsetZone(offsetMinutes: number): string {
|
||||
if (offsetMinutes === 0 || offsetMinutes % 60 !== 0) return 'Etc/UTC';
|
||||
const hours = offsetMinutes / 60;
|
||||
if (hours > 14 || hours < -12) return 'Etc/UTC';
|
||||
return hours > 0 ? `Etc/GMT-${hours}` : `Etc/GMT+${-hours}`;
|
||||
}
|
||||
|
||||
type AirportTable = { zones: string[]; airports: Record<string, number> };
|
||||
let airportTable: Promise<AirportTable> | null = null;
|
||||
|
||||
/**
|
||||
* Load the IATA → IANA table (scripts/generate-airport-timezones.mjs). It is
|
||||
* its own chunk, fetched from the app's origin the first time a flight card
|
||||
* needs it. A failed load is forgotten so the next card can retry.
|
||||
*/
|
||||
export function loadAirportZones(): Promise<(code: string | undefined) => string | undefined> {
|
||||
if (!airportTable) {
|
||||
airportTable = import('./data/airport-timezones.json').then((mod) => (mod.default ?? mod) as AirportTable);
|
||||
airportTable.catch(() => { airportTable = null; });
|
||||
}
|
||||
return airportTable.then((table) => (code) => {
|
||||
const key = code?.trim().toUpperCase();
|
||||
if (!key || !/^[A-Z]{3}$/.test(key)) return undefined;
|
||||
const index = table.airports[key];
|
||||
return index === undefined ? undefined : table.zones[index];
|
||||
});
|
||||
}
|
||||
|
||||
export type ZoneSource = 'airport' | 'country' | 'offset' | 'utc' | 'floating';
|
||||
|
||||
export interface ResolvedZone {
|
||||
/** IANA zone, or null for a floating time that stays as written. */
|
||||
zone: string | null;
|
||||
source: ZoneSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the zone for one end of a booking. `named` is the zone the place
|
||||
* implies (airport table or single-zone country). With an offset from the
|
||||
* mail, the named zone must give that same offset at that instant, otherwise
|
||||
* the mail and the table disagree and the result is `mismatch` (the caller
|
||||
* drops the booking: we can't tell which of the two is wrong). Without a
|
||||
* named zone the offset gives a fixed-offset zone. Without an offset the
|
||||
* time is read in the named zone, or stays floating.
|
||||
*/
|
||||
export function resolveZone(input: {
|
||||
named?: { zone: string; source: 'airport' | 'country' };
|
||||
offsetMinutes?: number;
|
||||
utcMs?: number;
|
||||
}): ResolvedZone | 'mismatch' {
|
||||
const { named, offsetMinutes, utcMs } = input;
|
||||
if (offsetMinutes !== undefined && utcMs !== undefined) {
|
||||
if (named) {
|
||||
return zoneOffsetAt(named.zone, utcMs) === offsetMinutes ? { zone: named.zone, source: named.source } : 'mismatch';
|
||||
}
|
||||
const zone = fixedOffsetZone(offsetMinutes);
|
||||
return { zone, source: zone === 'Etc/UTC' && offsetMinutes !== 0 ? 'utc' : 'offset' };
|
||||
}
|
||||
if (named) return { zone: named.zone, source: named.source };
|
||||
return { zone: null, source: 'floating' };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Generate the IATA airport code → IANA time zone table that flight cards
|
||||
* use (lib/structured-mail/data/airport-timezones.json).
|
||||
*
|
||||
* node scripts/generate-airport-timezones.mjs [path/to/airports.json]
|
||||
*
|
||||
* Booking markup gives flight times with a UTC offset only; the calendar
|
||||
* wants the airport's IANA zone, so the event stays right across DST changes.
|
||||
* The source is mwgg/Airports (MIT, see NOTICE), pinned to one commit and
|
||||
* checked against its SHA-256. Pass a local copy of airports.json to skip the
|
||||
* download. The output is committed; rerun only when moving the pin.
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const COMMIT = 'a89e4e0dda9d9878337e2da190ebe1ef1d6af705';
|
||||
const SHA256 = 'a7754cbe722ea21f0f9ab1ef07f4f07ee1e85b9cc99191ebde0b49fabb3c149a';
|
||||
const SOURCE = `https://raw.githubusercontent.com/mwgg/Airports/${COMMIT}/airports.json`;
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT = path.resolve(here, '..', 'lib', 'structured-mail', 'data', 'airport-timezones.json');
|
||||
|
||||
async function readSource() {
|
||||
if (process.argv[2]) return fs.readFileSync(process.argv[2]);
|
||||
const response = await fetch(SOURCE);
|
||||
if (!response.ok) throw new Error(`GET ${SOURCE}: HTTP ${response.status}`);
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
function isValidZone(zone) {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en', { timeZone: zone });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const raw = await readSource();
|
||||
const digest = crypto.createHash('sha256').update(raw).digest('hex');
|
||||
if (digest !== SHA256) throw new Error(`airports.json SHA-256 ${digest}, expected ${SHA256}`);
|
||||
|
||||
const byCode = new Map();
|
||||
let conflicts = 0;
|
||||
for (const airport of Object.values(JSON.parse(raw.toString('utf8')))) {
|
||||
const code = typeof airport.iata === 'string' ? airport.iata.trim().toUpperCase() : '';
|
||||
if (!/^[A-Z]{3}$/.test(code) || !airport.tz || !isValidZone(airport.tz)) continue;
|
||||
const known = byCode.get(code);
|
||||
if (known && known !== airport.tz) {
|
||||
// Two airports claim one code with different zones: drop the code, a
|
||||
// wrong zone is worse than the offset fallback.
|
||||
byCode.set(code, null);
|
||||
conflicts++;
|
||||
continue;
|
||||
}
|
||||
if (known === undefined) byCode.set(code, airport.tz);
|
||||
}
|
||||
|
||||
const zones = [...new Set([...byCode.values()].filter(Boolean))].sort();
|
||||
const zoneIndex = new Map(zones.map((zone, index) => [zone, index]));
|
||||
const airports = {};
|
||||
for (const code of [...byCode.keys()].sort()) {
|
||||
const zone = byCode.get(code);
|
||||
if (zone) airports[code] = zoneIndex.get(zone);
|
||||
}
|
||||
|
||||
const table = { source: `mwgg/Airports@${COMMIT.slice(0, 12)} (MIT)`, zones, airports };
|
||||
fs.mkdirSync(path.dirname(OUT), { recursive: true });
|
||||
fs.writeFileSync(OUT, JSON.stringify(table) + '\n');
|
||||
console.log(`${Object.keys(airports).length} airports, ${zones.length} zones, ${conflicts} conflicting codes dropped → ${path.relative(process.cwd(), OUT)}`);
|
||||
Reference in New Issue
Block a user