openvibers/openvibe.tours Commits

Places keep OpenStreetMap's English name, shown first and searched (#41)

OP OpenVibers committed 11 days ago · 811e332f3a · 10 files, +119 −24

252 places in Tokyo, Shanghai, Tianjin, Guangzhou, Beijing and Chongqing had a name in Chinese or
Japanese script only, so an English search never reached them (Hongya Cave is 洪崖洞).

Migration 0004 adds tours_places.name_en and marks those six cities for an OpenStreetMap refresh;
the OSM mapping keeps name:en when it differs; the places job names a version (name-en-1), so the
first boot with it runs again and refreshes them. The place page heads with the English name and
the place's own under it, lists show the English name with the own name in their second line, the
title and JSON-LD (alternateName) carry both, and the Search document is "Hongya Cave (洪崖洞)".

Changes

ddiff --git a/migrations/0004_place_english_names.sql b/migrations/0004_place_english_names.sql
nnew file mode 100644
iindex 0000000..420eef5
---- /dev/null
++++ b/migrations/0004_place_english_names.sql
@@@ -0,0 +1,11 @@
++-- phase: expand
++-- A place's English name from OpenStreetMap (name:en) beside its own (name). 252 places in Tokyo, Shanghai, Tianjin,
++-- Guangzhou, Beijing and Chongqing had a name in Chinese or Japanese script only, so an English search never found
++-- them (2026-11-02: "Hongya Cave" is 洪崖洞). The cities those places are in are marked for an OpenStreetMap refresh, so
++-- the next places run (it runs at boot: its version changed) fetches their English names first.
++
++ALTER TABLE tours_places ADD COLUMN IF NOT EXISTS name_en text;
++ALTER TABLE tours_places ADD CONSTRAINT tours_places_name_en CHECK (name_en IS NULL OR length(name_en) BETWEEN 1 AND 200);
++
++UPDATE tours_cities SET osm_fetched_at = NULL
++ WHERE id IN (SELECT DISTINCT city_id FROM tours_places WHERE source = 'openstreetmap' AND name !~ '[A-Za-zÀ-ɏ]');
ddiff --git a/public/css/tours.css b/public/css/tours.css
iindex 760dcb9..da9b929 100644
---- a/public/css/tours.css
++++ b/public/css/tours.css
@@@ -15,6 +15,7 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
.tr-page-h h1, .tr h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 3rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.06; overflow-wrap: anywhere; }
.tr-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong, var(--accent-light, #60a5fa)); }
.tr-lede { margin: 10px 0 0; max-width: 760px; color: var(--text-secondary, #96a7c2); line-height: 1.6; }
++.tr-local-name { margin: 6px 0 0; font-size: clamp(1.05rem, 2.4vw, 1.35rem); font-weight: 600; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }
.tr-muted { color: var(--text-muted, #7386a3); }
.tr-fine { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }
.tr-more { font-weight: 700; font-size: .9rem; }
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 0c66ccc..65023b3 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -109,11 +109,12 @@ function createPageRoutes(ctx) {
signedIn(req) ? store.tripsForSubject(req.viewer.subject) : Promise.resolve([]),
]);
return page(req, res, {
-- index: true, cache: PUBLIC_CACHE, title: place.name,
-- description: place.description || `${place.name} in ${place.city_name}: coordinates, address, opening hours, source and map.`,
++ index: true, cache: PUBLIC_CACHE, title: views.localName(place) ? `${views.placeName(place)} (${place.name})` : place.name,
++ description: place.description || `${views.placeName(place)} in ${place.city_name}: coordinates, address, opening hours, source and map.`,
jsonLd: [
{
-- '@context': 'https://schema.org', '@type': 'TouristAttraction', name: place.name,
++ '@context': 'https://schema.org', '@type': 'TouristAttraction', name: views.placeName(place),
++ ...(views.localName(place) ? { alternateName: place.name } : {}),
url: `${base()}/place/${place.id}`, description: place.description || undefined,
geo: { '@type': 'GeoCoordinates', latitude: place.lat, longitude: place.lon },
...(place.address ? { address: { '@type': 'PostalAddress', streetAddress: place.address, addressLocality: place.city_name } } : {}),
@@@ -121,7 +122,7 @@ function createPageRoutes(ctx) {
...(place.fee === 'no' ? { isAccessibleForFree: true } : place.fee === 'yes' ? { isAccessibleForFree: false } : {}),
sameAs: [place.osm_url],
},
-- seo.jsonLd.breadcrumbs([{ name: 'Cities', url: `${base()}/cities` }, { name: place.city_name, url: `${base()}/city/${place.city_slug}` }, { name: place.name, url: `${base()}/place/${place.id}` }]),
++ seo.jsonLd.breadcrumbs([{ name: 'Cities', url: `${base()}/cities` }, { name: place.city_name, url: `${base()}/city/${place.city_slug}` }, { name: views.placeName(place), url: `${base()}/place/${place.id}` }]),
],
body: raw(views.placePage({ place, routeList, trips })),
});
ddiff --git a/server/jobs/index.js b/server/jobs/index.js
iindex 9b396c8..9fd453c 100644
---- a/server/jobs/index.js
++++ b/server/jobs/index.js
@@@ -26,7 +26,7 @@ function createJobs({ config, s, store, fetchImpl = globalThis.fetch, log = cons
const jobs = {
cities: { run: creators.cities.runOnce, maxAgeMs: config.jobs.citiesEveryMs, intervalMs: config.jobs.citiesEveryMs },
-- places: { run: creators.places.runOnce, maxAgeMs: config.jobs.placesEveryMs, intervalMs: config.jobs.placesEveryMs },
++ places: { run: creators.places.runOnce, maxAgeMs: config.jobs.placesEveryMs, intervalMs: config.jobs.placesEveryMs, version: creators.places.version },
routes: { run: creators.routes.runOnce, maxAgeMs: config.jobs.routesEveryMs, intervalMs: config.jobs.routesEveryMs, version: creators.routes.version },
live: { run: creators.live.runOnce, maxAgeMs: config.jobs.liveEveryMs, intervalMs: config.jobs.liveEveryMs },
stays: { run: creators.stays.runOnce, maxAgeMs: config.jobs.staysEveryMs, intervalMs: config.jobs.staysEveryMs },
ddiff --git a/server/jobs/places.js b/server/jobs/places.js
iindex 62af842..52af7cc 100644
---- a/server/jobs/places.js
++++ b/server/jobs/places.js
@@@ -14,6 +14,10 @@ const { createOverpass } = require('../sources/osm');
const { parseCityPage } = require('../sources/wikivoyage');
const { stableId } = require('../store');
++// A run under older rules is not fresh (jobs/index.js): name-en-1 stores OpenStreetMap's English names, so the first
++// boot with it refreshes the cities marked by migration 0004.
++const PLACES_VERSION = 'name-en-1';
++
const R = 6_371_000;
function distanceM(a, b) {
const rad = (d) => (d * Math.PI) / 180;
@@@ -69,7 +73,7 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
}
async function runOnce({ limit = config.openData.cityLimit, osmLimit = config.openData.osmCitiesPerRun } = {}) {
-- const result = { cities: 0, wikivoyagePlaces: 0, places: 0, osmCities: 0, empty: [], errors: [] };
++ const result = { cities: 0, wikivoyagePlaces: 0, places: 0, osmCities: 0, empty: [], errors: [], version: PLACES_VERSION };
// 1. Wikivoyage places for every city (fast, cited, no external request).
const cities = await store.listCities({ limit: Math.max(1, Math.min(100, Number(limit) || 100)) });
@@@ -116,7 +120,7 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
return result;
}
-- return { runOnce, seedWikivoyage, distanceM };
++ return { runOnce, seedWikivoyage, distanceM, version: PLACES_VERSION };
}
module.exports = { createPlacesJob, distanceM };
ddiff --git a/server/search-index.js b/server/search-index.js
iindex 7f2c423..e414e2e 100644
---- a/server/search-index.js
++++ b/server/search-index.js
@@@ -26,13 +26,15 @@ const kindWord = (kind) => { const k = String(kind || 'place').replace(/_/g, ' '
function describePlace(row) {
const where = [row.city_name, row.country].filter(Boolean).join(', ');
++ // The English name first when OpenStreetMap has one (洪崖洞 → "Hongya Cave (洪崖洞)"), so both find the page.
++ const title = row.name_en && row.name_en !== row.name ? `${row.name_en} (${row.name})` : row.name;
const facts = [row.fee === 'no' ? 'Free to visit.' : row.fee === 'yes' ? 'Entry is paid.' : '', row.wheelchair === 'yes' ? 'Step-free.' : row.wheelchair === 'limited' ? 'Partly step-free.' : ''].filter(Boolean);
const about = `${kindWord(row.kind)} in ${where}.`;
return {
listed: true,
-- title: row.name,
++ title,
summary: [about, clip(row.description, 220), ...facts].filter(Boolean).join(' '),
-- body: [row.name, about, row.description, row.address, ...facts].filter(Boolean).join('\n'),
++ body: [title, about, row.description, row.address, ...facts].filter(Boolean).join('\n'),
facets: { city: row.city_slug, country: row.country, kind: row.kind },
authorship: { mode: 'imported' },
publishedAt: iso(row.created_at),
@@@ -88,7 +90,7 @@ function createSearchIndex({ config, s, log = console, outbox = null }) {
place: {
page: (row) => `/place/${row.id}`,
document: describePlace,
-- rows: page(`SELECT p.id, p.name, p.kind, p.description, p.address, p.fee, p.wheelchair, p.created_at, p.updated_at,
++ rows: page(`SELECT p.id, p.name, p.name_en, p.kind, p.description, p.address, p.fee, p.wheelchair, p.created_at, p.updated_at,
c.name AS city_name, c.slug AS city_slug, c.country
FROM tours_places p JOIN tours_cities c ON c.id = p.city_id
WHERE p.id > $1 ORDER BY p.id LIMIT $2`),
ddiff --git a/server/sources/osm.js b/server/sources/osm.js
iindex 79606e3..c057bec 100644
---- a/server/sources/osm.js
++++ b/server/sources/osm.js
@@@ -41,6 +41,8 @@ function placeFromElement(el, cityId, fetchedAt) {
osmType,
osmId: String(el.id),
name: String(tags.name).slice(0, 200),
++ // The English name when OpenStreetMap has one that differs (洪崖洞 → Hongya Cave): shown first and searched.
++ nameEn: tags['name:en'] && String(tags['name:en']).trim() && String(tags['name:en']).trim() !== String(tags.name).trim() ? String(tags['name:en']).trim().slice(0, 200) : null,
kind,
lat,
lon,
ddiff --git a/server/store.js b/server/store.js
iindex cac98a2..8fdc4fd 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -56,7 +56,7 @@ function wirePlace(row) {
if (!row) return null;
return {
id: row.id, city_id: row.city_id, city_slug: row.city_slug || null, city_name: row.city_name || null,
-- osm_type: row.osm_type, osm_id: row.osm_id, name: row.name, kind: row.kind, lat: num(row.lat), lon: num(row.lon),
++ osm_type: row.osm_type, osm_id: row.osm_id, name: row.name, name_en: row.name_en || null, kind: row.kind, lat: num(row.lat), lon: num(row.lon),
address: row.address || null, website: row.website || null, opening_hours: row.opening_hours || null,
wheelchair: row.wheelchair || null, fee: row.fee || null, wikidata_id: row.wikidata_id || null,
wikipedia: row.wikipedia || null, image_url: row.image_url || null, image_credit: row.image_credit || null,
@@@ -325,10 +325,10 @@ function createStore(s, { config = {} } = {}) {
const id = stableId('plc', `${p.osmType}:${p.osmId}`);
await db.exec(`INSERT INTO tours_places
(id, city_id, osm_type, osm_id, name, kind, lat, lon, address, website, opening_hours, wheelchair, fee,
-- wikidata_id, wikipedia, image_url, image_credit, image_license, description, source, source_url, fetched_at, updated_at)
-- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,now())
++ wikidata_id, wikipedia, image_url, image_credit, image_license, description, source, source_url, fetched_at, name_en, updated_at)
++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,now())
ON CONFLICT (osm_type, osm_id) DO UPDATE SET
-- city_id = EXCLUDED.city_id, name = EXCLUDED.name, kind = EXCLUDED.kind, lat = EXCLUDED.lat, lon = EXCLUDED.lon,
++ city_id = EXCLUDED.city_id, name = EXCLUDED.name, name_en = EXCLUDED.name_en, kind = EXCLUDED.kind, lat = EXCLUDED.lat, lon = EXCLUDED.lon,
address = EXCLUDED.address, website = EXCLUDED.website, opening_hours = EXCLUDED.opening_hours,
wheelchair = EXCLUDED.wheelchair, fee = EXCLUDED.fee, wikidata_id = EXCLUDED.wikidata_id, wikipedia = EXCLUDED.wikipedia,
image_url = EXCLUDED.image_url, image_credit = EXCLUDED.image_credit, image_license = EXCLUDED.image_license,
@@@ -339,7 +339,7 @@ function createStore(s, { config = {} } = {}) {
p.wheelchair || null, p.fee || null, p.wikidataId || null, p.wikipedia || null, p.imageUrl || null,
p.imageCredit ? clean(p.imageCredit, 250) : null, p.imageLicense ? clean(p.imageLicense, 120) : null,
p.description ? clean(p.description, 800) : null, p.source || 'openstreetmap', p.sourceUrl,
-- new Date(p.fetchedAt || s.now()).toISOString()]);
++ new Date(p.fetchedAt || s.now()).toISOString(), p.nameEn ? clean(p.nameEn, 200) : null]);
return id;
}
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 58f266f..1e7d30f 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -70,9 +70,14 @@ const cityCard = (c) => kit.card({
level: 3,
});
++/** A place's name as a reader of English finds it: OpenStreetMap's English name when there is one, else its own. */
++const placeName = (p) => (p && p.name_en) || (p && p.name) || '';
++/** The place's own name when the English one is shown instead (洪崖洞 beside Hongya Cave), else ''. */
++const localName = (p) => (p && p.name_en && p.name && p.name !== p.name_en ? p.name : '');
++
const placeCard = (p) => kit.card({
href: `/place/${p.id}`,
-- title: p.name,
++ title: placeName(p),
type: 'map',
image: safeImage(p.image_url) || '',
badge: p.kind ? p.kind.replace(/_/g, ' ') : '',
@@@ -125,8 +130,8 @@ function placeOfDayCard(p) {
if (!p) return '';
return `<article class="tr-pod" data-place="${esc(p.id)}">
<a class="tr-pod-link" href="/place/${esc(p.id)}">
--${safeImage(p.image_url) ? `<img src="${esc(p.image_url)}" alt="${esc(p.name)}" loading="lazy" decoding="async">` : '<span class="tr-pod-noimg" aria-hidden="true">◆</span>'}
--<span class="tr-pod-body"><span class="tr-kicker">Place of the day · ${esc(p.city_name || '')}</span><strong>${esc(p.name)}</strong>
++${safeImage(p.image_url) ? `<img src="${esc(p.image_url)}" alt="${esc(placeName(p))}" loading="lazy" decoding="async">` : '<span class="tr-pod-noimg" aria-hidden="true">◆</span>'}
++<span class="tr-pod-body"><span class="tr-kicker">Place of the day · ${esc(p.city_name || '')}</span><strong>${esc(placeName(p))}</strong>
<span class="tr-muted">${esc(p.kind ? p.kind.replace(/_/g, ' ') : 'place')}${p.description ? ` · ${esc(clip(p.description, 150))}` : ''}</span></span>
</a></article>`;
}
@@@ -243,7 +248,7 @@ ${img ? `<figure class="tr-city-img"><img src="${esc(img)}" alt="${esc(city.name
</header>
${routes.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Self-guided routes</h2><span class="tr-muted">generated from Wikivoyage's See list</span></div><ol class="tr-route-list">${routes.map((r) => `<li><a href="/routes/${esc(r.id)}"><strong>${esc(r.title)}</strong><span class="tr-muted">${Number(r.stop_count)} stops · ${km(r.distance_m)} · ${Number(r.duration_min)} min</span></a></li>`).join('')}</ol></section>` : ''}
<section class="tr-sec" id="tr-city-places"><div class="tr-sec-head"><h2>Places</h2><a class="tr-more" href="https://www.openstreetmap.org/?mlat=${city.lat}&mlon=${city.lon}#map=13/${city.lat}/${city.lon}" rel="noopener">OpenStreetMap</a></div>
--${places.length ? `<ol class="tr-place-list">${places.map((p) => `<li class="tr-place-row"><a href="/place/${esc(p.id)}"><strong>${esc(p.name)}</strong><span class="tr-muted">${esc(String(p.kind || '').replace(/_/g, ' '))}${p.address ? ` · ${esc(p.address)}` : ''}${p.wheelchair === 'yes' ? ' · step-free' : ''}${p.fee === 'no' ? ' · free' : ''}</span></a></li>`).join('')}</ol>` : empty('No OpenStreetMap place was stored for this city yet. The places job fills it.', '/cities', 'Another city')}
++${places.length ? `<ol class="tr-place-list">${places.map((p) => `<li class="tr-place-row"><a href="/place/${esc(p.id)}"><strong>${esc(placeName(p))}</strong><span class="tr-muted">${localName(p) ? `${esc(localName(p))} · ` : ''}${esc(String(p.kind || '').replace(/_/g, ' '))}${p.address ? ` · ${esc(p.address)}` : ''}${p.wheelchair === 'yes' ? ' · step-free' : ''}${p.fee === 'no' ? ' · free' : ''}</span></a></li>`).join('')}</ol>` : empty('No OpenStreetMap place was stored for this city yet. The places job fills it.', '/cities', 'Another city')}
<p class="tr-fine">${SOURCE.osm}</p></section>
${live.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Live tours and recordings</h2><a class="tr-more" href="/live">All live</a></div><ol class="tr-live-list">${live.map((l) => `<li><a href="${esc(l.url)}" rel="noopener"><strong>${esc(l.title)}</strong><span class="tr-muted">${esc(l.kind === 'stream' ? 'Live now' : l.kind === 'clip' ? 'Clip' : 'Recording')} · ${esc(l.display_name || l.username)}${l.city_name ? ` · ${esc(l.city_name)}` : ''}${l.kind === 'stream' ? ` · ${Number(l.viewer_count)} watching` : ''}</span></a></li>`).join('')}</ol><p class="tr-fine">OpenVibe.Live's public stream list · fetched ${when(live[0].fetched_at)}</p></section>` : ''}
${stays.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Stays</h2><a class="tr-more" href="/stays">All stays</a></div><ol class="tr-stay-list">${stays.map((s0) => `<li><a href="${esc(s0.url)}" rel="noopener"><strong>${esc(s0.title)}</strong><span class="tr-muted">${esc(s0.kind)} · ${money(s0.price, s0.currency)} / ${esc(s0.period)}${s0.neighbourhood ? ` · ${esc(s0.neighbourhood)}` : ''}</span></a></li>`).join('')}</ol><p class="tr-fine">OpenVibe.Rent's public listings · never a payment on Tours.</p></section>` : ''}
@@@ -254,10 +259,10 @@ ${stays.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Stays</h2
function placePage({ place, routeList = [], trips = [] } = {}) {
return `<div class="tr-wrap" id="tr-place-page">
<nav class="crumbs" aria-label="Breadcrumbs"><a href="/cities">Cities</a> / <a href="/city/${esc(place.city_slug)}">${esc(place.city_name)}</a></nav>
--<header class="tr-page-h"><p class="tr-kicker">${esc(String(place.kind || '').replace(/_/g, ' '))}</p><h1>${esc(place.name)}</h1>
--<p class="tr-lede">${esc(place.description || `${place.name} in ${place.city_name}, from OpenStreetMap.`)}</p>
++<header class="tr-page-h"><p class="tr-kicker">${esc(String(place.kind || '').replace(/_/g, ' '))}</p><h1>${esc(placeName(place))}</h1>${localName(place) ? `\n<p class="tr-local-name">${esc(localName(place))}</p>` : ''}
++<p class="tr-lede">${esc(place.description || `${placeName(place)} in ${place.city_name}, from OpenStreetMap.`)}</p>
<p class="tr-fine">${place.osm_url ? `<a href="${esc(place.osm_url)}" rel="noopener">OpenStreetMap ${esc(place.osm_type)} ${esc(place.osm_id)}</a> · ` : ''}<a href="${esc(place.source_url)}" rel="noopener">${esc(place.source === 'wikivoyage' ? 'Wikivoyage' : 'source')}</a> · fetched ${when(place.fetched_at)}${place.wikidata_id ? ` · <a href="https://www.wikidata.org/wiki/${esc(place.wikidata_id)}" rel="noopener">Wikidata</a>` : ''}</p></header>
--${safeImage(place.image_url) ? `<figure class="tr-place-img"><img src="${esc(place.image_url)}" alt="${esc(place.name)}" loading="lazy" decoding="async"><figcaption>${esc(place.image_credit || 'OpenStreetMap contributor')}${place.image_license ? ` · ${esc(place.image_license)}` : ''}</figcaption></figure>` : ''}
++${safeImage(place.image_url) ? `<figure class="tr-place-img"><img src="${esc(place.image_url)}" alt="${esc(placeName(place))}" loading="lazy" decoding="async"><figcaption>${esc(place.image_credit || 'OpenStreetMap contributor')}${place.image_license ? ` · ${esc(place.image_license)}` : ''}</figcaption></figure>` : ''}
<dl class="tr-facts">
${place.address ? `<div><dt>Address</dt><dd>${esc(place.address)}</dd></div>` : ''}
${place.opening_hours ? `<div><dt>Opening hours</dt><dd>${esc(place.opening_hours)}</dd></div>` : ''}
@@@ -393,7 +398,7 @@ ${mine ? `<section class="tr-sec tr-card"><h2>Share and edit</h2><p class="tr-mu
<label class="tr-field"><span>Notes</span><textarea name="notes" rows="3" maxlength="2000">${esc(trip.notes || '')}</textarea></label>
<button class="tr-btn" type="submit">Save the trip</button></form></details></section>` : ''}
<section class="tr-sec"><div class="tr-sec-head"><h2>Saved places</h2>${mine ? '' : `<span class="tr-muted">${places.length} places</span>`}</div>
--${places.length ? `<ol class="tr-trip-places">${places.map((p) => `<li><span class="tr-stop-num" aria-hidden="true">${Number(p.position)}</span><div><a href="/place/${esc(p.id)}"><strong>${esc(p.name)}</strong></a><span class="tr-muted">${esc(p.city_name)}${p.trip_note ? ` · ${esc(p.trip_note)}` : ''}</span>${mine ? `<form class="tr-inline-form" method="post" action="/trips/${esc(trip.slug)}/places/${esc(p.id)}/remove"><button class="tr-link-btn" type="submit">Remove</button></form>` : ''}</div></li>`).join('')}</ol>` : empty('No place saved yet. Open a city and a place, then save it here.', trip.city_slug ? `/city/${trip.city_slug}` : '/cities', 'Find places')}
++${places.length ? `<ol class="tr-trip-places">${places.map((p) => `<li><span class="tr-stop-num" aria-hidden="true">${Number(p.position)}</span><div><a href="/place/${esc(p.id)}"><strong>${esc(placeName(p))}</strong></a><span class="tr-muted">${localName(p) ? `${esc(localName(p))} · ` : ''}${esc(p.city_name)}${p.trip_note ? ` · ${esc(p.trip_note)}` : ''}</span>${mine ? `<form class="tr-inline-form" method="post" action="/trips/${esc(trip.slug)}/places/${esc(p.id)}/remove"><button class="tr-link-btn" type="submit">Remove</button></form>` : ''}</div></li>`).join('')}</ol>` : empty('No place saved yet. Open a city and a place, then save it here.', trip.city_slug ? `/city/${trip.city_slug}` : '/cities', 'Find places')}
${mine ? `<form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trip.slug)}/places"><label class="tr-field"><span>Place id</span><input name="place_id" required placeholder="plc_…" pattern="plc_[0-9A-HJKMNP-TV-Z]{26}"></label><label class="tr-field"><span>Note</span><input name="note" maxlength="500"></label><button class="tr-btn" type="submit">Save place</button></form><p class="tr-fine">The place id is on the place page. In the API: POST /api/v1/trips/${esc(trip.slug)}/places.</p>` : ''}
</section>
<section class="tr-sec"><div class="tr-sec-head"><h2>Route</h2>${mine ? `<a class="tr-more" href="${trip.city_slug ? `/city/${trip.city_slug}` : '/routes'}">Find a route</a>` : ''}</div>
@@@ -438,4 +443,4 @@ function notFound() {
return `<div class="tr-wrap tr-narrow tr-page-pad" id="tr-not-found"><p class="tr-kicker">404</p><h1>Not here</h1><p class="tr-lede">That city, place, route or trip does not exist, or it is a private trip.</p><p><a class="tr-btn" href="/">Go home</a> <a class="tr-btn tr-btn-quiet" href="/cities">Browse cities</a></p></div>`;
}
--module.exports = { HOME_FAQ, safeImage, home, citiesPage, cityPage, placePage, routePage, livePage, staysPage, flightsPage, signIn, tripNew, tripsPage, tripPage, developersPage, notFound, placeOfDayCard, heroMotif, miniMap, SOURCE };
++module.exports = { placeName, localName, HOME_FAQ, safeImage, home, citiesPage, cityPage, placePage, routePage, livePage, staysPage, flightsPage, signIn, tripNew, tripsPage, tripPage, developersPage, notFound, placeOfDayCard, heroMotif, miniMap, SOURCE };
ddiff --git a/test/place-names.test.js b/test/place-names.test.js
nnew file mode 100644
iindex 0000000..b4705e5
---- /dev/null
++++ b/test/place-names.test.js
@@@ -0,0 +1,69 @@
++'use strict';
++/**
++ * A place's English name (migration 0004, OpenStreetMap name:en): 252 places in six cities had a name in Chinese or
++ * Japanese script only, so an English search never found them ("Hongya Cave" is 洪崖洞). The OSM mapping keeps name:en
++ * when it differs; the page and lists show it first with the place's own name beside it; the title, JSON-LD and the
++ * Search document carry both; the places job names a version, so the first boot with it refreshes the marked cities.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { seed } = require('./helpers/seed');
++const osm = require('../server/sources/osm');
++const { describePlace } = require('../server/search-index');
++
++const node = (tags) => ({ type: 'node', id: 9001, lat: 29.56, lon: 106.58, tags: { tourism: 'attraction', ...tags } });
++
++(async () => {
++ const t = await boot();
++ try {
++ await seed(t);
++
++ await check('OpenStreetMap: name:en is kept when it differs from name, and only then', async () => {
++ assert.strictEqual(osm.placeFromElement(node({ name: '洪崖洞', 'name:en': 'Hongya Cave' }), 'c1', null).nameEn, 'Hongya Cave');
++ assert.strictEqual(osm.placeFromElement(node({ name: 'Old Tower', 'name:en': 'Old Tower' }), 'c1', null).nameEn, null);
++ assert.strictEqual(osm.placeFromElement(node({ name: '洪崖洞' }), 'c1', null).nameEn, null);
++ });
++
++ const city = await t.ctx.s.db.maybe('SELECT id, slug, name FROM tours_cities ORDER BY id LIMIT 1');
++ const id = await t.ctx.store.upsertPlace({ ...osm.placeFromElement(node({ name: '洪崖洞', 'name:en': 'Hongya Cave' }), city.id, new Date().toISOString()) });
++
++ await check('the place page: the English name as the heading, its own name beside it, both in the title and JSON-LD', async () => {
++ const r = await t.get(`/place/${id}`);
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<h1>Hongya Cave<\/h1>/);
++ assert.match(r.text, /<p class="tr-local-name">洪崖洞<\/p>/);
++ assert.match(r.text, /<title>Hongya Cave \(洪崖洞\)/);
++ assert.match(r.text, /"name":"Hongya Cave","alternateName":"洪崖洞"/);
++ });
++
++ await check('the city page lists it by its English name, with its own name in the line under it', async () => {
++ const r = await t.get(`/city/${city.slug}`);
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /<strong>Hongya Cave<\/strong><span class="tr-muted">洪崖洞 · attraction/);
++ });
++
++ await check('the Search document is titled with both names, and the sweep sends it', async () => {
++ assert.strictEqual(describePlace({ name: '洪崖洞', name_en: 'Hongya Cave', kind: 'attraction', city_name: 'Chongqing', country: 'China' }).title, 'Hongya Cave (洪崖洞)');
++ assert.strictEqual(describePlace({ name: 'Old Tower', name_en: null, kind: 'attraction', city_name: 'X', country: 'Y' }).title, 'Old Tower');
++ await t.ctx.searchIndex.sweep();
++ const env = (await t.ctx.s.db.many("SELECT envelope FROM event_outbox WHERE envelope->'payload'->>'id' = $1", [id])).pop();
++ assert.ok(env, 'an envelope for the place');
++ assert.strictEqual(env.envelope.payload.title, 'Hongya Cave (洪崖洞)');
++ assert.match(env.envelope.payload.body, /Hongya Cave \(洪崖洞\)/);
++ });
++
++ await check('the places job names a version, so a run under older rules is not fresh', async () => {
++ const runs = await t.ctx.store.latestJobRuns();
++ const places = runs.find((r) => r.job === 'places');
++ const counts = typeof places.counts === 'string' ? JSON.parse(places.counts) : places.counts;
++ assert.strictEqual(counts.version, 'name-en-1');
++ });
++
++ await check('the migration marks a city by a name with no Latin letter, not by an accent', async () => {
++ const row = await t.ctx.s.db.maybe("SELECT ('洪崖洞' !~ '[A-Za-zÀ-ɏ]') AS cjk, ('Musée du Louvre' !~ '[A-Za-zÀ-ɏ]') AS accented, ('Ἀκρόπολις' !~ '[A-Za-zÀ-ɏ]') AS greek");
++ assert.deepStrictEqual([row.cjk, row.accented, row.greek], [true, false, true]);
++ });
++ } finally {
++ done();
++ }
++})();