This commit is contained in:
Rufus Uttley 2026-08-14 09:31:12 -04:00 committed by GitHub
commit 1c6fae45de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 69 additions and 30 deletions

View file

@ -72,7 +72,7 @@ describe('/map', () => {
expect(body).toEqual([
{
city: 'Palisade',
country: 'United States of America',
country: 'United States',
id: expect.any(String),
lat: expect.closeTo(39.115),
lon: expect.closeTo(-108.400968),
@ -80,7 +80,7 @@ describe('/map', () => {
},
{
city: 'Ralston',
country: 'United States of America',
country: 'United States',
id: expect.any(String),
lat: expect.closeTo(41.2203),
lon: expect.closeTo(-96.071625),
@ -120,7 +120,7 @@ describe('/map', () => {
expect(body).toEqual([
{
city: 'Palisade',
country: 'United States of America',
country: 'United States',
id: expect.any(String),
lat: expect.closeTo(39.115),
lon: expect.closeTo(-108.400968),
@ -128,7 +128,7 @@ describe('/map', () => {
},
{
city: 'Ralston',
country: 'United States of America',
country: 'United States',
id: expect.any(String),
lat: expect.closeTo(41.2203),
lon: expect.closeTo(-96.071625),

View file

@ -491,6 +491,7 @@ describe('/search', () => {
.get('/search/suggestions?type=country&includeNull=true')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([
'China',
'Cuba',
'France',
'Georgia',
@ -498,12 +499,11 @@ describe('/search', () => {
'Ghana',
'Japan',
'Morocco',
"People's Republic of China",
'Russian Federation',
'Russia',
'Singapore',
'Spain',
'Switzerland',
'United States of America',
'United States',
null,
]);
expect(status).toBe(200);
@ -514,6 +514,7 @@ describe('/search', () => {
.get('/search/suggestions?type=country')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([
'China',
'Cuba',
'France',
'Georgia',
@ -521,12 +522,11 @@ describe('/search', () => {
'Ghana',
'Japan',
'Morocco',
"People's Republic of China",
'Russian Federation',
'Russia',
'Singapore',
'Spain',
'Switzerland',
'United States of America',
'United States',
]);
expect(status).toBe(200);
});

16
pnpm-lock.yaml generated
View file

@ -526,9 +526,6 @@ importers:
helmet:
specifier: ^8.1.0
version: 8.3.0
i18n-iso-countries:
specifier: ^7.6.0
version: 7.14.0
ioredis:
specifier: ^5.8.2
version: 5.11.1(supports-color@8.1.1)
@ -7257,9 +7254,6 @@ packages:
dezalgo@1.0.4:
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
diacritics@1.3.0:
resolution: {integrity: sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==}
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
@ -8382,10 +8376,6 @@ packages:
resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==}
engines: {node: '>=10.18'}
i18n-iso-countries@7.14.0:
resolution: {integrity: sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==}
engines: {node: '>= 12'}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@ -20171,8 +20161,6 @@ snapshots:
asap: 2.0.6
wrappy: 1.0.2
diacritics@1.3.0: {}
didyoumean@1.2.2: {}
diff-sequences@29.6.3: {}
@ -21708,10 +21696,6 @@ snapshots:
hyperdyperid@1.2.0: {}
i18n-iso-countries@7.14.0:
dependencies:
diacritics: 1.3.0
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2

View file

@ -79,7 +79,6 @@
"geo-tz": "^8.0.0",
"handlebars": "^4.7.8",
"helmet": "^8.1.0",
"i18n-iso-countries": "^7.6.0",
"ioredis": "^5.8.2",
"jose": "^6.0.0",
"js-yaml": "^5.0.0",

View file

@ -90,6 +90,7 @@ export interface EnvData {
admin1: string;
admin2: string;
cities500: string;
countryInfo: string;
naturalEarthCountriesPath: string;
};
web: {
@ -342,6 +343,7 @@ const getEnv = (): EnvData => {
admin1: join(folders.geodata, 'admin1CodesASCII.txt'),
admin2: join(folders.geodata, 'admin2Codes.txt'),
cities500: join(folders.geodata, citiesFile),
countryInfo: join(folders.geodata, 'countryInfo.txt'),
naturalEarthCountriesPath: join(folders.geodata, 'ne_10m_admin_0_countries.geojson'),
},
web: {

View file

@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common';
import { getName } from 'i18n-iso-countries';
import { Expression, Insertable, Kysely, NotNull, sql, SqlBool } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { createReadStream, existsSync } from 'node:fs';
@ -38,8 +37,15 @@ interface MapDB extends DB {
naturalearth_countries_tmp: NaturalEarthCountriesTable;
}
interface CountryNames {
byAlpha2: Map<string, string>;
byAlpha3: Map<string, string>;
}
@Injectable()
export class MapRepository {
private countryNames?: Promise<CountryNames>;
constructor(
private configRepository: ConfigRepository,
private metadataRepository: SystemMetadataRepository,
@ -148,6 +154,8 @@ export class MapRepository {
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
this.logger.debug(`Request: ${point.latitude},${point.longitude}`);
const countryNames = await this.loadCountryNames();
const response = await this.db
.selectFrom('geodata_places')
.selectAll()
@ -166,7 +174,7 @@ export class MapRepository {
this.logger.verboseFn(() => `Raw: ${JSON.stringify(response, null, 2)}`);
const { countryCode, name: city, admin1Name } = response;
const country = getName(countryCode, 'en') ?? null;
const country = countryNames.byAlpha2.get(countryCode) ?? null;
const state = admin1Name;
return { country, state, city };
@ -194,13 +202,58 @@ export class MapRepository {
this.logger.verboseFn(() => `Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`);
const { admin_a3 } = ne_response;
const country = getName(admin_a3, 'en') ?? null;
const country = countryNames.byAlpha3.get(admin_a3) ?? null;
const state = null;
const city = null;
return { country, state, city };
}
// Country names come from the GeoNames countryInfo.txt file, the same provider as the city and
// state names, giving neutral forms such as "Taiwan" and "Hong Kong" rather than the ISO 3166
// official names ("Taiwan, Province of China"). Loaded lazily and cached: init() skips the
// geodata import when it is already up to date, but reverse geocoding still needs the lookup.
private loadCountryNames(): Promise<CountryNames> {
this.countryNames ??= this.readCountryNames();
return this.countryNames;
}
private async readCountryNames(): Promise<CountryNames> {
const byAlpha2 = new Map<string, string>();
const byAlpha3 = new Map<string, string>();
const { resourcePaths } = this.configRepository.getEnv();
const filePath = resourcePaths.geodata.countryInfo;
if (!existsSync(filePath)) {
this.logger.error(`Geodata file ${filePath} not found; country names will be empty`);
return { byAlpha2, byAlpha3 };
}
const lineReader = readLine.createInterface({ input: createReadStream(filePath) });
for await (const line of lineReader) {
if (line.startsWith('#')) {
continue;
}
// Columns: ISO alpha-2, ISO alpha-3, ISO numeric, fips, Country, ...
const fields = line.split('\t', 5);
const alpha2 = fields[0];
const alpha3 = fields[1];
const name = fields[4];
if (!name) {
continue;
}
if (alpha2) {
byAlpha2.set(alpha2, name);
}
if (alpha3) {
byAlpha3.set(alpha3, name);
}
}
this.logger.log(`Loaded ${byAlpha2.size} country names from ${filePath}`);
return { byAlpha2, byAlpha3 };
}
private async importNaturalEarthCountries() {
const { resourcePaths } = this.configRepository.getEnv();
const geoJSONData = JSON.parse(await readFile(resourcePaths.geodata.naturalEarthCountriesPath, 'utf8'));

View file

@ -70,6 +70,7 @@ export const envData: EnvData = {
admin1: '/build/geodata/admin1CodesASCII.txt',
admin2: '/build/geodata/admin2Codes.txt',
cities500: '/build/geodata/cities500.txt',
countryInfo: '/build/geodata/countryInfo.txt',
naturalEarthCountriesPath: 'build/ne_10m_admin_0_countries.geojson',
},
web: {