mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
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.
This commit is contained in:
parent
29605f710e
commit
0ca8ff43e9
7 changed files with 69 additions and 30 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
16
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue