Use Google Maps feature IDs for place map links

This commit is contained in:
Azalea
2026-06-21 07:54:39 +00:00
committed by Maurice
parent 9669642c62
commit 91fcaa50f6
25 changed files with 271 additions and 62 deletions
@@ -35,6 +35,7 @@ import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal
import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal' import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal'
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter' import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types' import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
interface DayPlanSidebarProps { interface DayPlanSidebarProps {
tripId: number tripId: number
@@ -1603,14 +1604,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}} }}
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }} onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }} onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
onContextMenu={e => ctxMenu.open(e, [ onContextMenu={e => {
canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) }, const googleMapsUrl = getGoogleMapsUrlForPlace(place)
canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) }, ctxMenu.open(e, [
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') }, canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') }, canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
{ divider: true }, place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, googleMapsUrl && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
])} { divider: true },
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
])
}}
onMouseEnter={e => { onMouseEnter={e => {
if (!isPlaceSelected && !lockedIds.has(assignment.id)) if (!isPlaceSelected && !lockedIds.has(assignment.id))
e.currentTarget.style.background = 'var(--bg-hover)' e.currentTarget.style.background = 'var(--bg-hover)'
@@ -2296,4 +2300,4 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
) )
}) })
export default DayPlanSidebar export default DayPlanSidebar
@@ -13,6 +13,7 @@ export interface PlaceFormData {
// Populated from a maps-search pick (not part of the initial blank form). // Populated from a maps-search pick (not part of the initial blank form).
phone?: string phone?: string
google_place_id?: string google_place_id?: string
google_ftid?: string
osm_id?: string osm_id?: string
} }
@@ -217,6 +217,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
address: resolved.address || prev.address, address: resolved.address || prev.address,
lat: String(resolved.lat), lat: String(resolved.lat),
lng: String(resolved.lng), lng: String(resolved.lng),
google_ftid: resolved.google_ftid || prev.google_ftid,
})) }))
setMapsResults([]) setMapsResults([])
setMapsSearch('') setMapsSearch('')
@@ -241,6 +242,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
lat: result.lat || prev.lat, lat: result.lat || prev.lat,
lng: result.lng || prev.lng, lng: result.lng || prev.lng,
google_place_id: result.google_place_id || prev.google_place_id, google_place_id: result.google_place_id || prev.google_place_id,
google_ftid: result.google_ftid || prev.google_ftid,
osm_id: result.osm_id || prev.osm_id, osm_id: result.osm_id || prev.osm_id,
website: result.website || prev.website, website: result.website || prev.website,
phone: result.phone || prev.phone, phone: result.phone || prev.phone,
@@ -618,6 +618,22 @@ describe('PlaceInspector', () => {
expect(mapsBtn).toBeTruthy(); expect(mapsBtn).toBeTruthy();
}); });
it('FE-PLANNER-INSPECTOR-043b: Google Maps action uses google_ftid over coordinates', async () => {
const user = userEvent.setup();
const mapsUrl = "https://www.google.com/maps/place/?q=St.%20Jacobs%20Farmers'%20Market&ftid=0x882bf179e806d471:0x8591dde29c821a93";
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
render(<PlaceInspector {...defaultProps} place={buildPlace({
name: "St. Jacobs Farmers' Market",
lat: 43.5118527,
lng: -80.5542617,
google_ftid: '0x882bf179e806d471:0x8591dde29c821a93',
})} />);
const mapsBtn = screen.getAllByRole('button').find(btn => btn.textContent?.includes('Google Maps'))!;
await user.click(mapsBtn);
expect(openSpy).toHaveBeenCalledWith(mapsUrl, '_blank');
openSpy.mockRestore();
});
// ── No files section when no upload handler and no files ────────────────── // ── No files section when no upload handler and no files ──────────────────
it('FE-PLANNER-INSPECTOR-044: files section hidden when no files and no onFileUpload', () => { it('FE-PLANNER-INSPECTOR-044: files section hidden when no files and no onFileUpload', () => {
@@ -686,4 +702,3 @@ describe('PlaceInspector', () => {
}); });
}); });
@@ -13,6 +13,7 @@ import { useTranslation } from '../../i18n'
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types' import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
import { splitReservationDateTime, formatTime } from '../../utils/formatters' import { splitReservationDateTime, formatTime } from '../../utils/formatters'
import { formatDistance, formatElevation } from '../../utils/units' import { formatDistance, formatElevation } from '../../utils/units'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
const detailsCache = new Map() const detailsCache = new Map()
@@ -164,6 +165,7 @@ export default function PlaceInspector({
const openingHours = googleDetails?.opening_hours || null const openingHours = googleDetails?.opening_hours || null
const openNow = googleDetails?.open_now ?? null const openNow = googleDetails?.open_now ?? null
const googleMapsUrl = getGoogleMapsUrlForPlace(place, googleDetails?.google_maps_url)
const selectedDay = days?.find(d => d.id === selectedDayId) const selectedDay = days?.find(d => d.id === selectedDayId)
const weekdayIndex = getWeekdayIndex(selectedDay?.date) const weekdayIndex = getWeekdayIndex(selectedDay?.date)
@@ -291,14 +293,10 @@ export default function PlaceInspector({
<ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} /> <ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
) )
)} )}
{googleDetails?.google_maps_url && ( {googleMapsUrl && (
<ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={<Navigation size={13} />} <ActionButton onClick={() => window.open(googleMapsUrl, '_blank')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} /> label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
)} )}
{!googleDetails?.google_maps_url && place.lat && place.lng && (
<ActionButton onClick={() => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">Google Maps</span>} />
)}
{(place.website || googleDetails?.website) && ( {(place.website || googleDetails?.website) && (
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />} <ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} /> label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
@@ -0,0 +1,19 @@
import type { AssignmentPlace, Place } from '../../types'
type PlaceLike = Pick<Place | AssignmentPlace, 'name' | 'lat' | 'lng' | 'google_place_id' | 'google_ftid'>
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i
export function getGoogleMapsUrlForPlace(place: PlaceLike | null | undefined, detailsUrl?: string | null): string | null {
if (!place) return null
const ftid = place.google_ftid?.trim()
if (ftid && GOOGLE_FTID_RE.test(ftid)) {
return `https://www.google.com/maps/place/?q=${encodeURIComponent(place.name)}&ftid=${ftid}`
}
const placeId = place.google_place_id?.trim()
if (placeId) {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(place.name)}&query_place_id=${encodeURIComponent(placeId)}`
}
if (detailsUrl) return detailsUrl
if (place.lat == null || place.lng == null) return null
return `https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`
}
@@ -9,6 +9,7 @@ import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useAuthStore } from '../../store/authStore' import { useAuthStore } from '../../store/authStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types' import type { Place, Category, Day, AssignmentsMap } from '../../types'
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
export interface PlacesSidebarProps { export interface PlacesSidebarProps {
tripId: number tripId: number
@@ -234,11 +235,12 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => { const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
const selDayId = selectedDayIdRef.current const selDayId = selectedDayIdRef.current
const googleMapsUrl = getGoogleMapsUrlForPlace(place)
ctxMenu.open(e, [ ctxMenu.open(e, [
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => props.onEditPlace(place) }, canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => props.onEditPlace(place) },
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => props.onAssignToDay(place.id, selDayId) }, selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => props.onAssignToDay(place.id, selDayId) },
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') }, place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${(place as any).google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + (place as any).google_place_id : place.lat + ',' + place.lng}`, '_blank') }, googleMapsUrl && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
{ divider: true }, { divider: true },
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => props.onDeletePlace(place.id) }, canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => props.onDeletePlace(place.id) },
]) ])
+8
View File
@@ -3054,6 +3054,14 @@ function runMigrations(db: Database.Database): void {
if (!err.message?.includes('duplicate column name')) throw err; if (!err.message?.includes('duplicate column name')) throw err;
} }
}, },
// Store Google Maps feature IDs separately from real Google Places API IDs.
() => {
try {
db.exec('ALTER TABLE places ADD COLUMN google_ftid TEXT');
} catch (err: any) {
if (!err.message?.includes('duplicate column name')) throw err;
}
},
]; ];
if (currentVersion < migrations.length) { if (currentVersion < migrations.length) {
+1
View File
@@ -138,6 +138,7 @@ function createTables(db: Database.Database): void {
notes TEXT, notes TEXT,
image_url TEXT, image_url TEXT,
google_place_id TEXT, google_place_id TEXT,
google_ftid TEXT,
website TEXT, website TEXT,
phone TEXT, phone TEXT,
transport_mode TEXT DEFAULT 'walking', transport_mode TEXT DEFAULT 'walking',
+2 -2
View File
@@ -48,7 +48,7 @@ You are connected to TREK, a travel planning application. Below is a compact ref
**Loading trip context:** Before planning or modifying a trip, call \`get_trip_summary\` once. It returns all days (with assignments and notes), accommodations, budget, packing, reservations, collab notes, and todos in a single round-trip. Use this data to answer follow-up questions without extra tool calls. **Loading trip context:** Before planning or modifying a trip, call \`get_trip_summary\` once. It returns all days (with assignments and notes), accommodations, budget, packing, reservations, collab notes, and todos in a single round-trip. Use this data to answer follow-up questions without extra tool calls.
**Adding a place to the itinerary (correct order):** **Adding a place to the itinerary (correct order):**
1. \`search_place\` — find the real-world POI; note the \`osm_id\` and/or \`google_place_id\` in the result. 1. \`search_place\` — find the real-world POI; note the \`osm_id\`, \`google_place_id\`, and/or \`google_ftid\` in the result.
2. \`create_place\` — add it to the trip's place pool, passing the IDs from step 1 (enables opening hours, ratings, and map linking in the app). 2. \`create_place\` — add it to the trip's place pool, passing the IDs from step 1 (enables opening hours, ratings, and map linking in the app).
3. \`assign_place_to_day\` — schedule it on the desired day using the dayId from \`get_trip_summary\`. 3. \`assign_place_to_day\` — schedule it on the desired day using the dayId from \`get_trip_summary\`.
@@ -348,4 +348,4 @@ export function closeMcpSessions(): void {
} }
sessions.clear(); sessions.clear();
rateLimitMap.clear(); rateLimitMap.clear();
} }
+3 -2
View File
@@ -131,6 +131,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
address: z.string().max(500).optional(), address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'), category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'), google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'),
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'), osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
place_notes: z.string().max(2000).optional().describe('Notes for the place'), place_notes: z.string().max(2000).optional().describe('Notes for the place'),
website: z.string().max(500).optional(), website: z.string().max(500).optional(),
@@ -147,7 +148,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, accommodation_notes, price, currency }) => { async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, accommodation_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied(); if (!hasTripPermission('day_edit', tripId, userId)) return permissionDenied();
@@ -155,7 +156,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri
if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true }; if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true };
try { try {
const run = db.transaction(() => { const run = db.transaction(() => {
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency }); const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes }); const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes: accommodation_notes });
return { place, accommodation }; return { place, accommodation };
}); });
+11 -8
View File
@@ -23,7 +23,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
if (W) server.registerTool( if (W) server.registerTool(
'create_place', 'create_place',
{ {
description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings. Set price + currency to record the cost so it shows on the item.', description: 'Add a new place/POI to a trip. Set google_place_id, google_ftid, or osm_id (from search_place) so the app can show opening hours, ratings, and direct Google Maps links. Set price + currency to record the cost so it shows on the item.',
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
@@ -33,6 +33,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
address: z.string().max(500).optional(), address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'), category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'), google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'),
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345") — enables opening hours if no Google ID'), osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345") — enables opening hours if no Google ID'),
notes: z.string().max(2000).optional(), notes: z.string().max(2000).optional(),
website: z.string().max(500).optional(), website: z.string().max(500).optional(),
@@ -42,11 +43,11 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }) => { async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, price, currency }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied(); if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }); const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes, website, phone, price, currency });
safeBroadcast(tripId, 'place:created', { place }); safeBroadcast(tripId, 'place:created', { place });
return ok({ place }); return ok({ place });
} }
@@ -66,6 +67,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
address: z.string().max(500).optional(), address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'), category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'),
google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'), google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'),
google_ftid: z.string().optional().describe('Google Maps feature ID from search_place — enables direct Google Maps links'),
osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'), osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'),
place_notes: z.string().max(2000).optional().describe('Notes for the place'), place_notes: z.string().max(2000).optional().describe('Notes for the place'),
website: z.string().max(500).optional(), website: z.string().max(500).optional(),
@@ -76,14 +78,14 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => { async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied(); if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
try { try {
const run = db.transaction(() => { const run = db.transaction(() => {
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency }); const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, notes: place_notes, website, phone, price, currency });
const assignment = createAssignment(dayId, place.id, assignment_notes ?? null); const assignment = createAssignment(dayId, place.id, assignment_notes ?? null);
return { place, assignment }; return { place, assignment };
}); });
@@ -121,14 +123,15 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(), transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'), osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'),
google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'), google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'),
google_ftid: z.string().optional().describe('Google Maps feature ID (e.g. "0x89c259b7abdd4769:0x103aaf1c8bf8a050")'),
}, },
annotations: TOOL_ANNOTATIONS_WRITE, annotations: TOOL_ANNOTATIONS_WRITE,
}, },
async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }) => { async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id, google_ftid }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied(); if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }); const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id, google_ftid });
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
safeBroadcast(tripId, 'place:updated', { place }); safeBroadcast(tripId, 'place:updated', { place });
return ok({ place }); return ok({ place });
@@ -196,7 +199,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
if (R) server.registerTool( if (R) server.registerTool(
'search_place', 'search_place',
{ {
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id if configured). Use these IDs when calling create_place so the app can display opening hours and ratings.', description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links.',
inputSchema: { inputSchema: {
query: z.string().min(1).max(500).describe('Place name or address to search for'), query: z.string().min(1).max(500).describe('Place name or address to search for'),
}, },
+3 -2
View File
@@ -9,7 +9,7 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) {
COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time, COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes, p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da FROM day_assignments da
JOIN places p ON da.place_id = p.id JOIN places p ON da.place_id = p.id
@@ -59,6 +59,7 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) {
image_url: a.image_url, image_url: a.image_url,
transport_mode: a.transport_mode, transport_mode: a.transport_mode,
google_place_id: a.google_place_id, google_place_id: a.google_place_id,
google_ftid: a.google_ftid,
website: a.website, website: a.website,
phone: a.phone, phone: a.phone,
category: a.category_id ? { category: a.category_id ? {
@@ -79,7 +80,7 @@ export function listDayAssignments(dayId: string | number) {
COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time, COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes, p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da FROM day_assignments da
JOIN places p ON da.place_id = p.id JOIN places p ON da.place_id = p.id
+3 -2
View File
@@ -15,7 +15,7 @@ export function getAssignmentsForDay(dayId: number | string) {
COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time, COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes, p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da FROM day_assignments da
JOIN places p ON da.place_id = p.id JOIN places p ON da.place_id = p.id
@@ -54,6 +54,7 @@ export function getAssignmentsForDay(dayId: number | string) {
image_url: a.image_url, image_url: a.image_url,
transport_mode: a.transport_mode, transport_mode: a.transport_mode,
google_place_id: a.google_place_id, google_place_id: a.google_place_id,
google_ftid: a.google_ftid,
website: a.website, website: a.website,
phone: a.phone, phone: a.phone,
category: a.category_id ? { category: a.category_id ? {
@@ -88,7 +89,7 @@ export function listDays(tripId: string | number) {
COALESCE(da.assignment_time, p.place_time) as place_time, COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time, COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes, p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.website, p.phone, p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.website, p.phone,
c.name as category_name, c.color as category_color, c.icon as category_icon c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da FROM day_assignments da
JOIN places p ON da.place_id = p.id JOIN places p ON da.place_id = p.id
+20 -4
View File
@@ -45,6 +45,7 @@ interface GooglePlaceResult {
websiteUri?: string; websiteUri?: string;
nationalPhoneNumber?: string; nationalPhoneNumber?: string;
types?: string[]; types?: string[];
googleMapsUri?: string;
} }
interface GoogleAutocompleteSuggestion { interface GoogleAutocompleteSuggestion {
@@ -60,7 +61,6 @@ interface GoogleAutocompleteSuggestion {
interface GooglePlaceDetails extends GooglePlaceResult { interface GooglePlaceDetails extends GooglePlaceResult {
userRatingCount?: number; userRatingCount?: number;
regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean }; regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean };
googleMapsUri?: string;
editorialSummary?: { text: string }; editorialSummary?: { text: string };
reviews?: { authorAttribution?: { displayName?: string; photoUri?: string }; rating?: number; text?: { text?: string }; relativePublishTimeDescription?: string }[]; reviews?: { authorAttribution?: { displayName?: string; photoUri?: string }; rating?: number; text?: { text?: string }; relativePublishTimeDescription?: string }[];
photos?: { name: string; authorAttributions?: { displayName?: string }[] }[]; photos?: { name: string; authorAttributions?: { displayName?: string }[] }[];
@@ -88,6 +88,18 @@ function toApiLang(lang: string | undefined, fallback = 'en'): string {
return API_LANG_OVERRIDES[code] ?? code; return API_LANG_OVERRIDES[code] ?? code;
} }
const GOOGLE_FTID_RE = /^0x[0-9a-f]+:0x[0-9a-f]+$/i;
export function googleFtidFromMapsUrl(url?: string | null): string | null {
if (!url) return null;
try {
const ftid = new URL(url).searchParams.get('ftid')?.trim();
return ftid && GOOGLE_FTID_RE.test(ftid) ? ftid.toLowerCase() : null;
} catch {
return null;
}
}
// ── Photo cache (disk-backed) ──────────────────────────────────────────────── // ── Photo cache (disk-backed) ────────────────────────────────────────────────
import * as placePhotoCache from './placePhotoCache'; import * as placePhotoCache from './placePhotoCache';
@@ -145,6 +157,7 @@ export async function searchNominatim(query: string, lang?: string) {
const data = await response.json() as NominatimResult[]; const data = await response.json() as NominatimResult[];
return data.map(item => ({ return data.map(item => ({
google_place_id: null, google_place_id: null,
google_ftid: null,
osm_id: `${item.osm_type}:${item.osm_id}`, osm_id: `${item.osm_type}:${item.osm_id}`,
name: item.name || item.display_name?.split(',')[0] || '', name: item.name || item.display_name?.split(',')[0] || '',
address: item.display_name || '', address: item.display_name || '',
@@ -573,7 +586,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Goog-Api-Key': apiKey, 'X-Goog-Api-Key': apiKey,
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types', 'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri',
}, },
body: JSON.stringify(searchBody), body: JSON.stringify(searchBody),
}); });
@@ -588,6 +601,7 @@ export async function searchPlaces(userId: number, query: string, lang?: string,
const places = (data.places || []).map((p: GooglePlaceResult) => ({ const places = (data.places || []).map((p: GooglePlaceResult) => ({
google_place_id: p.id, google_place_id: p.id,
google_ftid: googleFtidFromMapsUrl(p.googleMapsUri),
name: p.displayName?.text || '', name: p.displayName?.text || '',
address: p.formattedAddress || '', address: p.formattedAddress || '',
lat: p.location?.latitude || null, lat: p.location?.latitude || null,
@@ -740,6 +754,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
const place = { const place = {
google_place_id: data.id, google_place_id: data.id,
google_ftid: googleFtidFromMapsUrl(data.googleMapsUri),
name: data.displayName?.text || '', name: data.displayName?.text || '',
address: data.formattedAddress || '', address: data.formattedAddress || '',
lat: data.location?.latitude || null, lat: data.location?.latitude || null,
@@ -799,6 +814,7 @@ export async function getPlaceDetailsExpanded(userId: number, placeId: string, l
const place = { const place = {
google_place_id: data.id, google_place_id: data.id,
google_ftid: googleFtidFromMapsUrl(data.googleMapsUri),
name: data.displayName?.text || '', name: data.displayName?.text || '',
address: data.formattedAddress || '', address: data.formattedAddress || '',
lat: data.location?.latitude || null, lat: data.location?.latitude || null,
@@ -983,7 +999,7 @@ export async function reverseGeocode(lat: string, lng: string, lang?: string): P
// ── Resolve Google Maps URL ────────────────────────────────────────────────── // ── Resolve Google Maps URL ──────────────────────────────────────────────────
export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null }> { export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null; google_ftid: string | null }> {
let resolvedUrl = url; let resolvedUrl = url;
// Extract coordinates from a string (URL or page body). Google Maps encodes // Extract coordinates from a string (URL or page body). Google Maps encodes
@@ -1064,5 +1080,5 @@ export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number;
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null; const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
const address = nominatim.display_name || null; const address = nominatim.display_name || null;
return { lat, lng, name, address }; return { lat, lng, name, address, google_ftid: googleFtidFromMapsUrl(resolvedUrl) };
} }
+11 -8
View File
@@ -10,8 +10,8 @@ import { getMapsKey, searchPlaces, getPlacePhoto } from './mapsService';
* open/closed). When the importer opts in and a Google Maps key is configured, * open/closed). When the importer opts in and a Google Maps key is configured,
* we re-resolve each place by name biased to and validated against the * we re-resolve each place by name biased to and validated against the
* imported coordinates to a real Google place, then fill in the empty fields * imported coordinates to a real Google place, then fill in the empty fields
* and persist the resolved `google_place_id` (which is what powers on-demand * and persist the resolved `google_place_id` plus `google_ftid` (which power
* opening hours / the proper Maps link going forward). * on-demand opening hours and proper Maps links going forward).
* *
* This runs detached from the import request (fire-and-forget) so a long list * This runs detached from the import request (fire-and-forget) so a long list
* never blocks the response, and pushes each enriched row over the websocket so * never blocks the response, and pushes each enriched row over the websocket so
@@ -26,6 +26,7 @@ export interface EnrichablePlace {
lat: number; lat: number;
lng: number; lng: number;
google_place_id?: string | null; google_place_id?: string | null;
google_ftid?: string | null;
address?: string | null; address?: string | null;
website?: string | null; website?: string | null;
phone?: string | null; phone?: string | null;
@@ -105,18 +106,20 @@ async function enrichOne(tripId: string, userId: number, place: EnrichablePlace,
const gpid = str(match.google_place_id); const gpid = str(match.google_place_id);
if (!gpid) return; if (!gpid) return;
const gftid = str(match.google_ftid);
// COALESCE so enrichment only fills empty columns — never overwrites data the // COALESCE so enrichment only fills empty columns — never overwrites data the
// import already captured (e.g. Naver's address) or anything the user edited. // import already captured (e.g. Naver's address) or anything the user edited.
db.prepare( db.prepare(
`UPDATE places `UPDATE places
SET google_place_id = COALESCE(google_place_id, ?), SET google_place_id = COALESCE(google_place_id, ?),
address = COALESCE(address, ?), google_ftid = COALESCE(google_ftid, ?),
website = COALESCE(website, ?), address = COALESCE(address, ?),
phone = COALESCE(phone, ?), website = COALESCE(website, ?),
updated_at = CURRENT_TIMESTAMP phone = COALESCE(phone, ?),
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND trip_id = ?`, WHERE id = ? AND trip_id = ?`,
).run(gpid, str(match.address), str(match.website), str(match.phone), place.id, tripId); ).run(gpid, gftid, str(match.address), str(match.website), str(match.phone), place.id, tripId);
// Photo is best-effort: Google often has none, and getPlacePhoto throws 404 in // Photo is best-effort: Google often has none, and getPlacePhoto throws 404 in
// that case — a missing photo must never abort the rest of the enrichment. // that case — a missing photo must never abort the rest of the enrichment.
+78 -12
View File
@@ -123,27 +123,27 @@ export function createPlace(
category_id?: number; price?: number; currency?: string; category_id?: number; price?: number; currency?: string;
place_time?: string; end_time?: string; place_time?: string; end_time?: string;
duration_minutes?: number; notes?: string; image_url?: string; duration_minutes?: number; notes?: string; image_url?: string;
google_place_id?: string; osm_id?: string; website?: string; phone?: string; google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string;
transport_mode?: string; tags?: number[]; transport_mode?: string; tags?: number[];
}, },
) { ) {
const { const {
name, description, lat, lng, address, category_id, price, currency, name, description, lat, lng, address, category_id, price, currency,
place_time, end_time, place_time, end_time,
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone,
transport_mode, tags = [], transport_mode, tags = [],
} = body; } = body;
const result = db.prepare(` const result = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
place_time, end_time, place_time, end_time,
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, transport_mode) duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, transport_mode)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
tripId, name, description || null, lat || null, lng || null, address || null, tripId, name, description || null, lat || null, lng || null, address || null,
category_id || null, price || null, currency || null, category_id || null, price || null, currency || null,
place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null, place_time || null, end_time || null, duration_minutes || 60, notes || null, image_url || null,
google_place_id || null, osm_id || null, website || null, phone || null, transport_mode || 'walking', google_place_id || null, google_ftid || null, osm_id || null, website || null, phone || null, transport_mode || 'walking',
); );
const placeId = result.lastInsertRowid; const placeId = result.lastInsertRowid;
@@ -180,7 +180,7 @@ export function updatePlace(
category_id?: number; price?: number; currency?: string; category_id?: number; price?: number; currency?: string;
place_time?: string; end_time?: string; place_time?: string; end_time?: string;
duration_minutes?: number; notes?: string; image_url?: string; duration_minutes?: number; notes?: string; image_url?: string;
google_place_id?: string; osm_id?: string; website?: string; phone?: string; google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string;
transport_mode?: string; tags?: number[]; transport_mode?: string; tags?: number[];
}, },
) { ) {
@@ -190,7 +190,7 @@ export function updatePlace(
const { const {
name, description, lat, lng, address, category_id, price, currency, name, description, lat, lng, address, category_id, price, currency,
place_time, end_time, place_time, end_time,
duration_minutes, notes, image_url, google_place_id, osm_id, website, phone, duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone,
transport_mode, tags, transport_mode, tags,
} = body; } = body;
@@ -210,6 +210,7 @@ export function updatePlace(
notes = ?, notes = ?,
image_url = ?, image_url = ?,
google_place_id = ?, google_place_id = ?,
google_ftid = ?,
osm_id = ?, osm_id = ?,
website = ?, website = ?,
phone = ?, phone = ?,
@@ -231,6 +232,7 @@ export function updatePlace(
notes !== undefined ? notes : existingPlace.notes, notes !== undefined ? notes : existingPlace.notes,
image_url !== undefined ? image_url : existingPlace.image_url, image_url !== undefined ? image_url : existingPlace.image_url,
google_place_id !== undefined ? google_place_id : existingPlace.google_place_id, google_place_id !== undefined ? google_place_id : existingPlace.google_place_id,
google_ftid !== undefined ? google_ftid : existingPlace.google_ftid,
osm_id !== undefined ? osm_id : existingPlace.osm_id, osm_id !== undefined ? osm_id : existingPlace.osm_id,
website !== undefined ? website : existingPlace.website, website !== undefined ? website : existingPlace.website,
phone !== undefined ? phone : existingPlace.phone, phone !== undefined ? phone : existingPlace.phone,
@@ -625,6 +627,65 @@ export async function importMapFile(tripId: string, fileBuffer: Buffer, filename
// Import Google Maps list // Import Google Maps list
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function googleMapsHexId(value: unknown): string | null {
if (typeof value !== 'string' && typeof value !== 'number') return null;
const raw = String(value).trim();
if (/^0x[0-9a-f]+$/i.test(raw)) return raw.toLowerCase();
if (!/^-?\d+$/.test(raw)) return null;
try {
const parsed = BigInt(raw);
const unsigned = parsed < 0n ? (1n << 64n) + parsed : parsed;
return `0x${unsigned.toString(16)}`;
} catch {
return null;
}
}
function googleMapsFeatureIdFromItem(item: unknown): string | null {
if (!Array.isArray(item)) return null;
const candidates = [
Array.isArray(item[1]) ? item[1][6] : null,
Array.isArray(item[7]) ? item[7][1] : null,
];
for (const ids of candidates) {
if (!Array.isArray(ids) || ids.length < 2) continue;
const first = googleMapsHexId(ids[0]);
const second = googleMapsHexId(ids[1]);
if (first && second) return `${first}:${second}`;
}
return null;
}
function findDuplicatePlace(
tripId: string,
place: { name: string | null | undefined; lat: number | null; lng: number | null },
): { id: number; google_ftid: string | null } | null {
const normalizedName = place.name?.trim().toLowerCase();
if (normalizedName) {
const duplicate = db.prepare(`
SELECT id, google_ftid FROM places
WHERE trip_id = ? AND lower(trim(name)) = ?
ORDER BY id ASC
LIMIT 1
`).get(tripId, normalizedName) as { id: number; google_ftid: string | null } | undefined;
if (duplicate) return duplicate;
}
if (place.lat != null && place.lng != null) {
return db.prepare(`
SELECT id, google_ftid FROM places
WHERE trip_id = ?
AND lat IS NOT NULL AND lng IS NOT NULL
AND abs(lat - ?) <= ?
AND abs(lng - ?) <= ?
ORDER BY id ASC
LIMIT 1
`).get(tripId, place.lat, COORD_DEDUP_TOLERANCE, place.lng, COORD_DEDUP_TOLERANCE) as { id: number; google_ftid: string | null } | undefined || null;
}
return null;
}
export async function importGoogleList(tripId: string, url: string, opts?: ListImportOptions) { export async function importGoogleList(tripId: string, url: string, opts?: ListImportOptions) {
let listId: string | null = null; let listId: string | null = null;
let resolvedUrl = url; let resolvedUrl = url;
@@ -689,7 +750,7 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI
} }
// Parse place data from items // Parse place data from items
const places: { name: string; lat: number; lng: number; notes: string | null }[] = []; const places: { name: string; lat: number; lng: number; notes: string | null; googleFtid: string | null }[] = [];
for (const item of items) { for (const item of items) {
const coords = item?.[1]?.[5]; const coords = item?.[1]?.[5];
const lat = coords?.[2]; const lat = coords?.[2];
@@ -698,7 +759,7 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI
const note = item?.[3] || null; const note = item?.[3] || null;
if (name && typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) { if (name && typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) {
places.push({ name, lat, lng, notes: note || null }); places.push({ name, lat, lng, notes: note || null, googleFtid: googleMapsFeatureIdFromItem(item) });
} }
} }
@@ -708,18 +769,23 @@ export async function importGoogleList(tripId: string, url: string, opts?: ListI
const dedup = buildDedupSet(tripId); const dedup = buildDedupSet(tripId);
const insertStmt = db.prepare(` const insertStmt = db.prepare(`
INSERT INTO places (trip_id, name, lat, lng, notes, transport_mode) INSERT INTO places (trip_id, name, lat, lng, notes, google_ftid, transport_mode)
VALUES (?, ?, ?, ?, ?, 'walking') VALUES (?, ?, ?, ?, ?, ?, 'walking')
`); `);
const updateGoogleFtidStmt = db.prepare('UPDATE places SET google_ftid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?');
const created: any[] = []; const created: any[] = [];
let skipped = 0; let skipped = 0;
const insertAll = db.transaction(() => { const insertAll = db.transaction(() => {
for (const p of places) { for (const p of places) {
if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) { if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) {
const duplicate = findDuplicatePlace(tripId, p);
if (duplicate && !duplicate.google_ftid && p.googleFtid) {
updateGoogleFtidStmt.run(p.googleFtid, duplicate.id);
}
skipped++; skipped++;
continue; continue;
} }
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes); const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes, p.googleFtid);
const place = getPlaceWithTags(Number(result.lastInsertRowid)); const place = getPlaceWithTags(Number(result.lastInsertRowid));
created.push(place); created.push(place);
trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup); trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup);
+1
View File
@@ -80,6 +80,7 @@ function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[], parti
image_url: a.image_url, image_url: a.image_url,
transport_mode: a.transport_mode, transport_mode: a.transport_mode,
google_place_id: a.google_place_id, google_place_id: a.google_place_id,
google_ftid: a.google_ftid,
website: a.website, website: a.website,
phone: a.phone, phone: a.phone,
category: a.category_id ? { category: a.category_id ? {
+3 -3
View File
@@ -632,14 +632,14 @@ export function copyTripById(sourceTripId: string | number, newOwnerId: number,
const insertPlace = db.prepare(` const insertPlace = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
reservation_status, reservation_notes, reservation_datetime, place_time, end_time, reservation_status, reservation_notes, reservation_datetime, place_time, end_time,
duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode, osm_id) duration_minutes, notes, image_url, google_place_id, google_ftid, website, phone, transport_mode, osm_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
for (const p of oldPlaces) { for (const p of oldPlaces) {
const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id, const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id,
p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime, p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime,
p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id, p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id,
p.website, p.phone, p.transport_mode, p.osm_id); p.google_ftid, p.website, p.phone, p.transport_mode, p.osm_id);
placeMap.set(p.id, r.lastInsertRowid); placeMap.set(p.id, r.lastInsertRowid);
} }
+2
View File
@@ -67,6 +67,7 @@ export interface Place {
notes?: string | null; notes?: string | null;
image_url?: string | null; image_url?: string | null;
google_place_id?: string | null; google_place_id?: string | null;
google_ftid?: string | null;
osm_id?: string | null; osm_id?: string | null;
website?: string | null; website?: string | null;
phone?: string | null; phone?: string | null;
@@ -323,6 +324,7 @@ export interface AssignmentRow extends DayAssignment {
image_url: string | null; image_url: string | null;
transport_mode: string; transport_mode: string;
google_place_id: string | null; google_place_id: string | null;
google_ftid: string | null;
website: string | null; website: string | null;
phone: string | null; phone: string | null;
category_name: string | null; category_name: string | null;
+11 -2
View File
@@ -751,13 +751,20 @@ describe('searchPlaces (fetch stubbed)', () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true, ok: true,
json: async () => ({ json: async () => ({
places: [{ id: 'gid1', displayName: { text: 'Eiffel Tower' }, formattedAddress: 'Paris', location: { latitude: 48.8, longitude: 2.3 } }], places: [{
id: 'gid1',
displayName: { text: 'Eiffel Tower' },
formattedAddress: 'Paris',
location: { latitude: 48.8, longitude: 2.3 },
googleMapsUri: 'https://www.google.com/maps/place/?q=Eiffel%20Tower&ftid=0x882bf179e806d471:0x8591dde29c821a93',
}],
}), }),
})); }));
const { searchPlaces } = await import('../../../src/services/mapsService'); const { searchPlaces } = await import('../../../src/services/mapsService');
const result = await searchPlaces(1, 'Eiffel Tower'); const result = await searchPlaces(1, 'Eiffel Tower');
expect(result.source).toBe('google'); expect(result.source).toBe('google');
expect((result.places[0] as any).google_place_id).toBe('gid1'); expect((result.places[0] as any).google_place_id).toBe('gid1');
expect((result.places[0] as any).google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93');
}); });
it('MAPS-039b: throws with Google error status when Google API returns non-ok', async () => { it('MAPS-039b: throws with Google error status when Google API returns non-ok', async () => {
@@ -813,6 +820,7 @@ describe('searchPlaces (fetch stubbed)', () => {
const result = await searchPlaces(1, 'sparse'); const result = await searchPlaces(1, 'sparse');
const place = result.places[0] as any; const place = result.places[0] as any;
expect(place.google_place_id).toBe('gid-sparse'); expect(place.google_place_id).toBe('gid-sparse');
expect(place.google_ftid).toBeNull();
expect(place.name).toBe(''); expect(place.name).toBe('');
expect(place.address).toBe(''); expect(place.address).toBe('');
expect(place.lat).toBeNull(); expect(place.lat).toBeNull();
@@ -1082,7 +1090,7 @@ describe('getPlaceDetails (fetch stubbed)', () => {
weekdayDescriptions: ['Monday: 9:00 AM 12:00 AM'], weekdayDescriptions: ['Monday: 9:00 AM 12:00 AM'],
openNow: true, openNow: true,
}, },
googleMapsUri: 'https://maps.google.com/?cid=123', googleMapsUri: 'https://maps.google.com/?cid=123&ftid=0x882bf179e806d471:0x8591dde29c821a93',
editorialSummary: { text: 'Iconic iron tower.' }, editorialSummary: { text: 'Iconic iron tower.' },
reviews: [ reviews: [
{ {
@@ -1099,6 +1107,7 @@ describe('getPlaceDetails (fetch stubbed)', () => {
const result = await getPlaceDetails(1, 'ChIJ123'); const result = await getPlaceDetails(1, 'ChIJ123');
const place = result.place as any; const place = result.place as any;
expect(place.google_place_id).toBe('ChIJ123'); expect(place.google_place_id).toBe('ChIJ123');
expect(place.google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93');
expect(place.name).toBe('Eiffel Tower'); expect(place.name).toBe('Eiffel Tower');
expect(place.rating).toBe(4.7); expect(place.rating).toBe(4.7);
expect(place.rating_count).toBe(200000); expect(place.rating_count).toBe(200000);
@@ -449,6 +449,57 @@ describe('importGoogleList', () => {
expect(result.places[1].name).toBe('London'); expect(result.places[1].name).toBe('London');
}); });
it('PLACE-SVC-028b — stores a Google Maps ftid separately from google_place_id', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const listPayload = [
[null, null, null, null, 'My Test List', null, null, null, [
[null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"],
]],
];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => 'prefix\n' + JSON.stringify(listPayload),
}));
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
const result = await importGoogleList(String(trip.id), url) as any;
expect(result.places).toHaveLength(1);
expect(result.places[0].google_place_id).toBeNull();
expect(result.places[0].google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93');
});
it('PLACE-SVC-028c — backfills google_ftid when re-import skips a duplicate', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const existing = createPlace(testDb, trip.id, {
name: "St. Jacobs Farmers' Market",
lat: 43.5118527,
lng: -80.5542617,
}) as any;
const listPayload = [
[null, null, null, null, 'My Test List', null, null, null, [
[null, [null, null, null, null, '878 Weber St N', [null, null, 43.5118527, -80.5542617], ['-8634542354666695567', '-8822026229683971437']], "St. Jacobs Farmers' Market"],
]],
];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => 'prefix\n' + JSON.stringify(listPayload),
}));
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
const result = await importGoogleList(String(trip.id), url) as any;
const row = testDb.prepare('SELECT google_place_id, google_ftid FROM places WHERE id = ?').get(existing.id) as any;
expect(result.places).toHaveLength(0);
expect(result.skipped).toBe(1);
expect(row.google_place_id).toBeNull();
expect(row.google_ftid).toBe('0x882bf179e806d471:0x8591dde29c821a93');
});
it('PLACE-SVC-029 — returns error when list items array is empty', async () => { it('PLACE-SVC-029 — returns error when list items array is empty', async () => {
const { user } = createUser(testDb); const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id); const trip = createTrip(testDb, user.id);
@@ -33,6 +33,7 @@ function makeRow(overrides: Partial<AssignmentRow> = {}): AssignmentRow {
image_url: 'https://example.com/img.jpg', image_url: 'https://example.com/img.jpg',
transport_mode: 'walk', transport_mode: 'walk',
google_place_id: 'ChIJLU7jZClu5kcR4PcOOO6p3I0', google_place_id: 'ChIJLU7jZClu5kcR4PcOOO6p3I0',
google_ftid: '0x47e66e2c94e34e2d:0x8ddca9ee380ef7e0',
website: 'https://eiffel-tower.com', website: 'https://eiffel-tower.com',
phone: '+33 1 2345 6789', phone: '+33 1 2345 6789',
...overrides, ...overrides,
@@ -66,6 +67,7 @@ describe('formatAssignmentWithPlace', () => {
expect(place.image_url).toBe('https://example.com/img.jpg'); expect(place.image_url).toBe('https://example.com/img.jpg');
expect(place.transport_mode).toBe('walk'); expect(place.transport_mode).toBe('walk');
expect(place.google_place_id).toBe('ChIJLU7jZClu5kcR4PcOOO6p3I0'); expect(place.google_place_id).toBe('ChIJLU7jZClu5kcR4PcOOO6p3I0');
expect(place.google_ftid).toBe('0x47e66e2c94e34e2d:0x8ddca9ee380ef7e0');
expect(place.website).toBe('https://eiffel-tower.com'); expect(place.website).toBe('https://eiffel-tower.com');
expect(place.phone).toBe('+33 1 2345 6789'); expect(place.phone).toBe('+33 1 2345 6789');
}); });
+1
View File
@@ -84,5 +84,6 @@ export const mapsResolveUrlResultSchema = z.object({
lng: z.number(), lng: z.number(),
name: z.string().nullable(), name: z.string().nullable(),
address: z.string().nullable(), address: z.string().nullable(),
google_ftid: z.string().nullable().optional(),
}); });
export type MapsResolveUrlResult = z.infer<typeof mapsResolveUrlResultSchema>; export type MapsResolveUrlResult = z.infer<typeof mapsResolveUrlResultSchema>;
+2
View File
@@ -58,6 +58,7 @@ export const placeSchema = z.object({
notes: z.string().nullable().optional(), notes: z.string().nullable().optional(),
image_url: z.string().nullable().optional(), image_url: z.string().nullable().optional(),
google_place_id: z.string().nullable().optional(), google_place_id: z.string().nullable().optional(),
google_ftid: z.string().nullable().optional(),
osm_id: z.string().nullable().optional(), osm_id: z.string().nullable().optional(),
route_geometry: z.string().nullable().optional(), route_geometry: z.string().nullable().optional(),
website: z.string().nullable().optional(), website: z.string().nullable().optional(),
@@ -93,6 +94,7 @@ export const assignmentPlaceSchema = z.object({
image_url: z.string().nullable().optional(), image_url: z.string().nullable().optional(),
transport_mode: z.string().nullable().optional(), transport_mode: z.string().nullable().optional(),
google_place_id: z.string().nullable().optional(), google_place_id: z.string().nullable().optional(),
google_ftid: z.string().nullable().optional(),
website: z.string().nullable().optional(), website: z.string().nullable().optional(),
phone: z.string().nullable().optional(), phone: z.string().nullable().optional(),
category: placeCategorySchema.optional(), category: placeCategorySchema.optional(),