openvibers/openvibe.tours Commits
Tours on real data: walks keep to their city, pictures load, '1 place', no half words (#3)
Changes
@@@ -83,7 +83,7 @@ async function createApp(opts = {}) {scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com', 'https://unpkg.com'],styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://unpkg.com'],fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],-- imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.live', 'https://openvibe.rent', 'https://upload.wikimedia.org', 'https://commons.wikimedia.org', 'https://*.tile.openstreetmap.org', 'https://unpkg.com'],++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.live', 'https://openvibe.rent', 'https://upload.wikimedia.org', 'https://thumb.wikimedia.org', 'https://openvibe.media', 'https://commons.wikimedia.org', 'https://*.tile.openstreetmap.org', 'https://unpkg.com'],connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.live', 'https://openvibe.rent', 'https://*.tile.openstreetmap.org', 'https://unpkg.com'],workerSrc: ["'self'", 'blob:'],frameSrc: ["'self'", 'https://openvibe.network'],@@@ -27,7 +27,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 },-- routes: { run: creators.routes.runOnce, maxAgeMs: config.jobs.routesEveryMs, intervalMs: config.jobs.routesEveryMs },++ 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 },};@@@ -50,6 +50,8 @@ function createJobs({ config, s, store, fetchImpl = globalThis.fetch, log = conslet counts = row.counts;if (typeof counts === 'string') { try { counts = JSON.parse(counts); } catch { counts = null; } }if (!producedOf(counts)) return false;++ // A run made under older rules (a job that names a version) is not fresh: the new rules apply at the next boot.++ if (jobs[name].version && (!counts || counts.version !== jobs[name].version)) return false;return Date.now() - new Date(row.finished_at).getTime() < jobs[name].maxAgeMs;}@@@ -26,6 +26,7 @@ function createRoutesJob({ config, s, store, fetchImpl, log = console }) {async function runOnce({ limit = config.openData.cityLimit } = {}) {const cities = await store.listCities({ limit });const result = { cities: 0, routes: 0, stops: 0, noStops: [], errors: [] };++ result.version = ROUTES_VERSION;for (const city of cities) {const source = await store.citySource(city.id);if (!source) { result.noStops.push(city.slug); continue; }@@@ -41,9 +42,10 @@ function createRoutesJob({ config, s, store, fetchImpl, log = console }) {}}const usable = listings.filter((x) => x && x.hasCoords && Number.isFinite(Number(x.lat)) && Number.isFinite(Number(x.lon)));-- if (usable.length < 4) { result.noStops.push(city.slug); continue; }++ // A city with no walk left keeps no routes from an earlier run (they may be the ones this fixes).++ if (usable.length < 4) { await store.replaceRoutesForCity(city.id, []); result.noStops.push(city.slug); continue; }const places = await store.cityPlaces(city.id, { limit: 200 });-- const chunks = usable.length >= 8 ? [usable.slice(0, 7), usable.slice(7, 14)] : [usable.slice(0, 8)];++ const chunks = walksOf(usable, city);const routes = [];for (const [i, stops] of chunks.entries()) {if (stops.length < 4) continue;@@@ -74,7 +76,7 @@ function createRoutesJob({ config, s, store, fetchImpl, log = console }) {}),});}-- if (!routes.length) { result.noStops.push(city.slug); continue; }++ if (!routes.length) { await store.replaceRoutesForCity(city.id, []); result.noStops.push(city.slug); continue; }await store.replaceRoutesForCity(city.id, routes);result.cities += 1;result.routes += routes.length;@@@ -84,7 +86,46 @@ function createRoutesJob({ config, s, store, fetchImpl, log = console }) {return result;}-- return { runOnce, haversineM };++ return { runOnce, haversineM, version: ROUTES_VERSION };}--module.exports = { createRoutesJob, haversineM, routeTitle };++/**++ * The walks in a See list, as walks (2026-11-01: production showed "Pune: 16623.8 km · 221686 min"). A listing whose++ * coordinates are far from the city (a namesake elsewhere, a typo) is left out; the rest are put in walking order, nearest++ * next, starting from the one closest to the centre; a walk ends at a hop longer than MAX_HOP_M or at MAX_STOPS stops.++ * → [[stop, …], …] each with at least MIN_STOPS stops, at most two walks.++ */++// The walks' rules, by name: a run made under older rules is not fresh, so a deploy that changes them rebuilds every++// route at once instead of in a week (jobs/index.js isFresh).++const ROUTES_VERSION = 'walks-1';++const MAX_FROM_CENTRE_M = 30_000;++const MAX_HOP_M = 8_000; // a city's See list spans districts: a long hop is a tram ride, a 16 000 km one is a namesake++const MAX_STOPS = 7;++const MIN_STOPS = 4;++function walksOf(listings, centre) {++ const pts = listings.filter((x) => x && Number.isFinite(Number(x.lat)) && Number.isFinite(Number(x.lon))).map((x) => ({ ...x, lat: Number(x.lat), lon: Number(x.lon) }));++ if (!pts.length) return [];++ let c = centre && Number.isFinite(Number(centre.lat)) && Number.isFinite(Number(centre.lon)) ? { lat: Number(centre.lat), lon: Number(centre.lon) } : null;++ if (!c) {++ const mid = (arr) => { const s = arr.slice().sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; };++ c = { lat: mid(pts.map((p) => p.lat)), lon: mid(pts.map((p) => p.lon)) };++ }++ const left = pts.filter((p) => haversineM(c, p) <= MAX_FROM_CENTRE_M);++ const walks = [];++ while (left.length >= MIN_STOPS && walks.length < 2) {++ left.sort((a, b) => haversineM(c, a) - haversineM(c, b));++ const walk = [left.shift()];++ while (walk.length < MAX_STOPS && left.length) {++ const here = walk[walk.length - 1];++ let best = 0;++ for (let i = 1; i < left.length; i += 1) if (haversineM(here, left[i]) < haversineM(here, left[best])) best = i;++ if (haversineM(here, left[best]) > MAX_HOP_M) break;++ walk.push(left.splice(best, 1)[0]);++ }++ if (walk.length >= MIN_STOPS) walks.push(walk);++ else if (!walks.length && left.length < MIN_STOPS) break;++ }++ return walks;++}++++module.exports = { createRoutesJob, haversineM, routeTitle, walksOf, MAX_HOP_M, MAX_FROM_CENTRE_M };@@@ -55,7 +55,7 @@ const cityCard = (c) => kit.card({alt: c.image_credit ? `${c.name} (${c.image_credit})` : c.name,badge: c.country || '',who: c.country || '',-- count: [Number(c.place_count) || 0, 'places'],++ count: [Number(c.place_count) || 0, Number(c.place_count) === 1 ? 'place' : 'places'],peek: c.summary ? String(c.summary).slice(0, 120) : '',level: 3,});@@@ -108,13 +108,16 @@ const stayCard = (s) => kit.card({level: 3,});++// Cut at a word, with an ellipsis: never half a word ("was built at Dnyan").++const clip = (s, n) => { const t = String(s || '').trim(); if (t.length <= n) return t; const cut = t.slice(0, n); const at = cut.lastIndexOf(' '); return `${(at > n * 0.6 ? cut.slice(0, at) : cut).replace(/[\s,;:·-]+$/, '')}…`; };++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)}">${safeHttp(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>--<span class="tr-muted">${esc(p.kind ? p.kind.replace(/_/g, ' ') : 'place')}${p.description ? ` · ${esc(String(p.description).slice(0, 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>`;}@@@ -55,7 +55,8 @@ const { boot, check, done } = require('./helpers/boot');const out = await t.ctx.jobs.runOnce('routes', { limit: 100 });assert.strictEqual(out.status, 'ok', JSON.stringify(out));assert.strictEqual(out.counts.routes, 6);-- assert.strictEqual(out.counts.stops, 36);++ // 34, not every See listing: two sights more than 8 km on are too few for a second walk (routes.js walksOf).++ assert.strictEqual(out.counts.stops, 34);const route = await t.ctx.s.db.maybe("SELECT * FROM tours_routes WHERE slug = 'tokyo-see-1'");assert.strictEqual(route.generated, true);assert.match(route.source_url, /wikivoyage\.org/);@@@ -67,7 +68,7 @@ const { boot, check, done } = require('./helpers/boot');const again = await t.ctx.jobs.runOnce('routes', { limit: 100 });assert.strictEqual(again.counts.routes, 6);assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM tours_routes'), 6);-- assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM tours_route_stops'), 36);++ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM tours_route_stops'), 34);});await check('live: IRL streams, VODs and clips only, public Live/video URLs, a city when the title names one', async () => {@@@ -106,7 +107,30 @@ const { boot, check, done } = require('./helpers/boot');await db.exec(`INSERT INTO tours_job_runs (job, started_at, finished_at, status, counts) VALUES ('routes', now(), now(), 'ok', $1::jsonb)`, [JSON.stringify({ cities: 0, routes: 0, errors: [] })]);assert.strictEqual(await t.ctx.jobs.isFresh('routes'), false, 'an empty run is not fresh');await db.exec(`INSERT INTO tours_job_runs (job, started_at, finished_at, status, counts) VALUES ('routes', now() + interval '1 second', now() + interval '1 second', 'ok', $1::jsonb)`, [JSON.stringify({ cities: 3, routes: 2, errors: [] })]);-- assert.strictEqual(await t.ctx.jobs.isFresh('routes'), true, 'a run that worked is');++ assert.strictEqual(await t.ctx.jobs.isFresh('routes'), false, 'a run made under older walk rules is not fresh either');++ await db.exec(`INSERT INTO tours_job_runs (job, started_at, finished_at, status, counts) VALUES ('routes', now() + interval '2 seconds', now() + interval '2 seconds', 'ok', $1::jsonb)`, [JSON.stringify({ cities: 3, routes: 2, errors: [], version: require('../server/jobs/routes').ROUTES_VERSION || 'walks-1' })]);++ assert.strictEqual(await t.ctx.jobs.isFresh('routes'), true, 'a run that worked under the current rules is');++ });++++ await check('a walk keeps to its city and to walking order (production had "Pune: 16623.8 km")', async () => {++ const { walksOf, haversineM } = require('../server/jobs/routes');++ const lisbon = { lat: 38.7223, lon: -9.1393 };++ const see = [++ { name: 'Castelo de São Jorge', lat: 38.7139, lon: -9.1335 },++ { name: 'Belém Tower', lat: 38.6916, lon: -9.2160 },++ { name: 'Lisbon Cathedral', lat: 38.7097, lon: -9.1336 },++ { name: 'Santa Justa Lift', lat: 38.7121, lon: -9.1394 },++ { name: 'Jerónimos (a namesake in Brazil)', lat: -22.9, lon: -43.2 },++ { name: 'Praça do Comércio', lat: 38.7075, lon: -9.1364 },++ ];++ const walks = walksOf(see, lisbon);++ assert.strictEqual(walks.length, 1);++ assert.ok(!walks[0].some((s) => /Brazil/.test(s.name)), 'the namesake an ocean away is left out');++ const km = walks[0].slice(1).reduce((n, s, i) => n + haversineM(walks[0][i], s), 0) / 1000;++ assert.ok(km < 15, `a walk, not a flight (${km.toFixed(1)} km)`);++ const nearest = see.slice().sort((a, b) => haversineM(lisbon, a) - haversineM(lisbon, b))[0].name;++ assert.strictEqual(walks[0][0].name, nearest, 'it starts nearest the centre');++ assert.deepStrictEqual(walksOf(see.slice(0, 3), lisbon), [], 'fewer than four stops is no walk');});await check('every job left a receipt with its counts', async () => {@@@ -28,6 +28,22 @@ const { seed } = require('./helpers/seed');assert.match(r.text, /<main id="main"/);});++ await check('home: the pictures it shows are allowed to load (Wikimedia thumbnails, Live\'s recordings)', async () => {++ const r = await t.get('/');++ const img = String(r.headers.get('content-security-policy') || '').split(';').find((d) => d.trim().startsWith('img-src')) || '';++ ['https://upload.wikimedia.org', 'https://thumb.wikimedia.org', 'https://openvibe.media'].forEach((h) => assert.ok(img.includes(h), `img-src allows ${h}`));++ assert.ok(!/\b1 places\b/.test(r.text), 'one place is "1 place"');++ });++++ await check('place of the day: a long description is cut at a word, with an ellipsis', async () => {++ const views = require('../server/views/index');++ const html = views.home ? views.home({ placeOfDay: { id: 'plc_x', name: 'Alandi', city_name: 'Pune', kind: 'attraction', description: 'The resting place of the 13th century Marathi saint Sant Dnyaneshwar. His devotees believe that he is still alive. A temple complex was built at Dnyaneshwar samadhi.' } }) : '';++ if (!html) return;++ const m = /Place of the day[\s\S]*?<span class="tr-muted">([^<]*)<\/span>/.exec(String(html));++ assert.ok(m, 'the card renders');++ assert.ok(/…$/.test(m[1]) && !/Dnyan…$/.test(m[1]), `cut at a word: ${m[1].slice(-30)}`);++ });++await check('city page: summary, image licence, places, routes, live, stays and Tourist JSON-LD', async () => {const r = await t.get('/city/lisbon');assert.strictEqual(r.status, 200);
@@ -83,7 +83,7 @@ async function createApp(opts = {}) {
@@ -83,7 +83,7 @@ async function createApp(opts = {}) {
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com', 'https://unpkg.com'],
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com', 'https://unpkg.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://unpkg.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://unpkg.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
- imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.live', 'https://openvibe.rent', 'https://upload.wikimedia.org', 'https://commons.wikimedia.org', 'https://*.tile.openstreetmap.org', 'https://unpkg.com'],
+ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.live', 'https://openvibe.rent', 'https://upload.wikimedia.org', 'https://thumb.wikimedia.org', 'https://openvibe.media', 'https://commons.wikimedia.org', 'https://*.tile.openstreetmap.org', 'https://unpkg.com'],
connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.live', 'https://openvibe.rent', 'https://*.tile.openstreetmap.org', 'https://unpkg.com'],
connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.live', 'https://openvibe.rent', 'https://*.tile.openstreetmap.org', 'https://unpkg.com'],
workerSrc: ["'self'", 'blob:'],
workerSrc: ["'self'", 'blob:'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameSrc: ["'self'", 'https://openvibe.network'],
@@ -27,7 +27,7 @@ function createJobs({ config, s, store, fetchImpl = globalThis.fetch, log = cons
@@ -27,7 +27,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 },
- routes: { run: creators.routes.runOnce, maxAgeMs: config.jobs.routesEveryMs, intervalMs: config.jobs.routesEveryMs },
+ 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 },
};
};
@@ -50,6 +50,8 @@ function createJobs({ config, s, store, fetchImpl = globalThis.fetch, log = cons
@@ -50,6 +50,8 @@ function createJobs({ config, s, store, fetchImpl = globalThis.fetch, log = cons
let counts = row.counts;
let counts = row.counts;
if (typeof counts === 'string') { try { counts = JSON.parse(counts); } catch { counts = null; } }
if (typeof counts === 'string') { try { counts = JSON.parse(counts); } catch { counts = null; } }
if (!producedOf(counts)) return false;
if (!producedOf(counts)) return false;
+ // A run made under older rules (a job that names a version) is not fresh: the new rules apply at the next boot.
+ if (jobs[name].version && (!counts || counts.version !== jobs[name].version)) return false;
return Date.now() - new Date(row.finished_at).getTime() < jobs[name].maxAgeMs;
return Date.now() - new Date(row.finished_at).getTime() < jobs[name].maxAgeMs;
}
}
@@ -26,6 +26,7 @@ function createRoutesJob({ config, s, store, fetchImpl, log = console }) {
@@ -26,6 +26,7 @@ function createRoutesJob({ config, s, store, fetchImpl, log = console }) {
async function runOnce({ limit = config.openData.cityLimit } = {}) {
async function runOnce({ limit = config.openData.cityLimit } = {}) {
const cities = await store.listCities({ limit });
const cities = await store.listCities({ limit });
const result = { cities: 0, routes: 0, stops: 0, noStops: [], errors: [] };
const result = { cities: 0, routes: 0, stops: 0, noStops: [], errors: [] };
+ result.version = ROUTES_VERSION;
for (const city of cities) {
for (const city of cities) {
const source = await store.citySource(city.id);
const source = await store.citySource(city.id);
if (!source) { result.noStops.push(city.slug); continue; }
if (!source) { result.noStops.push(city.slug); continue; }
@@ -41,9 +42,10 @@ function createRoutesJob({ config, s, store, fetchImpl, log = console }) {
@@ -41,9 +42,10 @@ function createRoutesJob({ config, s, store, fetchImpl, log = console }) {
}
}
}
}
const usable = listings.filter((x) => x && x.hasCoords && Number.isFinite(Number(x.lat)) && Number.isFinite(Number(x.lon)));
const usable = listings.filter((x) => x && x.hasCoords && Number.isFinite(Number(x.lat)) && Number.isFinite(Number(x.lon)));
- if (usable.length < 4) { result.noStops.push(city.slug); continue; }
+ // A city with no walk left keeps no routes from an earlier run (they may be the ones this fixes).
+ if (usable.length < 4) { await store.replaceRoutesForCity(city.id, []); result.noStops.push(city.slug); continue; }
const places = await store.cityPlaces(city.id, { limit: 200 });
const places = await store.cityPlaces(city.id, { limit: 200 });
- const chunks = usable.length >= 8 ? [usable.slice(0, 7), usable.slice(7, 14)] : [usable.slice(0, 8)];
+ const chunks = walksOf(usable, city);
const routes = [];
const routes = [];
for (const [i, stops] of chunks.entries()) {
for (const [i, stops] of chunks.entries()) {
if (stops.length < 4) continue;
if (stops.length < 4) continue;
@@ -74,7 +76,7 @@ function createRoutesJob({ config, s, store, fetchImpl, log = console }) {
@@ -74,7 +76,7 @@ function createRoutesJob({ config, s, store, fetchImpl, log = console }) {
}),
}),
});
});
}
}
- if (!routes.length) { result.noStops.push(city.slug); continue; }
+ if (!routes.length) { await store.replaceRoutesForCity(city.id, []); result.noStops.push(city.slug); continue; }
await store.replaceRoutesForCity(city.id, routes);
await store.replaceRoutesForCity(city.id, routes);
result.cities += 1;
result.cities += 1;
result.routes += routes.length;
result.routes += routes.length;
@@ -84,7 +86,46 @@ function createRoutesJob({ config, s, store, fetchImpl, log = console }) {
@@ -84,7 +86,46 @@ function createRoutesJob({ config, s, store, fetchImpl, log = console }) {
return result;
return result;
}
}
- return { runOnce, haversineM };
+ return { runOnce, haversineM, version: ROUTES_VERSION };
}
}
-module.exports = { createRoutesJob, haversineM, routeTitle };
+/**
+ * The walks in a See list, as walks (2026-11-01: production showed "Pune: 16623.8 km · 221686 min"). A listing whose
+ * coordinates are far from the city (a namesake elsewhere, a typo) is left out; the rest are put in walking order, nearest
+ * next, starting from the one closest to the centre; a walk ends at a hop longer than MAX_HOP_M or at MAX_STOPS stops.
+ * → [[stop, …], …] each with at least MIN_STOPS stops, at most two walks.
+ */
+// The walks' rules, by name: a run made under older rules is not fresh, so a deploy that changes them rebuilds every
+// route at once instead of in a week (jobs/index.js isFresh).
+const ROUTES_VERSION = 'walks-1';
+const MAX_FROM_CENTRE_M = 30_000;
+const MAX_HOP_M = 8_000; // a city's See list spans districts: a long hop is a tram ride, a 16 000 km one is a namesake
+const MAX_STOPS = 7;
+const MIN_STOPS = 4;
+function walksOf(listings, centre) {
+ const pts = listings.filter((x) => x && Number.isFinite(Number(x.lat)) && Number.isFinite(Number(x.lon))).map((x) => ({ ...x, lat: Number(x.lat), lon: Number(x.lon) }));
+ if (!pts.length) return [];
+ let c = centre && Number.isFinite(Number(centre.lat)) && Number.isFinite(Number(centre.lon)) ? { lat: Number(centre.lat), lon: Number(centre.lon) } : null;
+ if (!c) {
+ const mid = (arr) => { const s = arr.slice().sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; };
+ c = { lat: mid(pts.map((p) => p.lat)), lon: mid(pts.map((p) => p.lon)) };
+ }
+ const left = pts.filter((p) => haversineM(c, p) <= MAX_FROM_CENTRE_M);
+ const walks = [];
+ while (left.length >= MIN_STOPS && walks.length < 2) {
+ left.sort((a, b) => haversineM(c, a) - haversineM(c, b));
+ const walk = [left.shift()];
+ while (walk.length < MAX_STOPS && left.length) {
+ const here = walk[walk.length - 1];
+ let best = 0;
+ for (let i = 1; i < left.length; i += 1) if (haversineM(here, left[i]) < haversineM(here, left[best])) best = i;
+ if (haversineM(here, left[best]) > MAX_HOP_M) break;
+ walk.push(left.splice(best, 1)[0]);
+ }
+ if (walk.length >= MIN_STOPS) walks.push(walk);
+ else if (!walks.length && left.length < MIN_STOPS) break;
+ }
+ return walks;
+}
+
+module.exports = { createRoutesJob, haversineM, routeTitle, walksOf, MAX_HOP_M, MAX_FROM_CENTRE_M };
@@ -55,7 +55,7 @@ const cityCard = (c) => kit.card({
@@ -55,7 +55,7 @@ const cityCard = (c) => kit.card({
alt: c.image_credit ? `${c.name} (${c.image_credit})` : c.name,
alt: c.image_credit ? `${c.name} (${c.image_credit})` : c.name,
badge: c.country || '',
badge: c.country || '',
who: c.country || '',
who: c.country || '',
- count: [Number(c.place_count) || 0, 'places'],
+ count: [Number(c.place_count) || 0, Number(c.place_count) === 1 ? 'place' : 'places'],
peek: c.summary ? String(c.summary).slice(0, 120) : '',
peek: c.summary ? String(c.summary).slice(0, 120) : '',
level: 3,
level: 3,
});
});
@@ -108,13 +108,16 @@ const stayCard = (s) => kit.card({
@@ -108,13 +108,16 @@ const stayCard = (s) => kit.card({
level: 3,
level: 3,
});
});
+// Cut at a word, with an ellipsis: never half a word ("was built at Dnyan").
+const clip = (s, n) => { const t = String(s || '').trim(); if (t.length <= n) return t; const cut = t.slice(0, n); const at = cut.lastIndexOf(' '); return `${(at > n * 0.6 ? cut.slice(0, at) : cut).replace(/[\s,;:·-]+$/, '')}…`; };
+
function placeOfDayCard(p) {
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)}">
${safeHttp(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>'}
${safeHttp(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>
<span class="tr-pod-body"><span class="tr-kicker">Place of the day · ${esc(p.city_name || '')}</span><strong>${esc(p.name)}</strong>
-<span class="tr-muted">${esc(p.kind ? p.kind.replace(/_/g, ' ') : 'place')}${p.description ? ` · ${esc(String(p.description).slice(0, 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>`;
}
}
@@ -55,7 +55,8 @@ const { boot, check, done } = require('./helpers/boot');
@@ -55,7 +55,8 @@ const { boot, check, done } = require('./helpers/boot');
const out = await t.ctx.jobs.runOnce('routes', { limit: 100 });
const out = await t.ctx.jobs.runOnce('routes', { limit: 100 });
assert.strictEqual(out.status, 'ok', JSON.stringify(out));
assert.strictEqual(out.status, 'ok', JSON.stringify(out));
assert.strictEqual(out.counts.routes, 6);
assert.strictEqual(out.counts.routes, 6);
- assert.strictEqual(out.counts.stops, 36);
+ // 34, not every See listing: two sights more than 8 km on are too few for a second walk (routes.js walksOf).
+ assert.strictEqual(out.counts.stops, 34);
const route = await t.ctx.s.db.maybe("SELECT * FROM tours_routes WHERE slug = 'tokyo-see-1'");
const route = await t.ctx.s.db.maybe("SELECT * FROM tours_routes WHERE slug = 'tokyo-see-1'");
assert.strictEqual(route.generated, true);
assert.strictEqual(route.generated, true);
assert.match(route.source_url, /wikivoyage\.org/);
assert.match(route.source_url, /wikivoyage\.org/);
@@ -67,7 +68,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -67,7 +68,7 @@ const { boot, check, done } = require('./helpers/boot');
const again = await t.ctx.jobs.runOnce('routes', { limit: 100 });
const again = await t.ctx.jobs.runOnce('routes', { limit: 100 });
assert.strictEqual(again.counts.routes, 6);
assert.strictEqual(again.counts.routes, 6);
assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM tours_routes'), 6);
assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM tours_routes'), 6);
- assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM tours_route_stops'), 36);
+ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM tours_route_stops'), 34);
});
});
await check('live: IRL streams, VODs and clips only, public Live/video URLs, a city when the title names one', async () => {
await check('live: IRL streams, VODs and clips only, public Live/video URLs, a city when the title names one', async () => {
@@ -106,7 +107,30 @@ const { boot, check, done } = require('./helpers/boot');
@@ -106,7 +107,30 @@ const { boot, check, done } = require('./helpers/boot');
await db.exec(`INSERT INTO tours_job_runs (job, started_at, finished_at, status, counts) VALUES ('routes', now(), now(), 'ok', $1::jsonb)`, [JSON.stringify({ cities: 0, routes: 0, errors: [] })]);
await db.exec(`INSERT INTO tours_job_runs (job, started_at, finished_at, status, counts) VALUES ('routes', now(), now(), 'ok', $1::jsonb)`, [JSON.stringify({ cities: 0, routes: 0, errors: [] })]);
assert.strictEqual(await t.ctx.jobs.isFresh('routes'), false, 'an empty run is not fresh');
assert.strictEqual(await t.ctx.jobs.isFresh('routes'), false, 'an empty run is not fresh');
await db.exec(`INSERT INTO tours_job_runs (job, started_at, finished_at, status, counts) VALUES ('routes', now() + interval '1 second', now() + interval '1 second', 'ok', $1::jsonb)`, [JSON.stringify({ cities: 3, routes: 2, errors: [] })]);
await db.exec(`INSERT INTO tours_job_runs (job, started_at, finished_at, status, counts) VALUES ('routes', now() + interval '1 second', now() + interval '1 second', 'ok', $1::jsonb)`, [JSON.stringify({ cities: 3, routes: 2, errors: [] })]);
- assert.strictEqual(await t.ctx.jobs.isFresh('routes'), true, 'a run that worked is');
+ assert.strictEqual(await t.ctx.jobs.isFresh('routes'), false, 'a run made under older walk rules is not fresh either');
+ await db.exec(`INSERT INTO tours_job_runs (job, started_at, finished_at, status, counts) VALUES ('routes', now() + interval '2 seconds', now() + interval '2 seconds', 'ok', $1::jsonb)`, [JSON.stringify({ cities: 3, routes: 2, errors: [], version: require('../server/jobs/routes').ROUTES_VERSION || 'walks-1' })]);
+ assert.strictEqual(await t.ctx.jobs.isFresh('routes'), true, 'a run that worked under the current rules is');
+ });
+
+ await check('a walk keeps to its city and to walking order (production had "Pune: 16623.8 km")', async () => {
+ const { walksOf, haversineM } = require('../server/jobs/routes');
+ const lisbon = { lat: 38.7223, lon: -9.1393 };
+ const see = [
+ { name: 'Castelo de São Jorge', lat: 38.7139, lon: -9.1335 },
+ { name: 'Belém Tower', lat: 38.6916, lon: -9.2160 },
+ { name: 'Lisbon Cathedral', lat: 38.7097, lon: -9.1336 },
+ { name: 'Santa Justa Lift', lat: 38.7121, lon: -9.1394 },
+ { name: 'Jerónimos (a namesake in Brazil)', lat: -22.9, lon: -43.2 },
+ { name: 'Praça do Comércio', lat: 38.7075, lon: -9.1364 },
+ ];
+ const walks = walksOf(see, lisbon);
+ assert.strictEqual(walks.length, 1);
+ assert.ok(!walks[0].some((s) => /Brazil/.test(s.name)), 'the namesake an ocean away is left out');
+ const km = walks[0].slice(1).reduce((n, s, i) => n + haversineM(walks[0][i], s), 0) / 1000;
+ assert.ok(km < 15, `a walk, not a flight (${km.toFixed(1)} km)`);
+ const nearest = see.slice().sort((a, b) => haversineM(lisbon, a) - haversineM(lisbon, b))[0].name;
+ assert.strictEqual(walks[0][0].name, nearest, 'it starts nearest the centre');
+ assert.deepStrictEqual(walksOf(see.slice(0, 3), lisbon), [], 'fewer than four stops is no walk');
});
});
await check('every job left a receipt with its counts', async () => {
await check('every job left a receipt with its counts', async () => {
@@ -28,6 +28,22 @@ const { seed } = require('./helpers/seed');
@@ -28,6 +28,22 @@ const { seed } = require('./helpers/seed');
assert.match(r.text, /<main id="main"/);
assert.match(r.text, /<main id="main"/);
});
});
+ await check('home: the pictures it shows are allowed to load (Wikimedia thumbnails, Live\'s recordings)', async () => {
+ const r = await t.get('/');
+ const img = String(r.headers.get('content-security-policy') || '').split(';').find((d) => d.trim().startsWith('img-src')) || '';
+ ['https://upload.wikimedia.org', 'https://thumb.wikimedia.org', 'https://openvibe.media'].forEach((h) => assert.ok(img.includes(h), `img-src allows ${h}`));
+ assert.ok(!/\b1 places\b/.test(r.text), 'one place is "1 place"');
+ });
+
+ await check('place of the day: a long description is cut at a word, with an ellipsis', async () => {
+ const views = require('../server/views/index');
+ const html = views.home ? views.home({ placeOfDay: { id: 'plc_x', name: 'Alandi', city_name: 'Pune', kind: 'attraction', description: 'The resting place of the 13th century Marathi saint Sant Dnyaneshwar. His devotees believe that he is still alive. A temple complex was built at Dnyaneshwar samadhi.' } }) : '';
+ if (!html) return;
+ const m = /Place of the day[\s\S]*?<span class="tr-muted">([^<]*)<\/span>/.exec(String(html));
+ assert.ok(m, 'the card renders');
+ assert.ok(/…$/.test(m[1]) && !/Dnyan…$/.test(m[1]), `cut at a word: ${m[1].slice(-30)}`);
+ });
+
await check('city page: summary, image licence, places, routes, live, stays and Tourist JSON-LD', async () => {
await check('city page: summary, image licence, places, routes, live, stays and Tourist JSON-LD', async () => {
const r = await t.get('/city/lisbon');
const r = await t.get('/city/lisbon');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);