openvibers/openvibe.tours Commits
Places keep OpenStreetMap's English name, shown first and searched (#41)
Changes
nnew file mode 100644@@@ -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À-ɏ]');@@@ -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; }@@@ -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 })),});@@@ -26,7 +26,7 @@ function createJobs({ config, s, store, fetchImpl = globalThis.fetch, log = consconst 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 },@@@ -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 };@@@ -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.countryFROM tours_places p JOIN tours_cities c ON c.id = p.city_idWHERE p.id > $1 ORDER BY p.id LIMIT $2`),@@@ -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,@@@ -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;}@@@ -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</h2function 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 };nnew file mode 100644@@@ -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();++ }++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -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À-ɏ]');
@@ -15,6 +15,7 @@ html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--tex
@@ -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-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-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-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-muted { color: var(--text-muted, #7386a3); }
.tr-fine { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }
.tr-fine { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }
.tr-more { font-weight: 700; font-size: .9rem; }
.tr-more { font-weight: 700; font-size: .9rem; }
@@ -109,11 +109,12 @@ function createPageRoutes(ctx) {
@@ -109,11 +109,12 @@ function createPageRoutes(ctx) {
signedIn(req) ? store.tripsForSubject(req.viewer.subject) : Promise.resolve([]),
signedIn(req) ? store.tripsForSubject(req.viewer.subject) : Promise.resolve([]),
]);
]);
return page(req, res, {
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: [
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,
url: `${base()}/place/${place.id}`, description: place.description || undefined,
geo: { '@type': 'GeoCoordinates', latitude: place.lat, longitude: place.lon },
geo: { '@type': 'GeoCoordinates', latitude: place.lat, longitude: place.lon },
...(place.address ? { address: { '@type': 'PostalAddress', streetAddress: place.address, addressLocality: place.city_name } } : {}),
...(place.address ? { address: { '@type': 'PostalAddress', streetAddress: place.address, addressLocality: place.city_name } } : {}),
@@ -121,7 +122,7 @@ function createPageRoutes(ctx) {
@@ -121,7 +122,7 @@ function createPageRoutes(ctx) {
...(place.fee === 'no' ? { isAccessibleForFree: true } : place.fee === 'yes' ? { isAccessibleForFree: false } : {}),
...(place.fee === 'no' ? { isAccessibleForFree: true } : place.fee === 'yes' ? { isAccessibleForFree: false } : {}),
sameAs: [place.osm_url],
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 })),
body: raw(views.placePage({ place, routeList, trips })),
});
});
@@ -26,7 +26,7 @@ function createJobs({ config, s, store, fetchImpl = globalThis.fetch, log = cons
@@ -26,7 +26,7 @@ function createJobs({ config, s, store, fetchImpl = globalThis.fetch, log = cons
const jobs = {
const jobs = {
cities: { run: creators.cities.runOnce, maxAgeMs: config.jobs.citiesEveryMs, intervalMs: config.jobs.citiesEveryMs },
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 },
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 },
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 },
stays: { run: creators.stays.runOnce, maxAgeMs: config.jobs.staysEveryMs, intervalMs: config.jobs.staysEveryMs },
@@ -14,6 +14,10 @@ const { createOverpass } = require('../sources/osm');
@@ -14,6 +14,10 @@ const { createOverpass } = require('../sources/osm');
const { parseCityPage } = require('../sources/wikivoyage');
const { parseCityPage } = require('../sources/wikivoyage');
const { stableId } = require('../store');
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;
const R = 6_371_000;
function distanceM(a, b) {
function distanceM(a, b) {
const rad = (d) => (d * Math.PI) / 180;
const rad = (d) => (d * Math.PI) / 180;
@@ -69,7 +73,7 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
@@ -69,7 +73,7 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
}
}
async function runOnce({ limit = config.openData.cityLimit, osmLimit = config.openData.osmCitiesPerRun } = {}) {
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).
// 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)) });
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 }) {
@@ -116,7 +120,7 @@ function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
return result;
return result;
}
}
- return { runOnce, seedWikivoyage, distanceM };
+ return { runOnce, seedWikivoyage, distanceM, version: PLACES_VERSION };
}
}
module.exports = { createPlacesJob, distanceM };
module.exports = { createPlacesJob, distanceM };
@@ -26,13 +26,15 @@ const kindWord = (kind) => { const k = String(kind || 'place').replace(/_/g, ' '
@@ -26,13 +26,15 @@ const kindWord = (kind) => { const k = String(kind || 'place').replace(/_/g, ' '
function describePlace(row) {
function describePlace(row) {
const where = [row.city_name, row.country].filter(Boolean).join(', ');
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 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}.`;
const about = `${kindWord(row.kind)} in ${where}.`;
return {
return {
listed: true,
listed: true,
- title: row.name,
+ title,
summary: [about, clip(row.description, 220), ...facts].filter(Boolean).join(' '),
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 },
facets: { city: row.city_slug, country: row.country, kind: row.kind },
authorship: { mode: 'imported' },
authorship: { mode: 'imported' },
publishedAt: iso(row.created_at),
publishedAt: iso(row.created_at),
@@ -88,7 +90,7 @@ function createSearchIndex({ config, s, log = console, outbox = null }) {
@@ -88,7 +90,7 @@ function createSearchIndex({ config, s, log = console, outbox = null }) {
place: {
place: {
page: (row) => `/place/${row.id}`,
page: (row) => `/place/${row.id}`,
document: describePlace,
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
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
FROM tours_places p JOIN tours_cities c ON c.id = p.city_id
WHERE p.id > $1 ORDER BY p.id LIMIT $2`),
WHERE p.id > $1 ORDER BY p.id LIMIT $2`),
@@ -41,6 +41,8 @@ function placeFromElement(el, cityId, fetchedAt) {
@@ -41,6 +41,8 @@ function placeFromElement(el, cityId, fetchedAt) {
osmType,
osmType,
osmId: String(el.id),
osmId: String(el.id),
name: String(tags.name).slice(0, 200),
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,
kind,
lat,
lat,
lon,
lon,
@@ -56,7 +56,7 @@ function wirePlace(row) {
@@ -56,7 +56,7 @@ function wirePlace(row) {
if (!row) return null;
if (!row) return null;
return {
return {
id: row.id, city_id: row.city_id, city_slug: row.city_slug || null, city_name: row.city_name || null,
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,
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,
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,
wikipedia: row.wikipedia || null, image_url: row.image_url || null, image_credit: row.image_credit || null,
@@ -325,10 +325,10 @@ function createStore(s, { config = {} } = {}) {
@@ -325,10 +325,10 @@ function createStore(s, { config = {} } = {}) {
const id = stableId('plc', `${p.osmType}:${p.osmId}`);
const id = stableId('plc', `${p.osmType}:${p.osmId}`);
await db.exec(`INSERT INTO tours_places
await db.exec(`INSERT INTO tours_places
(id, city_id, osm_type, osm_id, name, kind, lat, lon, address, website, opening_hours, wheelchair, fee,
(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
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,
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,
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,
image_url = EXCLUDED.image_url, image_credit = EXCLUDED.image_credit, image_license = EXCLUDED.image_license,
@@ -339,7 +339,7 @@ function createStore(s, { config = {} } = {}) {
@@ -339,7 +339,7 @@ function createStore(s, { config = {} } = {}) {
p.wheelchair || null, p.fee || null, p.wikidataId || null, p.wikipedia || null, p.imageUrl || null,
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.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,
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;
return id;
}
}
@@ -70,9 +70,14 @@ const cityCard = (c) => kit.card({
@@ -70,9 +70,14 @@ const cityCard = (c) => kit.card({
level: 3,
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({
const placeCard = (p) => kit.card({
href: `/place/${p.id}`,
href: `/place/${p.id}`,
- title: p.name,
+ title: placeName(p),
type: 'map',
type: 'map',
image: safeImage(p.image_url) || '',
image: safeImage(p.image_url) || '',
badge: p.kind ? p.kind.replace(/_/g, ' ') : '',
badge: p.kind ? p.kind.replace(/_/g, ' ') : '',
@@ -125,8 +130,8 @@ function placeOfDayCard(p) {
@@ -125,8 +130,8 @@ function placeOfDayCard(p) {
if (!p) return '';
if (!p) return '';
return `<article class="tr-pod" data-place="${esc(p.id)}">
return `<article class="tr-pod" data-place="${esc(p.id)}">
<a class="tr-pod-link" href="/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>
<span class="tr-muted">${esc(p.kind ? p.kind.replace(/_/g, ' ') : 'place')}${p.description ? ` · ${esc(clip(p.description, 150))}` : ''}</span></span>
</a></article>`;
</a></article>`;
}
}
@@ -243,7 +248,7 @@ ${img ? `<figure class="tr-city-img"><img src="${esc(img)}" alt="${esc(city.name
@@ -243,7 +248,7 @@ ${img ? `<figure class="tr-city-img"><img src="${esc(img)}" alt="${esc(city.name
</header>
</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>` : ''}
${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>
<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>
<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>` : ''}
${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>` : ''}
${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
@@ -254,10 +259,10 @@ ${stays.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Stays</h2
function placePage({ place, routeList = [], trips = [] } = {}) {
function placePage({ place, routeList = [], trips = [] } = {}) {
return `<div class="tr-wrap" id="tr-place-page">
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>
<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>
<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">
<dl class="tr-facts">
${place.address ? `<div><dt>Address</dt><dd>${esc(place.address)}</dd></div>` : ''}
${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>` : ''}
${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
@@ -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>
<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>` : ''}
<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>
<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>` : ''}
${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>
<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>
<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() {
@@ -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>`;
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -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();
+ }
+})();