Files
tinycast/Scripts/gen-countries.js
T
Abue Ammar f2430e6efc Answer time-zone queries for countries (#710)
`time in uk` earned no card: the zone lookup only knew IANA city names and
a curated alias table, and Foundation carries no country for a zone.

Scripts/gen-countries.js now emits CountryZoneData.generated.swift by
joining IANA's zone.tab, which lists each country's zones most populous
first, with CLDR's English territory names, short forms included. Where
zone.tab's geographic order puts a remote edge first (Lord Howe, Kaliningrad),
the country answers with its capital's clock instead. CalcTimeZone checks
aliases, then cities, then countries, so no existing name changes meaning.
`usa` and `uae` join the aliases, since CLDR carries neither.
2026-09-15 02:20:06 +06:00

142 lines
5.4 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Generate Tinycast/Features/Calculator/Model/CountryZoneData.generated.swift.
//
// Usage: node Scripts/gen-countries.js [zone.tab cldr-territories.json]
// Downloads the sources when paths aren't given. Run occasionally, commit the output.
//
// Two sources, joined on the ISO 3166 code:
// - IANA's zone.tab decides *which* clock a country answers with. It lists every zone per country,
// most populous first where geography allows, so its first row is the country's main clock.
// Foundation carries the zones themselves but no notion of which country they belong to.
// - CLDR's `en` territory names decide what humans *call* the country, including the short forms
// ("UK", "US"). Read from cldr-json rather than the host's `Intl`, whose output shifts with ICU.
//
// A spelling two countries share is left out rather than guessed.
"use strict";
const fs = require("fs");
const path = require("path");
const ZONE_TAB = "https://data.iana.org/time-zones/tzdb/zone.tab";
const CLDR_TERRITORIES =
"https://raw.githubusercontent.com/unicode-org/cldr-json/main/cldr-json/cldr-localenames-full/main/en/territories.json";
// zone.tab orders by geography before population, which puts these countries' remote edges first.
// Each answers with its capital's clock instead; the script fails if one stops being listed.
const CAPITAL_ZONES = {
AU: "Australia/Sydney",
BR: "America/Sao_Paulo",
CA: "America/Toronto",
FM: "Pacific/Pohnpei",
RU: "Europe/Moscow",
UA: "Europe/Kyiv",
UZ: "Asia/Tashkent",
};
// Territories with no capital and clocks a hemisphere apart name no single time at all.
const NO_SINGLE_CLOCK = new Set(["AQ", "UM"]);
// zone.tab lists ~250 codes; well below that is a bad response.
const MIN_EXPECTED = 230;
async function loadText(url, argPath) {
if (argPath) return fs.readFileSync(argPath, "utf8");
const response = await fetch(url);
if (!response.ok) throw new Error(`${url} -> HTTP ${response.status}`);
return response.text();
}
/// Every lookup form of a name: folded and lowercased as the query is, plus the spellings a keyboard
/// produces for CLDR's typographic `&` and `’`.
function spellings(name) {
const base = name
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
const forms = new Set([base, base.replace(/’/g, "'")]);
for (const form of [...forms]) if (form.includes(" & ")) forms.add(form.replace(/ & /g, " and "));
return forms;
}
function claim(map, key, code) {
if (!map.has(key)) map.set(key, new Set());
map.get(key).add(code);
}
function swiftString(value) {
if (value.includes('"') || value.includes("\\")) throw new Error(`unsafe literal ${JSON.stringify(value)}`);
return `"${value}"`;
}
/// Country code -> zones, in zone.tab's own order.
function zonesByCountry(text) {
const zones = new Map();
for (const line of text.split("\n")) {
if (!line || line.startsWith("#")) continue;
const [code, , zone] = line.split("\t");
if (!/^[A-Z]{2}$/.test(code) || !zone) throw new Error(`unexpected zone.tab row: ${line}`);
if (!zones.has(code)) zones.set(code, []);
zones.get(code).push(zone);
}
return zones;
}
function mainClock(code, zones) {
const capital = CAPITAL_ZONES[code];
if (!capital) return zones[0];
if (!zones.includes(capital)) throw new Error(`${capital} is no longer listed for ${code}`);
return capital;
}
async function main() {
const zones = zonesByCountry(await loadText(ZONE_TAB, process.argv[2]));
const cldr = JSON.parse(await loadText(CLDR_TERRITORIES, process.argv[3]));
const names = cldr?.main?.en?.localeDisplayNames?.territories;
if (!names) throw new Error("unexpected CLDR shape: main.en.localeDisplayNames.territories missing");
if (zones.size < MIN_EXPECTED) throw new Error(`suspiciously few countries: ${zones.size}`);
const claims = new Map();
const unnamed = [];
for (const [code, list] of zones) {
if (NO_SINGLE_CLOCK.has(code)) continue;
// `GB`, `GB-alt-short` and `GB-alt-variant` are all names for the one country.
const keys = Object.keys(names).filter((key) => key === code || key.startsWith(`${code}-alt-`));
if (keys.length === 0) unnamed.push(code);
const zone = mainClock(code, list);
for (const key of keys) for (const form of spellings(names[key])) claim(claims, form, zone);
}
const table = [...claims]
.filter(([, owners]) => owners.size === 1)
.map(([name, owners]) => [name, [...owners][0]])
.sort((a, b) => a[0].localeCompare(b[0]));
const contested = claims.size - table.length;
const out = path.resolve(
__dirname,
"..",
"Tinycast/Features/Calculator/Model/CountryZoneData.generated.swift",
);
fs.writeFileSync(
out,
"// Generated by Scripts/gen-countries.js — do not edit by hand.\n" +
"// Clocks from IANA zone.tab; country names from Unicode CLDR (en).\n" +
"enum CountryZoneData {\n" +
" /// Folded, lowercased country name → the IANA zone of its main clock.\n" +
" static let zones: [String: String] = [\n" +
table.map(([name, zone]) => ` ${swiftString(name)}: ${swiftString(zone)},`).join("\n") +
"\n ]\n" +
"}\n",
);
console.log(
`wrote ${out} — ${table.length} names for ${zones.size - NO_SINGLE_CLOCK.size} countries ` +
`(${contested} contested, unnamed: ${unnamed.join(", ") || "none"})`,
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});