CDN-значения вида SE (ARN) больше не помечаются как ошибка. У каждого GeoIP-сервиса — бренд-иконка, как в разделе блокировок. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,8 +7,26 @@ import { normalizeIngestResult, summarizeResults } from './normalize.js'
|
||||
|
||||
describe('canonicalizeCountryValue', () => {
|
||||
it('мапит ISO в ok', () => {
|
||||
expect(canonicalizeCountryValue('RU')).toEqual({ status: 'ok', country: 'RU' })
|
||||
expect(canonicalizeCountryValue(' de ')).toEqual({ status: 'ok', country: 'DE' })
|
||||
expect(canonicalizeCountryValue('RU')).toEqual({ status: 'ok', country: 'RU', iata: null })
|
||||
expect(canonicalizeCountryValue(' de ')).toEqual({ status: 'ok', country: 'DE', iata: null })
|
||||
})
|
||||
|
||||
it('мапит CDN ISO + IATA', () => {
|
||||
expect(canonicalizeCountryValue('SE (ARN)')).toEqual({
|
||||
status: 'ok',
|
||||
country: 'SE',
|
||||
iata: 'ARN',
|
||||
})
|
||||
expect(canonicalizeCountryValue('RS (BEG)')).toEqual({
|
||||
status: 'ok',
|
||||
country: 'RS',
|
||||
iata: 'BEG',
|
||||
})
|
||||
expect(canonicalizeCountryValue('(ARN)')).toEqual({
|
||||
status: 'ok',
|
||||
country: null,
|
||||
iata: 'ARN',
|
||||
})
|
||||
})
|
||||
|
||||
it('мапит статусы ipregion', () => {
|
||||
@@ -46,6 +64,31 @@ describe('ipregion normalize', () => {
|
||||
expect(row.group).toBe('cdn')
|
||||
})
|
||||
|
||||
it('сохраняет CDN ISO + IATA и N/A', () => {
|
||||
const cf = normalizeIngestResult({
|
||||
service: 'Cloudflare CDN',
|
||||
group: 'cdn',
|
||||
ipv4: 'SE (ARN)',
|
||||
})
|
||||
expect(cf.status).toBe('ok')
|
||||
expect(cf.countryIpv4).toBe('SE (ARN)')
|
||||
expect(cf.group).toBe('cdn')
|
||||
|
||||
const yt = normalizeIngestResult({
|
||||
service: 'YouTube CDN',
|
||||
ipv4: 'RS (BEG)',
|
||||
})
|
||||
expect(yt.status).toBe('ok')
|
||||
expect(yt.countryIpv4).toBe('RS (BEG)')
|
||||
|
||||
const nflx = normalizeIngestResult({
|
||||
service: 'Netflix CDN',
|
||||
ipv4: 'N/A',
|
||||
})
|
||||
expect(nflx.status).toBe('na')
|
||||
expect(nflx.countryIpv4).toBeNull()
|
||||
})
|
||||
|
||||
it('считает summary и partial', () => {
|
||||
const { summary, runStatus } = summarizeResults([
|
||||
{ status: 'ok' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
canonicalizeCountryValue,
|
||||
emptyIpregionSummary,
|
||||
formatCanonicalCountry,
|
||||
inferIpregionGroup,
|
||||
type IpregionGroup,
|
||||
type IpregionIngestResult,
|
||||
@@ -29,8 +30,8 @@ export function normalizeIngestResult(item: IpregionIngestResult): NormalizedIpr
|
||||
serviceKey,
|
||||
serviceLabel,
|
||||
group: item.group ?? inferIpregionGroup(serviceKey),
|
||||
countryIpv4: ipv4.country,
|
||||
countryIpv6: ipv6.country,
|
||||
countryIpv4: formatCanonicalCountry(ipv4),
|
||||
countryIpv6: formatCanonicalCountry(ipv6),
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@cfdm/ui/components/tooltip'
|
||||
import { canonicalizeCountryValue } from '@cfdm/shared/contracts/ipregion'
|
||||
import { COUNTRY_BY_CODE } from '@cfdm/shared/geo'
|
||||
import { countryBadgeText } from './geo-filters'
|
||||
import { IPREGION_STATUS_LABELS, formatCheckedAt } from './types'
|
||||
|
||||
const STATUS_VARIANT: Record<
|
||||
@@ -20,7 +22,16 @@ const STATUS_VARIANT: Record<
|
||||
|
||||
function countryLabel(code: string | null | undefined): string {
|
||||
if (!code) return ''
|
||||
return COUNTRY_BY_CODE[code.toUpperCase()]?.name ?? code
|
||||
const iso = canonicalizeCountryValue(code).country ?? code
|
||||
return COUNTRY_BY_CODE[iso.toUpperCase()]?.name ?? iso
|
||||
}
|
||||
|
||||
function formatTipCountry(value: string | null | undefined): string | null {
|
||||
const parsed = canonicalizeCountryValue(value)
|
||||
const iso = parsed.country
|
||||
const name = iso ? countryLabel(iso) : ''
|
||||
const parts = [iso, parsed.iata, name && name !== iso ? name : null].filter(Boolean)
|
||||
return parts.length ? parts.join(' · ') : null
|
||||
}
|
||||
|
||||
/** Compact ISO cell — preview: https://reui.io/docs/components/base/badge · data-grid-base-4 */
|
||||
@@ -41,14 +52,15 @@ export function CountryMatrixCell({
|
||||
checkedAt?: string
|
||||
onSelect?: () => void
|
||||
}) {
|
||||
const iso = countryIpv4 || countryIpv6 || null
|
||||
const stored = countryIpv4 || countryIpv6 || null
|
||||
const badgeIso = countryBadgeText(stored)
|
||||
const statusLabel = status ? (IPREGION_STATUS_LABELS[status] ?? status) : 'Нет результата'
|
||||
const display = status === 'ok' && iso ? iso : status ? (IPREGION_STATUS_LABELS[status] ?? status) : '—'
|
||||
const display = status === 'ok' && badgeIso ? badgeIso : status ? (IPREGION_STATUS_LABELS[status] ?? status) : '—'
|
||||
const variant = status ? (STATUS_VARIANT[status] ?? 'outline') : 'outline'
|
||||
const tip = [
|
||||
serviceLabel,
|
||||
vpsLabel,
|
||||
iso ? `${iso}${countryLabel(iso) ? ` · ${countryLabel(iso)}` : ''}` : statusLabel,
|
||||
formatTipCountry(stored) ?? statusLabel,
|
||||
countryIpv4 ? `IPv4 ${countryIpv4}` : null,
|
||||
countryIpv6 ? `IPv6 ${countryIpv6}` : null,
|
||||
checkedAt ? formatCheckedAt(checkedAt) : null,
|
||||
|
||||
@@ -108,6 +108,27 @@ describe('uniqueCountries / mismatch', () => {
|
||||
expect(uniqueCountries([run()]).sort()).toEqual(['NL', 'US'])
|
||||
})
|
||||
|
||||
it('извлекает ISO из CDN SE (ARN)', () => {
|
||||
expect(
|
||||
uniqueCountries([
|
||||
run({
|
||||
results: [
|
||||
{
|
||||
id: 'r-cdn',
|
||||
runId: 'iprun-1',
|
||||
serviceKey: 'cloudflare cdn',
|
||||
serviceLabel: 'Cloudflare CDN',
|
||||
group: 'cdn',
|
||||
countryIpv4: 'SE (ARN)',
|
||||
countryIpv6: null,
|
||||
status: 'ok',
|
||||
},
|
||||
],
|
||||
}),
|
||||
]),
|
||||
).toEqual(['SE'])
|
||||
})
|
||||
|
||||
it('считает расхождение с инвентарём', () => {
|
||||
expect(
|
||||
isGeoMismatch(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { canonicalizeCountryValue } from '@cfdm/shared/contracts/ipregion'
|
||||
import { COUNTRY_BY_CODE, COUNTRY_BY_NAME_RU } from '@cfdm/shared/geo'
|
||||
import {
|
||||
IPREGION_CDN_SERVICES,
|
||||
@@ -14,6 +15,10 @@ import {
|
||||
|
||||
const GROUP_RANK: Record<string, number> = { primary: 0, custom: 1, cdn: 2 }
|
||||
|
||||
export function resultCountryIso(value: string | null | undefined): string | null {
|
||||
return canonicalizeCountryValue(value).country
|
||||
}
|
||||
|
||||
export function inventoryCountryCode(run: IpregionRunDto): string | null {
|
||||
const raw = run.vps?.country?.trim() ?? ''
|
||||
if (!raw) return null
|
||||
@@ -23,13 +28,22 @@ export function inventoryCountryCode(run: IpregionRunDto): string | null {
|
||||
|
||||
export function countryName(code: string | null | undefined): string {
|
||||
if (!code) return ''
|
||||
return COUNTRY_BY_CODE[code.toUpperCase()]?.name ?? code
|
||||
const iso = resultCountryIso(code) ?? code
|
||||
return COUNTRY_BY_CODE[iso.toUpperCase()]?.name ?? iso
|
||||
}
|
||||
|
||||
export function countryBadgeText(value: string | null | undefined): string {
|
||||
const parsed = canonicalizeCountryValue(value)
|
||||
if (parsed.country && parsed.iata) return parsed.country
|
||||
if (parsed.country) return parsed.country
|
||||
if (parsed.iata) return parsed.iata
|
||||
return value?.trim() || ''
|
||||
}
|
||||
|
||||
export function majorityCountry(run: IpregionRunDto): string | null {
|
||||
const counts = new Map<string, number>()
|
||||
for (const row of run.results ?? []) {
|
||||
const code = row.countryIpv4 || row.countryIpv6
|
||||
const code = resultCountryIso(row.countryIpv4) || resultCountryIso(row.countryIpv6)
|
||||
if (row.status !== 'ok' || !code) continue
|
||||
counts.set(code, (counts.get(code) ?? 0) + 1)
|
||||
}
|
||||
@@ -55,8 +69,12 @@ export function uniqueCountries(runs: IpregionRunDto[]): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const run of runs) {
|
||||
for (const row of run.results ?? []) {
|
||||
if (row.status === 'ok' && row.countryIpv4) set.add(row.countryIpv4)
|
||||
if (row.status === 'ok' && row.countryIpv6) set.add(row.countryIpv6)
|
||||
if (row.status === 'ok') {
|
||||
const v4 = resultCountryIso(row.countryIpv4)
|
||||
const v6 = resultCountryIso(row.countryIpv6)
|
||||
if (v4) set.add(v4)
|
||||
if (v6) set.add(v6)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...set].sort()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link } from '@tanstack/react-router'
|
||||
import { GlobeIcon, ServerIcon } from 'lucide-react'
|
||||
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { dataGridCellStack, dataGridCellWithIcon } from '@/components/data-grid-cells'
|
||||
import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit'
|
||||
import {
|
||||
collectProbeColumns,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type GeoServiceRow,
|
||||
} from './geo-filters'
|
||||
import { CountryMatrixCell } from './country-matrix-cell'
|
||||
import { resolveServiceIcon, ServiceGlyph } from './service-icons'
|
||||
import { runHosterLabel, type IpregionRunDto } from './types'
|
||||
|
||||
/** DNA data-grid-base-4: auto width + H-scroll + pin start. Preview: https://reui.io/preview/base/data-grid-base-4 */
|
||||
@@ -73,6 +74,7 @@ export function GeoVpsGrid({
|
||||
key: `svc:${svc.key}`,
|
||||
header: svc.label,
|
||||
headerTitle: svc.title,
|
||||
icon: resolveServiceIcon(svc.key),
|
||||
className: MATRIX_CELL,
|
||||
headerClassName: MATRIX_CELL,
|
||||
size: 72,
|
||||
@@ -140,7 +142,11 @@ export function GeoServiceGrid({
|
||||
size: 180,
|
||||
minSize: 140,
|
||||
sortValue: (row) => row.serviceKey,
|
||||
cell: (row) => dataGridCellStack(row.serviceLabel, row.group),
|
||||
cell: (row) =>
|
||||
dataGridCellWithIcon(
|
||||
<ServiceGlyph serviceKey={row.serviceKey} />,
|
||||
dataGridCellStack(row.serviceLabel, row.group),
|
||||
),
|
||||
},
|
||||
...probeCols.map(
|
||||
(probe): DataGridColumn<GeoServiceRow> => ({
|
||||
|
||||
@@ -14,6 +14,7 @@ import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { ipregionRunQueryOptions } from '@/queries/ipregion'
|
||||
import { countryName } from './geo-filters'
|
||||
import { ServiceGlyph } from './service-icons'
|
||||
import {
|
||||
IPREGION_STATUS_LABELS,
|
||||
formatCheckedAt,
|
||||
@@ -101,14 +102,19 @@ export function GeoRunSheet({ run, open, onOpenChange }: GeoRunSheetProps) {
|
||||
<div className="flex flex-col gap-2">
|
||||
{(detail.results ?? []).map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{item.serviceLabel}</span>
|
||||
<span className="text-muted-foreground text-xs">{item.group}</span>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ServiceGlyph serviceKey={item.serviceKey} className="text-muted-foreground" />
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{item.serviceLabel}</span>
|
||||
<span className="text-muted-foreground text-xs">{item.group}</span>
|
||||
</div>
|
||||
</div>
|
||||
{item.status === 'ok' && item.countryIpv4 ? (
|
||||
{item.status === 'ok' && (item.countryIpv4 || item.countryIpv6) ? (
|
||||
<Badge size="sm" variant="success-light" radius="full">
|
||||
{item.countryIpv4}
|
||||
{countryName(item.countryIpv4) ? ` · ${countryName(item.countryIpv4)}` : ''}
|
||||
{item.countryIpv4 || item.countryIpv6}
|
||||
{countryName(item.countryIpv4 || item.countryIpv6)
|
||||
? ` · ${countryName(item.countryIpv4 || item.countryIpv6)}`
|
||||
: ''}
|
||||
</Badge>
|
||||
) : (
|
||||
<StatusBadge
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { GlobeIcon } from 'lucide-react'
|
||||
import {
|
||||
IPREGION_CDN_SERVICES,
|
||||
IPREGION_CUSTOM_SERVICES,
|
||||
IPREGION_PRIMARY_SERVICES,
|
||||
} from '@cfdm/shared/contracts/ipregion'
|
||||
|
||||
import { resolveServiceIcon } from './service-icons'
|
||||
|
||||
describe('resolveServiceIcon', () => {
|
||||
it('резолвит все GeoIP-сервисы без fallback Globe', () => {
|
||||
const keys = [
|
||||
...IPREGION_PRIMARY_SERVICES,
|
||||
...IPREGION_CUSTOM_SERVICES,
|
||||
...IPREGION_CDN_SERVICES,
|
||||
]
|
||||
for (const key of keys) {
|
||||
expect(resolveServiceIcon(key), key).not.toBe(GlobeIcon)
|
||||
}
|
||||
})
|
||||
|
||||
it('unknown / custom → Globe', () => {
|
||||
expect(resolveServiceIcon('unknown.example')).toBe(GlobeIcon)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import {
|
||||
AppWindowIcon,
|
||||
BinaryIcon,
|
||||
CircleHelpIcon,
|
||||
ClapperboardIcon,
|
||||
CodeXmlIcon,
|
||||
DatabaseIcon,
|
||||
EarthIcon,
|
||||
FileJsonIcon,
|
||||
FlagIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
LayersIcon,
|
||||
LibraryIcon,
|
||||
MapIcon,
|
||||
MapPinIcon,
|
||||
MapPinnedIcon,
|
||||
NetworkIcon,
|
||||
SearchIcon,
|
||||
SparklesIcon,
|
||||
TerminalIcon,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
siApple,
|
||||
siCloudflare,
|
||||
siGoogle,
|
||||
siGooglegemini,
|
||||
siJetbrains,
|
||||
siNetflix,
|
||||
siPlaystation,
|
||||
siReddit,
|
||||
siSpeedtest,
|
||||
siSpotify,
|
||||
siSteam,
|
||||
siTiktok,
|
||||
siTwitch,
|
||||
siYoutube,
|
||||
type SimpleIcon,
|
||||
} from 'simple-icons'
|
||||
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
function BrandGlyph({
|
||||
icon,
|
||||
className,
|
||||
}: {
|
||||
icon: SimpleIcon
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
className={cn('size-3.5 shrink-0', className)}
|
||||
aria-hidden
|
||||
>
|
||||
<title>{icon.title}</title>
|
||||
<path fill="currentColor" d={icon.path} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function brandComponent(icon: SimpleIcon): ComponentType<{ className?: string }> {
|
||||
function BrandIcon({ className }: { className?: string }) {
|
||||
return <BrandGlyph icon={icon} className={className} />
|
||||
}
|
||||
BrandIcon.displayName = `BrandIcon(${icon.slug})`
|
||||
return BrandIcon
|
||||
}
|
||||
|
||||
const BRAND_ICONS: Record<string, ComponentType<{ className?: string }>> = {
|
||||
'cloudflare.com': brandComponent(siCloudflare),
|
||||
'cloudflare cdn': brandComponent(siCloudflare),
|
||||
google: brandComponent(siGoogle),
|
||||
'google search captcha': brandComponent(siGoogle),
|
||||
youtube: brandComponent(siYoutube),
|
||||
'youtube premium': brandComponent(siYoutube),
|
||||
'youtube cdn': brandComponent(siYoutube),
|
||||
twitch: brandComponent(siTwitch),
|
||||
netflix: brandComponent(siNetflix),
|
||||
'netflix cdn': brandComponent(siNetflix),
|
||||
spotify: brandComponent(siSpotify),
|
||||
'spotify signup': brandComponent(siSpotify),
|
||||
reddit: brandComponent(siReddit),
|
||||
'reddit (guest access)': brandComponent(siReddit),
|
||||
apple: brandComponent(siApple),
|
||||
steam: brandComponent(siSteam),
|
||||
tiktok: brandComponent(siTiktok),
|
||||
jetbrains: brandComponent(siJetbrains),
|
||||
playstation: brandComponent(siPlaystation),
|
||||
'gemini supported': brandComponent(siGooglegemini),
|
||||
'ookla speedtest': brandComponent(siSpeedtest),
|
||||
}
|
||||
|
||||
const GENERIC_ICONS: Record<string, LucideIcon> = {
|
||||
'maxmind.com': DatabaseIcon,
|
||||
'rdap.db.ripe.net': NetworkIcon,
|
||||
'ipinfo.io': InfoIcon,
|
||||
'ipregistry.co': LibraryIcon,
|
||||
'ipapi.co': CodeXmlIcon,
|
||||
'ifconfig.co': TerminalIcon,
|
||||
'ip2location.io': MapPinnedIcon,
|
||||
'iplocation.com': MapIcon,
|
||||
'country.is': FlagIcon,
|
||||
'geoapify.com': MapPinIcon,
|
||||
'geojs.io': FileJsonIcon,
|
||||
'ipapi.is': BinaryIcon,
|
||||
'ipbase.com': LayersIcon,
|
||||
'ipquery.io': SearchIcon,
|
||||
'ipwho.is': CircleHelpIcon,
|
||||
'ip-api.com': EarthIcon,
|
||||
chatgpt: SparklesIcon,
|
||||
'disney+': ClapperboardIcon,
|
||||
'disney+ access': ClapperboardIcon,
|
||||
microsoft: AppWindowIcon,
|
||||
}
|
||||
|
||||
export function resolveServiceIcon(
|
||||
serviceKey: string,
|
||||
): ComponentType<SVGProps<SVGSVGElement> & { className?: string }> {
|
||||
const key = serviceKey.trim().toLowerCase()
|
||||
return BRAND_ICONS[key] ?? GENERIC_ICONS[key] ?? GlobeIcon
|
||||
}
|
||||
|
||||
export function ServiceGlyph({
|
||||
serviceKey,
|
||||
className,
|
||||
}: {
|
||||
serviceKey: string
|
||||
className?: string
|
||||
}) {
|
||||
const Icon = resolveServiceIcon(serviceKey)
|
||||
return <Icon className={cn('size-3.5 shrink-0', className)} />
|
||||
}
|
||||
@@ -131,19 +131,48 @@ export function emptyIpregionSummary(): IpregionSummary {
|
||||
}
|
||||
|
||||
const ISO_RE = /^[A-Z]{2}$/
|
||||
const ISO_IATA_RE = /^([A-Z]{2})\s*\(([A-Z]{3})\)$/
|
||||
const IATA_RE = /^[A-Z]{3}$/
|
||||
|
||||
/** ISO `RU` → ok; иначе статусы ipregion (`N/A`, `Denied`, `Rate-limit`, `Server error`). */
|
||||
export type CanonicalCountry = {
|
||||
status: IpregionStatus
|
||||
country: string | null
|
||||
iata: string | null
|
||||
}
|
||||
|
||||
/** ISO `RU` / CDN `SE (ARN)` → ok; иначе статусы ipregion (`N/A`, `Denied`, `Rate-limit`, `Server error`). */
|
||||
export function canonicalizeCountryValue(
|
||||
raw: string | null | undefined,
|
||||
): { status: IpregionStatus; country: string | null } {
|
||||
): CanonicalCountry {
|
||||
const value = raw?.replace(/\s+/g, ' ').trim() ?? ''
|
||||
if (!value || value === 'null' || /^n\/a$/i.test(value)) {
|
||||
return { status: 'na', country: null }
|
||||
if (!value || /^null$/i.test(value) || /^n\/a$/i.test(value)) {
|
||||
return { status: 'na', country: null, iata: null }
|
||||
}
|
||||
if (/^denied$/i.test(value)) return { status: 'denied', country: null }
|
||||
if (/^rate[- ]?limit$/i.test(value)) return { status: 'rate_limit', country: null }
|
||||
if (/^server error$/i.test(value)) return { status: 'server_error', country: null }
|
||||
const iso = value.toUpperCase()
|
||||
if (ISO_RE.test(iso)) return { status: 'ok', country: iso }
|
||||
return { status: 'server_error', country: null }
|
||||
if (/^denied$/i.test(value)) return { status: 'denied', country: null, iata: null }
|
||||
if (/^rate[- ]?limit$/i.test(value)) return { status: 'rate_limit', country: null, iata: null }
|
||||
if (/^server error$/i.test(value)) return { status: 'server_error', country: null, iata: null }
|
||||
|
||||
const upper = value.toUpperCase()
|
||||
const withIata = upper.match(ISO_IATA_RE)
|
||||
if (withIata) {
|
||||
return { status: 'ok', country: withIata[1]!, iata: withIata[2]! }
|
||||
}
|
||||
if (ISO_RE.test(upper)) {
|
||||
return { status: 'ok', country: upper, iata: null }
|
||||
}
|
||||
const iataOnly = upper.match(/^\(([A-Z]{3})\)$/)
|
||||
if (iataOnly) {
|
||||
return { status: 'ok', country: null, iata: iataOnly[1]! }
|
||||
}
|
||||
if (IATA_RE.test(upper)) {
|
||||
return { status: 'ok', country: null, iata: upper }
|
||||
}
|
||||
return { status: 'server_error', country: null, iata: null }
|
||||
}
|
||||
|
||||
export function formatCanonicalCountry(parsed: CanonicalCountry): string | null {
|
||||
if (parsed.country && parsed.iata) return `${parsed.country} (${parsed.iata})`
|
||||
if (parsed.country) return parsed.country
|
||||
if (parsed.iata) return parsed.iata
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user