fix(server): use GeoNames for reverse-geocoded country names (#30199)

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
Rufus Uttley
2026-09-28 11:06:30 +00:00
committed by GitHub
co-authored by Daniel Dietzler
parent 970c496e55
commit aa02337847
10 changed files with 129 additions and 34 deletions
+4 -4
View File
@@ -65,7 +65,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),
@@ -73,7 +73,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),
@@ -113,7 +113,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),
@@ -121,7 +121,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),
+6 -6
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
View File
@@ -529,9 +529,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)
@@ -7436,9 +7433,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==}
@@ -8651,10 +8645,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'}
@@ -20614,8 +20604,6 @@ snapshots:
asap: 2.0.6
wrappy: 1.0.2
diacritics@1.3.0: {}
didyoumean@1.2.2: {}
diff-sequences@29.6.3: {}
@@ -22341,10 +22329,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
-1
View File
@@ -82,7 +82,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",
@@ -90,6 +90,7 @@ export interface EnvData {
admin1: string;
admin2: string;
cities500: string;
countryInfo: string;
naturalEarthCountriesPath: string;
};
web: {
@@ -338,6 +339,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: {
+58 -6
View File
@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import isoCountries from 'i18n-iso-countries';
import { type Expression, type Insertable, type Kysely, type NotNull, type SqlBool, sql } from 'kysely';
import { Expression, Insertable, Kysely, NotNull, SqlBool, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
@@ -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,8 +174,7 @@ export class MapRepository {
this.logger.verboseFn(() => `Raw: ${JSON.stringify(response, null, 2)}`);
const { countryCode, name: city, admin1Name } = response;
// eslint-disable-next-line import-x/no-named-as-default-member
const country = isoCountries.getName(countryCode, 'en') ?? null;
const country = countryNames.byAlpha2.get(countryCode) ?? null;
const state = admin1Name;
return { country, state, city };
@@ -195,14 +202,59 @@ export class MapRepository {
this.logger.verboseFn(() => `Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`);
const { admin_a3 } = ne_response;
// eslint-disable-next-line import-x/no-named-as-default-member
const country = isoCountries.getName(admin_a3, 'en') ?? null;
const country = countryNames.byAlpha3.get(admin_a3) ?? null;
const state = null;
const city = null;
return { country, state, city };
}
// init() skips the geodata import when it is up to date, so load the names on demand
private async loadCountryNames(): Promise<CountryNames> {
this.countryNames ??= this.readCountryNames();
try {
return await this.countryNames;
} catch (error) {
this.countryNames = undefined;
throw error;
}
}
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)) {
throw new Error(`Geodata file ${filePath} not found`);
}
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]?.trim();
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'));
@@ -0,0 +1,56 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// (#30199) Reverse geocoding now uses the GeoNames names, so rewrite the stored ones
await sql`
WITH renames ("old", "new") AS (
VALUES
('Åland Islands', 'Aland Islands'),
('Bonaire, Sint Eustatius and Saba', 'Bonaire, Saint Eustatius and Saba'),
('Brunei Darussalam', 'Brunei'),
('Cape Verde', 'Cabo Verde'),
('Cocos (Keeling) Islands', 'Cocos Islands'),
('Cote d''Ivoire', 'Ivory Coast'),
('Cote D''Ivoire', 'Ivory Coast'),
('Curaçao', 'Curacao'),
('Czech Republic', 'Czechia'),
('Falkland Islands (Malvinas)', 'Falkland Islands'),
('Holy See (Vatican City State)', 'Vatican'),
('Islamic Republic of Iran', 'Iran'),
('Lao People''s Democratic Republic', 'Laos'),
('Micronesia, Federated States of', 'Micronesia'),
('Moldova, Republic of', 'Moldova'),
('Netherlands', 'The Netherlands'),
('People''s Republic of China', 'China'),
('Republic of The Gambia', 'Gambia'),
('Russian Federation', 'Russia'),
('Saint Barthélemy', 'Saint Barthelemy'),
('Saint Martin (French part)', 'Saint Martin'),
('Sint Maarten (Dutch part)', 'Sint Maarten'),
('State of Palestine', 'Palestinian Territory'),
('Syrian Arab Republic', 'Syria'),
('Taiwan, Province of China', 'Taiwan'),
('The Republic of North Macedonia', 'North Macedonia'),
('Timor-Leste', 'Timor Leste'),
('Türkiye', 'Turkey'),
('United Republic of Tanzania', 'Tanzania'),
('United States of America', 'United States'),
('Virgin Islands, British', 'British Virgin Islands'),
('Virgin Islands, U.S.', 'U.S. Virgin Islands')
),
exif AS (
UPDATE "asset_exif"
SET "country" = renames."new"
FROM renames
WHERE "country" = renames."old"
)
UPDATE "workflow_step"
SET "config" = jsonb_set("config", '{region,country}', to_jsonb(renames."new"))
FROM renames
WHERE "config"->'region'->>'country' = renames."old"
`.execute(db);
}
export async function down(): Promise<void> {
// Not implemented: the old names came from a removed dependency
}
+1
View File
@@ -95,3 +95,4 @@
1787148183729-ClusterGroups
1787148183730-DeleteMismatchedMemoryAssets
1789419229196-ConvertUserOAuthIdEmptyStringToNull
1790587508209-RenameGeoNamesCountries
+1 -1
View File
@@ -19,7 +19,7 @@ export class AssetExifFactory {
bitsPerSample: null,
city: 'Austin',
colorspace: null,
country: 'United States of America',
country: 'United States',
dateTimeOriginal: factory.date(),
description: '',
exifImageHeight: 420,
@@ -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: {