3 Commits
Author SHA1 Message Date
Denozordec d6e46d30b7 fix(ui): update node role labels and location display in nodes page
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 5s
quality / api (push) Skipped
quality / docker-check (push) Skipped
quality / web (push) Successful in 45s
CD / quality (push) Successful in 53s
CD / publish (push) Successful in 1m27s
- Changed role labels to be more user-friendly (e.g., 'hub' to 'Hub').
- Enhanced location display to show city names and country flags instead of location codes.
- Removed unused MapPinIcon from the UI for cleaner presentation.
2026-09-04 15:48:29 +07:00
DenozordecandCursor 654b08c8e5 fix(ui): полный справочник стран и городов как в VPS Tracker
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 4s
quality / docker-check (push) Skipped
quality / web (push) Successful in 45s
quality / api (push) Successful in 36s
CD / quality (push) Successful in 1m30s
CD / publish (push) Successful in 1m51s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 15:15:34 +07:00
Denozordec 146afc1369 feat(dns): Implement DNS preview records generation and enhance hostname preview functionality
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
quality / web (push) Successful in 56s
quality / api (push) Successful in 43s
CD / quality (push) Successful in 1m46s
CD / publish (push) Successful in 1m40s
- Added `buildDnsPreviewRecords` function to generate DNS records based on hostname, TTL, and provided IPv4/IPv6 addresses.
- Updated `fleetRoutes` to utilize the new DNS records generation for hostname previews.
- Enhanced frontend API calls to support IPv4, IPv6, and node ID parameters for more comprehensive hostname previews.
- Updated UI components to display DNS preview information, including A, AAAA, and CNAME records.
2026-09-04 15:09:30 +07:00
22 changed files with 1690 additions and 125 deletions
+2
View File
@@ -3,6 +3,7 @@ import fp from "fastify-plugin";
import {
createDb,
createMemoryDb,
ensureCatalogLocations,
healthCheck,
runMigrations,
type Db,
@@ -31,6 +32,7 @@ async function dbPlugin(
: createDb(opts.config!.databaseUrl);
runMigrations(sqlite);
ensureCatalogLocations(db);
app.decorate("db", db);
app.decorate("sqlite", sqlite);
+24 -8
View File
@@ -35,6 +35,7 @@ import {
} from "@cdnmanager/db";
import { AppError } from "../errors.js";
import {
buildDnsPreviewRecords,
buildHostname,
isValidIpv4,
isValidIpv6,
@@ -322,15 +323,30 @@ export const fleetRoutes: FastifyPluginAsync = async (app) => {
const loc = listLocations(app.db).find((l) => l.id === q.locationId);
if (!loc) throw AppError.notFound("location not found");
const indexNum = Number(q.indexNum ?? "1") || 1;
const hostname = buildHostname({
template: q.template || zone.namingTemplate,
locationCode: loc.code,
role: q.role,
indexNum,
zoneName: zone.name,
providerTag: q.providerTag,
});
const aliases = q.nodeId
? listAliases(app.db, { zoneId: zone.id }).filter(
(a) => a.targetNodeId === q.nodeId,
)
: [];
const records = buildDnsPreviewRecords({
hostname,
ttl: zone.defaultTtl,
ipv4: q.ipv4,
ipv6: q.ipv6,
aliases: aliases.map((a) => ({ name: a.name, purpose: a.purpose })),
});
return {
hostname: buildHostname({
template: q.template || zone.namingTemplate,
locationCode: loc.code,
role: q.role,
indexNum,
zoneName: zone.name,
providerTag: q.providerTag,
}),
hostname,
ttl: zone.defaultTtl,
records,
};
});
};
+50
View File
@@ -24,6 +24,56 @@ export function buildHostname(opts: {
return host.replace(/\.$/, "");
}
export type DnsPreviewRecord = {
type: "A" | "AAAA" | "CNAME";
name: string;
content: string;
ttl: number;
/** purpose / note (e.g. alias purpose) */
note?: string;
};
/** Desired-state DNS for node form preview (A/AAAA + CNAME aliases → hostname). */
export function buildDnsPreviewRecords(opts: {
hostname: string;
ttl: number;
ipv4?: string | null;
ipv6?: string | null;
aliases?: Array<{ name: string; purpose?: string | null }>;
}): DnsPreviewRecord[] {
const records: DnsPreviewRecord[] = [];
const ipv4 = opts.ipv4?.trim();
const ipv6 = opts.ipv6?.trim();
if (ipv4) {
records.push({
type: "A",
name: opts.hostname,
content: ipv4,
ttl: opts.ttl,
note: "dns-only",
});
}
if (ipv6) {
records.push({
type: "AAAA",
name: opts.hostname,
content: ipv6,
ttl: opts.ttl,
note: "dns-only",
});
}
for (const alias of opts.aliases ?? []) {
records.push({
type: "CNAME",
name: alias.name,
content: opts.hostname,
ttl: opts.ttl,
note: alias.purpose ?? undefined,
});
}
return records;
}
export function normalizeFqdn(name: string): string {
return name.trim().toLowerCase().replace(/\.$/, "");
}
+39
View File
@@ -112,5 +112,44 @@ describe("fleet api", () => {
});
expect(topo.statusCode).toBe(200);
expect(topo.json().nodes).toHaveLength(1);
const preview = await app.inject({
method: "GET",
url: `/api/v1/naming/preview?${new URLSearchParams({
zoneId: zone.id,
locationId: msk.id,
role: "gw",
indexNum: "2",
ipv4: "198.51.100.10",
ipv6: "2001:db8::10",
nodeId: node.id,
})}`,
headers: authHeader,
});
expect(preview.statusCode).toBe(200);
const body = preview.json() as {
hostname: string;
records: Array<{ type: string; name: string; content: string }>;
};
expect(body.hostname).toBe("msk-gw02.rtnt.top");
expect(body.records).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "A",
name: "msk-gw02.rtnt.top",
content: "198.51.100.10",
}),
expect.objectContaining({
type: "AAAA",
name: "msk-gw02.rtnt.top",
content: "2001:db8::10",
}),
expect.objectContaining({
type: "CNAME",
name: "msk.rtnt.top",
content: "msk-gw02.rtnt.top",
}),
]),
);
});
});
+7 -1
View File
@@ -1,4 +1,5 @@
import { cn } from '@cdnmanager/ui/lib/utils'
import { COUNTRY_BY_NAME_RU } from '@cdnmanager/shared'
import { countryCodeFromName, getCountryFlagUrl } from '@/lib/country-labels'
interface CountryFlagProps {
@@ -8,7 +9,12 @@ interface CountryFlagProps {
}
export function CountryFlag({ code, country, className }: CountryFlagProps) {
const resolvedCode = code ?? (country ? countryCodeFromName(country) : undefined)
const resolvedCode =
code ??
(country
? COUNTRY_BY_NAME_RU[country.trim().toLowerCase()]?.code ??
countryCodeFromName(country)
: undefined)
const url = getCountryFlagUrl(resolvedCode)
if (!url) return null
+6 -14
View File
@@ -1,26 +1,18 @@
/** ISO → русское название (seed fleet locations). */
const COUNTRY_NAME_BY_CODE: Record<string, string> = {
RU: 'Россия',
DE: 'Германия',
NL: 'Нидерланды',
FI: 'Финляндия',
FR: 'Франция',
}
const COUNTRY_CODE_BY_NAME: Record<string, string> = Object.fromEntries(
Object.entries(COUNTRY_NAME_BY_CODE).map(([code, name]) => [name.toLowerCase(), code]),
)
import {
COUNTRY_BY_CODE,
COUNTRY_BY_NAME_RU,
} from '@cdnmanager/shared'
export function countryNameFromCode(code: string | null | undefined): string {
if (!code) return ''
return COUNTRY_NAME_BY_CODE[code.toUpperCase()] ?? code
return COUNTRY_BY_CODE[code.toUpperCase()]?.name ?? code
}
export function countryCodeFromName(name: string | null | undefined): string {
if (!name?.trim()) return ''
const trimmed = name.trim()
if (trimmed.length === 2) return trimmed.toUpperCase()
return COUNTRY_CODE_BY_NAME[trimmed.toLowerCase()] ?? ''
return COUNTRY_BY_NAME_RU[trimmed.toLowerCase()]?.code ?? ''
}
export function getCountryFlagUrl(code?: string): string | undefined {
+17 -1
View File
@@ -180,6 +180,9 @@ export async function previewHostname(params: {
role: string
indexNum?: number
providerTag?: string
ipv4?: string
ipv6?: string
nodeId?: string
}) {
const p = new URLSearchParams({
zoneId: params.zoneId,
@@ -188,5 +191,18 @@ export async function previewHostname(params: {
indexNum: String(params.indexNum ?? 1),
})
if (params.providerTag) p.set('providerTag', params.providerTag)
return api.get<{ hostname: string }>(`/api/v1/naming/preview?${p}`)
if (params.ipv4) p.set('ipv4', params.ipv4)
if (params.ipv6) p.set('ipv6', params.ipv6)
if (params.nodeId) p.set('nodeId', params.nodeId)
return api.get<{
hostname: string
ttl: number
records: Array<{
type: 'A' | 'AAAA' | 'CNAME'
name: string
content: string
ttl: number
note?: string
}>
}>(`/api/v1/naming/preview?${p}`)
}
+229 -99
View File
@@ -1,5 +1,5 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
@@ -8,13 +8,19 @@ import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import {
CloudIcon,
MapPinIcon,
PlusIcon,
RefreshCwIcon,
ServerIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import type { Node, NodeRole } from '@cdnmanager/shared'
import {
COUNTRIES,
COUNTRY_BY_NAME_RU,
buildCityOptions,
cityMatchesCountry,
resolveCountryForCityFromRows,
} from '@cdnmanager/shared'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { ResourcePage } from '@/components/reui-kit'
@@ -68,12 +74,16 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>
const ROLES: { value: NodeRole; label: string }[] = [
{ value: 'hub', label: 'hub' },
{ value: 'gw', label: 'gw' },
{ value: 'edge', label: 'edge' },
{ value: 'ix', label: 'ix' },
{ value: 'hub', label: 'Hub' },
{ value: 'gw', label: 'Gateway' },
{ value: 'edge', label: 'Edge' },
{ value: 'ix', label: 'IX' },
]
const ROLE_LABEL: Record<NodeRole, string> = Object.fromEntries(
ROLES.map((r) => [r.value, r.label]),
) as Record<NodeRole, string>
function NodesPage() {
const qc = useQueryClient()
const { data: nodes = [], isLoading, isError, error, refetch } = useQuery(
@@ -86,7 +96,16 @@ function NodesPage() {
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<Node | null>(null)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [preview, setPreview] = useState('')
const [previewHost, setPreviewHost] = useState('')
const [previewRecords, setPreviewRecords] = useState<
Array<{
type: 'A' | 'AAAA' | 'CNAME'
name: string
content: string
ttl: number
note?: string
}>
>([])
const [countryName, setCountryName] = useState('')
const [locationQuery, setLocationQuery] = useState('')
@@ -110,39 +129,62 @@ function NodesPage() {
const watchRole = form.watch('role')
const watchIndex = form.watch('indexNum')
const watchProvider = form.watch('providerTag')
const watchIpv4 = form.watch('ipv4')
const watchIpv6 = form.watch('ipv6')
const countryCode = countryCodeFromName(countryName)
const locationRowsForGeo = useMemo(
() =>
locations.map((l) => ({
city: l.name,
country: countryNameFromCode(l.country),
})),
[locations],
)
const countryOptions = useMemo(() => {
const codes = new Set<string>()
const names = new Set(COUNTRIES.map((c) => c.name))
for (const loc of locations) {
if (loc.country) codes.add(loc.country)
const n = countryNameFromCode(loc.country)
if (n) names.add(n)
}
return [...codes]
.map((code) => {
const name = countryNameFromCode(code)
return [...names]
.sort((a, b) => a.localeCompare(b, 'ru'))
.map((name) => {
const ref = COUNTRY_BY_NAME_RU[name.toLowerCase()]
return {
value: name,
label: name,
leading: <CountryFlag code={code} country={name} />,
leading: <CountryFlag code={ref?.code} country={name} />,
}
})
.sort((a, b) => a.label.localeCompare(b.label, 'ru'))
}, [locations])
const locationOptions = useMemo(() => {
if (!countryCode) return []
return locations
.filter((l) => l.country === countryCode)
.map((l) => ({
value: `${l.code}${l.name}`,
label: `${l.code}${l.name}`,
}))
}, [locations, countryCode])
const cityOptions = useMemo(
() =>
buildCityOptions(locationRowsForGeo, countryName.trim() || undefined, {
includeCatalog: true,
}),
[locationRowsForGeo, countryName],
)
function locationLabel(locationId: string): string {
const loc = locations.find((l) => l.id === locationId)
return loc ? `${loc.code}${loc.name}` : ''
function locationIdForCity(cityName: string, country?: string): string {
const q = cityName.trim().toLowerCase()
if (!q) return ''
const code = country ? countryCodeFromName(country) : countryCode
const match = locations.find((l) => {
if (l.name.trim().toLowerCase() !== q) return false
if (code && l.country && l.country.toUpperCase() !== code.toUpperCase()) {
return false
}
return true
})
return match?.id ?? ''
}
function cityNameForLocationId(locationId: string): string {
return locations.find((l) => l.id === locationId)?.name ?? ''
}
function countryNameForLocationId(locationId: string): string {
@@ -150,33 +192,59 @@ function NodesPage() {
return countryNameFromCode(code)
}
function locationIdFromDisplay(display: string): string {
const q = display.trim().toLowerCase()
if (!q) return ''
const match = locations.find((l) => {
if (countryCode && l.country !== countryCode) return false
const label = `${l.code}${l.name}`.toLowerCase()
return label === q || l.code.toLowerCase() === q || l.name.toLowerCase() === q
})
return match?.id ?? ''
}
async function refreshPreview() {
if (!watchZone || !watchLoc || !watchRole) return
async function refreshPreview(
overrides?: Partial<{
zoneId: string
locationId: string
role: NodeRole
indexNum: number
providerTag: string
ipv4: string
ipv6: string
}>,
) {
const values = { ...form.getValues(), ...overrides }
if (!values.zoneId || !values.locationId || !values.role) {
setPreviewHost('')
setPreviewRecords([])
return
}
try {
const res = await previewHostname({
zoneId: watchZone,
locationId: watchLoc,
role: watchRole,
indexNum: Number(watchIndex) || 1,
providerTag: watchProvider || undefined,
zoneId: values.zoneId,
locationId: values.locationId,
role: values.role,
indexNum: Number(values.indexNum) || 1,
providerTag: values.providerTag || undefined,
ipv4: values.ipv4 || undefined,
ipv6: values.ipv6 || undefined,
nodeId: editing?.id,
})
setPreview(res.hostname)
setPreviewHost(res.hostname)
setPreviewRecords(res.records ?? [])
} catch {
setPreview('')
setPreviewHost('')
setPreviewRecords([])
}
}
useEffect(() => {
if (!sheetOpen) return
void refreshPreview()
// form + editing captured via refreshPreview closures; watches drive re-run
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional field watches
}, [
sheetOpen,
watchZone,
watchLoc,
watchRole,
watchIndex,
watchProvider,
watchIpv4,
watchIpv6,
editing?.id,
])
const saveMutation = useMutation({
mutationFn: async (values: FormValues) => {
if (editing) {
@@ -237,7 +305,10 @@ function NodesPage() {
key: 'locationCode',
label: 'Локация',
type: 'select',
options: locations.map((l) => ({ value: l.code, label: l.code })),
options: locations.map((l) => ({
value: l.code,
label: l.name,
})),
},
{
key: 'role',
@@ -275,16 +346,35 @@ function NodesPage() {
{
accessorKey: 'locationCode',
header: 'Локация',
cell: ({ row }) => (
<span className="flex items-center gap-1.5 text-sm">
<MapPinIcon className="size-3.5" />
{row.original.locationCode ?? '—'}
</span>
),
cell: ({ row }) => {
const n = row.original
const loc = locations.find((l) => l.id === n.locationId)
const city = n.locationName ?? loc?.name ?? n.locationCode ?? '—'
const countryCode = loc?.country ?? undefined
return (
<span className="flex items-center gap-2 text-sm">
<CountryFlag code={countryCode ?? undefined} country={countryNameFromCode(countryCode)} />
<span className="font-medium">{city}</span>
{n.locationCode ? (
<span className="text-muted-foreground font-mono text-xs">
{n.locationCode}
</span>
) : null}
</span>
)
},
},
{
accessorKey: 'role',
header: 'Роль',
cell: ({ row }) => {
const role = row.original.role as NodeRole
return (
<span className="text-sm">
{ROLE_LABEL[role] ?? row.original.role}
</span>
)
},
},
{
id: 'ip',
@@ -329,7 +419,7 @@ function NodesPage() {
const n = row.original
setEditing(n)
setCountryName(countryNameForLocationId(n.locationId))
setLocationQuery(locationLabel(n.locationId))
setLocationQuery(cityNameForLocationId(n.locationId))
form.reset({
zoneId: n.zoneId,
locationId: n.locationId,
@@ -341,7 +431,8 @@ function NodesPage() {
notes: n.notes ?? '',
hostname: n.hostname,
})
setPreview(n.hostname)
setPreviewHost(n.hostname)
setPreviewRecords([])
setSheetOpen(true)
}}
>
@@ -369,12 +460,11 @@ function NodesPage() {
size="sm"
onClick={() => {
setEditing(null)
const firstLoc = locations[0]
setCountryName(countryNameFromCode(firstLoc?.country))
setLocationQuery(firstLoc ? `${firstLoc.code}${firstLoc.name}` : '')
setCountryName('')
setLocationQuery('')
form.reset({
zoneId: zones[0]?.id ?? '',
locationId: firstLoc?.id ?? '',
locationId: '',
role: 'gw',
indexNum: 1,
ipv4: '',
@@ -383,9 +473,9 @@ function NodesPage() {
notes: '',
hostname: '',
})
setPreview('')
setPreviewHost('')
setPreviewRecords([])
setSheetOpen(true)
void refreshPreview()
}}
disabled={zones.length === 0}
>
@@ -453,8 +543,7 @@ function NodesPage() {
<SelectField
value={form.watch('zoneId')}
onValueChange={(v) => {
form.setValue('zoneId', v ?? '')
void refreshPreview()
form.setValue('zoneId', v ?? '', { shouldDirty: true })
}}
placeholder="Зона"
options={zones.map((z) => ({ value: z.id, label: z.name }))}
@@ -469,38 +558,46 @@ function NodesPage() {
value={countryName}
onChange={(v) => {
setCountryName(v)
const nextCode = countryCodeFromName(v)
const currentLoc = locations.find((l) => l.id === form.getValues('locationId'))
if (!nextCode || !currentLoc || currentLoc.country !== nextCode) {
form.setValue('locationId', '')
if (
v.trim() &&
locationQuery.trim() &&
!cityMatchesCountry(locationQuery, v, locationRowsForGeo)
) {
setLocationQuery('')
setPreview('')
form.setValue('locationId', '')
setPreviewHost('')
setPreviewRecords([])
}
void refreshPreview()
}}
options={countryOptions}
searchPlaceholder="Поиск страны…"
emptyText="Нет вариантов"
/>
</FormFieldSimple>
<FormFieldSimple label="Локация" htmlFor="node-location">
<FormFieldSimple label="Город" htmlFor="node-city">
<AutoCompleteInput
id="node-location"
placeholder="Любая"
id="node-city"
placeholder="Любой"
value={locationQuery}
onChange={(v) => {
setLocationQuery(v)
const id = locationIdFromDisplay(v)
const resolvedCountry =
resolveCountryForCityFromRows(v, locationRowsForGeo) ?? countryName
if (resolvedCountry && resolvedCountry !== countryName) {
setCountryName(resolvedCountry)
}
const id = locationIdForCity(v, resolvedCountry || countryName)
form.setValue('locationId', id)
const loc = locations.find((l) => l.id === id)
if (loc?.country) setCountryName(countryNameFromCode(loc.country))
void refreshPreview()
if (id) void refreshPreview({ locationId: id })
else {
setPreviewHost('')
setPreviewRecords([])
}
}}
options={locationOptions}
searchPlaceholder="Поиск локации…"
emptyText={countryCode ? 'Нет вариантов' : 'Сначала выберите страну'}
options={cityOptions}
searchPlaceholder="Поиск города…"
emptyText="Нет вариантов"
showLeadingInInput={false}
disabled={!countryCode}
/>
</FormFieldSimple>
@@ -510,8 +607,9 @@ function NodesPage() {
<SelectField
value={form.watch('role')}
onValueChange={(v) => {
form.setValue('role', (v as NodeRole) ?? 'gw')
void refreshPreview()
const role = (v as NodeRole) ?? 'gw'
form.setValue('role', role)
void refreshPreview({ role })
}}
options={ROLES.map((r) => ({ value: r.value, label: r.label }))}
/>
@@ -522,27 +620,17 @@ function NodesPage() {
type="number"
min={1}
max={99}
{...form.register('indexNum', { valueAsNumber: true })}
onBlur={() => void refreshPreview()}
{...form.register('indexNum', {
valueAsNumber: true,
onChange: (e) => {
const n = Number(e.target.value) || 1
void refreshPreview({ indexNum: n })
},
})}
/>
</div>
</div>
<div className="bg-muted/40 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm">
<CloudIcon className="size-4 shrink-0" />
<span className="text-muted-foreground">Preview:</span>
<code className="font-medium">{preview || '—'}</code>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto"
onClick={() => void refreshPreview()}
>
<RefreshCwIcon className="size-3.5" />
</Button>
</div>
<div className="flex flex-col gap-2">
<Label>IPv4</Label>
<Input {...form.register('ipv4')} placeholder="198.51.100.10" />
@@ -559,6 +647,48 @@ function NodesPage() {
<Label>Заметки</Label>
<Input {...form.register('notes')} />
</div>
<div className="bg-muted/40 flex flex-col gap-2 rounded-lg border px-3 py-2 text-sm">
<div className="flex items-center gap-2">
<CloudIcon className="size-4 shrink-0" />
<span className="text-muted-foreground">Preview FQDN:</span>
<code className="font-medium">{previewHost || '—'}</code>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto"
onClick={() => void refreshPreview()}
>
<RefreshCwIcon className="size-3.5" />
</Button>
</div>
{previewRecords.length > 0 ? (
<ul className="flex flex-col gap-1 font-mono text-xs tabular-nums">
{previewRecords.map((r) => (
<li
key={`${r.type}:${r.name}:${r.content}`}
className="text-muted-foreground flex flex-wrap items-baseline gap-x-2"
>
<span className="text-foreground w-12 shrink-0 font-semibold">
{r.type}
</span>
<span className="min-w-0 break-all">
{r.name} {r.content}
</span>
{r.note ? (
<span className="opacity-70">({r.note})</span>
) : null}
</li>
))}
</ul>
) : previewHost ? (
<p className="text-muted-foreground text-xs">
Укажите IPv4/IPv6 появятся A/AAAA. При редактировании CNAME
алиасов на эту ноду.
</p>
) : null}
</div>
</div>
</FormSheet>
+7 -1
View File
@@ -2863,6 +2863,12 @@ type AliasRow = {
};
declare function listLocations(db: Db): LocationRow[];
declare function getLocation(db: Db, locationId: string): LocationRow;
/**
* Дополняет таблицу locations городами из geo-каталога (тот же, что VPS Tracker).
* Существующие коды (msk/fra/…) не перезаписываются.
*/
declare function ensureCatalogLocations(db: Db): number;
declare function findLocationByCity(db: Db, cityName: string, countryCode?: string | null): LocationRow | null;
declare function listZones(db: Db): ZoneRow[];
declare function getZone(db: Db, zoneId: string): ZoneRow;
declare function createZone(db: Db, input: {
@@ -2987,4 +2993,4 @@ declare function dashboardCounts(db: Db): {
lastSyncAt: string | null;
};
export { type AliasRow, type AppSettingsDto, type AppSettingsPatch, ConflictError, type Db, type LocationRow, type NodeAddressRow, type NodeRow, NotFoundError, type Sqlite, type ZoneRow, addSyncEvent, aliases, appSettings, createAlias, createDb, createMemoryDb, createNode, createSyncJob, createZone, dashboardCounts, deleteAlias, deleteNode, deleteZone, getAlias, getAppSettings, getLocation, getNode, getSyncJob, getZone, healthCheck, ignoreOrphan, ignoredOrphans, listAliases, listIgnoredOrphans, listLocations, listNodes, listSyncJobs, listZones, locations, nodeAddresses, nodes, resolveDatabasePath, runMigrations, schema, syncEvents, syncJobs, unignoreOrphan, updateAlias, updateAppSettings, updateNode, updateSyncJob, updateZone, zones };
export { type AliasRow, type AppSettingsDto, type AppSettingsPatch, ConflictError, type Db, type LocationRow, type NodeAddressRow, type NodeRow, NotFoundError, type Sqlite, type ZoneRow, addSyncEvent, aliases, appSettings, createAlias, createDb, createMemoryDb, createNode, createSyncJob, createZone, dashboardCounts, deleteAlias, deleteNode, deleteZone, ensureCatalogLocations, findLocationByCity, getAlias, getAppSettings, getLocation, getNode, getSyncJob, getZone, healthCheck, ignoreOrphan, ignoredOrphans, listAliases, listIgnoredOrphans, listLocations, listNodes, listSyncJobs, listZones, locations, nodeAddresses, nodes, resolveDatabasePath, runMigrations, schema, syncEvents, syncJobs, unignoreOrphan, updateAlias, updateAppSettings, updateNode, updateSyncJob, updateZone, zones };
+62
View File
@@ -236,6 +236,7 @@ function updateAppSettings(db, patch) {
// src/fleet-repo.ts
import { randomUUID } from "crypto";
import { and, asc, count, eq as eq2, sql as sql2 } from "drizzle-orm";
import { catalogCitiesWithLocCodes } from "@cdnmanager/shared";
function now() {
return (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
}
@@ -272,6 +273,65 @@ function getLocation(db, locationId) {
if (!row) throw new NotFoundError(`location ${locationId}`);
return mapLocation(row);
}
function ensureCatalogLocations(db) {
const existing = db.select().from(locations).all();
const byCode = new Map(existing.map((r) => [r.code.toLowerCase(), r]));
const byNameCountry = new Map(
existing.map((r) => [
`${(r.name || "").trim().toLowerCase()}|${(r.country || "").toUpperCase()}`,
r
])
);
let inserted = 0;
let sortOrder = 100;
for (const city of catalogCitiesWithLocCodes()) {
const nameKey = `${city.name.trim().toLowerCase()}|${city.countryCode}`;
if (byNameCountry.has(nameKey)) continue;
if (byCode.has(city.locCode.toLowerCase())) {
const alt = `${city.locCode}${city.countryCode.toLowerCase()}`;
if (byCode.has(alt) || byNameCountry.has(nameKey)) continue;
db.insert(locations).values({
id: `loc-${alt}`,
code: alt,
name: city.name,
country: city.countryCode,
sort_order: sortOrder++
}).run();
byCode.set(alt, { id: `loc-${alt}` });
byNameCountry.set(nameKey, { id: `loc-${alt}` });
inserted += 1;
continue;
}
db.insert(locations).values({
id: `loc-${city.locCode}`,
code: city.locCode,
name: city.name,
country: city.countryCode,
sort_order: sortOrder++
}).run();
byCode.set(city.locCode.toLowerCase(), {
id: `loc-${city.locCode}`
});
byNameCountry.set(nameKey, {
id: `loc-${city.locCode}`
});
inserted += 1;
}
return inserted;
}
function findLocationByCity(db, cityName, countryCode) {
const q = cityName.trim().toLowerCase();
if (!q) return null;
const rows = listLocations(db);
const match = rows.find((l) => {
if (l.name.trim().toLowerCase() !== q) return false;
if (countryCode && l.country && l.country.toUpperCase() !== countryCode.toUpperCase()) {
return false;
}
return true;
});
return match ?? null;
}
function listZones(db) {
return db.select().from(zones).orderBy(asc(zones.name)).all().map(mapZone);
}
@@ -645,6 +705,8 @@ export {
deleteAlias,
deleteNode,
deleteZone,
ensureCatalogLocations,
findLocationByCity,
getAlias,
getAppSettings,
getLocation,
+77
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { and, asc, count, eq, sql } from "drizzle-orm";
import { catalogCitiesWithLocCodes } from "@cdnmanager/shared";
import type { Db } from "./client.js";
import { NotFoundError, ConflictError } from "./errors.js";
import {
@@ -126,6 +127,82 @@ export function getLocation(db: Db, locationId: string): LocationRow {
return mapLocation(row);
}
/**
* Дополняет таблицу locations городами из geo-каталога (тот же, что VPS Tracker).
* Существующие коды (msk/fra/…) не перезаписываются.
*/
export function ensureCatalogLocations(db: Db): number {
const existing = db.select().from(locations).all();
const byCode = new Map(existing.map((r) => [r.code.toLowerCase(), r]));
const byNameCountry = new Map(
existing.map((r) => [
`${(r.name || "").trim().toLowerCase()}|${(r.country || "").toUpperCase()}`,
r,
]),
);
let inserted = 0;
let sortOrder = 100;
for (const city of catalogCitiesWithLocCodes()) {
const nameKey = `${city.name.trim().toLowerCase()}|${city.countryCode}`;
if (byNameCountry.has(nameKey)) continue;
if (byCode.has(city.locCode.toLowerCase())) {
// код занят другой записью — пробуем расширенный код
const alt = `${city.locCode}${city.countryCode.toLowerCase()}`;
if (byCode.has(alt) || byNameCountry.has(nameKey)) continue;
db.insert(locations)
.values({
id: `loc-${alt}`,
code: alt,
name: city.name,
country: city.countryCode,
sort_order: sortOrder++,
})
.run();
byCode.set(alt, { id: `loc-${alt}` } as (typeof existing)[0]);
byNameCountry.set(nameKey, { id: `loc-${alt}` } as (typeof existing)[0]);
inserted += 1;
continue;
}
db.insert(locations)
.values({
id: `loc-${city.locCode}`,
code: city.locCode,
name: city.name,
country: city.countryCode,
sort_order: sortOrder++,
})
.run();
byCode.set(city.locCode.toLowerCase(), {
id: `loc-${city.locCode}`,
} as (typeof existing)[0]);
byNameCountry.set(nameKey, {
id: `loc-${city.locCode}`,
} as (typeof existing)[0]);
inserted += 1;
}
return inserted;
}
export function findLocationByCity(
db: Db,
cityName: string,
countryCode?: string | null,
): LocationRow | null {
const q = cityName.trim().toLowerCase();
if (!q) return null;
const rows = listLocations(db);
const match = rows.find((l) => {
if (l.name.trim().toLowerCase() !== q) return false;
if (countryCode && l.country && l.country.toUpperCase() !== countryCode.toUpperCase()) {
return false;
}
return true;
});
return match ?? null;
}
export function listZones(db: Db): ZoneRow[] {
return db
.select()
+467
View File
@@ -0,0 +1,467 @@
// src/geo/countries.ts
var COUNTRIES = [
{ code: "AU", name: "\u0410\u0432\u0441\u0442\u0440\u0430\u043B\u0438\u044F", nameEn: "Australia" },
{ code: "AT", name: "\u0410\u0432\u0441\u0442\u0440\u0438\u044F", nameEn: "Austria" },
{ code: "AZ", name: "\u0410\u0437\u0435\u0440\u0431\u0430\u0439\u0434\u0436\u0430\u043D", nameEn: "Azerbaijan" },
{ code: "AL", name: "\u0410\u043B\u0431\u0430\u043D\u0438\u044F", nameEn: "Albania" },
{ code: "DZ", name: "\u0410\u043B\u0436\u0438\u0440", nameEn: "Algeria" },
{ code: "AO", name: "\u0410\u043D\u0433\u043E\u043B\u0430", nameEn: "Angola" },
{ code: "AR", name: "\u0410\u0440\u0433\u0435\u043D\u0442\u0438\u043D\u0430", nameEn: "Argentina" },
{ code: "AM", name: "\u0410\u0440\u043C\u0435\u043D\u0438\u044F", nameEn: "Armenia" },
{ code: "AF", name: "\u0410\u0444\u0433\u0430\u043D\u0438\u0441\u0442\u0430\u043D", nameEn: "Afghanistan" },
{ code: "BE", name: "\u0411\u0435\u043B\u044C\u0433\u0438\u044F", nameEn: "Belgium" },
{ code: "BG", name: "\u0411\u043E\u043B\u0433\u0430\u0440\u0438\u044F", nameEn: "Bulgaria" },
{ code: "BO", name: "\u0411\u043E\u043B\u0438\u0432\u0438\u044F", nameEn: "Bolivia" },
{ code: "BA", name: "\u0411\u043E\u0441\u043D\u0438\u044F \u0438 \u0413\u0435\u0440\u0446\u0435\u0433\u043E\u0432\u0438\u043D\u0430", nameEn: "Bosnia and Herzegovina" },
{ code: "BR", name: "\u0411\u0440\u0430\u0437\u0438\u043B\u0438\u044F", nameEn: "Brazil" },
{ code: "GB", name: "\u0412\u0435\u043B\u0438\u043A\u043E\u0431\u0440\u0438\u0442\u0430\u043D\u0438\u044F", nameEn: "United Kingdom" },
{ code: "HU", name: "\u0412\u0435\u043D\u0433\u0440\u0438\u044F", nameEn: "Hungary" },
{ code: "VE", name: "\u0412\u0435\u043D\u0435\u0441\u0443\u044D\u043B\u0430", nameEn: "Venezuela" },
{ code: "VN", name: "\u0412\u044C\u0435\u0442\u043D\u0430\u043C", nameEn: "Vietnam" },
{ code: "GA", name: "\u0413\u0430\u0431\u043E\u043D", nameEn: "Gabon" },
{ code: "HT", name: "\u0413\u0430\u0438\u0442\u0438", nameEn: "Haiti" },
{ code: "GY", name: "\u0413\u0430\u0439\u0430\u043D\u0430", nameEn: "Guyana" },
{ code: "GM", name: "\u0413\u0430\u043C\u0431\u0438\u044F", nameEn: "Gambia" },
{ code: "GH", name: "\u0413\u0430\u043D\u0430", nameEn: "Ghana" },
{ code: "GT", name: "\u0413\u0432\u0430\u0442\u0435\u043C\u0430\u043B\u0430", nameEn: "Guatemala" },
{ code: "GN", name: "\u0413\u0432\u0438\u043D\u0435\u044F", nameEn: "Guinea" },
{ code: "DE", name: "\u0413\u0435\u0440\u043C\u0430\u043D\u0438\u044F", nameEn: "Germany" },
{ code: "HN", name: "\u0413\u043E\u043D\u0434\u0443\u0440\u0430\u0441", nameEn: "Honduras" },
{ code: "GR", name: "\u0413\u0440\u0435\u0446\u0438\u044F", nameEn: "Greece" },
{ code: "GE", name: "\u0413\u0440\u0443\u0437\u0438\u044F", nameEn: "Georgia" },
{ code: "DK", name: "\u0414\u0430\u043D\u0438\u044F", nameEn: "Denmark" },
{ code: "CD", name: "\u0414\u0420 \u041A\u043E\u043D\u0433\u043E", nameEn: "DR Congo" },
{ code: "EG", name: "\u0415\u0433\u0438\u043F\u0435\u0442", nameEn: "Egypt" },
{ code: "ZM", name: "\u0417\u0430\u043C\u0431\u0438\u044F", nameEn: "Zambia" },
{ code: "ZW", name: "\u0417\u0438\u043C\u0431\u0430\u0431\u0432\u0435", nameEn: "Zimbabwe" },
{ code: "IL", name: "\u0418\u0437\u0440\u0430\u0438\u043B\u044C", nameEn: "Israel" },
{ code: "IN", name: "\u0418\u043D\u0434\u0438\u044F", nameEn: "India" },
{ code: "ID", name: "\u0418\u043D\u0434\u043E\u043D\u0435\u0437\u0438\u044F", nameEn: "Indonesia" },
{ code: "JO", name: "\u0418\u043E\u0440\u0434\u0430\u043D\u0438\u044F", nameEn: "Jordan" },
{ code: "IQ", name: "\u0418\u0440\u0430\u043A", nameEn: "Iraq" },
{ code: "IR", name: "\u0418\u0440\u0430\u043D", nameEn: "Iran" },
{ code: "IE", name: "\u0418\u0440\u043B\u0430\u043D\u0434\u0438\u044F", nameEn: "Ireland" },
{ code: "IS", name: "\u0418\u0441\u043B\u0430\u043D\u0434\u0438\u044F", nameEn: "Iceland" },
{ code: "ES", name: "\u0418\u0441\u043F\u0430\u043D\u0438\u044F", nameEn: "Spain" },
{ code: "IT", name: "\u0418\u0442\u0430\u043B\u0438\u044F", nameEn: "Italy" },
{ code: "YE", name: "\u0419\u0435\u043C\u0435\u043D", nameEn: "Yemen" },
{ code: "KZ", name: "\u041A\u0430\u0437\u0430\u0445\u0441\u0442\u0430\u043D", nameEn: "Kazakhstan" },
{ code: "KH", name: "\u041A\u0430\u043C\u0431\u043E\u0434\u0436\u0430", nameEn: "Cambodia" },
{ code: "CM", name: "\u041A\u0430\u043C\u0435\u0440\u0443\u043D", nameEn: "Cameroon" },
{ code: "CA", name: "\u041A\u0430\u043D\u0430\u0434\u0430", nameEn: "Canada" },
{ code: "QA", name: "\u041A\u0430\u0442\u0430\u0440", nameEn: "Qatar" },
{ code: "KE", name: "\u041A\u0435\u043D\u0438\u044F", nameEn: "Kenya" },
{ code: "CY", name: "\u041A\u0438\u043F\u0440", nameEn: "Cyprus" },
{ code: "KG", name: "\u041A\u0438\u0440\u0433\u0438\u0437\u0438\u044F", nameEn: "Kyrgyzstan" },
{ code: "CN", name: "\u041A\u0438\u0442\u0430\u0439", nameEn: "China" },
{ code: "CO", name: "\u041A\u043E\u043B\u0443\u043C\u0431\u0438\u044F", nameEn: "Colombia" },
{ code: "CG", name: "\u041A\u043E\u043D\u0433\u043E", nameEn: "Congo" },
{ code: "KR", name: "\u042E\u0436\u043D\u0430\u044F \u041A\u043E\u0440\u0435\u044F", nameEn: "South Korea" },
{ code: "KP", name: "\u0421\u0435\u0432\u0435\u0440\u043D\u0430\u044F \u041A\u043E\u0440\u0435\u044F", nameEn: "North Korea" },
{ code: "XK", name: "\u041A\u043E\u0441\u043E\u0432\u043E", nameEn: "Kosovo" },
{ code: "CR", name: "\u041A\u043E\u0441\u0442\u0430-\u0420\u0438\u043A\u0430", nameEn: "Costa Rica" },
{ code: "CI", name: "\u041A\u043E\u0442-\u0434'\u0418\u0432\u0443\u0430\u0440", nameEn: "C\xF4te d'Ivoire" },
{ code: "CU", name: "\u041A\u0443\u0431\u0430", nameEn: "Cuba" },
{ code: "KW", name: "\u041A\u0443\u0432\u0435\u0439\u0442", nameEn: "Kuwait" },
{ code: "LA", name: "\u041B\u0430\u043E\u0441", nameEn: "Laos" },
{ code: "LV", name: "\u041B\u0430\u0442\u0432\u0438\u044F", nameEn: "Latvia" },
{ code: "LR", name: "\u041B\u0438\u0431\u0435\u0440\u0438\u044F", nameEn: "Liberia" },
{ code: "LB", name: "\u041B\u0438\u0432\u0430\u043D", nameEn: "Lebanon" },
{ code: "LY", name: "\u041B\u0438\u0432\u0438\u044F", nameEn: "Libya" },
{ code: "LT", name: "\u041B\u0438\u0442\u0432\u0430", nameEn: "Lithuania" },
{ code: "LI", name: "\u041B\u0438\u0445\u0442\u0435\u043D\u0448\u0442\u0435\u0439\u043D", nameEn: "Liechtenstein" },
{ code: "LU", name: "\u041B\u044E\u043A\u0441\u0435\u043C\u0431\u0443\u0440\u0433", nameEn: "Luxembourg" },
{ code: "MU", name: "\u041C\u0430\u0432\u0440\u0438\u043A\u0438\u0439", nameEn: "Mauritius" },
{ code: "MR", name: "\u041C\u0430\u0432\u0440\u0438\u0442\u0430\u043D\u0438\u044F", nameEn: "Mauritania" },
{ code: "MG", name: "\u041C\u0430\u0434\u0430\u0433\u0430\u0441\u043A\u0430\u0440", nameEn: "Madagascar" },
{ code: "MO", name: "\u041C\u0430\u043A\u0430\u043E", nameEn: "Macao" },
{ code: "MW", name: "\u041C\u0430\u043B\u0430\u0432\u0438", nameEn: "Malawi" },
{ code: "MY", name: "\u041C\u0430\u043B\u0430\u0439\u0437\u0438\u044F", nameEn: "Malaysia" },
{ code: "ML", name: "\u041C\u0430\u043B\u0438", nameEn: "Mali" },
{ code: "MT", name: "\u041C\u0430\u043B\u044C\u0442\u0430", nameEn: "Malta" },
{ code: "MA", name: "\u041C\u0430\u0440\u043E\u043A\u043A\u043E", nameEn: "Morocco" },
{ code: "MX", name: "\u041C\u0435\u043A\u0441\u0438\u043A\u0430", nameEn: "Mexico" },
{ code: "MZ", name: "\u041C\u043E\u0437\u0430\u043C\u0431\u0438\u043A", nameEn: "Mozambique" },
{ code: "MD", name: "\u041C\u043E\u043B\u0434\u0430\u0432\u0438\u044F", nameEn: "Moldova" },
{ code: "MC", name: "\u041C\u043E\u043D\u0430\u043A\u043E", nameEn: "Monaco" },
{ code: "MN", name: "\u041C\u043E\u043D\u0433\u043E\u043B\u0438\u044F", nameEn: "Mongolia" },
{ code: "MM", name: "\u041C\u044C\u044F\u043D\u043C\u0430", nameEn: "Myanmar" },
{ code: "NA", name: "\u041D\u0430\u043C\u0438\u0431\u0438\u044F", nameEn: "Namibia" },
{ code: "NP", name: "\u041D\u0435\u043F\u0430\u043B", nameEn: "Nepal" },
{ code: "NE", name: "\u041D\u0438\u0433\u0435\u0440", nameEn: "Niger" },
{ code: "NG", name: "\u041D\u0438\u0433\u0435\u0440\u0438\u044F", nameEn: "Nigeria" },
{ code: "NL", name: "\u041D\u0438\u0434\u0435\u0440\u043B\u0430\u043D\u0434\u044B", nameEn: "Netherlands" },
{ code: "NI", name: "\u041D\u0438\u043A\u0430\u0440\u0430\u0433\u0443\u0430", nameEn: "Nicaragua" },
{ code: "NZ", name: "\u041D\u043E\u0432\u0430\u044F \u0417\u0435\u043B\u0430\u043D\u0434\u0438\u044F", nameEn: "New Zealand" },
{ code: "NO", name: "\u041D\u043E\u0440\u0432\u0435\u0433\u0438\u044F", nameEn: "Norway" },
{ code: "AE", name: "\u041E\u0410\u042D", nameEn: "United Arab Emirates" },
{ code: "OM", name: "\u041E\u043C\u0430\u043D", nameEn: "Oman" },
{ code: "PK", name: "\u041F\u0430\u043A\u0438\u0441\u0442\u0430\u043D", nameEn: "Pakistan" },
{ code: "PA", name: "\u041F\u0430\u043D\u0430\u043C\u0430", nameEn: "Panama" },
{ code: "PG", name: "\u041F\u0430\u043F\u0443\u0430 \u2014 \u041D\u043E\u0432\u0430\u044F \u0413\u0432\u0438\u043D\u0435\u044F", nameEn: "Papua New Guinea" },
{ code: "PY", name: "\u041F\u0430\u0440\u0430\u0433\u0432\u0430\u0439", nameEn: "Paraguay" },
{ code: "PE", name: "\u041F\u0435\u0440\u0443", nameEn: "Peru" },
{ code: "PL", name: "\u041F\u043E\u043B\u044C\u0448\u0430", nameEn: "Poland" },
{ code: "PT", name: "\u041F\u043E\u0440\u0442\u0443\u0433\u0430\u043B\u0438\u044F", nameEn: "Portugal" },
{ code: "RU", name: "\u0420\u043E\u0441\u0441\u0438\u044F", nameEn: "Russia" },
{ code: "RW", name: "\u0420\u0443\u0430\u043D\u0434\u0430", nameEn: "Rwanda" },
{ code: "RO", name: "\u0420\u0443\u043C\u044B\u043D\u0438\u044F", nameEn: "Romania" },
{ code: "SV", name: "\u0421\u0430\u043B\u044C\u0432\u0430\u0434\u043E\u0440", nameEn: "El Salvador" },
{ code: "SA", name: "\u0421\u0430\u0443\u0434\u043E\u0432\u0441\u043A\u0430\u044F \u0410\u0440\u0430\u0432\u0438\u044F", nameEn: "Saudi Arabia" },
{ code: "MK", name: "\u0421\u0435\u0432\u0435\u0440\u043D\u0430\u044F \u041C\u0430\u043A\u0435\u0434\u043E\u043D\u0438\u044F", nameEn: "North Macedonia" },
{ code: "SC", name: "\u0421\u0435\u0439\u0448\u0435\u043B\u044B", nameEn: "Seychelles" },
{ code: "SN", name: "\u0421\u0435\u043D\u0435\u0433\u0430\u043B", nameEn: "Senegal" },
{ code: "RS", name: "\u0421\u0435\u0440\u0431\u0438\u044F", nameEn: "Serbia" },
{ code: "SG", name: "\u0421\u0438\u043D\u0433\u0430\u043F\u0443\u0440", nameEn: "Singapore" },
{ code: "SK", name: "\u0421\u043B\u043E\u0432\u0430\u043A\u0438\u044F", nameEn: "Slovakia" },
{ code: "SI", name: "\u0421\u043B\u043E\u0432\u0435\u043D\u0438\u044F", nameEn: "Slovenia" },
{ code: "SO", name: "\u0421\u043E\u043C\u0430\u043B\u0438", nameEn: "Somalia" },
{ code: "SD", name: "\u0421\u0443\u0434\u0430\u043D", nameEn: "Sudan" },
{ code: "US", name: "\u0421\u0428\u0410", nameEn: "United States" },
{ code: "TJ", name: "\u0422\u0430\u0434\u0436\u0438\u043A\u0438\u0441\u0442\u0430\u043D", nameEn: "Tajikistan" },
{ code: "TH", name: "\u0422\u0430\u0438\u043B\u0430\u043D\u0434", nameEn: "Thailand" },
{ code: "TZ", name: "\u0422\u0430\u043D\u0437\u0430\u043D\u0438\u044F", nameEn: "Tanzania" },
{ code: "TG", name: "\u0422\u043E\u0433\u043E", nameEn: "Togo" },
{ code: "TT", name: "\u0422\u0440\u0438\u043D\u0438\u0434\u0430\u0434 \u0438 \u0422\u043E\u0431\u0430\u0433\u043E", nameEn: "Trinidad and Tobago" },
{ code: "TV", name: "\u0422\u0443\u0432\u0430\u043B\u0443", nameEn: "Tuvalu" },
{ code: "TN", name: "\u0422\u0443\u043D\u0438\u0441", nameEn: "Tunisia" },
{ code: "TM", name: "\u0422\u0443\u0440\u043A\u043C\u0435\u043D\u0438\u044F", nameEn: "Turkmenistan" },
{ code: "TR", name: "\u0422\u0443\u0440\u0446\u0438\u044F", nameEn: "Turkey" },
{ code: "UG", name: "\u0423\u0433\u0430\u043D\u0434\u0430", nameEn: "Uganda" },
{ code: "UZ", name: "\u0423\u0437\u0431\u0435\u043A\u0438\u0441\u0442\u0430\u043D", nameEn: "Uzbekistan" },
{ code: "UA", name: "\u0423\u043A\u0440\u0430\u0438\u043D\u0430", nameEn: "Ukraine" },
{ code: "UY", name: "\u0423\u0440\u0443\u0433\u0432\u0430\u0439", nameEn: "Uruguay" },
{ code: "FJ", name: "\u0424\u0438\u0434\u0436\u0438", nameEn: "Fiji" },
{ code: "PH", name: "\u0424\u0438\u043B\u0438\u043F\u043F\u0438\u043D\u044B", nameEn: "Philippines" },
{ code: "FI", name: "\u0424\u0438\u043D\u043B\u044F\u043D\u0434\u0438\u044F", nameEn: "Finland" },
{ code: "FR", name: "\u0424\u0440\u0430\u043D\u0446\u0438\u044F", nameEn: "France" },
{ code: "HR", name: "\u0425\u043E\u0440\u0432\u0430\u0442\u0438\u044F", nameEn: "Croatia" },
{ code: "TD", name: "\u0427\u0430\u0434", nameEn: "Chad" },
{ code: "ME", name: "\u0427\u0435\u0440\u043D\u043E\u0433\u043E\u0440\u0438\u044F", nameEn: "Montenegro" },
{ code: "CZ", name: "\u0427\u0435\u0445\u0438\u044F", nameEn: "Czechia" },
{ code: "CL", name: "\u0427\u0438\u043B\u0438", nameEn: "Chile" },
{ code: "CH", name: "\u0428\u0432\u0435\u0439\u0446\u0430\u0440\u0438\u044F", nameEn: "Switzerland" },
{ code: "SE", name: "\u0428\u0432\u0435\u0446\u0438\u044F", nameEn: "Sweden" },
{ code: "LK", name: "\u0428\u0440\u0438-\u041B\u0430\u043D\u043A\u0430", nameEn: "Sri Lanka" },
{ code: "EC", name: "\u042D\u043A\u0432\u0430\u0434\u043E\u0440", nameEn: "Ecuador" },
{ code: "GQ", name: "\u042D\u043A\u0432\u0430\u0442\u043E\u0440\u0438\u0430\u043B\u044C\u043D\u0430\u044F \u0413\u0432\u0438\u043D\u0435\u044F", nameEn: "Equatorial Guinea" },
{ code: "ER", name: "\u042D\u0440\u0438\u0442\u0440\u0435\u044F", nameEn: "Eritrea" },
{ code: "EE", name: "\u042D\u0441\u0442\u043E\u043D\u0438\u044F", nameEn: "Estonia" },
{ code: "ET", name: "\u042D\u0444\u0438\u043E\u043F\u0438\u044F", nameEn: "Ethiopia" },
{ code: "ZA", name: "\u042E\u0410\u0420", nameEn: "South Africa" },
{ code: "JM", name: "\u042F\u043C\u0430\u0439\u043A\u0430", nameEn: "Jamaica" },
{ code: "JP", name: "\u042F\u043F\u043E\u043D\u0438\u044F", nameEn: "Japan" }
];
var COUNTRY_BY_CODE = Object.fromEntries(
COUNTRIES.map((c) => [c.code, c])
);
var COUNTRY_BY_NAME_RU = Object.fromEntries(
COUNTRIES.map((c) => [c.name.toLowerCase(), c])
);
// src/geo/cities.ts
var CITIES = [
// Россия
{ countryCode: "RU", name: "\u041C\u043E\u0441\u043A\u0432\u0430", nameEn: "Moscow" },
{ countryCode: "RU", name: "\u0421\u0430\u043D\u043A\u0442-\u041F\u0435\u0442\u0435\u0440\u0431\u0443\u0440\u0433", nameEn: "Saint Petersburg" },
{ countryCode: "RU", name: "\u041D\u043E\u0432\u043E\u0441\u0438\u0431\u0438\u0440\u0441\u043A", nameEn: "Novosibirsk" },
{ countryCode: "RU", name: "\u0415\u043A\u0430\u0442\u0435\u0440\u0438\u043D\u0431\u0443\u0440\u0433", nameEn: "Yekaterinburg" },
{ countryCode: "RU", name: "\u041A\u0430\u0437\u0430\u043D\u044C", nameEn: "Kazan" },
{ countryCode: "RU", name: "\u041D\u0438\u0436\u043D\u0438\u0439 \u041D\u043E\u0432\u0433\u043E\u0440\u043E\u0434", nameEn: "Nizhny Novgorod" },
{ countryCode: "RU", name: "\u0421\u0430\u043C\u0430\u0440\u0430", nameEn: "Samara" },
{ countryCode: "RU", name: "\u0420\u043E\u0441\u0442\u043E\u0432-\u043D\u0430-\u0414\u043E\u043D\u0443", nameEn: "Rostov-on-Don" },
{ countryCode: "RU", name: "\u0423\u0444\u0430", nameEn: "Ufa" },
{ countryCode: "RU", name: "\u041A\u0440\u0430\u0441\u043D\u043E\u0434\u0430\u0440", nameEn: "Krasnodar" },
{ countryCode: "RU", name: "\u0421\u043E\u0447\u0438", nameEn: "Sochi" },
{ countryCode: "RU", name: "\u0412\u043B\u0430\u0434\u0438\u0432\u043E\u0441\u0442\u043E\u043A", nameEn: "Vladivostok" },
{ countryCode: "RU", name: "\u0418\u0440\u043A\u0443\u0442\u0441\u043A", nameEn: "Irkutsk" },
{ countryCode: "RU", name: "\u0425\u0430\u0431\u0430\u0440\u043E\u0432\u0441\u043A", nameEn: "Khabarovsk" },
{ countryCode: "RU", name: "\u0422\u044E\u043C\u0435\u043D\u044C", nameEn: "Tyumen" },
// Нидерланды
{ countryCode: "NL", name: "\u0410\u043C\u0441\u0442\u0435\u0440\u0434\u0430\u043C", nameEn: "Amsterdam" },
{ countryCode: "NL", name: "\u0420\u043E\u0442\u0442\u0435\u0440\u0434\u0430\u043C", nameEn: "Rotterdam" },
{ countryCode: "NL", name: "\u0413\u0430\u0430\u0433\u0430", nameEn: "The Hague" },
// Германия
{ countryCode: "DE", name: "\u0424\u0440\u0430\u043D\u043A\u0444\u0443\u0440\u0442", nameEn: "Frankfurt" },
{ countryCode: "DE", name: "\u0411\u0435\u0440\u043B\u0438\u043D", nameEn: "Berlin" },
{ countryCode: "DE", name: "\u041C\u044E\u043D\u0445\u0435\u043D", nameEn: "Munich" },
{ countryCode: "DE", name: "\u0413\u0430\u043C\u0431\u0443\u0440\u0433", nameEn: "Hamburg" },
{ countryCode: "DE", name: "\u041A\u0451\u043B\u044C\u043D", nameEn: "Cologne" },
{ countryCode: "DE", name: "\u0414\u044E\u0441\u0441\u0435\u043B\u044C\u0434\u043E\u0440\u0444", nameEn: "D\xFCsseldorf" },
{ countryCode: "DE", name: "\u0428\u0442\u0443\u0442\u0433\u0430\u0440\u0442", nameEn: "Stuttgart" },
// Финляндия
{ countryCode: "FI", name: "\u0425\u0435\u043B\u044C\u0441\u0438\u043D\u043A\u0438", nameEn: "Helsinki" },
{ countryCode: "FI", name: "\u042D\u0441\u043F\u043E\u043E", nameEn: "Espoo" },
{ countryCode: "FI", name: "\u0422\u0430\u043C\u043F\u0435\u0440\u0435", nameEn: "Tampere" },
// Швеция
{ countryCode: "SE", name: "\u0421\u0442\u043E\u043A\u0433\u043E\u043B\u044C\u043C", nameEn: "Stockholm" },
{ countryCode: "SE", name: "\u0413\u0451\u0442\u0435\u0431\u043E\u0440\u0433", nameEn: "Gothenburg" },
{ countryCode: "SE", name: "\u041C\u0430\u043B\u044C\u043C\u0451", nameEn: "Malm\xF6" },
// Норвегия
{ countryCode: "NO", name: "\u041E\u0441\u043B\u043E", nameEn: "Oslo" },
{ countryCode: "NO", name: "\u0411\u0435\u0440\u0433\u0435\u043D", nameEn: "Bergen" },
// Дания
{ countryCode: "DK", name: "\u041A\u043E\u043F\u0435\u043D\u0433\u0430\u0433\u0435\u043D", nameEn: "Copenhagen" },
{ countryCode: "DK", name: "\u041E\u0440\u0445\u0443\u0441", nameEn: "Aarhus" },
// Великобритания
{ countryCode: "GB", name: "\u041B\u043E\u043D\u0434\u043E\u043D", nameEn: "London" },
{ countryCode: "GB", name: "\u041C\u0430\u043D\u0447\u0435\u0441\u0442\u0435\u0440", nameEn: "Manchester" },
{ countryCode: "GB", name: "\u0413\u043B\u0430\u0437\u0433\u043E", nameEn: "Glasgow" },
{ countryCode: "GB", name: "\u041A\u0430\u0440\u0434\u0438\u0444\u0444", nameEn: "Cardiff" },
// Франция
{ countryCode: "FR", name: "\u041F\u0430\u0440\u0438\u0436", nameEn: "Paris" },
{ countryCode: "FR", name: "\u041C\u0430\u0440\u0441\u0435\u043B\u044C", nameEn: "Marseille" },
{ countryCode: "FR", name: "\u041B\u0438\u043E\u043D", nameEn: "Lyon" },
{ countryCode: "FR", name: "\u0421\u0442\u0440\u0430\u0441\u0431\u0443\u0440\u0433", nameEn: "Strasbourg" },
// Испания
{ countryCode: "ES", name: "\u041C\u0430\u0434\u0440\u0438\u0434", nameEn: "Madrid" },
{ countryCode: "ES", name: "\u0411\u0430\u0440\u0441\u0435\u043B\u043E\u043D\u0430", nameEn: "Barcelona" },
{ countryCode: "ES", name: "\u0412\u0430\u043B\u0435\u043D\u0441\u0438\u044F", nameEn: "Valencia" },
// Италия
{ countryCode: "IT", name: "\u0420\u0438\u043C", nameEn: "Rome" },
{ countryCode: "IT", name: "\u041C\u0438\u043B\u0430\u043D", nameEn: "Milan" },
{ countryCode: "IT", name: "\u0422\u0443\u0440\u0438\u043D", nameEn: "Turin" },
// Чехия
{ countryCode: "CZ", name: "\u041F\u0440\u0430\u0433\u0430", nameEn: "Prague" },
{ countryCode: "CZ", name: "\u0411\u0440\u043D\u043E", nameEn: "Brno" },
// Польша
{ countryCode: "PL", name: "\u0412\u0430\u0440\u0448\u0430\u0432\u0430", nameEn: "Warsaw" },
{ countryCode: "PL", name: "\u041A\u0440\u0430\u043A\u043E\u0432", nameEn: "Krak\xF3w" },
{ countryCode: "PL", name: "\u0412\u0440\u043E\u0446\u043B\u0430\u0432", nameEn: "Wroc\u0142aw" },
{ countryCode: "PL", name: "\u0413\u0434\u0430\u043D\u044C\u0441\u043A", nameEn: "Gda\u0144sk" },
// Швейцария
{ countryCode: "CH", name: "\u0426\u044E\u0440\u0438\u0445", nameEn: "Zurich" },
{ countryCode: "CH", name: "\u0416\u0435\u043D\u0435\u0432\u0430", nameEn: "Geneva" },
{ countryCode: "CH", name: "\u0411\u0430\u0437\u0435\u043B\u044C", nameEn: "Basel" },
// Бельгия
{ countryCode: "BE", name: "\u0411\u0440\u044E\u0441\u0441\u0435\u043B\u044C", nameEn: "Brussels" },
{ countryCode: "BE", name: "\u0410\u043D\u0442\u0432\u0435\u0440\u043F\u0435\u043D", nameEn: "Antwerp" },
// Австрия
{ countryCode: "AT", name: "\u0412\u0435\u043D\u0430", nameEn: "Vienna" },
{ countryCode: "AT", name: "\u0413\u0440\u0430\u0446", nameEn: "Graz" },
// Латвия
{ countryCode: "LV", name: "\u0420\u0438\u0433\u0430", nameEn: "Riga" },
// Литва
{ countryCode: "LT", name: "\u0412\u0438\u043B\u044C\u043D\u044E\u0441", nameEn: "Vilnius" },
{ countryCode: "LT", name: "\u041A\u0430\u0443\u043D\u0430\u0441", nameEn: "Kaunas" },
// Эстония
{ countryCode: "EE", name: "\u0422\u0430\u043B\u043B\u0438\u043D", nameEn: "Tallinn" },
// Украина
{ countryCode: "UA", name: "\u041A\u0438\u0435\u0432", nameEn: "Kyiv" },
{ countryCode: "UA", name: "\u0425\u0430\u0440\u044C\u043A\u043E\u0432", nameEn: "Kharkiv" },
{ countryCode: "UA", name: "\u041E\u0434\u0435\u0441\u0441\u0430", nameEn: "Odesa" },
{ countryCode: "UA", name: "\u041B\u044C\u0432\u043E\u0432", nameEn: "Lviv" },
// Казахстан
{ countryCode: "KZ", name: "\u0410\u043B\u043C\u0430\u0442\u044B", nameEn: "Almaty" },
{ countryCode: "KZ", name: "\u0410\u0441\u0442\u0430\u043D\u0430", nameEn: "Astana" },
// Беларусь
{ countryCode: "BY", name: "\u041C\u0438\u043D\u0441\u043A", nameEn: "Minsk" },
// США
{ countryCode: "US", name: "\u041D\u044C\u044E-\u0419\u043E\u0440\u043A", nameEn: "New York" },
{ countryCode: "US", name: "\u041B\u043E\u0441-\u0410\u043D\u0434\u0436\u0435\u043B\u0435\u0441", nameEn: "Los Angeles" },
{ countryCode: "US", name: "\u0427\u0438\u043A\u0430\u0433\u043E", nameEn: "Chicago" },
{ countryCode: "US", name: "\u0425\u044C\u044E\u0441\u0442\u043E\u043D", nameEn: "Houston" },
{ countryCode: "US", name: "\u0414\u0430\u043B\u043B\u0430\u0441", nameEn: "Dallas" },
{ countryCode: "US", name: "\u041C\u0430\u0439\u0430\u043C\u0438", nameEn: "Miami" },
{ countryCode: "US", name: "\u0421\u0438\u044D\u0442\u043B", nameEn: "Seattle" },
{ countryCode: "US", name: "\u0421\u0430\u043D-\u0424\u0440\u0430\u043D\u0446\u0438\u0441\u043A\u043E", nameEn: "San Francisco" },
{ countryCode: "US", name: "\u0412\u0430\u0448\u0438\u043D\u0433\u0442\u043E\u043D", nameEn: "Washington" },
{ countryCode: "US", name: "\u0410\u0442\u043B\u0430\u043D\u0442\u0430", nameEn: "Atlanta" },
{ countryCode: "US", name: "\u0414\u0435\u043D\u0432\u0435\u0440", nameEn: "Denver" },
// Канада
{ countryCode: "CA", name: "\u0422\u043E\u0440\u043E\u043D\u0442\u043E", nameEn: "Toronto" },
{ countryCode: "CA", name: "\u041C\u043E\u043D\u0440\u0435\u0430\u043B\u044C", nameEn: "Montreal" },
{ countryCode: "CA", name: "\u0412\u0430\u043D\u043A\u0443\u0432\u0435\u0440", nameEn: "Vancouver" },
// Сингапур
{ countryCode: "SG", name: "\u0421\u0438\u043D\u0433\u0430\u043F\u0443\u0440", nameEn: "Singapore" },
// Япония
{ countryCode: "JP", name: "\u0422\u043E\u043A\u0438\u043E", nameEn: "Tokyo" },
{ countryCode: "JP", name: "\u041E\u0441\u0430\u043A\u0430", nameEn: "Osaka" },
// Южная Корея
{ countryCode: "KR", name: "\u0421\u0435\u0443\u043B", nameEn: "Seoul" },
// Китай
{ countryCode: "CN", name: "\u041F\u0435\u043A\u0438\u043D", nameEn: "Beijing" },
{ countryCode: "CN", name: "\u0428\u0430\u043D\u0445\u0430\u0439", nameEn: "Shanghai" },
{ countryCode: "CN", name: "\u0413\u0443\u0430\u043D\u0447\u0436\u043E\u0443", nameEn: "Guangzhou" },
{ countryCode: "CN", name: "\u0428\u044D\u043D\u044C\u0447\u0436\u044D\u043D\u044C", nameEn: "Shenzhen" },
{ countryCode: "CN", name: "\u0413\u043E\u043D\u043A\u043E\u043D\u0433", nameEn: "Hong Kong" },
// Индия
{ countryCode: "IN", name: "\u041C\u0443\u043C\u0431\u0430\u0438", nameEn: "Mumbai" },
{ countryCode: "IN", name: "\u0414\u0435\u043B\u0438", nameEn: "Delhi" },
{ countryCode: "IN", name: "\u0411\u0430\u043D\u0433\u0430\u043B\u043E\u0440", nameEn: "Bangalore" },
{ countryCode: "IN", name: "\u0427\u0435\u043D\u043D\u0430\u0438", nameEn: "Chennai" },
// ОАЭ
{ countryCode: "AE", name: "\u0414\u0443\u0431\u0430\u0439", nameEn: "Dubai" },
{ countryCode: "AE", name: "\u0410\u0431\u0443-\u0414\u0430\u0431\u0438", nameEn: "Abu Dhabi" },
// Турция
{ countryCode: "TR", name: "\u0421\u0442\u0430\u043C\u0431\u0443\u043B", nameEn: "Istanbul" },
{ countryCode: "TR", name: "\u0410\u043D\u043A\u0430\u0440\u0430", nameEn: "Ankara" },
{ countryCode: "TR", name: "\u0418\u0437\u043C\u0438\u0440", nameEn: "Izmir" },
// Бразилия
{ countryCode: "BR", name: "\u0421\u0430\u043D-\u041F\u0430\u0443\u043B\u0443", nameEn: "S\xE3o Paulo" },
{ countryCode: "BR", name: "\u0420\u0438\u043E-\u0434\u0435-\u0416\u0430\u043D\u0435\u0439\u0440\u043E", nameEn: "Rio de Janeiro" },
// Австралия
{ countryCode: "AU", name: "\u0421\u0438\u0434\u043D\u0435\u0439", nameEn: "Sydney" },
{ countryCode: "AU", name: "\u041C\u0435\u043B\u044C\u0431\u0443\u0440\u043D", nameEn: "Melbourne" },
// Болгария
{ countryCode: "BG", name: "\u0421\u043E\u0444\u0438\u044F", nameEn: "Sofia" },
// Румыния
{ countryCode: "RO", name: "\u0411\u0443\u0445\u0430\u0440\u0435\u0441\u0442", nameEn: "Bucharest" },
// Венгрия
{ countryCode: "HU", name: "\u0411\u0443\u0434\u0430\u043F\u0435\u0448\u0442", nameEn: "Budapest" },
// Греция
{ countryCode: "GR", name: "\u0410\u0444\u0438\u043D\u044B", nameEn: "Athens" },
// Португалия
{ countryCode: "PT", name: "\u041B\u0438\u0441\u0441\u0430\u0431\u043E\u043D", nameEn: "Lisbon" },
// Ирландия
{ countryCode: "IE", name: "\u0414\u0443\u0431\u043B\u0438\u043D", nameEn: "Dublin" },
// Израиль
{ countryCode: "IL", name: "\u0422\u0435\u043B\u044C-\u0410\u0432\u0438\u0432", nameEn: "Tel Aviv" }
];
var CITY_BY_NAME_RU = Object.fromEntries(
CITIES.map((c) => [c.name.toLowerCase(), c])
);
function resolveCityRef(cityName) {
return CITY_BY_NAME_RU[cityName.trim().toLowerCase()];
}
function resolveCountryForCity(cityName) {
const city = resolveCityRef(cityName);
return city ? COUNTRY_BY_CODE[city.countryCode] : void 0;
}
function cityMatchesCountry(cityName, countryName, rows) {
const countryCode = COUNTRY_BY_NAME_RU[countryName.trim().toLowerCase()]?.code;
if (!countryCode) return true;
const catalog = resolveCityRef(cityName);
if (catalog) return catalog.countryCode === countryCode;
return (rows ?? []).some(
(row) => row.city?.trim().toLowerCase() === cityName.trim().toLowerCase() && COUNTRY_BY_NAME_RU[(row.country || "").trim().toLowerCase()]?.code === countryCode
);
}
function buildCityOptions(rows, countryName, options) {
const includeCatalog = options?.includeCatalog ?? true;
const names = /* @__PURE__ */ new Set();
const countryCode = countryName?.trim() ? COUNTRY_BY_NAME_RU[countryName.trim().toLowerCase()]?.code : void 0;
const belongsToCountry = (city, rowCountry) => {
if (!countryCode) return true;
const catalog = resolveCityRef(city);
if (catalog) return catalog.countryCode === countryCode;
if (rowCountry?.trim()) {
return COUNTRY_BY_NAME_RU[rowCountry.trim().toLowerCase()]?.code === countryCode;
}
return false;
};
for (const row of rows ?? []) {
const city = (row.city || "").trim();
if (!city) continue;
if (belongsToCountry(city, row.country ?? void 0)) {
names.add(city);
}
}
if (includeCatalog) {
for (const { name } of listCities(countryCode)) {
names.add(name);
}
}
return [...names].sort((a, b) => a.localeCompare(b, "ru")).map((name) => ({ value: name, label: name }));
}
function resolveCountryForCityFromRows(cityName, rows) {
const trimmed = cityName.trim();
if (!trimmed) return void 0;
const fromCatalog = resolveCountryForCity(trimmed);
if (fromCatalog) return fromCatalog.name;
const row = (rows ?? []).find(
(r) => r.city?.trim().toLowerCase() === trimmed.toLowerCase() && r.country?.trim()
);
return row?.country?.trim();
}
function listCities(countryCode) {
return CITIES.filter((c) => !countryCode || c.countryCode === countryCode).map((c) => ({
...c,
country: COUNTRY_BY_CODE[c.countryCode]
}));
}
function countriesWithCities() {
const codes = new Set(CITIES.map((c) => c.countryCode));
return COUNTRIES.filter((c) => codes.has(c.code));
}
// src/geo/loc-codes.ts
var CANONICAL_LOC_CODES = {
\u043C\u043E\u0441\u043A\u0432\u0430: "msk",
\u0444\u0440\u0430\u043D\u043A\u0444\u0443\u0440\u0442: "fra",
\u0430\u043C\u0441\u0442\u0435\u0440\u0434\u0430\u043C: "ams",
\u0445\u0435\u043B\u044C\u0441\u0438\u043D\u043A\u0438: "hel",
\u043F\u0430\u0440\u0438\u0436: "par",
"\u0441\u0430\u043D\u043A\u0442-\u043F\u0435\u0442\u0435\u0440\u0431\u0443\u0440\u0433": "led",
\u0431\u0435\u0440\u043B\u0438\u043D: "ber",
\u043C\u044E\u043D\u0445\u0435\u043D: "muc",
\u043B\u043E\u043D\u0434\u043E\u043D: "lon",
"\u043D\u044C\u044E-\u0439\u043E\u0440\u043A": "nyc",
\u0442\u043E\u043A\u0438\u043E: "tyo",
\u0441\u0438\u043D\u0433\u0430\u043F\u0443\u0440: "sin",
\u0434\u0443\u0431\u0430\u0439: "dxb",
\u0432\u0430\u0440\u0448\u0430\u0432\u0430: "waw",
\u043F\u0440\u0430\u0433\u0430: "prg",
\u0432\u0435\u043D\u0430: "vie",
\u0440\u0438\u0433\u0430: "rix",
\u0442\u0430\u043B\u043B\u0438\u043D: "tll",
\u043C\u0438\u043D\u0441\u043A: "msq",
\u043A\u0438\u0435\u0432: "iev",
\u0441\u0442\u043E\u043A\u0433\u043E\u043B\u044C\u043C: "arn",
\u043E\u0441\u043B\u043E: "osl",
\u043A\u043E\u043F\u0435\u043D\u0433\u0430\u0433\u0435\u043D: "cph",
\u0446\u044E\u0440\u0438\u0445: "zrh",
\u0431\u0440\u044E\u0441\u0441\u0435\u043B\u044C: "bru",
\u043C\u0430\u0434\u0440\u0438\u0434: "mad",
\u0431\u0430\u0440\u0441\u0435\u043B\u043E\u043D\u0430: "bcn",
\u043C\u0438\u043B\u0430\u043D: "mxp",
\u0440\u0438\u043C: "rom"
};
function locCodeForCity(city, usedCodes) {
const known = CANONICAL_LOC_CODES[city.name.trim().toLowerCase()];
if (known && !usedCodes.has(known)) return known;
if (known) {
}
const base = city.nameEn.toLowerCase().replace(/[^a-z]/g, "");
if (!base) {
return `${city.countryCode.toLowerCase()}${usedCodes.size}`;
}
for (let len = 3; len <= Math.max(3, base.length); len++) {
const candidate = base.slice(0, len);
if (!usedCodes.has(candidate)) return candidate;
}
let i = 2;
while (usedCodes.has(`${base.slice(0, 2)}${i}`)) i += 1;
return `${base.slice(0, 2)}${i}`;
}
function catalogCitiesWithLocCodes() {
const used = /* @__PURE__ */ new Set();
const ordered = [...CITIES].sort((a, b) => {
const ak = CANONICAL_LOC_CODES[a.name.toLowerCase()] ? 0 : 1;
const bk = CANONICAL_LOC_CODES[b.name.toLowerCase()] ? 0 : 1;
return ak - bk;
});
return ordered.map((city) => {
const locCode = locCodeForCity(city, used);
used.add(locCode);
return { ...city, locCode };
});
}
export {
COUNTRIES,
COUNTRY_BY_CODE,
COUNTRY_BY_NAME_RU,
CITIES,
CITY_BY_NAME_RU,
resolveCityRef,
resolveCountryForCity,
cityMatchesCountry,
buildCityOptions,
resolveCountryForCityFromRows,
listCities,
countriesWithCities,
CANONICAL_LOC_CODES,
locCodeForCity,
catalogCitiesWithLocCodes
};
+76
View File
@@ -0,0 +1,76 @@
/**
* Стандартизированный справочник стран (ISO 3166-1 alpha-2) с русскими названиями.
* Источник: общедоступный справочник. Не зависит от данных в БД vps-tracker.
*/
interface CountryRef {
/** ISO 3166-1 alpha-2 код */
code: string;
/** Русское название */
name: string;
/** Английское название */
nameEn: string;
}
declare const COUNTRIES: readonly CountryRef[];
declare const COUNTRY_BY_CODE: Record<string, CountryRef>;
declare const COUNTRY_BY_NAME_RU: Record<string, CountryRef>;
/**
* Стандартизированный справочник городов по странам.
* Используется в фильтрах VPS и форме редактирования.
* Не зависит от данных в БД vps-tracker — общедоступный статичный справочник.
*/
interface CityRef {
/** ISO 3166-1 alpha-2 код страны */
countryCode: string;
/** Русское название города */
name: string;
/** Английское название города */
nameEn: string;
}
/**
* Топ-города по странам (включая города с крупными дата-центрами).
* Список намеренно ограничен — расширяется по мере необходимости.
*/
declare const CITIES: readonly CityRef[];
declare const CITY_BY_NAME_RU: Record<string, CityRef>;
/** Найти город в справочнике по русскому названию. */
declare function resolveCityRef(cityName: string): CityRef | undefined;
/** Страна из справочника для города (если есть привязка). */
declare function resolveCountryForCity(cityName: string): CountryRef | undefined;
interface CityLocationRow {
city?: string | null;
country?: string | null;
}
/** Проверить, относится ли город к выбранной стране (справочник или данные VPS). */
declare function cityMatchesCountry(cityName: string, countryName: string, rows?: readonly CityLocationRow[]): boolean;
/** Опции городов: из VPS и опционально справочника, опционально по стране. */
declare function buildCityOptions(rows: readonly CityLocationRow[] | undefined, countryName?: string, options?: {
includeCatalog?: boolean;
}): {
value: string;
label: string;
}[];
/** Страна для города: справочник, иначе первая запись VPS с таким городом. */
declare function resolveCountryForCityFromRows(cityName: string, rows: readonly CityLocationRow[] | undefined): string | undefined;
interface CityOption extends CityRef {
country: CountryRef;
}
/** Список городов с привязкой к стране. */
declare function listCities(countryCode?: string): CityOption[];
/** Список стран, у которых есть города в справочнике. */
declare function countriesWithCities(): CountryRef[];
/**
* Канонические коды {loc} для hostname (из naming seed / docs).
* Ключ — русское имя города (lower case).
*/
declare const CANONICAL_LOC_CODES: Record<string, string>;
/** Детерминированный уникальный loc-код для города (hostname {loc}). */
declare function locCodeForCity(city: Pick<CityRef, 'name' | 'nameEn' | 'countryCode'>, usedCodes: ReadonlySet<string>): string;
/** Все города каталога с выделенными loc-кодами (без коллизий). */
declare function catalogCitiesWithLocCodes(): Array<CityRef & {
locCode: string;
}>;
export { CANONICAL_LOC_CODES, CITIES, CITY_BY_NAME_RU, COUNTRIES, COUNTRY_BY_CODE, COUNTRY_BY_NAME_RU, type CityLocationRow, type CityOption, type CityRef, type CountryRef, buildCityOptions, catalogCitiesWithLocCodes, cityMatchesCountry, countriesWithCities, listCities, locCodeForCity, resolveCityRef, resolveCountryForCity, resolveCountryForCityFromRows };
+34
View File
@@ -0,0 +1,34 @@
import {
CANONICAL_LOC_CODES,
CITIES,
CITY_BY_NAME_RU,
COUNTRIES,
COUNTRY_BY_CODE,
COUNTRY_BY_NAME_RU,
buildCityOptions,
catalogCitiesWithLocCodes,
cityMatchesCountry,
countriesWithCities,
listCities,
locCodeForCity,
resolveCityRef,
resolveCountryForCity,
resolveCountryForCityFromRows
} from "../chunk-FK3U7DSV.js";
export {
CANONICAL_LOC_CODES,
CITIES,
CITY_BY_NAME_RU,
COUNTRIES,
COUNTRY_BY_CODE,
COUNTRY_BY_NAME_RU,
buildCityOptions,
catalogCitiesWithLocCodes,
cityMatchesCountry,
countriesWithCities,
listCities,
locCodeForCity,
resolveCityRef,
resolveCountryForCity,
resolveCountryForCityFromRows
};
+1
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
export { CANONICAL_LOC_CODES, CITIES, CITY_BY_NAME_RU, COUNTRIES, COUNTRY_BY_CODE, COUNTRY_BY_NAME_RU, CityLocationRow, CityOption, CityRef, CountryRef, buildCityOptions, catalogCitiesWithLocCodes, cityMatchesCountry, countriesWithCities, listCities, locCodeForCity, resolveCityRef, resolveCountryForCity, resolveCountryForCityFromRows } from './geo/index.js';
declare const appSwitcherIconSchema: z.ZodEnum<{
server: "server";
+33
View File
@@ -1,3 +1,21 @@
import {
CANONICAL_LOC_CODES,
CITIES,
CITY_BY_NAME_RU,
COUNTRIES,
COUNTRY_BY_CODE,
COUNTRY_BY_NAME_RU,
buildCityOptions,
catalogCitiesWithLocCodes,
cityMatchesCountry,
countriesWithCities,
listCities,
locCodeForCity,
resolveCityRef,
resolveCountryForCity,
resolveCountryForCityFromRows
} from "./chunk-FK3U7DSV.js";
// src/app-switcher.ts
import { z } from "zod";
var appSwitcherIconSchema = z.enum([
@@ -265,6 +283,12 @@ var ValidationError = class extends Error {
}
};
export {
CANONICAL_LOC_CODES,
CITIES,
CITY_BY_NAME_RU,
COUNTRIES,
COUNTRY_BY_CODE,
COUNTRY_BY_NAME_RU,
ValidationError,
addressFamilySchema,
aliasCreateSchema,
@@ -279,10 +303,16 @@ export {
appSwitcherEntrySchema,
appSwitcherIconSchema,
bindExportSchema,
buildCityOptions,
catalogCitiesWithLocCodes,
cfDnsRecordSchema,
cfZoneSchema,
cityMatchesCountry,
countriesWithCities,
createDnsRecordPayloadSchema,
dashboardStatsSchema,
listCities,
locCodeForCity,
locationSchema,
loginSchema,
nodeAddressSchema,
@@ -292,6 +322,9 @@ export {
nodeSchema,
orphanIgnoreSchema,
patchDnsRecordPayloadSchema,
resolveCityRef,
resolveCountryForCity,
resolveCountryForCityFromRows,
syncApplySchema,
syncDiffOpSchema,
syncJobSchema,
+6 -1
View File
@@ -12,10 +12,15 @@
"types": "./dist/index.d.ts",
"development": "./src/index.ts",
"import": "./dist/index.js"
},
"./geo": {
"types": "./dist/geo/index.d.ts",
"development": "./src/geo/index.ts",
"import": "./dist/geo/index.js"
}
},
"scripts": {
"build": "tsup src/index.ts --format esm --dts",
"build": "tsup src/index.ts src/geo/index.ts --format esm --dts",
"dev": "tsup src/index.ts --format esm --dts --watch",
"test": "vitest run --passWithNoTests"
},
+294
View File
@@ -0,0 +1,294 @@
/**
* Стандартизированный справочник городов по странам.
* Используется в фильтрах VPS и форме редактирования.
* Не зависит от данных в БД vps-tracker — общедоступный статичный справочник.
*/
import { COUNTRY_BY_CODE, COUNTRY_BY_NAME_RU, COUNTRIES, type CountryRef } from './countries.js'
export interface CityRef {
/** ISO 3166-1 alpha-2 код страны */
countryCode: string
/** Русское название города */
name: string
/** Английское название города */
nameEn: string
}
/**
* Топ-города по странам (включая города с крупными дата-центрами).
* Список намеренно ограничен — расширяется по мере необходимости.
*/
export const CITIES: readonly CityRef[] = [
// Россия
{ countryCode: 'RU', name: 'Москва', nameEn: 'Moscow' },
{ countryCode: 'RU', name: 'Санкт-Петербург', nameEn: 'Saint Petersburg' },
{ countryCode: 'RU', name: 'Новосибирск', nameEn: 'Novosibirsk' },
{ countryCode: 'RU', name: 'Екатеринбург', nameEn: 'Yekaterinburg' },
{ countryCode: 'RU', name: 'Казань', nameEn: 'Kazan' },
{ countryCode: 'RU', name: 'Нижний Новгород', nameEn: 'Nizhny Novgorod' },
{ countryCode: 'RU', name: 'Самара', nameEn: 'Samara' },
{ countryCode: 'RU', name: 'Ростов-на-Дону', nameEn: 'Rostov-on-Don' },
{ countryCode: 'RU', name: 'Уфа', nameEn: 'Ufa' },
{ countryCode: 'RU', name: 'Краснодар', nameEn: 'Krasnodar' },
{ countryCode: 'RU', name: 'Сочи', nameEn: 'Sochi' },
{ countryCode: 'RU', name: 'Владивосток', nameEn: 'Vladivostok' },
{ countryCode: 'RU', name: 'Иркутск', nameEn: 'Irkutsk' },
{ countryCode: 'RU', name: 'Хабаровск', nameEn: 'Khabarovsk' },
{ countryCode: 'RU', name: 'Тюмень', nameEn: 'Tyumen' },
// Нидерланды
{ countryCode: 'NL', name: 'Амстердам', nameEn: 'Amsterdam' },
{ countryCode: 'NL', name: 'Роттердам', nameEn: 'Rotterdam' },
{ countryCode: 'NL', name: 'Гаага', nameEn: 'The Hague' },
// Германия
{ countryCode: 'DE', name: 'Франкфурт', nameEn: 'Frankfurt' },
{ countryCode: 'DE', name: 'Берлин', nameEn: 'Berlin' },
{ countryCode: 'DE', name: 'Мюнхен', nameEn: 'Munich' },
{ countryCode: 'DE', name: 'Гамбург', nameEn: 'Hamburg' },
{ countryCode: 'DE', name: 'Кёльн', nameEn: 'Cologne' },
{ countryCode: 'DE', name: 'Дюссельдорф', nameEn: 'Düsseldorf' },
{ countryCode: 'DE', name: 'Штутгарт', nameEn: 'Stuttgart' },
// Финляндия
{ countryCode: 'FI', name: 'Хельсинки', nameEn: 'Helsinki' },
{ countryCode: 'FI', name: 'Эспоо', nameEn: 'Espoo' },
{ countryCode: 'FI', name: 'Тампере', nameEn: 'Tampere' },
// Швеция
{ countryCode: 'SE', name: 'Стокгольм', nameEn: 'Stockholm' },
{ countryCode: 'SE', name: 'Гётеборг', nameEn: 'Gothenburg' },
{ countryCode: 'SE', name: 'Мальмё', nameEn: 'Malmö' },
// Норвегия
{ countryCode: 'NO', name: 'Осло', nameEn: 'Oslo' },
{ countryCode: 'NO', name: 'Берген', nameEn: 'Bergen' },
// Дания
{ countryCode: 'DK', name: 'Копенгаген', nameEn: 'Copenhagen' },
{ countryCode: 'DK', name: 'Орхус', nameEn: 'Aarhus' },
// Великобритания
{ countryCode: 'GB', name: 'Лондон', nameEn: 'London' },
{ countryCode: 'GB', name: 'Манчестер', nameEn: 'Manchester' },
{ countryCode: 'GB', name: 'Глазго', nameEn: 'Glasgow' },
{ countryCode: 'GB', name: 'Кардифф', nameEn: 'Cardiff' },
// Франция
{ countryCode: 'FR', name: 'Париж', nameEn: 'Paris' },
{ countryCode: 'FR', name: 'Марсель', nameEn: 'Marseille' },
{ countryCode: 'FR', name: 'Лион', nameEn: 'Lyon' },
{ countryCode: 'FR', name: 'Страсбург', nameEn: 'Strasbourg' },
// Испания
{ countryCode: 'ES', name: 'Мадрид', nameEn: 'Madrid' },
{ countryCode: 'ES', name: 'Барселона', nameEn: 'Barcelona' },
{ countryCode: 'ES', name: 'Валенсия', nameEn: 'Valencia' },
// Италия
{ countryCode: 'IT', name: 'Рим', nameEn: 'Rome' },
{ countryCode: 'IT', name: 'Милан', nameEn: 'Milan' },
{ countryCode: 'IT', name: 'Турин', nameEn: 'Turin' },
// Чехия
{ countryCode: 'CZ', name: 'Прага', nameEn: 'Prague' },
{ countryCode: 'CZ', name: 'Брно', nameEn: 'Brno' },
// Польша
{ countryCode: 'PL', name: 'Варшава', nameEn: 'Warsaw' },
{ countryCode: 'PL', name: 'Краков', nameEn: 'Kraków' },
{ countryCode: 'PL', name: 'Вроцлав', nameEn: 'Wrocław' },
{ countryCode: 'PL', name: 'Гданьск', nameEn: 'Gdańsk' },
// Швейцария
{ countryCode: 'CH', name: 'Цюрих', nameEn: 'Zurich' },
{ countryCode: 'CH', name: 'Женева', nameEn: 'Geneva' },
{ countryCode: 'CH', name: 'Базель', nameEn: 'Basel' },
// Бельгия
{ countryCode: 'BE', name: 'Брюссель', nameEn: 'Brussels' },
{ countryCode: 'BE', name: 'Антверпен', nameEn: 'Antwerp' },
// Австрия
{ countryCode: 'AT', name: 'Вена', nameEn: 'Vienna' },
{ countryCode: 'AT', name: 'Грац', nameEn: 'Graz' },
// Латвия
{ countryCode: 'LV', name: 'Рига', nameEn: 'Riga' },
// Литва
{ countryCode: 'LT', name: 'Вильнюс', nameEn: 'Vilnius' },
{ countryCode: 'LT', name: 'Каунас', nameEn: 'Kaunas' },
// Эстония
{ countryCode: 'EE', name: 'Таллин', nameEn: 'Tallinn' },
// Украина
{ countryCode: 'UA', name: 'Киев', nameEn: 'Kyiv' },
{ countryCode: 'UA', name: 'Харьков', nameEn: 'Kharkiv' },
{ countryCode: 'UA', name: 'Одесса', nameEn: 'Odesa' },
{ countryCode: 'UA', name: 'Львов', nameEn: 'Lviv' },
// Казахстан
{ countryCode: 'KZ', name: 'Алматы', nameEn: 'Almaty' },
{ countryCode: 'KZ', name: 'Астана', nameEn: 'Astana' },
// Беларусь
{ countryCode: 'BY', name: 'Минск', nameEn: 'Minsk' },
// США
{ countryCode: 'US', name: 'Нью-Йорк', nameEn: 'New York' },
{ countryCode: 'US', name: 'Лос-Анджелес', nameEn: 'Los Angeles' },
{ countryCode: 'US', name: 'Чикаго', nameEn: 'Chicago' },
{ countryCode: 'US', name: 'Хьюстон', nameEn: 'Houston' },
{ countryCode: 'US', name: 'Даллас', nameEn: 'Dallas' },
{ countryCode: 'US', name: 'Майами', nameEn: 'Miami' },
{ countryCode: 'US', name: 'Сиэтл', nameEn: 'Seattle' },
{ countryCode: 'US', name: 'Сан-Франциско', nameEn: 'San Francisco' },
{ countryCode: 'US', name: 'Вашингтон', nameEn: 'Washington' },
{ countryCode: 'US', name: 'Атланта', nameEn: 'Atlanta' },
{ countryCode: 'US', name: 'Денвер', nameEn: 'Denver' },
// Канада
{ countryCode: 'CA', name: 'Торонто', nameEn: 'Toronto' },
{ countryCode: 'CA', name: 'Монреаль', nameEn: 'Montreal' },
{ countryCode: 'CA', name: 'Ванкувер', nameEn: 'Vancouver' },
// Сингапур
{ countryCode: 'SG', name: 'Сингапур', nameEn: 'Singapore' },
// Япония
{ countryCode: 'JP', name: 'Токио', nameEn: 'Tokyo' },
{ countryCode: 'JP', name: 'Осака', nameEn: 'Osaka' },
// Южная Корея
{ countryCode: 'KR', name: 'Сеул', nameEn: 'Seoul' },
// Китай
{ countryCode: 'CN', name: 'Пекин', nameEn: 'Beijing' },
{ countryCode: 'CN', name: 'Шанхай', nameEn: 'Shanghai' },
{ countryCode: 'CN', name: 'Гуанчжоу', nameEn: 'Guangzhou' },
{ countryCode: 'CN', name: 'Шэньчжэнь', nameEn: 'Shenzhen' },
{ countryCode: 'CN', name: 'Гонконг', nameEn: 'Hong Kong' },
// Индия
{ countryCode: 'IN', name: 'Мумбаи', nameEn: 'Mumbai' },
{ countryCode: 'IN', name: 'Дели', nameEn: 'Delhi' },
{ countryCode: 'IN', name: 'Бангалор', nameEn: 'Bangalore' },
{ countryCode: 'IN', name: 'Ченнаи', nameEn: 'Chennai' },
// ОАЭ
{ countryCode: 'AE', name: 'Дубай', nameEn: 'Dubai' },
{ countryCode: 'AE', name: 'Абу-Даби', nameEn: 'Abu Dhabi' },
// Турция
{ countryCode: 'TR', name: 'Стамбул', nameEn: 'Istanbul' },
{ countryCode: 'TR', name: 'Анкара', nameEn: 'Ankara' },
{ countryCode: 'TR', name: 'Измир', nameEn: 'Izmir' },
// Бразилия
{ countryCode: 'BR', name: 'Сан-Паулу', nameEn: 'São Paulo' },
{ countryCode: 'BR', name: 'Рио-де-Жанейро', nameEn: 'Rio de Janeiro' },
// Австралия
{ countryCode: 'AU', name: 'Сидней', nameEn: 'Sydney' },
{ countryCode: 'AU', name: 'Мельбурн', nameEn: 'Melbourne' },
// Болгария
{ countryCode: 'BG', name: 'София', nameEn: 'Sofia' },
// Румыния
{ countryCode: 'RO', name: 'Бухарест', nameEn: 'Bucharest' },
// Венгрия
{ countryCode: 'HU', name: 'Будапешт', nameEn: 'Budapest' },
// Греция
{ countryCode: 'GR', name: 'Афины', nameEn: 'Athens' },
// Португалия
{ countryCode: 'PT', name: 'Лиссабон', nameEn: 'Lisbon' },
// Ирландия
{ countryCode: 'IE', name: 'Дублин', nameEn: 'Dublin' },
// Израиль
{ countryCode: 'IL', name: 'Тель-Авив', nameEn: 'Tel Aviv' },
]
export const CITY_BY_NAME_RU: Record<string, CityRef> = Object.fromEntries(
CITIES.map((c) => [c.name.toLowerCase(), c]),
)
/** Найти город в справочнике по русскому названию. */
export function resolveCityRef(cityName: string): CityRef | undefined {
return CITY_BY_NAME_RU[cityName.trim().toLowerCase()]
}
/** Страна из справочника для города (если есть привязка). */
export function resolveCountryForCity(cityName: string): CountryRef | undefined {
const city = resolveCityRef(cityName)
return city ? COUNTRY_BY_CODE[city.countryCode] : undefined
}
export interface CityLocationRow {
city?: string | null
country?: string | null
}
/** Проверить, относится ли город к выбранной стране (справочник или данные VPS). */
export function cityMatchesCountry(
cityName: string,
countryName: string,
rows?: readonly CityLocationRow[],
): boolean {
const countryCode = COUNTRY_BY_NAME_RU[countryName.trim().toLowerCase()]?.code
if (!countryCode) return true
const catalog = resolveCityRef(cityName)
if (catalog) return catalog.countryCode === countryCode
return (rows ?? []).some(
(row) =>
row.city?.trim().toLowerCase() === cityName.trim().toLowerCase() &&
COUNTRY_BY_NAME_RU[(row.country || '').trim().toLowerCase()]?.code === countryCode,
)
}
/** Опции городов: из VPS и опционально справочника, опционально по стране. */
export function buildCityOptions(
rows: readonly CityLocationRow[] | undefined,
countryName?: string,
options?: { includeCatalog?: boolean },
): { value: string; label: string }[] {
const includeCatalog = options?.includeCatalog ?? true
const names = new Set<string>()
const countryCode = countryName?.trim()
? COUNTRY_BY_NAME_RU[countryName.trim().toLowerCase()]?.code
: undefined
const belongsToCountry = (city: string, rowCountry?: string): boolean => {
if (!countryCode) return true
const catalog = resolveCityRef(city)
if (catalog) return catalog.countryCode === countryCode
if (rowCountry?.trim()) {
return COUNTRY_BY_NAME_RU[rowCountry.trim().toLowerCase()]?.code === countryCode
}
return false
}
for (const row of rows ?? []) {
const city = (row.city || '').trim()
if (!city) continue
if (belongsToCountry(city, row.country ?? undefined)) {
names.add(city)
}
}
if (includeCatalog) {
for (const { name } of listCities(countryCode)) {
names.add(name)
}
}
return [...names]
.sort((a, b) => a.localeCompare(b, 'ru'))
.map((name) => ({ value: name, label: name }))
}
/** Страна для города: справочник, иначе первая запись VPS с таким городом. */
export function resolveCountryForCityFromRows(
cityName: string,
rows: readonly CityLocationRow[] | undefined,
): string | undefined {
const trimmed = cityName.trim()
if (!trimmed) return undefined
const fromCatalog = resolveCountryForCity(trimmed)
if (fromCatalog) return fromCatalog.name
const row = (rows ?? []).find(
(r) => r.city?.trim().toLowerCase() === trimmed.toLowerCase() && r.country?.trim(),
)
return row?.country?.trim()
}
export interface CityOption extends CityRef {
country: CountryRef
}
/** Список городов с привязкой к стране. */
export function listCities(countryCode?: string): CityOption[] {
return CITIES.filter((c) => !countryCode || c.countryCode === countryCode).map((c) => ({
...c,
country: COUNTRY_BY_CODE[c.countryCode],
}))
}
/** Список стран, у которых есть города в справочнике. */
export function countriesWithCities(): CountryRef[] {
const codes = new Set(CITIES.map((c) => c.countryCode))
return COUNTRIES.filter((c) => codes.has(c.code))
}
+175
View File
@@ -0,0 +1,175 @@
/**
* Стандартизированный справочник стран (ISO 3166-1 alpha-2) с русскими названиями.
* Источник: общедоступный справочник. Не зависит от данных в БД vps-tracker.
*/
export interface CountryRef {
/** ISO 3166-1 alpha-2 код */
code: string
/** Русское название */
name: string
/** Английское название */
nameEn: string
}
export const COUNTRIES: readonly CountryRef[] = [
{ code: 'AU', name: 'Австралия', nameEn: 'Australia' },
{ code: 'AT', name: 'Австрия', nameEn: 'Austria' },
{ code: 'AZ', name: 'Азербайджан', nameEn: 'Azerbaijan' },
{ code: 'AL', name: 'Албания', nameEn: 'Albania' },
{ code: 'DZ', name: 'Алжир', nameEn: 'Algeria' },
{ code: 'AO', name: 'Ангола', nameEn: 'Angola' },
{ code: 'AR', name: 'Аргентина', nameEn: 'Argentina' },
{ code: 'AM', name: 'Армения', nameEn: 'Armenia' },
{ code: 'AF', name: 'Афганистан', nameEn: 'Afghanistan' },
{ code: 'BE', name: 'Бельгия', nameEn: 'Belgium' },
{ code: 'BG', name: 'Болгария', nameEn: 'Bulgaria' },
{ code: 'BO', name: 'Боливия', nameEn: 'Bolivia' },
{ code: 'BA', name: 'Босния и Герцеговина', nameEn: 'Bosnia and Herzegovina' },
{ code: 'BR', name: 'Бразилия', nameEn: 'Brazil' },
{ code: 'GB', name: 'Великобритания', nameEn: 'United Kingdom' },
{ code: 'HU', name: 'Венгрия', nameEn: 'Hungary' },
{ code: 'VE', name: 'Венесуэла', nameEn: 'Venezuela' },
{ code: 'VN', name: 'Вьетнам', nameEn: 'Vietnam' },
{ code: 'GA', name: 'Габон', nameEn: 'Gabon' },
{ code: 'HT', name: 'Гаити', nameEn: 'Haiti' },
{ code: 'GY', name: 'Гайана', nameEn: 'Guyana' },
{ code: 'GM', name: 'Гамбия', nameEn: 'Gambia' },
{ code: 'GH', name: 'Гана', nameEn: 'Ghana' },
{ code: 'GT', name: 'Гватемала', nameEn: 'Guatemala' },
{ code: 'GN', name: 'Гвинея', nameEn: 'Guinea' },
{ code: 'DE', name: 'Германия', nameEn: 'Germany' },
{ code: 'HN', name: 'Гондурас', nameEn: 'Honduras' },
{ code: 'GR', name: 'Греция', nameEn: 'Greece' },
{ code: 'GE', name: 'Грузия', nameEn: 'Georgia' },
{ code: 'DK', name: 'Дания', nameEn: 'Denmark' },
{ code: 'CD', name: 'ДР Конго', nameEn: 'DR Congo' },
{ code: 'EG', name: 'Египет', nameEn: 'Egypt' },
{ code: 'ZM', name: 'Замбия', nameEn: 'Zambia' },
{ code: 'ZW', name: 'Зимбабве', nameEn: 'Zimbabwe' },
{ code: 'IL', name: 'Израиль', nameEn: 'Israel' },
{ code: 'IN', name: 'Индия', nameEn: 'India' },
{ code: 'ID', name: 'Индонезия', nameEn: 'Indonesia' },
{ code: 'JO', name: 'Иордания', nameEn: 'Jordan' },
{ code: 'IQ', name: 'Ирак', nameEn: 'Iraq' },
{ code: 'IR', name: 'Иран', nameEn: 'Iran' },
{ code: 'IE', name: 'Ирландия', nameEn: 'Ireland' },
{ code: 'IS', name: 'Исландия', nameEn: 'Iceland' },
{ code: 'ES', name: 'Испания', nameEn: 'Spain' },
{ code: 'IT', name: 'Италия', nameEn: 'Italy' },
{ code: 'YE', name: 'Йемен', nameEn: 'Yemen' },
{ code: 'KZ', name: 'Казахстан', nameEn: 'Kazakhstan' },
{ code: 'KH', name: 'Камбоджа', nameEn: 'Cambodia' },
{ code: 'CM', name: 'Камерун', nameEn: 'Cameroon' },
{ code: 'CA', name: 'Канада', nameEn: 'Canada' },
{ code: 'QA', name: 'Катар', nameEn: 'Qatar' },
{ code: 'KE', name: 'Кения', nameEn: 'Kenya' },
{ code: 'CY', name: 'Кипр', nameEn: 'Cyprus' },
{ code: 'KG', name: 'Киргизия', nameEn: 'Kyrgyzstan' },
{ code: 'CN', name: 'Китай', nameEn: 'China' },
{ code: 'CO', name: 'Колумбия', nameEn: 'Colombia' },
{ code: 'CG', name: 'Конго', nameEn: 'Congo' },
{ code: 'KR', name: 'Южная Корея', nameEn: 'South Korea' },
{ code: 'KP', name: 'Северная Корея', nameEn: 'North Korea' },
{ code: 'XK', name: 'Косово', nameEn: 'Kosovo' },
{ code: 'CR', name: 'Коста-Рика', nameEn: 'Costa Rica' },
{ code: 'CI', name: "Кот-д'Ивуар", nameEn: "Côte d'Ivoire" },
{ code: 'CU', name: 'Куба', nameEn: 'Cuba' },
{ code: 'KW', name: 'Кувейт', nameEn: 'Kuwait' },
{ code: 'LA', name: 'Лаос', nameEn: 'Laos' },
{ code: 'LV', name: 'Латвия', nameEn: 'Latvia' },
{ code: 'LR', name: 'Либерия', nameEn: 'Liberia' },
{ code: 'LB', name: 'Ливан', nameEn: 'Lebanon' },
{ code: 'LY', name: 'Ливия', nameEn: 'Libya' },
{ code: 'LT', name: 'Литва', nameEn: 'Lithuania' },
{ code: 'LI', name: 'Лихтенштейн', nameEn: 'Liechtenstein' },
{ code: 'LU', name: 'Люксембург', nameEn: 'Luxembourg' },
{ code: 'MU', name: 'Маврикий', nameEn: 'Mauritius' },
{ code: 'MR', name: 'Мавритания', nameEn: 'Mauritania' },
{ code: 'MG', name: 'Мадагаскар', nameEn: 'Madagascar' },
{ code: 'MO', name: 'Макао', nameEn: 'Macao' },
{ code: 'MW', name: 'Малави', nameEn: 'Malawi' },
{ code: 'MY', name: 'Малайзия', nameEn: 'Malaysia' },
{ code: 'ML', name: 'Мали', nameEn: 'Mali' },
{ code: 'MT', name: 'Мальта', nameEn: 'Malta' },
{ code: 'MA', name: 'Марокко', nameEn: 'Morocco' },
{ code: 'MX', name: 'Мексика', nameEn: 'Mexico' },
{ code: 'MZ', name: 'Мозамбик', nameEn: 'Mozambique' },
{ code: 'MD', name: 'Молдавия', nameEn: 'Moldova' },
{ code: 'MC', name: 'Монако', nameEn: 'Monaco' },
{ code: 'MN', name: 'Монголия', nameEn: 'Mongolia' },
{ code: 'MM', name: 'Мьянма', nameEn: 'Myanmar' },
{ code: 'NA', name: 'Намибия', nameEn: 'Namibia' },
{ code: 'NP', name: 'Непал', nameEn: 'Nepal' },
{ code: 'NE', name: 'Нигер', nameEn: 'Niger' },
{ code: 'NG', name: 'Нигерия', nameEn: 'Nigeria' },
{ code: 'NL', name: 'Нидерланды', nameEn: 'Netherlands' },
{ code: 'NI', name: 'Никарагуа', nameEn: 'Nicaragua' },
{ code: 'NZ', name: 'Новая Зеландия', nameEn: 'New Zealand' },
{ code: 'NO', name: 'Норвегия', nameEn: 'Norway' },
{ code: 'AE', name: 'ОАЭ', nameEn: 'United Arab Emirates' },
{ code: 'OM', name: 'Оман', nameEn: 'Oman' },
{ code: 'PK', name: 'Пакистан', nameEn: 'Pakistan' },
{ code: 'PA', name: 'Панама', nameEn: 'Panama' },
{ code: 'PG', name: 'Папуа — Новая Гвинея', nameEn: 'Papua New Guinea' },
{ code: 'PY', name: 'Парагвай', nameEn: 'Paraguay' },
{ code: 'PE', name: 'Перу', nameEn: 'Peru' },
{ code: 'PL', name: 'Польша', nameEn: 'Poland' },
{ code: 'PT', name: 'Португалия', nameEn: 'Portugal' },
{ code: 'RU', name: 'Россия', nameEn: 'Russia' },
{ code: 'RW', name: 'Руанда', nameEn: 'Rwanda' },
{ code: 'RO', name: 'Румыния', nameEn: 'Romania' },
{ code: 'SV', name: 'Сальвадор', nameEn: 'El Salvador' },
{ code: 'SA', name: 'Саудовская Аравия', nameEn: 'Saudi Arabia' },
{ code: 'MK', name: 'Северная Македония', nameEn: 'North Macedonia' },
{ code: 'SC', name: 'Сейшелы', nameEn: 'Seychelles' },
{ code: 'SN', name: 'Сенегал', nameEn: 'Senegal' },
{ code: 'RS', name: 'Сербия', nameEn: 'Serbia' },
{ code: 'SG', name: 'Сингапур', nameEn: 'Singapore' },
{ code: 'SK', name: 'Словакия', nameEn: 'Slovakia' },
{ code: 'SI', name: 'Словения', nameEn: 'Slovenia' },
{ code: 'SO', name: 'Сомали', nameEn: 'Somalia' },
{ code: 'SD', name: 'Судан', nameEn: 'Sudan' },
{ code: 'US', name: 'США', nameEn: 'United States' },
{ code: 'TJ', name: 'Таджикистан', nameEn: 'Tajikistan' },
{ code: 'TH', name: 'Таиланд', nameEn: 'Thailand' },
{ code: 'TZ', name: 'Танзания', nameEn: 'Tanzania' },
{ code: 'TG', name: 'Того', nameEn: 'Togo' },
{ code: 'TT', name: 'Тринидад и Тобаго', nameEn: 'Trinidad and Tobago' },
{ code: 'TV', name: 'Тувалу', nameEn: 'Tuvalu' },
{ code: 'TN', name: 'Тунис', nameEn: 'Tunisia' },
{ code: 'TM', name: 'Туркмения', nameEn: 'Turkmenistan' },
{ code: 'TR', name: 'Турция', nameEn: 'Turkey' },
{ code: 'UG', name: 'Уганда', nameEn: 'Uganda' },
{ code: 'UZ', name: 'Узбекистан', nameEn: 'Uzbekistan' },
{ code: 'UA', name: 'Украина', nameEn: 'Ukraine' },
{ code: 'UY', name: 'Уругвай', nameEn: 'Uruguay' },
{ code: 'FJ', name: 'Фиджи', nameEn: 'Fiji' },
{ code: 'PH', name: 'Филиппины', nameEn: 'Philippines' },
{ code: 'FI', name: 'Финляндия', nameEn: 'Finland' },
{ code: 'FR', name: 'Франция', nameEn: 'France' },
{ code: 'HR', name: 'Хорватия', nameEn: 'Croatia' },
{ code: 'TD', name: 'Чад', nameEn: 'Chad' },
{ code: 'ME', name: 'Черногория', nameEn: 'Montenegro' },
{ code: 'CZ', name: 'Чехия', nameEn: 'Czechia' },
{ code: 'CL', name: 'Чили', nameEn: 'Chile' },
{ code: 'CH', name: 'Швейцария', nameEn: 'Switzerland' },
{ code: 'SE', name: 'Швеция', nameEn: 'Sweden' },
{ code: 'LK', name: 'Шри-Ланка', nameEn: 'Sri Lanka' },
{ code: 'EC', name: 'Эквадор', nameEn: 'Ecuador' },
{ code: 'GQ', name: 'Экваториальная Гвинея', nameEn: 'Equatorial Guinea' },
{ code: 'ER', name: 'Эритрея', nameEn: 'Eritrea' },
{ code: 'EE', name: 'Эстония', nameEn: 'Estonia' },
{ code: 'ET', name: 'Эфиопия', nameEn: 'Ethiopia' },
{ code: 'ZA', name: 'ЮАР', nameEn: 'South Africa' },
{ code: 'JM', name: 'Ямайка', nameEn: 'Jamaica' },
{ code: 'JP', name: 'Япония', nameEn: 'Japan' },
]
export const COUNTRY_BY_CODE: Record<string, CountryRef> = Object.fromEntries(
COUNTRIES.map((c) => [c.code, c]),
)
export const COUNTRY_BY_NAME_RU: Record<string, CountryRef> = Object.fromEntries(
COUNTRIES.map((c) => [c.name.toLowerCase(), c]),
)
+3
View File
@@ -0,0 +1,3 @@
export * from './countries.js'
export * from './cities.js'
export * from './loc-codes.js'
+80
View File
@@ -0,0 +1,80 @@
import { CITIES, type CityRef } from './cities.js'
/**
* Канонические коды {loc} для hostname (из naming seed / docs).
* Ключ — русское имя города (lower case).
*/
export const CANONICAL_LOC_CODES: Record<string, string> = {
москва: 'msk',
франкфурт: 'fra',
амстердам: 'ams',
хельсинки: 'hel',
париж: 'par',
'санкт-петербург': 'led',
берлин: 'ber',
мюнхен: 'muc',
лондон: 'lon',
'нью-йорк': 'nyc',
токио: 'tyo',
сингапур: 'sin',
дубай: 'dxb',
варшава: 'waw',
прага: 'prg',
вена: 'vie',
рига: 'rix',
таллин: 'tll',
минск: 'msq',
киев: 'iev',
стокгольм: 'arn',
осло: 'osl',
копенгаген: 'cph',
цюрих: 'zrh',
брюссель: 'bru',
мадрид: 'mad',
барселона: 'bcn',
милан: 'mxp',
рим: 'rom',
}
/** Детерминированный уникальный loc-код для города (hostname {loc}). */
export function locCodeForCity(
city: Pick<CityRef, 'name' | 'nameEn' | 'countryCode'>,
usedCodes: ReadonlySet<string>,
): string {
const known = CANONICAL_LOC_CODES[city.name.trim().toLowerCase()]
if (known && !usedCodes.has(known)) return known
if (known) {
// known занят другим — fallback ниже
}
const base = city.nameEn.toLowerCase().replace(/[^a-z]/g, '')
if (!base) {
return `${city.countryCode.toLowerCase()}${usedCodes.size}`
}
for (let len = 3; len <= Math.max(3, base.length); len++) {
const candidate = base.slice(0, len)
if (!usedCodes.has(candidate)) return candidate
}
let i = 2
while (usedCodes.has(`${base.slice(0, 2)}${i}`)) i += 1
return `${base.slice(0, 2)}${i}`
}
/** Все города каталога с выделенными loc-кодами (без коллизий). */
export function catalogCitiesWithLocCodes(): Array<CityRef & { locCode: string }> {
const used = new Set<string>()
// Сначала закрепить канонические, чтобы generate не занял msk раньше Москвы
const ordered = [...CITIES].sort((a, b) => {
const ak = CANONICAL_LOC_CODES[a.name.toLowerCase()] ? 0 : 1
const bk = CANONICAL_LOC_CODES[b.name.toLowerCase()] ? 0 : 1
return ak - bk
})
return ordered.map((city) => {
const locCode = locCodeForCity(city, used)
used.add(locCode)
return { ...city, locCode }
})
}
+1
View File
@@ -1,6 +1,7 @@
export * from "./app-switcher.js";
export * from "./settings.js";
export * from "./fleet.js";
export * from "./geo/index.js";
export class ValidationError extends Error {
constructor(message: string) {