mirror of
https://github.com/abue-ammar/tinycast.git
synced 2026-10-02 08:14:38 +08:00
`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.
142 lines
5.4 KiB
JavaScript
Executable File
142 lines
5.4 KiB
JavaScript
Executable File
#!/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);
|
||
});
|