SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
3 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
20.0 KB · 346 lines typescript
Raw Blame History
1import type { FacilityStatus, FacilityType } from "./types.js";23export function slugify(s: string): string {4  return s5    .normalize("NFKD")6    .replace(/[̀-ͯ]/g, "")7    .toLowerCase()8    .replace(/&/g, " and ")9    .replace(/[^a-z0-9]+/g, "-")10    .replace(/^-+|-+$/g, "")11    .slice(0, 96);12}1314const NOISE_WORDS = new Set(["data", "center", "centre", "datacenter", "datacentre", "dc", "facility", "campus", "site", "the", "of", "inc", "llc", "ltd", "limited", "corp", "corporation", "gmbh", "sa", "ag", "bv", "nv", "plc", "co", "company", "group", "holdings", "s.a.", "s.a", "srl", "sas", "pty", "kk", "k.k."]);1516/** Aggressive name normalization for entity resolution: lowercase, strip punctuation, drop noise tokens, sort-independent. */17export function normalizeName(name: string): string {18  return name19    .normalize("NFKD")20    .replace(/[̀-ͯ]/g, "")21    .toLowerCase()22    .replace(/\(.*?\)/g, " ")23    .replace(/[^a-z0-9]+/g, " ")24    .split(" ")25    .filter((t) => t && !NOISE_WORDS.has(t))26    .join(" ")27    .trim();28}2930/** Lighter normalization keeping identifiers like "DC12", "FR5", "AM3". */31export function normalizeNameKeepCodes(name: string): string {32  return name33    .normalize("NFKD")34    .replace(/[̀-ͯ]/g, "")35    .toLowerCase()36    .replace(/\b(data\s*cent(er|re)s?|datacent(er|re)s?)\b/g, " ")37    .replace(/[^a-z0-9]+/g, " ")38    .replace(/\s+/g, " ")39    .trim();40}4142/**43 * Words that precede a number without naming a facility ("Tier 3", "Route 7", "Suite 200") — never a facility code.44 * Units and address vocabulary are excluded so that "50 MW" or "Highway 9" never become identifiers.45 */46export const FACILITY_CODE_STOPWORDS: ReadonlySet<string> = new Set([47  "TIER", "LEVEL", "FLOOR", "SUITE", "UNIT", "ROUTE", "HIGHWAY", "HWY", "ROAD", "RD", "STREET", "ST", "AVENUE", "AVE", "BOULEVARD", "BLVD", "NO", "TOP", "MW", "KW", "GW", "PUE", "RACK", "RACKS", "ZONE", "REGION", "PHONE", "TEL", "FAX", "BOX", "PO", "KM", "MI", "SQ", "SQM", "SQFT", "ACRE", "ACRES", "PHASE",48  // prepositions / verbs that precede a figure in prose ("with 50 MW", "opened 2019", "built in 1998")49  "WITH", "OF", "AND", "IN", "AT", "TO", "THE", "OVER", "THAN", "ABOUT", "UP", "FOR", "BY", "ON", "FROM", "OR", "NEAR", "PLUS", "AROUND", "APPROX", "CIRCA", "SINCE", "BUILT", "EST", "ESTABLISHED", "OPENED", "OPEN", "OPENING", "YEAR", "NR", "NUM", "NUMBER", "TOTAL", "ONLY", "SOME", "ALL", "PER",50]);51/** A figure immediately followed by a unit is a quantity, not a code ("50 MW", "3 acres", "12%"). */52const UNIT_AFTER_RE = /^\s?(MW|GW|KW|MWH|KWH|TW|SQ|SQM|SQFT|M2|FT|ACRES?|HA|KM|MI|%|MILLION|BILLION|MILLIONS|RACKS?|STOREYS?|STORIES|FLOORS?|BUILDINGS?|HALLS?|PHASES?|YEARS?)(?![A-Z])/;5354/**55 * Extract facility codes: `DC12`, `FR5`, `LD8`, `SV1`, `LONDON6`, `LON6`, `DC-12`, `FRA01A`, `IAD12`, `SYD01`, `SC-2`,56 * `S3`, `Inzai 3`, `Fremont 2`, `Las Vegas 8` (→ `VEGAS8`). A single-letter prefix is only accepted when attached57 * (`S3`, `M1`), a word prefix may be separated by a space or a hyphen. Leading zeros are dropped (`SYD01` = `SYD1`)58 * so that a code compares equal across sources. Stop-words (`Tier 3`, `Route 7`, `50 MW`) never yield a code.59 */60export function extractFacilityCodes(name: string): string[] {61  const out = new Set<string>();62  const up = name.normalize("NFKD").replace(/[̀-ͯ]/g, "").toUpperCase();63  const add = (prefix: string, digits: string, suffix: string, rest: string) => {64    if (FACILITY_CODE_STOPWORDS.has(prefix) || UNIT_AFTER_RE.test(rest)) return;65    const n = digits.replace(/^0+(?=\d)/, "");66    out.add(`${prefix}${n}${suffix}`);67  };68  // attached: DC12, FRA01A, LONDON6, S3, MEL01D69  for (const m of up.matchAll(/(?<![A-Z0-9])([A-Z]{1,10})(\d{1,3})([A-Z]?)(?![A-Z0-9])/g)) add(m[1]!, m[2]!, m[3]!, up.slice((m.index ?? 0) + m[0].length));70  // separated by a space or a hyphen: DC-12, SC-2, Inzai 3, Paris 5, Las Vegas 8, London 471  for (const m of up.matchAll(/(?<![A-Z0-9])([A-Z]{2,10})[\s-]+(\d{1,3})([A-Z]?)(?![A-Z0-9])/g)) add(m[1]!, m[2]!, m[3]!, up.slice((m.index ?? 0) + m[0].length));72  return [...out];73}7475/** Alphabetic prefix of a facility code (`LONDON6` → `LONDON`). */76export function facilityCodePrefix(code: string): string {77  return code.replace(/\d.*$/, "");78}7980/**81 * Compare two code sets: `1` when a code is shared and no shared prefix disagrees, `0` when a prefix present on both82 * sides carries different numbers (`LONDON6` vs `LONDON5`, `Inzai 3` vs `Inzai 4`), `null` when either side has no83 * code or the prefixes do not overlap (`SYD1` vs `MEL1` says nothing — different prefixes are different naming schemes).84 */85export function compareFacilityCodes(a: string[], b: string[]): 0 | 1 | null {86  if (!a.length || !b.length) return null;87  const byPrefix = (codes: string[]) => {88    const m = new Map<string, Set<string>>();89    for (const c of codes) { const p = facilityCodePrefix(c); if (!m.has(p)) m.set(p, new Set()); m.get(p)!.add(c); }90    return m;91  };92  const pa = byPrefix(a), pb = byPrefix(b);93  // `SY5` and `SY5B` agree (a hall of the same building); `SY5A` vs `SY5B` do not94  const base = (c: string) => c.replace(/(\d)[A-Z]$/, "$1");95  const agree = (x: string, y: string) => x === y || (base(x) === base(y) && (x === base(x) || y === base(y)));96  let shared = false, conflict = false;97  for (const [prefix, ca] of pa) {98    const cb = pb.get(prefix);99    if (!cb) continue;100    if ([...ca].some((c) => [...cb].some((d) => agree(c, d)))) shared = true;101    else conflict = true;102  }103  if (conflict) return 0;104  return shared ? 1 : null;105}106107export function tokenSet(s: string): Set<string> {108  return new Set(normalizeName(s).split(" ").filter(Boolean));109}110111export function jaccard(a: Set<string>, b: Set<string>): number {112  if (!a.size && !b.size) return 1;113  let inter = 0;114  for (const t of a) if (b.has(t)) inter++;115  return inter / (a.size + b.size - inter);116}117118/** Parse a power figure in MW from free text like "36 MW", "36MW", "120 megawatts", "1.2 GW", "36,000 kW". Returns MW. */119export function parseMw(text: string | null | undefined): number | null {120  if (!text) return null;121  const s = text.replace(/ /g, " ");122  // "40+MW", "150+ MW" (at least) parse as the stated figure123  const m = s.match(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:[.,]\d+)?)\+?\s*(gigawatts?|gw|megawatts?|mw|kilowatts?|kw)\b/i);124  if (!m) return null;125  let num = m[1]!;126  // "36,000" → 36000 ; "1,2" (fr decimal) → 1.2 ; "1.2" → 1.2 ; "0.779" → 0.779 (a decimal, never 779) ;127  // "1.500.000" (two dot groups, European thousands) → 1500000. A single dot followed by three digits is a128  // decimal in every English-language capacity figure ("0.779MW", "1.250 MW") — DataBank MSP4 was stored as 779 MW.129  if (/^\d{1,3}(,\d{3})+$/.test(num)) num = num.replace(/,/g, "");130  else if (/^\d{1,3}(\.\d{3}){2,}$/.test(num)) num = num.replace(/\./g, "");131  else num = num.replace(",", ".");132  const v = Number(num);133  if (!Number.isFinite(v)) return null;134  const unit = m[2]!.toLowerCase();135  if (unit.startsWith("g")) return round(v * 1000, 3);136  if (unit.startsWith("k")) return round(v / 1000, 3);137  return round(v, 3);138}139140/** All MW figures in a text, deduplicated, descending. */141export function parseAllMw(text: string): number[] {142  const out = new Set<number>();143  for (const m of text.matchAll(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:[.,]\d+)?)\+?\s*(gigawatts?|gw|megawatts?|mw)\b/gi)) {144    const v = parseMw(m[0]);145    if (v != null && v > 0 && v < 100_000) out.add(v);146  }147  return [...out].sort((a, b) => b - a);148}149150/** Square feet / square metres → m². */151export function parseAreaSqm(text: string | null | undefined): number | null {152  if (!text) return null;153  const m = text.replace(/ /g, " ").match(/(\d{1,3}(?:[.,]\d{3})+|\d+(?:\.\d+)?)\s*(million\s+)?(sq\.?\s*ft|square\s*feet|sqft|ft²|sq\.?\s*m|square\s*met(?:er|re)s?|sqm|m²|m2)/i);154  if (!m) return null;155  let v = Number(m[1]!.replace(/,/g, "").replace(/\.(?=\d{3}\b)/g, ""));156  if (m[2]) v *= 1_000_000;157  if (!Number.isFinite(v)) return null;158  const unit = m[3]!.toLowerCase();159  return /ft|feet/.test(unit) ? round(v * 0.09290304, 1) : round(v, 1);160}161162/** Acres / hectares → hectares. */163export function parseAreaHa(text: string | null | undefined): number | null {164  if (!text) return null;165  const m = text.match(/(\d{1,3}(?:,\d{3})+|\d+(?:\.\d+)?)\s*(acres?|hectares?|ha)\b/i);166  if (!m) return null;167  const v = Number(m[1]!.replace(/,/g, ""));168  if (!Number.isFinite(v)) return null;169  return /acre/i.test(m[2]!) ? round(v * 0.40468564, 2) : round(v, 2);170}171172/** Money in USD from "$1.2 billion", "US$450 million", "€800m", "£2bn". Non-USD is returned as-is with currency flag. */173export function parseMoney(text: string | null | undefined): { amount: number; currency: string } | null {174  if (!text) return null;175  const m = text.replace(/ /g, " ").match(/(US\$|USD|\$|CA\$|C\$|€|EUR|£|GBP|A\$|AUD|¥|JPY|S\$|SGD|₹|INR|CHF|SEK|NOK|DKK)\s?(\d{1,3}(?:,\d{3})+|\d+(?:\.\d+)?)\s*(trillion|billion|million|thousand|bn|m|k|b|tn)?\b/i);176  if (!m) return null;177  let v = Number(m[2]!.replace(/,/g, ""));178  const mult = (m[3] ?? "").toLowerCase();179  if (mult === "billion" || mult === "bn" || mult === "b") v *= 1e9;180  else if (mult === "million" || mult === "m") v *= 1e6;181  else if (mult === "thousand" || mult === "k") v *= 1e3;182  else if (mult === "trillion" || mult === "tn") v *= 1e12;183  const cur = m[1]!.toUpperCase();184  const currency = cur === "$" || cur === "US$" || cur === "USD" ? "USD" : cur === "CA$" || cur === "C$" ? "CAD" : cur === "€" || cur === "EUR" ? "EUR" : cur === "£" || cur === "GBP" ? "GBP" : cur === "A$" || cur === "AUD" ? "AUD" : cur === "¥" || cur === "JPY" ? "JPY" : cur === "S$" || cur === "SGD" ? "SGD" : cur === "₹" || cur === "INR" ? "INR" : cur;185  return { amount: Math.round(v), currency };186}187188const MONTHS: Record<string, string> = { january: "01", february: "02", march: "03", april: "04", june: "06", july: "07", august: "08", september: "09", october: "10", november: "11", december: "12", jan: "01", feb: "02", mar: "03", apr: "04", may: "05", jun: "06", jul: "07", aug: "08", sep: "09", sept: "09", oct: "10", nov: "11", dec: "12", janvier: "01", février: "02", fevrier: "02", mars: "03", avril: "04", mai: "05", juin: "06", juillet: "07", août: "08", aout: "08", septembre: "09", octobre: "10", novembre: "11", décembre: "12", decembre: "12" };189190/**191 * Parse a (possibly partial) date into ISO-like partial: "2027", "2027-Q2", "2027-06", "2027-06-15", "2027-H1".192 * Accepts "Q2 2027", "second half of 2027", "mid-2027", "June 2027", "2027-06-15T…", "15 June 2027".193 */194export function parsePartialDate(text: string | null | undefined): string | null {195  if (!text) return null;196  const s = text.trim();197  let m = s.match(/^(\d{4})-(\d{2})-(\d{2})/);198  if (m) return `${m[1]}-${m[2]}-${m[3]}`;199  m = s.match(/^(\d{4})-(\d{2})$/);200  if (m) return `${m[1]}-${m[2]}`;201  m = s.match(/\b(?:Q|quarter\s*)([1-4])\s*(?:of\s*)?(\d{4})\b/i) ?? s.match(/\b(\d{4})\s*-?\s*Q([1-4])\b/i);202  if (m) {203    const [q, y] = /^\d{4}/.test(m[1]!) ? [m[2], m[1]] : [m[1], m[2]];204    return `${y}-Q${q}`;205  }206  m = s.match(/\b(first|second|1st|2nd)\s+half\s+(?:of\s+)?(\d{4})\b/i) ?? s.match(/\bH([12])\s*(\d{4})\b/i);207  if (m) {208    const h = /^(first|1st|1)$/i.test(m[1]!) ? 1 : 2;209    return `${m[2]}-H${h}`;210  }211  m = s.match(/\b(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-zéû]+)\.?\s+(\d{4})\b/);212  if (m && MONTHS[m[2]!.toLowerCase()]) return `${m[3]}-${MONTHS[m[2]!.toLowerCase()]}-${m[1]!.padStart(2, "0")}`;213  m = s.match(/\b([A-Za-zéû]+)\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})\b/);214  if (m && MONTHS[m[1]!.toLowerCase()]) return `${m[3]}-${MONTHS[m[1]!.toLowerCase()]}-${m[2]!.padStart(2, "0")}`;215  m = s.match(/\b([A-Za-zéû]+)\.?\s+(\d{4})\b/);216  if (m && MONTHS[m[1]!.toLowerCase()]) return `${m[2]}-${MONTHS[m[1]!.toLowerCase()]}`;217  m = s.match(/\b(?:mid|early|late|end of|by|in)?-?\s*(20\d{2}|19\d{2})\b/);218  if (m) return m[1]!;219  return null;220}221222/** Sortable numeric key for a partial date (for ordering timelines): YYYY.fraction */223export function partialDateSortKey(d: string | null | undefined): number {224  if (!d) return Number.POSITIVE_INFINITY;225  const y = Number(d.slice(0, 4));226  if (!Number.isFinite(y)) return Number.POSITIVE_INFINITY;227  let frac = 0.5;228  const rest = d.slice(5);229  if (/^Q[1-4]/.test(rest)) frac = (Number(rest[1]) - 0.5) / 4;230  else if (/^H[12]/.test(rest)) frac = rest[1] === "1" ? 0.25 : 0.75;231  else if (/^\d{2}/.test(rest)) {232    const mo = Number(rest.slice(0, 2));233    const day = /^\d{2}-\d{2}/.test(rest) ? Number(rest.slice(3, 5)) : 15;234    frac = (mo - 1 + day / 31) / 12;235  }236  return y + frac;237}238239export function formatPartialDate(d: string | null | undefined): string {240  if (!d) return "—";241  const y = d.slice(0, 4);242  const rest = d.slice(5);243  if (!rest) return y;244  if (/^[QH]\d/.test(rest)) return `${rest} ${y}`;245  const mo = Number(rest.slice(0, 2));246  const names = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];247  const mn = names[mo - 1] ?? rest.slice(0, 2);248  if (/^\d{2}-\d{2}/.test(rest)) return `${mn} ${Number(rest.slice(3, 5))}, ${y}`;249  return `${mn} ${y}`;250}251252const STATUS_PATTERNS: Array<[RegExp, FacilityStatus]> = [253  [/\b(cancel+ed|scrapped|abandoned|withdrawn)\b/i, "cancelled"],254  [/\b(closed|decommissioned|shut\s?down|ceased operations)\b/i, "closed"],255  [/\b(delayed|postponed|on hold|paused)\b/i, "delayed"],256  [/\b(partially operational|phase\s*1\s*(is\s*)?(live|operational|complete))\b/i, "partially_operational"],257  [/\b(under construction|being built|construction (is )?under ?way|breaks? ground|breaking ground|broke ground|groundbreaking|topped out|topping out|construction (has )?(started|began|begins|commenced|starts))\b/i, "under_construction"],258  [/\b(approved|approval granted|granted planning|received approval|rezoning approved|permit(s)? (were |was )?(granted|issued))\b/i, "approved"],259  [/\b(planning application|permit application|rezoning (request|application)|seeking approval|applied for|filed plans|submitted plans|under review by)\b/i, "permitting"],260  [/\b(announced|announces|unveils?|unveiled|plans to build|plans? for|will build|to build|build of|to develop|to construct|set to build|reveals? plans|launch(es|ed)? plans|confirms? (the )?(build|plans?|development)|enters? [A-Z]\w+ with|doubles down)\b/i, "announced"],261  [/\b(proposed|proposal)\b/i, "proposed"],262  [/\b(rumou?red|reportedly|is said to|considering|exploring|in talks)\b/i, "rumored"],263  [/\b(expansion|expanding|expands|adds? capacity|adds? \d+ ?\+?(MW|GW)|new phase|additional \d+ ?\+?MW)\b/i, "expansion"],264  [/\b(now open|opened|opens|operational|live|online|commissioned|inaugurat(ed|es|ion)|ready for service|RFS)\b/i, "operational"],265];266267export function inferStatus(text: string | null | undefined): FacilityStatus | null {268  if (!text) return null;269  for (const [re, st] of STATUS_PATTERNS) if (re.test(text)) return st;270  return null;271}272273export function normalizeStatus(raw: string | null | undefined): FacilityStatus | null {274  if (!raw) return null;275  const s = raw.toLowerCase().trim().replace(/[\s-]+/g, "_");276  const direct: Record<string, FacilityStatus> = {277    operational: "operational", live: "operational", active: "operational", open: "operational", ok: "operational", operating: "operational", existing: "operational",278    under_construction: "under_construction", construction: "under_construction", building: "under_construction",279    planned: "announced", plan: "announced", announced: "announced", proposed: "proposed", rumored: "rumored", rumoured: "rumored",280    permitting: "permitting", approved: "approved", delayed: "delayed", cancelled: "cancelled", canceled: "cancelled", closed: "closed", decommissioned: "closed",281    expansion: "expansion", partially_operational: "partially_operational", partial: "partially_operational",282    coming_soon: "announced", future: "announced", in_development: "announced", development: "announced",283  };284  return direct[s] ?? inferStatus(raw);285}286287export function inferFacilityType(text: string | null | undefined): FacilityType | null {288  if (!text) return null;289  const s = text.toLowerCase();290  if (/\b(internet exchange|ixp?\b|peering exchange)/.test(s)) return "internet_exchange";291  if (/\b(carrier hotel|meet-?me room|interconnection hub)/.test(s)) return "carrier_hotel";292  if (/\b(ai (data ?center|campus|factory|cluster|infrastructure)|gpu (cluster|campus|farm)|accelerated computing|training cluster|inference)/.test(s)) return "ai";293  if (/\b(supercomput|hpc|high[- ]performance computing|exascale|petaflop)/.test(s)) return "hpc";294  if (/\b(sovereign cloud|sovereign region)/.test(s)) return "sovereign_cloud";295  if (/\b(cloud region|availability zone|aws region|azure region|gcp region)/.test(s)) return "cloud_region";296  if (/\b(hyperscale|hyperscaler)/.test(s)) return "hyperscale";297  if (/\b(edge (data ?center|site|node|facility)|micro data ?center)/.test(s)) return "edge";298  if (/\b(wholesale|build[- ]to[- ]suit|powered shell)/.test(s)) return "wholesale";299  if (/\b(government|federal|ministry|public sector|municipal)/.test(s)) return "government";300  if (/\b(university|research|laboratory|national lab)/.test(s)) return "research";301  if (/\b(colocation|colo\b|retail colocation|interconnection)/.test(s)) return "colocation";302  return null;303}304305export function round(v: number, digits = 2): number {306  const f = 10 ** digits;307  return Math.round(v * f) / f;308}309310export function clamp(v: number, lo: number, hi: number): number {311  return Math.min(hi, Math.max(lo, v));312}313314export function cleanText(s: string | null | undefined): string | null {315  if (s == null) return null;316  const t = s.replace(/\s+/g, " ").trim();317  return t || null;318}319320/** Country ISO-2 from common names / ISO-3 / demonyms found in text. */321export const COUNTRY_ALIASES: Record<string, string> = {322  "united states": "US", usa: "US", "u.s.": "US", "u.s.a.": "US", "united states of america": "US", america: "US",323  canada: "CA", "united kingdom": "GB", uk: "GB", "u.k.": "GB", britain: "GB", "great britain": "GB", england: "GB", scotland: "GB", wales: "GB",324  germany: "DE", deutschland: "DE", france: "FR", netherlands: "NL", "the netherlands": "NL", holland: "NL", ireland: "IE", spain: "ES", portugal: "PT", italy: "IT",325  sweden: "SE", norway: "NO", denmark: "DK", finland: "FI", iceland: "IS", belgium: "BE", luxembourg: "LU", switzerland: "CH", austria: "AT", poland: "PL",326  czechia: "CZ", "czech republic": "CZ", hungary: "HU", romania: "RO", bulgaria: "BG", greece: "GR", turkey: "TR", türkiye: "TR", russia: "RU", ukraine: "UA",327  japan: "JP", singapore: "SG", australia: "AU", "new zealand": "NZ", india: "IN", china: "CN", "hong kong": "HK", taiwan: "TW", "south korea": "KR", korea: "KR",328  malaysia: "MY", indonesia: "ID", thailand: "TH", vietnam: "VN", philippines: "PH", "united arab emirates": "AE", uae: "AE", "saudi arabia": "SA", qatar: "QA",329  bahrain: "BH", oman: "OM", kuwait: "KW", israel: "IL", "south africa": "ZA", nigeria: "NG", kenya: "KE", egypt: "EG", morocco: "MA", brazil: "BR", mexico: "MX",330  chile: "CL", argentina: "AR", colombia: "CO", peru: "PE", uruguay: "UY", estonia: "EE", latvia: "LV", lithuania: "LT", slovakia: "SK", slovenia: "SI", croatia: "HR",331  serbia: "RS", cyprus: "CY", malta: "MT", pakistan: "PK", bangladesh: "BD", "sri lanka": "LK", kazakhstan: "KZ", ghana: "GH", tanzania: "TZ", uganda: "UG",332  rwanda: "RW", ethiopia: "ET", angola: "AO", "côte d'ivoire": "CI", "ivory coast": "CI", senegal: "SN", tunisia: "TN", algeria: "DZ", jordan: "JO", panama: "PA",333  "costa rica": "CR", "puerto rico": "PR", "dominican republic": "DO", ecuador: "EC", bolivia: "BO", paraguay: "PY", venezuela: "VE", guatemala: "GT",334  cambodia: "KH", laos: "LA", myanmar: "MM", nepal: "NP", mongolia: "MN", macau: "MO", macao: "MO", brunei: "BN", fiji: "FJ", "papua new guinea": "PG",335  georgia: "GE", armenia: "AM", azerbaijan: "AZ", uzbekistan: "UZ", belarus: "BY", moldova: "MD", albania: "AL", "north macedonia": "MK", "bosnia and herzegovina": "BA", montenegro: "ME", kosovo: "XK",336  usa_: "US",337};338339export function countryFromText(text: string | null | undefined): string | null {340  if (!text) return null;341  const s = text.toLowerCase();342  const keys = Object.keys(COUNTRY_ALIASES).sort((a, b) => b.length - a.length);343  for (const k of keys) if (new RegExp(`(^|[^a-z])${k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z]|$)`, "i").test(s)) return COUNTRY_ALIASES[k]!;344  return null;345}346