mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Get the pictures right and stop reusing stale answers
Three things you could see on dev1. Pictures were "roughly right" rather than right, because a coordinate search only knows what was photographed within 300 m. Wikidata's P18 is one picture somebody chose to represent that exact object - "Zurletzteninstanz.jpg" for the restaurant, not a statue down the road - so it now leads, then the Commons category, and geosearch is the last resort it should always have been. Small venues carry a wikidata tag far more often than a Commons category, so this is also what reaches the places the other sources miss. Descriptions stayed on Wikipedia after the switch to Wikivoyage because the week-long result cache predates it. It carries a format version now; a release that changes the shape or the sources invalidates its own entries instead of serving last week's for another six days. Ratings and opening hours were being fetched from Google and then dropped on the floor - the fact list only looked at OpenStreetMap tags. Both are shown now, whichever provider supplied them. Also: the column follows a place opened from the map, not only one picked from search. A POI tap arrives as prefillCoords and never touched the selection, so the column kept showing the previously added place. Pictures are a tight three-column grid and only the picture in play is credited in full, on one line. Crediting all of them at two lines each drowned the column, and the licence attaches to the one that gets used - whose credit also stays visible in the inspector after saving. The request carries the place record the dialog already fetched, so the server skips a details lookup it was making in parallel with the client's own. That lookup was measured at 12.8 s for a large OSM relation.
This commit is contained in:
@@ -934,7 +934,7 @@ export const mapsApi = {
|
||||
// the caller is expected to abort it when the selection changes, and a longer
|
||||
// timeout than the global 8s — a cold Wikimedia connection alone can eat that.
|
||||
placeEnrichment: (
|
||||
body: { placeId?: string; lat: number; lng: number; name: string; lang?: string },
|
||||
body: { placeId?: string; lat: number; lng: number; name: string; lang?: string; details?: Record<string, unknown> },
|
||||
signal?: AbortSignal,
|
||||
) => apiClient.post('/maps/enrichment', body, { signal, timeout: 25000 })
|
||||
.then(r => checkInDev(mapsPlaceEnrichmentResultSchema, r.data, 'maps.placeEnrichment')),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ScrollText,
|
||||
ShoppingBag,
|
||||
Sprout,
|
||||
Star,
|
||||
Sun,
|
||||
Wifi,
|
||||
} from 'lucide-react'
|
||||
@@ -26,6 +27,8 @@ export interface PlaceDetailsSelection {
|
||||
lat: number
|
||||
lng: number
|
||||
name: string
|
||||
/** The picked search result, so the server can skip its own details lookup. */
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface PlaceDetailsColumnProps {
|
||||
@@ -128,7 +131,14 @@ export default function PlaceDetailsColumn({
|
||||
|
||||
mapsApi
|
||||
.placeEnrichment(
|
||||
{ placeId: selection.placeId, lat: selection.lat, lng: selection.lng, name: selection.name, lang: language },
|
||||
{
|
||||
placeId: selection.placeId,
|
||||
lat: selection.lat,
|
||||
lng: selection.lng,
|
||||
name: selection.name,
|
||||
lang: language,
|
||||
details: selection.details,
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
.then((result) => {
|
||||
@@ -204,11 +214,17 @@ export default function PlaceDetailsColumn({
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture strip: the first candidate wide, the rest as a row underneath.
|
||||
* The picture strip.
|
||||
*
|
||||
* A plain two-column grid left a hole whenever an odd number of pictures came
|
||||
* back, which is most of the time. A lead image also gives the column something
|
||||
* to look at instead of a page of equal squares.
|
||||
* A tight three-column grid rather than a lead image: this sits beside a form
|
||||
* in a dialog, and pictures that take half the column push the facts and the
|
||||
* description out of sight.
|
||||
*
|
||||
* Only the picture in play is credited in full. Crediting all of them at once
|
||||
* cost two lines each and drowned the column, and the licence obligation
|
||||
* attaches to the picture that gets used — which is the selected one, whose
|
||||
* credit also stays visible in the inspector after saving. Every tile still
|
||||
* carries the full credit as its tooltip and links to its source page.
|
||||
*/
|
||||
function PhotoStrip({
|
||||
photos,
|
||||
@@ -221,88 +237,95 @@ function PhotoStrip({
|
||||
onPickImage: (url: string | null) => void
|
||||
t: TranslationFn
|
||||
}): React.ReactElement | null {
|
||||
const [hovered, setHovered] = useState<string | null>(null)
|
||||
if (photos.length === 0) return null
|
||||
|
||||
const [lead, ...rest] = photos
|
||||
const shown = photos.find((p) => p.url === (hovered ?? selectedImageUrl)) ?? photos[0]
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Overline>{t('places.details.pickImage')}</Overline>
|
||||
<PhotoTile photo={lead} wide selected={selectedImageUrl === lead.url} onPick={onPickImage} t={t} />
|
||||
{rest.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{rest.map((photo) => (
|
||||
<PhotoTile
|
||||
key={photo.key}
|
||||
photo={photo}
|
||||
selected={selectedImageUrl === photo.url}
|
||||
onPick={onPickImage}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{photos.map((photo) => (
|
||||
<PhotoTile
|
||||
key={photo.key}
|
||||
photo={photo}
|
||||
selected={selectedImageUrl === photo.url}
|
||||
onPick={onPickImage}
|
||||
onHover={setHovered}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<PhotoCredit photo={shown} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PhotoTile({
|
||||
photo,
|
||||
wide = false,
|
||||
selected,
|
||||
onPick,
|
||||
onHover,
|
||||
t,
|
||||
}: {
|
||||
photo: PlacePhotoCandidate
|
||||
wide?: boolean
|
||||
selected: boolean
|
||||
onPick: (url: string | null) => void
|
||||
onHover: (url: string | null) => void
|
||||
t: TranslationFn
|
||||
}): React.ReactElement {
|
||||
const credit = photo.attribution || sourceLabelFor(photo.source)
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(selected ? null : photo.url)}
|
||||
aria-pressed={selected}
|
||||
aria-label={photo.attribution ? `${t('places.details.pickImage')} — ${photo.attribution}` : t('places.details.pickImage')}
|
||||
className={`group relative block w-full overflow-hidden rounded-lg transition-shadow ${
|
||||
wide ? 'aspect-[16/10]' : 'aspect-square'
|
||||
} ${selected ? 'ring-2 ring-accent ring-offset-2 ring-offset-surface-secondary' : 'ring-1 ring-edge hover:ring-content-muted'}`}
|
||||
>
|
||||
<img
|
||||
src={photo.url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.04]"
|
||||
/>
|
||||
{selected && (
|
||||
<span className="absolute top-1.5 right-1.5 rounded-full bg-accent p-1 shadow-card">
|
||||
<Check className="w-3 h-3 text-accent-on" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<PhotoCredit photo={photo} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(selected ? null : photo.url)}
|
||||
onMouseEnter={() => onHover(photo.url)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
onFocus={() => onHover(photo.url)}
|
||||
onBlur={() => onHover(null)}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${t('places.details.pickImage')} — ${credit}`}
|
||||
title={`${credit}${photo.license ? ` · ${photo.license}` : ''}`}
|
||||
className={`group relative block w-full aspect-square overflow-hidden rounded-lg transition-shadow ${
|
||||
selected ? 'ring-2 ring-accent ring-offset-2 ring-offset-surface-secondary' : 'ring-1 ring-edge hover:ring-content-muted'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={photo.url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.06]"
|
||||
/>
|
||||
{selected && (
|
||||
<span className="absolute top-1 right-1 rounded-full bg-accent p-0.5 shadow-card">
|
||||
<Check className="w-2.5 h-2.5 text-accent-on" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
|
||||
if (source === 'google') return 'Google'
|
||||
if (source === 'wikipedia') return 'Wikipedia'
|
||||
return 'Wikimedia Commons'
|
||||
}
|
||||
|
||||
/**
|
||||
* Author and licence under every thumbnail.
|
||||
* Author and licence for the picture currently in play.
|
||||
*
|
||||
* Not decoration: Commons images are largely CC BY / CC BY-SA, and reusing one
|
||||
* without naming its author does not satisfy those terms. Wrapped over two
|
||||
* lines rather than truncated to one, because a cut-off name credits nobody.
|
||||
* When a source hands us no author (Google), we say where it came from rather
|
||||
* than inventing a credit.
|
||||
* without naming its author does not satisfy those terms. When a source hands
|
||||
* us no author (Google), we say where it came from rather than inventing one.
|
||||
*/
|
||||
function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElement {
|
||||
const sourceLabel = photo.source === 'google' ? 'Google' : photo.source === 'wikipedia' ? 'Wikipedia' : 'Wikimedia Commons'
|
||||
const credit = photo.attribution || sourceLabel
|
||||
const credit = photo.attribution || sourceLabelFor(photo.source)
|
||||
const full = `${credit}${photo.license ? ` · ${photo.license}` : ''}`
|
||||
|
||||
return (
|
||||
<p className="text-caption leading-tight text-content-faint line-clamp-2" title={full}>
|
||||
<p className="text-caption leading-tight text-content-faint truncate" title={full}>
|
||||
{photo.sourceUrl ? (
|
||||
<a href={photo.sourceUrl} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
||||
{credit}
|
||||
@@ -327,6 +350,7 @@ function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElem
|
||||
}
|
||||
|
||||
const FACT_ICONS: Record<PlaceFact['kind'], typeof ChefHat> = {
|
||||
rating: Star,
|
||||
cuisine: ChefHat,
|
||||
openingHours: Clock,
|
||||
menu: ScrollText,
|
||||
|
||||
@@ -1014,6 +1014,21 @@ describe('PlaceFormModal', () => {
|
||||
expect(screen.getByText('screenshot.png')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-057: a prefilled place drives the detail column, not the previous one', () => {
|
||||
// A POI tapped on the map opens the dialog through prefillCoords, never
|
||||
// through the search handler — the column used to keep the last place.
|
||||
const { rerender } = render(
|
||||
<PlaceFormModal {...defaultProps} prefillCoords={{ lat: 53.55, lng: 9.99, name: 'Helgas Kitchen', osm_id: 'node:1' }} />,
|
||||
);
|
||||
expect(screen.getByDisplayValue('Helgas Kitchen')).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<PlaceFormModal {...defaultProps} prefillCoords={{ lat: 50.9, lng: 6.96, name: 'Museum Ludwig', osm_id: 'node:2' }} />,
|
||||
);
|
||||
expect(screen.getByDisplayValue('Museum Ludwig')).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('Helgas Kitchen')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-055: pasting plain text alone attaches nothing', () => {
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const form = document.querySelector('form') as HTMLFormElement;
|
||||
|
||||
@@ -138,6 +138,28 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
setPendingFiles([])
|
||||
setDuplicateWarning(null)
|
||||
// The column follows whatever the dialog was opened with, not only a search
|
||||
// pick: a POI tapped on the map and a right-click place arrive as
|
||||
// prefillCoords, and editing an existing place arrives as `place`. Without
|
||||
// this the column kept showing the previous place's pictures and facts,
|
||||
// because only handleSelectMapsResult ever set it.
|
||||
if (place && place.lat != null && place.lng != null) {
|
||||
setDetailsSelection({
|
||||
placeId: place.google_place_id || place.osm_id || undefined,
|
||||
lat: Number(place.lat),
|
||||
lng: Number(place.lng),
|
||||
name: place.name || '',
|
||||
})
|
||||
} else if (prefillCoords) {
|
||||
setDetailsSelection({
|
||||
placeId: prefillCoords.osm_id || undefined,
|
||||
lat: prefillCoords.lat,
|
||||
lng: prefillCoords.lng,
|
||||
name: prefillCoords.name || '',
|
||||
})
|
||||
} else {
|
||||
setDetailsSelection(null)
|
||||
}
|
||||
// dayAssignments is a fresh array each render; read it at open-time only and
|
||||
// re-run on identity changes (place/assignmentId/open), not on every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -280,6 +302,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
lat,
|
||||
lng,
|
||||
name: result.name || '',
|
||||
// Hand the record along: the server needs the same OSM tags, and
|
||||
// looking them up again costs an Overpass round trip it can skip.
|
||||
details: result,
|
||||
})
|
||||
setForm(prev => ({ ...prev, image_url: undefined }))
|
||||
}
|
||||
|
||||
@@ -729,6 +729,51 @@ export class MapsService {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one picture Wikidata records for a place (property P18).
|
||||
*
|
||||
* By far the most accurate source there is: someone chose this image to
|
||||
* represent this exact object, where a coordinate search only knows what was
|
||||
* photographed nearby. Restaurants and small venues carry a `wikidata` tag far
|
||||
* more often than a Commons category, so this is also the source that reaches
|
||||
* the places the others miss.
|
||||
*/
|
||||
async fetchWikidataImage(wikidataId: string): Promise<CommonsCandidate | null> {
|
||||
if (!/^Q\d+$/.test(wikidataId.trim())) return null;
|
||||
try {
|
||||
const claims = await fetch(
|
||||
`https://www.wikidata.org/w/api.php?action=wbgetclaims&entity=${wikidataId.trim()}&property=P18&format=json`,
|
||||
{ headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(WIKI_TIMEOUT_MS) },
|
||||
);
|
||||
if (!claims.ok) return null;
|
||||
const data = (await claims.json()) as {
|
||||
claims?: { P18?: { mainsnak?: { datavalue?: { value?: string } } }[] };
|
||||
};
|
||||
const fileName = data.claims?.P18?.[0]?.mainsnak?.datavalue?.value;
|
||||
if (!fileName) return null;
|
||||
|
||||
// Second hop for the URL and, more importantly, the licence — the claim
|
||||
// itself carries only a file name.
|
||||
const params = new URLSearchParams({
|
||||
action: 'query',
|
||||
format: 'json',
|
||||
titles: `File:${fileName}`,
|
||||
prop: 'imageinfo',
|
||||
iiprop: 'url|extmetadata|mime',
|
||||
iiurlwidth: '400',
|
||||
});
|
||||
const file = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, {
|
||||
headers: { 'User-Agent': UA },
|
||||
signal: AbortSignal.timeout(WIKI_TIMEOUT_MS),
|
||||
});
|
||||
if (!file.ok) return null;
|
||||
const fileData = (await file.json()) as { query?: { pages?: Record<string, WikiCommonsPage> } };
|
||||
return this.toCommonsCandidates(fileData.query?.pages, 1)[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commons images from a category, which is the set of pictures OF a place.
|
||||
*
|
||||
|
||||
@@ -31,6 +31,13 @@ const GOOGLE_CAP = 3;
|
||||
*/
|
||||
const CACHE_KIND = 2;
|
||||
const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
/**
|
||||
* Bumped whenever the shape or the sources change. Without it a release that
|
||||
* adds a field or swaps a provider keeps serving the previous week's answers
|
||||
* from before the change — which is exactly what happened when the facts and
|
||||
* Wikivoyage landed.
|
||||
*/
|
||||
const CACHE_VERSION = 2;
|
||||
|
||||
/** Cache key for one candidate picture. Deliberately not a bare place id. */
|
||||
function candidateKey(placeId: string, index: number): string {
|
||||
@@ -56,6 +63,10 @@ interface CachedEnrichment {
|
||||
facts: PlaceFact[];
|
||||
}
|
||||
|
||||
interface CachePayload extends CachedEnrichment {
|
||||
v?: number;
|
||||
}
|
||||
|
||||
/** OSM yes/no tags; anything else (limited, only, designated) is shown verbatim. */
|
||||
function yesNo(value: unknown): 'yes' | 'no' | string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null;
|
||||
@@ -73,19 +84,30 @@ function yesNo(value: unknown): 'yes' | 'no' | string | null {
|
||||
* and a deliberate `no` are indistinguishable to a reader either way.
|
||||
*/
|
||||
export function collectFacts(details: Record<string, unknown> | null): PlaceFact[] {
|
||||
if (!details || details.source !== 'openstreetmap') return [];
|
||||
if (!details) return [];
|
||||
|
||||
const facts: PlaceFact[] = [];
|
||||
const push = (kind: PlaceFact['kind'], value: string | null, url: string | null = null) =>
|
||||
facts.push({ kind, value, url });
|
||||
|
||||
const cuisine = typeof details.cuisine === 'string' ? details.cuisine : null;
|
||||
// OSM writes several cuisines semicolon-separated and underscored.
|
||||
if (cuisine) push('cuisine', cuisine.split(';').map((c) => c.replace(/_/g, ' ').trim()).filter(Boolean).join(', '));
|
||||
// Google supplies a rating and hours as well; both come back from the same
|
||||
// details lookup and were simply going unused.
|
||||
const rating = typeof details.rating === 'number' ? details.rating : null;
|
||||
if (rating) {
|
||||
const count = typeof details.rating_count === 'number' ? details.rating_count : null;
|
||||
push('rating', count ? `${rating} (${count})` : String(rating));
|
||||
}
|
||||
|
||||
const hours = Array.isArray(details.opening_hours) ? (details.opening_hours as string[]) : null;
|
||||
if (hours?.length) push('openingHours', hours.join(' · '));
|
||||
|
||||
// Everything below is OpenStreetMap tagging and has no Google equivalent.
|
||||
if (details.source !== 'openstreetmap') return facts;
|
||||
|
||||
const cuisine = typeof details.cuisine === 'string' ? details.cuisine : null;
|
||||
// OSM writes several cuisines semicolon-separated and underscored.
|
||||
if (cuisine) push('cuisine', cuisine.split(';').map((c) => c.replace(/_/g, ' ').trim()).filter(Boolean).join(', '));
|
||||
|
||||
const menu = typeof details.menu_url === 'string' ? details.menu_url : null;
|
||||
if (menu) push('menu', null, menu);
|
||||
|
||||
@@ -148,7 +170,10 @@ export class PlaceEnrichmentService {
|
||||
// category, the description needs its wiki tag, and the facts are its tags.
|
||||
// It is row-cached and the dialog just made the same call, so in practice
|
||||
// this is a cache read.
|
||||
const details = await this.readDetails(userId, placeId, lang);
|
||||
// The dialog already fetched this when the user picked the result, so it
|
||||
// comes along with the request. Refetching cost 12.8 seconds on a large
|
||||
// OSM relation, and it ran in parallel with the client's own lookup.
|
||||
const details = req.details ?? (await this.readDetails(userId, placeId, lang));
|
||||
|
||||
const [photos, description] = await Promise.all([
|
||||
this.collectPhotos(userId, placeId, req, details),
|
||||
@@ -184,12 +209,16 @@ export class PlaceEnrichmentService {
|
||||
|
||||
// Ask both providers at once — one listing call each, and they know nothing
|
||||
// about one another.
|
||||
// A Commons category is pictures OF the place; the coordinate search is
|
||||
// pictures NEAR it, which around a city centre means statues and passers-by.
|
||||
// Prefer the category whenever the place carries one.
|
||||
// Accuracy, best first. Wikidata's P18 is one picture somebody chose to
|
||||
// represent this exact object. A Commons category is pictures of it. The
|
||||
// coordinate search is only pictures NEAR it, which around a city centre
|
||||
// means statues and passers-by — it is the last resort, not the plan.
|
||||
const category = typeof details?.wikimedia_commons === 'string' ? details.wikimedia_commons : null;
|
||||
const [googleRefs, commons] = await Promise.all([
|
||||
const wikidata = typeof details?.wikidata === 'string' ? details.wikidata : null;
|
||||
|
||||
const [googleRefs, wikidataImage, commons] = await Promise.all([
|
||||
wantsGoogle ? this.maps.fetchGooglePhotoRefs(placeId, apiKey!, GOOGLE_CAP) : Promise.resolve([]),
|
||||
wikidata ? this.maps.fetchWikidataImage(wikidata) : Promise.resolve(null),
|
||||
category
|
||||
? this.maps
|
||||
.fetchCommonsCategoryCandidates(category, COMMONS_CAP)
|
||||
@@ -197,6 +226,12 @@ export class PlaceEnrichmentService {
|
||||
: this.maps.fetchCommonsCandidates(req.lat, req.lng, COMMONS_CAP),
|
||||
]);
|
||||
|
||||
// The Wikidata picture leads, and is dropped from the rest of the list when
|
||||
// a category happens to contain it too.
|
||||
const commonsList = wikidataImage
|
||||
? [wikidataImage, ...commons.filter((c) => c.photoUrl !== wikidataImage.photoUrl)].slice(0, COMMONS_CAP)
|
||||
: commons;
|
||||
|
||||
// Google first — its pictures are of the place itself, while Commons
|
||||
// geosearch finds whatever happens to have been photographed nearby.
|
||||
const pending: PlacePhotoCandidate[] = [
|
||||
@@ -211,7 +246,7 @@ export class PlaceEnrichmentService {
|
||||
sourceUrl: null,
|
||||
source: 'google' as const,
|
||||
})),
|
||||
...commons.map((candidate) => ({
|
||||
...commonsList.map((candidate) => ({
|
||||
key: '',
|
||||
url: '',
|
||||
attribution: candidate.attribution,
|
||||
@@ -232,7 +267,7 @@ export class PlaceEnrichmentService {
|
||||
const fetchBytes =
|
||||
candidate.source === 'google'
|
||||
? () => this.maps.fetchGooglePhotoBytes(googleRefs[index].name, apiKey!)
|
||||
: () => this.fetchRemoteBytes(commons[index - googleRefs.length].photoUrl);
|
||||
: () => this.fetchRemoteBytes(commonsList[index - googleRefs.length].photoUrl);
|
||||
const url = await this.storeCandidate(key, creditLine(candidate.attribution, candidate.license), fetchBytes);
|
||||
// The index is fixed before any download, so a picture that fails to
|
||||
// load leaves a gap rather than renumbering the ones after it — the key
|
||||
@@ -361,7 +396,8 @@ export class PlaceEnrichmentService {
|
||||
CACHE_KIND,
|
||||
);
|
||||
if (!row || Date.now() - row.fetched_at >= CACHE_TTL_MS) return null;
|
||||
const parsed = JSON.parse(row.payload_json) as CachedEnrichment;
|
||||
const parsed = JSON.parse(row.payload_json) as CachePayload;
|
||||
if (parsed.v !== CACHE_VERSION) return null;
|
||||
|
||||
// A cached candidate is only usable while its bytes are still on disk, and
|
||||
// the nightly sweep deletes every picture nobody picked — which is most of
|
||||
@@ -383,7 +419,7 @@ export class PlaceEnrichmentService {
|
||||
placeId,
|
||||
lang ?? '',
|
||||
CACHE_KIND,
|
||||
JSON.stringify(value),
|
||||
JSON.stringify({ ...value, v: CACHE_VERSION } satisfies CachePayload),
|
||||
Date.now(),
|
||||
);
|
||||
} catch (err) {
|
||||
|
||||
@@ -471,8 +471,10 @@ describe('collectDescription', () => {
|
||||
// ── Result cache ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('result cache', () => {
|
||||
const cachedRow = (photos: unknown[], fetchedAt = Date.now()) => ({
|
||||
payload_json: JSON.stringify({ photos, description: null }),
|
||||
// v is the cache format version; an entry written before a release that
|
||||
// changes the shape or the sources is discarded rather than served.
|
||||
const cachedRow = (photos: unknown[], fetchedAt = Date.now(), v: number | undefined = 2) => ({
|
||||
payload_json: JSON.stringify({ photos, description: null, facts: [], v }),
|
||||
fetched_at: fetchedAt,
|
||||
});
|
||||
|
||||
@@ -543,6 +545,18 @@ describe('result cache', () => {
|
||||
expect(maps.fetchCommonsCandidates).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ENRICH-023c: discards an entry written by an older format version', async () => {
|
||||
// Otherwise a release that adds a field keeps serving last week's answers.
|
||||
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
||||
mockDbGet.mockImplementation((sql: string) =>
|
||||
String(sql).includes('place_details_cache') ? cachedRow([], Date.now(), 1) : undefined,
|
||||
);
|
||||
|
||||
await make(maps, cacheStub()).enrich(1, REQ);
|
||||
|
||||
expect(maps.fetchCommonsCandidates).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ENRICH-024: ignores an unreadable cache row and rebuilds', async () => {
|
||||
const maps = mapsStub({ fetchCommonsCandidates: vi.fn(async () => [commonsCandidate()]) });
|
||||
mockDbGet.mockImplementation((sql: string) =>
|
||||
@@ -630,9 +644,10 @@ describe('collectFacts', () => {
|
||||
);
|
||||
|
||||
expect(facts).toEqual([
|
||||
// Hours come from the block shared with Google, so they lead.
|
||||
{ kind: 'openingHours', value: 'Mo-Sa 17:30+', url: null },
|
||||
// Semicolons and underscores are OSM syntax, not something to show a reader.
|
||||
{ kind: 'cuisine', value: 'regional, german', url: null },
|
||||
{ kind: 'openingHours', value: 'Mo-Sa 17:30+', url: null },
|
||||
{ kind: 'menu', value: null, url: 'https://example.org/menu' },
|
||||
{ kind: 'outdoorSeating', value: null, url: null },
|
||||
{ kind: 'wheelchair', value: 'limited', url: null },
|
||||
@@ -659,9 +674,20 @@ describe('collectFacts', () => {
|
||||
expect(facts.find((f) => f.kind === 'internetAccess')).toEqual({ kind: 'internetAccess', value: null, url: null });
|
||||
});
|
||||
|
||||
it('ENRICH-053: stays empty for a Google place and for no details at all', () => {
|
||||
it('ENRICH-053: takes the rating and hours from a Google place, but not its OSM-only tags', () => {
|
||||
expect(collectFacts(null)).toEqual([]);
|
||||
expect(collectFacts({ source: 'google', cuisine: 'italian' })).toEqual([]);
|
||||
// cuisine/outdoor_seating are OSM tagging and never appear on a Google blob.
|
||||
expect(collectFacts({ source: 'google', cuisine: 'italian', outdoor_seating: 'yes' })).toEqual([]);
|
||||
expect(
|
||||
collectFacts({ source: 'google', rating: 4.5, rating_count: 1234, opening_hours: ['Mo-Fr 09:00-18:00'] }),
|
||||
).toEqual([
|
||||
{ kind: 'rating', value: '4.5 (1234)', url: null },
|
||||
{ kind: 'openingHours', value: 'Mo-Fr 09:00-18:00', url: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ENRICH-053b: omits the review count when there is none', () => {
|
||||
expect(collectFacts({ source: 'google', rating: 4 })).toEqual([{ kind: 'rating', value: '4', url: null }]);
|
||||
});
|
||||
|
||||
it('ENRICH-054: joins multi-day opening hours into one readable line', () => {
|
||||
|
||||
@@ -97,6 +97,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'أفرغ حقل الوصف أولًا',
|
||||
'places.details.facts': 'معلومات مفيدة',
|
||||
'places.details.nothing': 'لم يتم العثور على شيء لهذا المكان.',
|
||||
'places.details.fact.rating': 'التقييم',
|
||||
'places.details.fact.cuisine': 'المطبخ',
|
||||
'places.details.fact.openingHours': 'ساعات العمل',
|
||||
'places.details.fact.menu': 'قائمة الطعام',
|
||||
|
||||
@@ -97,6 +97,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Esvazie primeiro o campo de descrição',
|
||||
'places.details.facts': 'Bom saber',
|
||||
'places.details.nothing': 'Nada encontrado para este lugar.',
|
||||
'places.details.fact.rating': 'Avaliação',
|
||||
'places.details.fact.cuisine': 'Cozinha',
|
||||
'places.details.fact.openingHours': 'Horário',
|
||||
'places.details.fact.menu': 'Cardápio',
|
||||
|
||||
@@ -95,6 +95,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Buida primer el camp de descripció',
|
||||
'places.details.facts': 'Bo saber',
|
||||
'places.details.nothing': "No s'ha trobat res per a aquest lloc.",
|
||||
'places.details.fact.rating': 'Valoració',
|
||||
'places.details.fact.cuisine': 'Cuina',
|
||||
'places.details.fact.openingHours': 'Horari',
|
||||
'places.details.fact.menu': 'Carta',
|
||||
|
||||
@@ -97,6 +97,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Nejdřív vyprázdni pole popisu',
|
||||
'places.details.facts': 'Dobré vědět',
|
||||
'places.details.nothing': 'Pro toto místo se nic nenašlo.',
|
||||
'places.details.fact.rating': 'Hodnocení',
|
||||
'places.details.fact.cuisine': 'Kuchyně',
|
||||
'places.details.fact.openingHours': 'Otevírací doba',
|
||||
'places.details.fact.menu': 'Menu',
|
||||
|
||||
@@ -97,6 +97,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Erst das Beschreibungsfeld leeren',
|
||||
'places.details.facts': 'Gut zu wissen',
|
||||
'places.details.nothing': 'Zu diesem Ort wurde nichts gefunden.',
|
||||
'places.details.fact.rating': 'Bewertung',
|
||||
'places.details.fact.cuisine': 'Küche',
|
||||
'places.details.fact.openingHours': 'Öffnungszeiten',
|
||||
'places.details.fact.menu': 'Speisekarte',
|
||||
|
||||
@@ -97,6 +97,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Clear the description field first',
|
||||
'places.details.facts': 'Good to know',
|
||||
'places.details.nothing': 'Nothing found for this place.',
|
||||
'places.details.fact.rating': 'Rating',
|
||||
'places.details.fact.cuisine': 'Cuisine',
|
||||
'places.details.fact.openingHours': 'Opening hours',
|
||||
'places.details.fact.menu': 'Menu',
|
||||
|
||||
@@ -97,6 +97,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Vacía primero el campo de descripción',
|
||||
'places.details.facts': 'Bueno saber',
|
||||
'places.details.nothing': 'No se encontró nada para este lugar.',
|
||||
'places.details.fact.rating': 'Valoración',
|
||||
'places.details.fact.cuisine': 'Cocina',
|
||||
'places.details.fact.openingHours': 'Horario',
|
||||
'places.details.fact.menu': 'Carta',
|
||||
|
||||
@@ -97,6 +97,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': "Videz d'abord le champ description",
|
||||
'places.details.facts': 'Bon à savoir',
|
||||
'places.details.nothing': 'Rien trouvé pour ce lieu.',
|
||||
'places.details.fact.rating': 'Note',
|
||||
'places.details.fact.cuisine': 'Cuisine',
|
||||
'places.details.fact.openingHours': 'Horaires',
|
||||
'places.details.fact.menu': 'Carte',
|
||||
|
||||
@@ -97,6 +97,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Άδειασε πρώτα το πεδίο περιγραφής',
|
||||
'places.details.facts': 'Καλό να ξέρεις',
|
||||
'places.details.nothing': 'Δεν βρέθηκε τίποτα για αυτήν την τοποθεσία.',
|
||||
'places.details.fact.rating': 'Βαθμολογία',
|
||||
'places.details.fact.cuisine': 'Κουζίνα',
|
||||
'places.details.fact.openingHours': 'Ωράριο',
|
||||
'places.details.fact.menu': 'Μενού',
|
||||
|
||||
@@ -97,6 +97,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Előbb ürítsd ki a leírás mezőt',
|
||||
'places.details.facts': 'Jó tudni',
|
||||
'places.details.nothing': 'Ehhez a helyhez nem található semmi.',
|
||||
'places.details.fact.rating': 'Értékelés',
|
||||
'places.details.fact.cuisine': 'Konyha',
|
||||
'places.details.fact.openingHours': 'Nyitvatartás',
|
||||
'places.details.fact.menu': 'Étlap',
|
||||
|
||||
@@ -96,6 +96,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Kosongkan dulu kolom deskripsi',
|
||||
'places.details.facts': 'Perlu diketahui',
|
||||
'places.details.nothing': 'Tidak ada yang ditemukan untuk tempat ini.',
|
||||
'places.details.fact.rating': 'Peringkat',
|
||||
'places.details.fact.cuisine': 'Masakan',
|
||||
'places.details.fact.openingHours': 'Jam buka',
|
||||
'places.details.fact.menu': 'Menu',
|
||||
|
||||
@@ -97,6 +97,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Svuota prima il campo descrizione',
|
||||
'places.details.facts': 'Da sapere',
|
||||
'places.details.nothing': 'Nessun risultato per questo luogo.',
|
||||
'places.details.fact.rating': 'Valutazione',
|
||||
'places.details.fact.cuisine': 'Cucina',
|
||||
'places.details.fact.openingHours': 'Orari',
|
||||
'places.details.fact.menu': 'Menu',
|
||||
|
||||
@@ -97,6 +97,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': '先に説明欄を空にしてください',
|
||||
'places.details.facts': '知っておくと便利',
|
||||
'places.details.nothing': 'この場所の情報は見つかりませんでした。',
|
||||
'places.details.fact.rating': '評価',
|
||||
'places.details.fact.cuisine': '料理',
|
||||
'places.details.fact.openingHours': '営業時間',
|
||||
'places.details.fact.menu': 'メニュー',
|
||||
|
||||
@@ -96,6 +96,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': '설명란을 먼저 비워 주세요',
|
||||
'places.details.facts': '알아두면 좋아요',
|
||||
'places.details.nothing': '이 장소에 대한 정보를 찾지 못했습니다.',
|
||||
'places.details.fact.rating': '평점',
|
||||
'places.details.fact.cuisine': '요리',
|
||||
'places.details.fact.openingHours': '영업시간',
|
||||
'places.details.fact.menu': '메뉴',
|
||||
|
||||
@@ -97,6 +97,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Maak eerst het beschrijvingsveld leeg',
|
||||
'places.details.facts': 'Goed om te weten',
|
||||
'places.details.nothing': 'Niets gevonden voor deze plaats.',
|
||||
'places.details.fact.rating': 'Beoordeling',
|
||||
'places.details.fact.cuisine': 'Keuken',
|
||||
'places.details.fact.openingHours': 'Openingstijden',
|
||||
'places.details.fact.menu': 'Menukaart',
|
||||
|
||||
@@ -88,6 +88,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Najpierw wyczyść pole opisu',
|
||||
'places.details.facts': 'Warto wiedzieć',
|
||||
'places.details.nothing': 'Nic nie znaleziono dla tego miejsca.',
|
||||
'places.details.fact.rating': 'Ocena',
|
||||
'places.details.fact.cuisine': 'Kuchnia',
|
||||
'places.details.fact.openingHours': 'Godziny otwarcia',
|
||||
'places.details.fact.menu': 'Menu',
|
||||
|
||||
@@ -97,6 +97,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Сначала очистите поле описания',
|
||||
'places.details.facts': 'Полезно знать',
|
||||
'places.details.nothing': 'Для этого места ничего не найдено.',
|
||||
'places.details.fact.rating': 'Рейтинг',
|
||||
'places.details.fact.cuisine': 'Кухня',
|
||||
'places.details.fact.openingHours': 'Часы работы',
|
||||
'places.details.fact.menu': 'Меню',
|
||||
|
||||
@@ -97,6 +97,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Töm beskrivningsfältet först',
|
||||
'places.details.facts': 'Bra att veta',
|
||||
'places.details.nothing': 'Inget hittades för den här platsen.',
|
||||
'places.details.fact.rating': 'Betyg',
|
||||
'places.details.fact.cuisine': 'Kök',
|
||||
'places.details.fact.openingHours': 'Öppettider',
|
||||
'places.details.fact.menu': 'Meny',
|
||||
|
||||
@@ -99,6 +99,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Önce açıklama alanını boşalt',
|
||||
'places.details.facts': 'Bilmekte fayda var',
|
||||
'places.details.nothing': 'Bu yer için bir şey bulunamadı.',
|
||||
'places.details.fact.rating': 'Puan',
|
||||
'places.details.fact.cuisine': 'Mutfak',
|
||||
'places.details.fact.openingHours': 'Çalışma saatleri',
|
||||
'places.details.fact.menu': 'Menü',
|
||||
|
||||
@@ -97,6 +97,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Спершу очистіть поле опису',
|
||||
'places.details.facts': 'Корисно знати',
|
||||
'places.details.nothing': 'Для цього місця нічого не знайдено.',
|
||||
'places.details.fact.rating': 'Рейтинг',
|
||||
'places.details.fact.cuisine': 'Кухня',
|
||||
'places.details.fact.openingHours': 'Години роботи',
|
||||
'places.details.fact.menu': 'Меню',
|
||||
|
||||
@@ -97,6 +97,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': 'Hãy xóa trống ô mô tả trước',
|
||||
'places.details.facts': 'Thông tin hữu ích',
|
||||
'places.details.nothing': 'Không tìm thấy gì cho địa điểm này.',
|
||||
'places.details.fact.rating': 'Đánh giá',
|
||||
'places.details.fact.cuisine': 'Ẩm thực',
|
||||
'places.details.fact.openingHours': 'Giờ mở cửa',
|
||||
'places.details.fact.menu': 'Thực đơn',
|
||||
|
||||
@@ -95,6 +95,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': '請先清空描述欄位',
|
||||
'places.details.facts': '值得一看',
|
||||
'places.details.nothing': '找不到此地點的資訊。',
|
||||
'places.details.fact.rating': '評分',
|
||||
'places.details.fact.cuisine': '菜系',
|
||||
'places.details.fact.openingHours': '營業時間',
|
||||
'places.details.fact.menu': '菜單',
|
||||
|
||||
@@ -95,6 +95,7 @@ const places: TranslationStrings = {
|
||||
'places.details.adoptBlocked': '请先清空描述字段',
|
||||
'places.details.facts': '值得一看',
|
||||
'places.details.nothing': '未找到该地点的信息。',
|
||||
'places.details.fact.rating': '评分',
|
||||
'places.details.fact.cuisine': '菜系',
|
||||
'places.details.fact.openingHours': '营业时间',
|
||||
'places.details.fact.menu': '菜单',
|
||||
|
||||
@@ -147,6 +147,7 @@ export type PlaceDescription = z.infer<typeof placeDescriptionSchema>;
|
||||
* `kind` is translated client-side; `value` is provider data and stays as-is.
|
||||
*/
|
||||
export const placeFactKindSchema = z.enum([
|
||||
'rating',
|
||||
'cuisine',
|
||||
'openingHours',
|
||||
'menu',
|
||||
@@ -176,6 +177,16 @@ export const mapsPlaceEnrichmentRequestSchema = z.object({
|
||||
/** Used to resolve a Wikipedia article when the place carries no wiki tag. */
|
||||
name: z.string().min(1).max(300),
|
||||
lang: z.string().max(35).optional(),
|
||||
/**
|
||||
* The place record the client already holds from picking the search result.
|
||||
*
|
||||
* Enrichment needs the same OSM tags that lookup returned, and fetching them
|
||||
* again is not cheap: an Overpass lookup for a large relation was measured at
|
||||
* 12.8 seconds. Passing them along turns a second slow round trip into none.
|
||||
* Only the tags are read, and the wiki tag is re-validated before use, so a
|
||||
* doctored payload can at worst mislead the user who sent it.
|
||||
*/
|
||||
details: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
export type MapsPlaceEnrichmentRequest = z.infer<typeof mapsPlaceEnrichmentRequestSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user