From 0ca8ff43e985a2f349cc8c34a01a4fbff77a9f84 Mon Sep 17 00:00:00 2001 From: Rufus Uttley Date: Thu, 23 Jul 2026 23:05:22 +0100 Subject: [PATCH] fix(server): use GeoNames for reverse-geocoded country names Reverse geocoding derived the country name from i18n-iso-countries' getName(), which returns ISO 3166 official names such as "Taiwan, Province of China" and "People's Republic of China". The city and state already come from the GeoNames geodata; only the country name took a separate path through a JavaScript library. Resolve the country name from GeoNames' countryInfo.txt instead, so the whole address comes from one source. This produces neutral names ("Taiwan", "China") and lets us drop the i18n-iso-countries dependency; the file's alpha-3 column also covers the Natural Earth fallback lookup. countryInfo.txt is added to the geodata bundle in immich-app/base-images. --- e2e/src/specs/server/api/map.e2e-spec.ts | 8 +-- e2e/src/specs/server/api/search.e2e-spec.ts | 12 ++-- pnpm-lock.yaml | 16 ----- server/package.json | 1 - server/src/repositories/config.repository.ts | 2 + server/src/repositories/map.repository.ts | 59 ++++++++++++++++++- .../repositories/config.repository.mock.ts | 1 + 7 files changed, 69 insertions(+), 30 deletions(-) diff --git a/e2e/src/specs/server/api/map.e2e-spec.ts b/e2e/src/specs/server/api/map.e2e-spec.ts index c177fa740b..bc1058cd46 100644 --- a/e2e/src/specs/server/api/map.e2e-spec.ts +++ b/e2e/src/specs/server/api/map.e2e-spec.ts @@ -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), diff --git a/e2e/src/specs/server/api/search.e2e-spec.ts b/e2e/src/specs/server/api/search.e2e-spec.ts index 47c88c238c..dc6f2ec0c1 100644 --- a/e2e/src/specs/server/api/search.e2e-spec.ts +++ b/e2e/src/specs/server/api/search.e2e-spec.ts @@ -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); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4d3e4246a..dcea4abf95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 @@ -7274,9 +7271,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==} @@ -8399,10 +8393,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'} @@ -20334,8 +20324,6 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 - diacritics@1.3.0: {} - didyoumean@1.2.2: {} diff-sequences@29.6.3: {} @@ -21856,10 +21844,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 diff --git a/server/package.json b/server/package.json index 23cbe12e5c..5c9d4a4cef 100644 --- a/server/package.json +++ b/server/package.json @@ -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": "^4.1.0", diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index 909d6d62dd..0c7924f6a1 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -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: { diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index 3d01474434..5be33c01bc 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -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; + byAlpha3: Map; +} + @Injectable() export class MapRepository { + private countryNames?: Promise; + constructor( private configRepository: ConfigRepository, private metadataRepository: SystemMetadataRepository, @@ -148,6 +154,8 @@ export class MapRepository { async reverseGeocode(point: GeoPoint): Promise { 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 { + this.countryNames ??= this.readCountryNames(); + return this.countryNames; + } + + private async readCountryNames(): Promise { + const byAlpha2 = new Map(); + const byAlpha3 = new Map(); + + 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')); diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index 76a4fe5cef..cd599c550b 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -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: {