From f5a483da64132762f7461aed838ed4cc8bb804d4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 9 Aug 2026 23:48:49 +0200 Subject: [PATCH] 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. --- client/src/api/client.ts | 2 +- .../components/Planner/PlaceDetailsColumn.tsx | 132 +++++++++++------- .../Planner/PlaceFormModal.test.tsx | 15 ++ .../src/components/Planner/PlaceFormModal.tsx | 25 ++++ server/src/nest/maps/maps.service.ts | 45 ++++++ .../place-enrichment.service.ts | 62 ++++++-- .../nest/place-enrichment.service.test.ts | 36 ++++- shared/src/i18n/ar/places.ts | 1 + shared/src/i18n/br/places.ts | 1 + shared/src/i18n/ca/places.ts | 1 + shared/src/i18n/cs/places.ts | 1 + shared/src/i18n/de/places.ts | 1 + shared/src/i18n/en/places.ts | 1 + shared/src/i18n/es/places.ts | 1 + shared/src/i18n/fr/places.ts | 1 + shared/src/i18n/gr/places.ts | 1 + shared/src/i18n/hu/places.ts | 1 + shared/src/i18n/id/places.ts | 1 + shared/src/i18n/it/places.ts | 1 + shared/src/i18n/ja/places.ts | 1 + shared/src/i18n/ko/places.ts | 1 + shared/src/i18n/nl/places.ts | 1 + shared/src/i18n/pl/places.ts | 1 + shared/src/i18n/ru/places.ts | 1 + shared/src/i18n/sv/places.ts | 1 + shared/src/i18n/tr/places.ts | 1 + shared/src/i18n/uk/places.ts | 1 + shared/src/i18n/vi/places.ts | 1 + shared/src/i18n/zh-TW/places.ts | 1 + shared/src/i18n/zh/places.ts | 1 + shared/src/maps/maps.schema.ts | 11 ++ 31 files changed, 278 insertions(+), 73 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 07f4c0ac3..11f4eb010 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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 }, signal?: AbortSignal, ) => apiClient.post('/maps/enrichment', body, { signal, timeout: 25000 }) .then(r => checkInDev(mapsPlaceEnrichmentResultSchema, r.data, 'maps.placeEnrichment')), diff --git a/client/src/components/Planner/PlaceDetailsColumn.tsx b/client/src/components/Planner/PlaceDetailsColumn.tsx index c8b8b3f84..31ae4b2d8 100644 --- a/client/src/components/Planner/PlaceDetailsColumn.tsx +++ b/client/src/components/Planner/PlaceDetailsColumn.tsx @@ -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 } 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(null) if (photos.length === 0) return null - const [lead, ...rest] = photos + const shown = photos.find((p) => p.url === (hovered ?? selectedImageUrl)) ?? photos[0] return (
{t('places.details.pickImage')} - - {rest.length > 0 && ( -
- {rest.map((photo) => ( - - ))} -
- )} +
+ {photos.map((photo) => ( + + ))} +
+
) } 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 ( -
- - -
+ ) } +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 ( -

+

{photo.sourceUrl ? ( {credit} @@ -327,6 +350,7 @@ function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElem } const FACT_ICONS: Record = { + rating: Star, cuisine: ChefHat, openingHours: Clock, menu: ScrollText, diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx index 155b31f0e..24d1b73b1 100644 --- a/client/src/components/Planner/PlaceFormModal.test.tsx +++ b/client/src/components/Planner/PlaceFormModal.test.tsx @@ -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( + , + ); + expect(screen.getByDisplayValue('Helgas Kitchen')).toBeInTheDocument(); + + rerender( + , + ); + 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(); const form = document.querySelector('form') as HTMLFormElement; diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 15a2ff064..8cf98376f 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -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 })) } diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index d3e37fdc4..e0355a7a0 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -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 { + 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 } }; + 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. * diff --git a/server/src/nest/place-enrichment/place-enrichment.service.ts b/server/src/nest/place-enrichment/place-enrichment.service.ts index e844029a5..eafb4216a 100644 --- a/server/src/nest/place-enrichment/place-enrichment.service.ts +++ b/server/src/nest/place-enrichment/place-enrichment.service.ts @@ -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 | 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) { diff --git a/server/tests/unit/nest/place-enrichment.service.test.ts b/server/tests/unit/nest/place-enrichment.service.test.ts index d20c115d0..46dff0930 100644 --- a/server/tests/unit/nest/place-enrichment.service.test.ts +++ b/server/tests/unit/nest/place-enrichment.service.test.ts @@ -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', () => { diff --git a/shared/src/i18n/ar/places.ts b/shared/src/i18n/ar/places.ts index b3ab9d2d7..79e3ecbb4 100644 --- a/shared/src/i18n/ar/places.ts +++ b/shared/src/i18n/ar/places.ts @@ -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': 'قائمة الطعام', diff --git a/shared/src/i18n/br/places.ts b/shared/src/i18n/br/places.ts index ea9c8f32f..eb80c6760 100644 --- a/shared/src/i18n/br/places.ts +++ b/shared/src/i18n/br/places.ts @@ -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', diff --git a/shared/src/i18n/ca/places.ts b/shared/src/i18n/ca/places.ts index afa9cc0d1..02625c97d 100644 --- a/shared/src/i18n/ca/places.ts +++ b/shared/src/i18n/ca/places.ts @@ -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', diff --git a/shared/src/i18n/cs/places.ts b/shared/src/i18n/cs/places.ts index 4db9ce406..2de4e44d0 100644 --- a/shared/src/i18n/cs/places.ts +++ b/shared/src/i18n/cs/places.ts @@ -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', diff --git a/shared/src/i18n/de/places.ts b/shared/src/i18n/de/places.ts index 7ee7a3da1..dde1a1bc0 100644 --- a/shared/src/i18n/de/places.ts +++ b/shared/src/i18n/de/places.ts @@ -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', diff --git a/shared/src/i18n/en/places.ts b/shared/src/i18n/en/places.ts index ad256ead5..36684268f 100644 --- a/shared/src/i18n/en/places.ts +++ b/shared/src/i18n/en/places.ts @@ -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', diff --git a/shared/src/i18n/es/places.ts b/shared/src/i18n/es/places.ts index e093ad3bc..7b9edac6b 100644 --- a/shared/src/i18n/es/places.ts +++ b/shared/src/i18n/es/places.ts @@ -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', diff --git a/shared/src/i18n/fr/places.ts b/shared/src/i18n/fr/places.ts index 38587792d..86d9f7a0a 100644 --- a/shared/src/i18n/fr/places.ts +++ b/shared/src/i18n/fr/places.ts @@ -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', diff --git a/shared/src/i18n/gr/places.ts b/shared/src/i18n/gr/places.ts index f48bd5d48..ec0df6355 100644 --- a/shared/src/i18n/gr/places.ts +++ b/shared/src/i18n/gr/places.ts @@ -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': 'Μενού', diff --git a/shared/src/i18n/hu/places.ts b/shared/src/i18n/hu/places.ts index 2fed6e805..c1841809f 100644 --- a/shared/src/i18n/hu/places.ts +++ b/shared/src/i18n/hu/places.ts @@ -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', diff --git a/shared/src/i18n/id/places.ts b/shared/src/i18n/id/places.ts index 6522bb154..3ed33a08d 100644 --- a/shared/src/i18n/id/places.ts +++ b/shared/src/i18n/id/places.ts @@ -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', diff --git a/shared/src/i18n/it/places.ts b/shared/src/i18n/it/places.ts index ec03063c1..b6df48d7a 100644 --- a/shared/src/i18n/it/places.ts +++ b/shared/src/i18n/it/places.ts @@ -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', diff --git a/shared/src/i18n/ja/places.ts b/shared/src/i18n/ja/places.ts index 57f753bbf..d9d547f99 100644 --- a/shared/src/i18n/ja/places.ts +++ b/shared/src/i18n/ja/places.ts @@ -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': 'メニュー', diff --git a/shared/src/i18n/ko/places.ts b/shared/src/i18n/ko/places.ts index 2a921b59b..e23d9d8d9 100644 --- a/shared/src/i18n/ko/places.ts +++ b/shared/src/i18n/ko/places.ts @@ -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': '메뉴', diff --git a/shared/src/i18n/nl/places.ts b/shared/src/i18n/nl/places.ts index d205dcd75..08c103399 100644 --- a/shared/src/i18n/nl/places.ts +++ b/shared/src/i18n/nl/places.ts @@ -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', diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts index 0888adad8..f50e9881e 100644 --- a/shared/src/i18n/pl/places.ts +++ b/shared/src/i18n/pl/places.ts @@ -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', diff --git a/shared/src/i18n/ru/places.ts b/shared/src/i18n/ru/places.ts index 18b1f128b..31b979f8a 100644 --- a/shared/src/i18n/ru/places.ts +++ b/shared/src/i18n/ru/places.ts @@ -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': 'Меню', diff --git a/shared/src/i18n/sv/places.ts b/shared/src/i18n/sv/places.ts index 9dbb01bde..123312d59 100644 --- a/shared/src/i18n/sv/places.ts +++ b/shared/src/i18n/sv/places.ts @@ -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', diff --git a/shared/src/i18n/tr/places.ts b/shared/src/i18n/tr/places.ts index 892ecfd00..d79d2ba24 100644 --- a/shared/src/i18n/tr/places.ts +++ b/shared/src/i18n/tr/places.ts @@ -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ü', diff --git a/shared/src/i18n/uk/places.ts b/shared/src/i18n/uk/places.ts index 043f095f9..6e78374bb 100644 --- a/shared/src/i18n/uk/places.ts +++ b/shared/src/i18n/uk/places.ts @@ -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': 'Меню', diff --git a/shared/src/i18n/vi/places.ts b/shared/src/i18n/vi/places.ts index deb99e9bd..5f6c3e134 100644 --- a/shared/src/i18n/vi/places.ts +++ b/shared/src/i18n/vi/places.ts @@ -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', diff --git a/shared/src/i18n/zh-TW/places.ts b/shared/src/i18n/zh-TW/places.ts index 36533e35c..6495b1f55 100644 --- a/shared/src/i18n/zh-TW/places.ts +++ b/shared/src/i18n/zh-TW/places.ts @@ -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': '菜單', diff --git a/shared/src/i18n/zh/places.ts b/shared/src/i18n/zh/places.ts index d8a877648..6d3b6c3b2 100644 --- a/shared/src/i18n/zh/places.ts +++ b/shared/src/i18n/zh/places.ts @@ -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': '菜单', diff --git a/shared/src/maps/maps.schema.ts b/shared/src/maps/maps.schema.ts index aae7a6ca2..97f00e6c0 100644 --- a/shared/src/maps/maps.schema.ts +++ b/shared/src/maps/maps.schema.ts @@ -147,6 +147,7 @@ export type PlaceDescription = z.infer; * `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;