Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions app/components/MapboxAutocomplete.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { LuSearch } from 'react-icons/lu';

import SelectInput from './SelectInput';
import useDebounce from '../hooks/useDebounce';
import { geocodingUrl } from '../utils/geocoding';


const propTypes = {
Expand Down Expand Up @@ -58,11 +59,7 @@ const MapboxAutocomplete = ({
useEffect(() => {
async function getLocation() {
const header = { 'Content-Type': 'application/json' };
let path = `https://api.mapbox.com/geocoding/v5/mapbox.places/${query}.json?access_token=${token}&autocomplete=true`;

if (types) {
path += `&types=${types.join(',')}`;
}
const path = geocodingUrl(query, { token, types });

if (query.length > 2) {
try {
Expand Down
126 changes: 0 additions & 126 deletions app/components/PhotonAutocomplete.jsx

This file was deleted.

8 changes: 3 additions & 5 deletions app/components/PlacesSearch.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,9 @@ const PlacesSearch = ({

if (context.id.includes('place')) city = context.text;

if (context.id.includes('address')) {
if (address.address)
street = address.address + ' ' + address.text;
else street = address.text;
}
// `address` was never defined here: an address in the context
// threw a ReferenceError.
if (context.id.includes('address')) street = context.text;
});

// there are some territories with short codes on region instead, e.g RE for Reunion Island
Expand Down
21 changes: 21 additions & 0 deletions app/utils/geocoding.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Mapbox geocoding (v5) shared by the location picker and the place-name
* backfill. Results are requested in English (#208): without `language`,
* Mapbox returns each place's local name ("Warszawa", "京都市").
*/
export const GEOCODING_LANGUAGE = 'en';

/** A forward (text) or reverse ([longitude, latitude]) geocoding URL. */
export function geocodingUrl(query, { token, types, language = GEOCODING_LANGUAGE, autocomplete = true } = {}) {
const search = Array.isArray(query) ? query.join(',') : encodeURIComponent(query);
const params = new URLSearchParams({ access_token: token, language });
if (!Array.isArray(query) && autocomplete) params.set('autocomplete', 'true');
if (types?.length) params.set('types', types.join(','));
return `https://api.mapbox.com/geocoding/v5/mapbox.places/${search}.json?${params}`;
}

/** The city and region names in a reverse-geocoding response's features. */
export function placeNames(features = []) {
const byType = (type) => features.find((feature) => feature.place_type?.includes(type))?.text ?? null;
return { city: byType('place'), region: byType('region') };
}
36 changes: 36 additions & 0 deletions app/utils/geocoding.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';

import { geocodingUrl, placeNames } from './geocoding';

describe('geocodingUrl', () => {
// #208: without language, Mapbox answered with local names.
it('asks for English names when searching', () => {
const url = new URL(geocodingUrl('Kraków, Poland', { token: 'pk.test', types: ['place', 'address'] }));
expect(url.pathname).toBe(`/geocoding/v5/mapbox.places/${encodeURIComponent('Kraków, Poland')}.json`);
expect(url.searchParams.get('language')).toBe('en');
expect(url.searchParams.get('types')).toBe('place,address');
expect(url.searchParams.get('autocomplete')).toBe('true');
});

it('reverse-geocodes coordinates as longitude,latitude', () => {
const url = new URL(geocodingUrl([21.01, 52.23], { token: 'pk.test', types: ['place', 'region'] }));
expect(url.pathname).toBe('/geocoding/v5/mapbox.places/21.01,52.23.json');
expect(url.searchParams.get('language')).toBe('en');
expect(url.searchParams.has('autocomplete')).toBe(false);
});
});

describe('placeNames', () => {
it('reads the city and region from the features', () => {
const features = [
{ place_type: ['place'], text: 'Warsaw' },
{ place_type: ['region'], text: 'Masovian' },
{ place_type: ['country'], text: 'Poland' },
];
expect(placeNames(features)).toEqual({ city: 'Warsaw', region: 'Masovian' });
});

it('leaves missing names null', () => {
expect(placeNames([{ place_type: ['country'], text: 'Monaco' }])).toEqual({ city: null, region: null });
});
});
17 changes: 17 additions & 0 deletions e2e/events.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,20 @@ test('an event without a cover shows the placeholder', async ({ page }) => {
await db.event.delete({ where: { id: event.id } });
}
});

// #208: the location picker didn't ask Mapbox for a language, so places
// were saved under their local names ("Warszawa").
test('the location picker asks for English place names', async ({ page }) => {
const requests = [];
await page.route('https://api.mapbox.com/geocoding/**', (route) => {
requests.push(new URL(route.request().url()));
route.fulfill({ json: { type: 'FeatureCollection', features: [] } });
});

await signIn(page, MEMBER);
await page.goto('/events/create');
await page.getByRole('textbox', { name: 'Location' }).fill('Warszawa');

await expect.poll(() => requests.length).toBeGreaterThan(0);
expect(requests.at(-1).searchParams.get('language')).toBe('en');
});
122 changes: 122 additions & 0 deletions scripts/backfill-place-names.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
#!/usr/bin/env node
/**
* Re-geocode every location's city and region in English (#208).
*
* Locations were saved with Mapbox's local-language names ("Warszawa",
* "京都市") because the picker didn't ask for a language. This looks each
* one up again from its coordinates, with `language=en`.
*
* Usage:
* MAPBOX_TOKEN=pk.… DATABASE_URL=… node scripts/backfill-place-names.mjs [--write] [--limit N]
*
* - Dry run by default: prints every change (before → after) and writes
* nothing. Pass --write to apply.
* - --limit N looks at the first N locations only (handy for a trial run).
* - Skips locations without coordinates, and lookups that fail or return
* no names (they're logged), and keeps a name when English has none.
* - Renaming can make two rows identical on the unique key (street, city,
* region, country, coordinates). The renamed row is then merged into the
* existing one: its orgs and events are re-pointed, and it's deleted.
* - Paces requests (~5 per second) to stay well inside Mapbox's limits.
*/
import { PrismaClient } from '@prisma/client';

import { geocodingUrl, placeNames } from '../app/utils/geocoding.js';

const args = process.argv.slice(2);
const write = args.includes('--write');
const limitIndex = args.indexOf('--limit');
const limit = limitIndex >= 0 ? Number(args[limitIndex + 1]) : undefined;

const token = process.env.MAPBOX_TOKEN;
if (!token) {
console.error('MAPBOX_TOKEN is required (the public pk.… token the location picker uses).');
process.exit(1);
}

const db = new PrismaClient();
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function lookup({ latitude, longitude }) {
const url = geocodingUrl([longitude, latitude], { token, types: ['place', 'region'] });
const response = await fetch(url);
if (!response.ok) throw new Error(`Mapbox answered ${response.status}`);
const { features } = await response.json();
return placeNames(features);
}

async function main() {
const locations = await db.location.findMany({
where: { latitude: { not: null }, longitude: { not: null } },
orderBy: { id: 'asc' },
take: limit,
});

console.log(`${write ? 'Updating' : 'Dry run over'} ${locations.length} locations…\n`);
const counts = { changed: 0, unchanged: 0, merged: 0, skipped: 0 };

for (const location of locations) {
let names;
try {
names = await lookup(location);
} catch (error) {
console.warn(`skip ${location.id}: ${error.message}`);
counts.skipped += 1;
continue;
} finally {
await pause(200);
}

const city = names.city ?? location.city;
const region = names.region ?? location.region;
if (!names.city && !names.region) {
console.warn(`skip ${location.id}: no names for ${location.latitude},${location.longitude}`);
counts.skipped += 1;
continue;
}
if (city === location.city && region === location.region) {
counts.unchanged += 1;
continue;
}

console.log(`${location.id} ${location.city ?? '—'}, ${location.region ?? '—'} → ${city ?? '—'}, ${region ?? '—'}`);
counts.changed += 1;
if (!write) continue;

const twin = await db.location.findFirst({
where: {
id: { not: location.id },
street: location.street,
city,
region,
country_code: location.country_code,
latitude: location.latitude,
longitude: location.longitude,
},
});

if (twin) {
await db.$transaction([
db.entity.updateMany({ where: { location_id: location.id }, data: { location_id: twin.id } }),
db.event.updateMany({ where: { location_id: location.id }, data: { location_id: twin.id } }),
db.location.delete({ where: { id: location.id } }),
]);
console.log(` merged into ${twin.id}`);
counts.merged += 1;
} else {
await db.location.update({ where: { id: location.id }, data: { city, region } });
}
}

console.log(
`\n${counts.changed} to rename${write ? ` (${counts.merged} merged)` : ''}, ${counts.unchanged} already right, ${counts.skipped} skipped.` +
(write ? '' : '\nNothing was written: run again with --write to apply.')
);
}

main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => db.$disconnect());
Loading