From 0e6fe5bcab09c5958351975a8aa3835a04073d82 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 11 Jul 2026 18:42:14 +0800 Subject: [PATCH] refactor: remove Google/Naver list import and Google URL resolution Server: - Remove resolveGoogleMapsUrl from mapsService.ts - Remove importGoogleList and importNaverList from placeService.ts - Remove Google/Naver import endpoints from places controller - Remove import methods from Nest PlacesService - Update MCP tools: resolve_maps_url now uses resolveAmapUri - Remove import_places_from_url MCP tool Client: - Remove importGoogleList/importNaverList from placesApi - Remove list import state, handler, and UI from PlacesSidebar - Remove ListImportModal component - Remove import button from PlacesSidebarHeader - Remove stale PlaceImportListRequest type import --- client/src/api/client.ts | 5 - .../src/components/Planner/PlacesSidebar.tsx | 4 +- .../Planner/PlacesSidebarHeader.tsx | 14 +- .../Planner/PlacesSidebarListImportModal.tsx | 98 ------- .../components/Planner/usePlacesSidebar.ts | 53 ---- server/src/mcp/tools/mapsWeather.ts | 9 +- server/src/mcp/tools/places.ts | 33 +-- server/src/nest/places/places.controller.ts | 39 --- server/src/nest/places/places.service.ts | 8 - server/src/services/mapsService.ts | 86 ------ server/src/services/placeService.ts | 261 ------------------ 11 files changed, 8 insertions(+), 602 deletions(-) delete mode 100644 client/src/components/Planner/PlacesSidebarListImportModal.tsx diff --git a/client/src/api/client.ts b/client/src/api/client.ts index fd2165ef..24ca7ba9 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -39,7 +39,6 @@ import { type FileUpdateRequest, type FileLinkRequest, type CreateTagRequest, type UpdateTagRequest, type CreateCategoryRequest, type UpdateCategoryRequest, - type PlaceImportListRequest, type BookingImportPreviewItem, type BookingImportPreviewResponse, type BookingImportConfirmResponse, @@ -379,10 +378,6 @@ export const placesApi = { if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths)) return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) }, - importGoogleList: (tripId: number | string, url: string, enrich?: boolean) => - apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data), - importNaverList: (tripId: number | string, url: string, enrich?: boolean) => - apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data), bulkDelete: (tripId: number | string, ids: number[]) => apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data), bulkUpdate: (tripId: number | string, ids: number[], data: Omit) => diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index cefe3538..369cd609 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -7,7 +7,6 @@ import { PlacesDropOverlay, PlacesHeader } from './PlacesSidebarHeader' import { PlacesSelectionBar } from './PlacesSidebarSelectionBar' import { PlacesList } from './PlacesSidebarList' import { MobileDayPickerSheet } from './PlacesSidebarMobileDayPicker' -import { ListImportModal } from './PlacesSidebarListImportModal' import { PlacesBulkCategoryModal } from './PlacesBulkCategoryModal' import SaveTripPlacesToListModal from '../Collections/SaveTripPlacesToListModal' @@ -15,7 +14,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp const S = usePlacesSidebar(props) const { sidebarDragOver, handleSidebarDragEnter, handleSidebarDragOver, handleSidebarDragLeave, handleSidebarDrop, - selectMode, filtered, t, dayPickerPlace, listImportOpen, + selectMode, filtered, t, dayPickerPlace, fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, tripId, pushUndo, ctxMenu, isMobile, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm, categories, selectedIds, exitSelectMode, onBulkChangeCategory, categoryPickerOpen, setCategoryPickerOpen, @@ -46,7 +45,6 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp {dayPickerPlace && } - {listImportOpen && } { setFileImportOpen(false); setSidebarDropFile(null) }} diff --git a/client/src/components/Planner/PlacesSidebarHeader.tsx b/client/src/components/Planner/PlacesSidebarHeader.tsx index 693b0fbf..532718c1 100644 --- a/client/src/components/Planner/PlacesSidebarHeader.tsx +++ b/client/src/components/Planner/PlacesSidebarHeader.tsx @@ -21,7 +21,7 @@ export function PlacesDropOverlay({ t }: SidebarState) { export function PlacesHeader(S: SidebarState) { const { - canEditPlaces, onAddPlace, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders, + canEditPlaces, onAddPlace, t, setFileImportOpen, places, categories, categoryFilters, search, setSearch, plannedIds, hasTracks, filter, setFilter, onPlacesFilterChange, setSelectedIds, selectMode, setSelectMode, catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFiltersLocal, onCategoryFilterChange, @@ -54,18 +54,6 @@ export function PlacesHeader(S: SidebarState) { > {t('places.importFile')} -
} diff --git a/client/src/components/Planner/PlacesSidebarListImportModal.tsx b/client/src/components/Planner/PlacesSidebarListImportModal.tsx deleted file mode 100644 index bed21ee3..00000000 --- a/client/src/components/Planner/PlacesSidebarListImportModal.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import ReactDOM from 'react-dom' -import ToggleSwitch from '../Settings/ToggleSwitch' -import type { SidebarState } from './usePlacesSidebar' - -export function ListImportModal(S: SidebarState) { - const { - setListImportOpen, setListImportUrl, t, hasMultipleListImportProviders, availableListImportProviders, - listImportProvider, setListImportProvider, listImportUrl, listImportLoading, handleListImport, - listImportEnrich, setListImportEnrich, canEnrichImport, - } = S - return ReactDOM.createPortal( -
{ setListImportOpen(false); setListImportUrl('') }} - className="bg-[rgba(0,0,0,0.4)]" - style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} - > -
e.stopPropagation()} - className="bg-surface-card" - style={{ borderRadius: 16, width: '100%', maxWidth: 440, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }} - > -
- {t('places.importList')} -
- {hasMultipleListImportProviders && ( -
- {availableListImportProviders.map(provider => ( - - ))} -
- )} -
- {t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')} -
- setListImportUrl(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter' && !listImportLoading) handleListImport() }} - placeholder={listImportProvider === 'google' ? 'https://maps.app.goo.gl/...' : 'https://naver.me/...'} - autoFocus - className="bg-surface-tertiary text-content" - style={{ - width: '100%', padding: '10px 14px', borderRadius: 10, - border: '1px solid var(--border-primary)', - fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', - fontFamily: 'inherit', boxSizing: 'border-box', - }} - /> - {canEnrichImport && ( -
-
-
{t('places.enrichOnImport')}
-
{t('places.enrichOnImportHint')}
-
- setListImportEnrich(!listImportEnrich)} /> -
- )} -
- - -
-
-
, - document.body - ) -} diff --git a/client/src/components/Planner/usePlacesSidebar.ts b/client/src/components/Planner/usePlacesSidebar.ts index c317d930..2e23de7c 100644 --- a/client/src/components/Planner/usePlacesSidebar.ts +++ b/client/src/components/Planner/usePlacesSidebar.ts @@ -7,7 +7,6 @@ import { useContextMenu } from '../shared/ContextMenu' import { placesApi } from '../../api/client' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' -import { useAuthStore } from '../../store/authStore' import { useAddonStore } from '../../store/addonStore' import { useSaveToCollectionStore } from '../../store/saveToCollectionStore' import { placeToSaveTarget } from '../Collections/saveTarget' @@ -56,9 +55,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { const can = useCanDo() const canEditPlaces = can('place_edit', trip) const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections')) - // Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it. - const canEnrichImport = useAuthStore((s) => s.hasMapsKey) - const isNaverListImportEnabled = true const [fileImportOpen, setFileImportOpen] = useState(false) const [sidebarDropFile, setSidebarDropFile] = useState(null) @@ -101,51 +97,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { setFileImportOpen(true) } - const [listImportOpen, setListImportOpen] = useState(false) - const [listImportUrl, setListImportUrl] = useState('') - const [listImportLoading, setListImportLoading] = useState(false) - const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google') - const [listImportEnrich, setListImportEnrich] = useState(false) - const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google'] - const hasMultipleListImportProviders = availableListImportProviders.length > 1 - - useEffect(() => { - if (!isNaverListImportEnabled && listImportProvider === 'naver') { - setListImportProvider('google') - } - }, [isNaverListImportEnabled, listImportProvider]) - - const handleListImport = async () => { - if (!listImportUrl.trim()) return - setListImportLoading(true) - const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google' - try { - const enrich = listImportEnrich && canEnrichImport - const result = provider === 'google' - ? await placesApi.importGoogleList(tripId, listImportUrl.trim(), enrich) - : await placesApi.importNaverList(tripId, listImportUrl.trim(), enrich) - await loadTrip(tripId) - if (result.count === 0 && result.skipped > 0) { - toast.warning(t('places.importAllSkipped')) - } else { - toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName })) - } - setListImportOpen(false) - setListImportUrl('') - if (result.places?.length > 0) { - const importedIds: number[] = result.places.map((p: { id: number }) => p.id) - pushUndo?.(t(provider === 'google' ? 'undo.importGoogleList' : 'undo.importNaverList'), async () => { - try { await placesApi.bulkDelete(tripId, importedIds) } catch {} - await loadTrip(tripId) - }) - } - } catch (err: any) { - toast.error(err?.response?.data?.error || t(provider === 'google' ? 'places.googleListError' : 'places.naverListError')) - } finally { - setListImportLoading(false) - } - } - const [search, setSearch] = useState('') const [filter, setFilter] = useState('all') const [categoryFilters, setCategoryFiltersLocal] = useState>(new Set()) @@ -260,10 +211,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) { fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, sidebarDragOver, handleSidebarDragEnter, handleSidebarDragOver, handleSidebarDragLeave, handleSidebarDrop, scrollContainerRef, onScrollTopChange, - listImportOpen, setListImportOpen, listImportUrl, setListImportUrl, - listImportLoading, listImportProvider, setListImportProvider, - listImportEnrich, setListImportEnrich, canEnrichImport, - availableListImportProviders, hasMultipleListImportProviders, handleListImport, search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal, selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds, categoryPickerOpen, setCategoryPickerOpen, diff --git a/server/src/mcp/tools/mapsWeather.ts b/server/src/mcp/tools/mapsWeather.ts index c2dad691..be334527 100644 --- a/server/src/mcp/tools/mapsWeather.ts +++ b/server/src/mcp/tools/mapsWeather.ts @@ -1,7 +1,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; import { findByIata, searchAirports } from '../../services/airportService'; -import { searchPlaces, getPlaceDetails, reverseGeocode, resolveGoogleMapsUrl } from '../../services/mapsService'; +import { searchPlaces, getPlaceDetails, reverseGeocode } from '../../services/mapsService'; +import { resolveAmapUri } from '../../services/amap/amap.places'; import { getWeather, getDetailedWeather } from '../../services/weatherService'; import { TOOL_ANNOTATIONS_READONLY, @@ -53,14 +54,14 @@ export function registerMapsWeatherTools(server: McpServer, userId: number, scop if (canGeo) server.registerTool( 'resolve_maps_url', { - description: 'Resolve a Google Maps share URL to coordinates and place name.', + description: 'Resolve an AMap share URL to coordinates and place name.', inputSchema: { - url: z.string().describe('Google Maps share URL'), + url: z.string().describe('AMap share URL'), }, annotations: TOOL_ANNOTATIONS_READONLY, }, async ({ url }) => { - const result = await resolveGoogleMapsUrl(url); + const result = resolveAmapUri(url); if (!result) return { content: [{ type: 'text' as const, text: 'Could not resolve URL or maps service not configured.' }], isError: true }; return ok(result); } diff --git a/server/src/mcp/tools/places.ts b/server/src/mcp/tools/places.ts index 283c6097..4511d3f2 100644 --- a/server/src/mcp/tools/places.ts +++ b/server/src/mcp/tools/places.ts @@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; import { canAccessTrip, db } from '../../db/database'; import { isDemoUser } from '../../services/authService'; -import { deletePlacesMany, updatePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService'; +import { deletePlacesMany, updatePlacesMany, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService'; import { createAssignment, dayExists } from '../../services/assignmentService'; import { onPlaceDeleted } from '../../services/journeyService'; import { listCategories } from '../../services/categoryService'; @@ -215,37 +215,6 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st } ); - if (W) server.registerTool( - 'import_places_from_url', - { - description: 'Import places from a shared Google Maps or Naver Maps list URL. Returns the imported places and count. The list must be shared publicly.', - inputSchema: { - tripId: z.number().int().positive(), - url: z.string().url().describe('Publicly shared Google Maps list URL (maps.app.goo.gl/...) or Naver Maps list URL'), - source: z.enum(['google-list', 'naver-list']).describe('List source: "google-list" for Google Maps saved places, "naver-list" for Naver Maps'), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async ({ tripId, url, source }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied(); - - const result = source === 'google-list' - ? await importGoogleList(String(tripId), url) - : await importNaverList(String(tripId), url); - - if ('error' in result) { - return { content: [{ type: 'text' as const, text: result.error }], isError: true }; - } - - for (const place of result.places) { - safeBroadcast(tripId, 'place:created', { place }); - } - return ok({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped }); - } - ); - if (W) server.registerTool( 'bulk_delete_places', { diff --git a/server/src/nest/places/places.controller.ts b/server/src/nest/places/places.controller.ts index 95bd6ee4..b1f63463 100644 --- a/server/src/nest/places/places.controller.ts +++ b/server/src/nest/places/places.controller.ts @@ -163,45 +163,6 @@ export class PlacesController { } } - @Post('import/google-list') - async importGoogle(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Body('enrich') enrich: unknown, @Headers('x-socket-id') socketId?: string) { - return this.importList('google', user, tripId, url, enrich, socketId); - } - - @Post('import/naver-list') - async importNaver(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Body('enrich') enrich: unknown, @Headers('x-socket-id') socketId?: string) { - return this.importList('naver', user, tripId, url, enrich, socketId); - } - - /** Shared google/naver list import — identical flow, different provider + error string. */ - private async importList(provider: 'google' | 'naver', user: User, tripId: string, url: unknown, enrich: unknown, socketId?: string) { - const trip = this.requireTrip(tripId, user); - this.requireEdit(trip, user); - if (!url || typeof url !== 'string') { - throw new HttpException({ error: 'URL is required' }, 400); - } - // Opt-in: re-resolve each imported place via the Places API to fill in - // photo / address / website / phone and persist a google_place_id (#886). - const opts = { enrich: parseBool(enrich, false), userId: user.id }; - const label = provider === 'google' ? 'Google' : 'Naver'; - try { - const result = provider === 'google' - ? await this.places.importGoogleList(tripId, url, opts) - : await this.places.importNaverList(tripId, url, opts); - if ('error' in result) { - throw new HttpException({ error: result.error }, result.status); - } - for (const place of result.places) { - this.places.broadcast(tripId, 'place:created', { place }, socketId); - } - return { places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped }; - } catch (err: unknown) { - if (err instanceof HttpException) throw err; - console.error(`[Places] ${label} list import error:`, err instanceof Error ? err.message : err); - throw new HttpException({ error: `Failed to import ${label} Maps list. Make sure the list is shared publicly.` }, 400); - } - } - @Post('bulk-delete') @HttpCode(200) // Express answers bulk-delete with res.json (200), unlike the 201 imports. bulkDelete( diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index a0312a08..6a45a144 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -68,14 +68,6 @@ export class PlacesService { return svc.importMapFile(tripId, buffer, filename, opts); } - importGoogleList(tripId: string, url: string, opts?: Parameters[2]) { - return svc.importGoogleList(tripId, url, opts); - } - - importNaverList(tripId: string, url: string, opts?: Parameters[2]) { - return svc.importNaverList(tripId, url, opts); - } - searchImage(tripId: string, id: string, userId: number) { return svc.searchPlaceImage(tripId, id, userId); } diff --git a/server/src/services/mapsService.ts b/server/src/services/mapsService.ts index f050fc6e..71f551f7 100644 --- a/server/src/services/mapsService.ts +++ b/server/src/services/mapsService.ts @@ -1042,89 +1042,3 @@ export async function reverseGeocode(lat: string, lng: string, lang?: string): P const name = data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null; return { name, address: data.display_name || null }; } - -// ── Resolve Google Maps URL ────────────────────────────────────────────────── - -export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null; google_ftid: string | null }> { - let resolvedUrl = url; - - // Extract coordinates from a string (URL or page body). Google Maps encodes - // them several ways: /@lat,lng,zoom · !3dlat!4dlng (map data param) · ?q=/?ll=. - const extractCoords = (s: string): { lat: number; lng: number } | null => { - const at = s.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/); - if (at) return { lat: parseFloat(at[1]), lng: parseFloat(at[2]) }; - const data = s.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/); - if (data) return { lat: parseFloat(data[1]), lng: parseFloat(data[2]) }; - const q = s.match(/[?&](?:q|ll)=(-?\d+\.\d+),(-?\d+\.\d+)/); - if (q) return { lat: parseFloat(q[1]), lng: parseFloat(q[2]) }; - return null; - }; - - const followRedirects = async (target: string, init?: RequestInit): Promise => { - try { - return await safeFetchFollow( - target, - { signal: AbortSignal.timeout(10000), ...init }, - { bypassInternalIpAllowed: true }, - ); - } catch (err) { - if (err instanceof SsrfBlockedError) { - throw Object.assign(new Error('URL blocked by SSRF check'), { status: 403 }); - } - throw err; - } - }; - - // Follow redirects for short URLs (goo.gl, maps.app.goo.gl) and for Google Maps - // URLs that carry no inline coordinates — e.g. ?cid= links (the format - // get_place_details returns) and "Share"-button links. The redirect target - // usually carries the !3d!4d data param we can then parse. Redirects are - // followed manually so every hop is SSRF-re-checked. - const parsed = new URL(url); - const GOOGLE_MAPS_HOSTS = ['goo.gl', 'maps.app.goo.gl', 'google.com', 'www.google.com', 'maps.google.com']; - const isShort = ['goo.gl', 'maps.app.goo.gl'].includes(parsed.hostname); - const isGoogleMaps = GOOGLE_MAPS_HOSTS.includes(parsed.hostname); - if (isShort || (isGoogleMaps && !extractCoords(url))) { - resolvedUrl = (await followRedirects(url)).url || resolvedUrl; - } - - let coords = extractCoords(resolvedUrl); - - // Still nothing (e.g. a cid page whose final URL lacks coordinates): fetch the - // page body once and parse the coordinates out of the embedded map data. - if (!coords) { - try { - const pageRes = await followRedirects(resolvedUrl, { - headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, - }); - coords = extractCoords(await pageRes.text()); - } catch (err) { - if ((err as { status?: number })?.status === 403) throw err; // SSRF block — surface it - // Otherwise fall through to the not-found error below. - } - } - - // Extract place name from URL path: /place/Place+Name/@... - let placeName: string | null = null; - const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/); - if (placeMatch) { - placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' ')); - } - - if (!coords || isNaN(coords.lat) || isNaN(coords.lng)) { - throw Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 }); - } - const { lat, lng } = coords; - - // Reverse geocode to get address - const nominatimRes = await fetch( - `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`, - { headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) } - ); - const nominatim = await nominatimRes.json() as { display_name?: string; name?: string; address?: Record }; - - const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null; - const address = nominatim.display_name || null; - - return { lat, lng, name, address, google_ftid: googleFtidFromMapsUrl(resolvedUrl) }; -} diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts index a0bb5c84..aa689ea1 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -13,7 +13,6 @@ import { resolveCategoryIdForFolder, type KmlImportSummary, } from './kmlImport'; -import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment'; import * as placePhotoCache from './placePhotoCache'; import { searchUnsplashPhotos, getUnsplashKey } from './unsplashService'; import { type UpdateConflict, isUpdateConflict } from './conflictResult'; @@ -31,13 +30,6 @@ function reclaimPhotoCache(googlePlaceId: string | null, imageUrl: string | null } } -/** Opt-in Places-API enrichment for list imports (#886). */ -export interface ListImportOptions { - enrich?: boolean; - userId?: number; - lang?: string; -} - interface PlaceWithCategory extends Place { category_name: string | null; category_color: string | null; @@ -720,259 +712,6 @@ function findDuplicatePlace( return null; } -export async function importGoogleList(tripId: string, url: string, opts?: ListImportOptions) { - let listId: string | null = null; - let resolvedUrl = url; - - // SSRF guard: validate user-supplied URL before fetching - const ssrf = await checkSsrf(url); - if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 }; - - // Follow redirects for short URLs (maps.app.goo.gl, goo.gl). Redirects are - // followed manually so every hop is re-checked against the SSRF guard — a - // short link that 302s to an internal IP is blocked even though the initial - // host is public. - if (url.includes('goo.gl') || url.includes('maps.app')) { - try { - const redirectRes = await safeFetchFollow(url, { signal: AbortSignal.timeout(10000) }); - resolvedUrl = redirectRes.url; - } catch (err) { - if (err instanceof SsrfBlockedError) return { error: 'URL is not allowed', status: 400 }; - throw err; - } - } - - // Pattern: /placelists/list/{ID} - const plMatch = resolvedUrl.match(/placelists\/list\/([A-Za-z0-9_-]+)/); - if (plMatch) listId = plMatch[1]; - - // Pattern: !2s{ID} in data URL params - if (!listId) { - const dataMatch = resolvedUrl.match(/!2s([A-Za-z0-9_-]{15,})/); - if (dataMatch) listId = dataMatch[1]; - } - - if (!listId) { - // A single-place share link (…/maps/place/…) carries no list id — point the user at - // the place search box instead of a cryptic "could not extract list ID" (#1304). - if (resolvedUrl.includes('/maps/place/')) { - return { error: 'That link points to a single place, not a list. To add it, paste the link into the place search box instead of using the list import.', status: 400 }; - } - return { error: 'Could not extract list ID from URL. Please use a shared Google Maps list link.', status: 400 }; - } - - // Fetch list data from Google Maps internal API - const apiUrl = `https://www.google.com/maps/preview/entitylist/getlist?authuser=0&hl=en&gl=us&pb=!1m1!1s${encodeURIComponent(listId)}!2e2!3e2!4i500!16b1`; - const apiRes = await fetch(apiUrl, { - headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }, - signal: AbortSignal.timeout(15000), - }); - - if (!apiRes.ok) { - return { error: 'Failed to fetch list from Google Maps', status: 502 }; - } - - const rawText = await apiRes.text(); - const jsonStr = rawText.substring(rawText.indexOf('\n') + 1); - const listData = JSON.parse(jsonStr); - - const meta = listData[0]; - if (!meta) { - return { error: 'Invalid list data received from Google Maps', status: 400 }; - } - - const listName = meta[4] || 'Google Maps List'; - const items = meta[8]; - - if (!Array.isArray(items) || items.length === 0) { - return { error: 'List is empty or could not be read', status: 400 }; - } - - // Parse place data from items - const places: { name: string; lat: number; lng: number; notes: string | null; googleFtid: string | null }[] = []; - for (const item of items) { - const coords = item?.[1]?.[5]; - const lat = coords?.[2]; - const lng = coords?.[3]; - const name = item?.[2]; - const note = item?.[3] || null; - - if (name && typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) { - places.push({ name, lat, lng, notes: note || null, googleFtid: googleMapsFeatureIdFromItem(item) }); - } - } - - if (places.length === 0) { - return { error: 'No places with coordinates found in list', status: 400 }; - } - - const dedup = buildDedupSet(tripId); - const insertStmt = db.prepare(` - INSERT INTO places (trip_id, name, lat, lng, notes, google_ftid, transport_mode) - VALUES (?, ?, ?, ?, ?, ?, 'walking') - `); - const updateGoogleFtidStmt = db.prepare('UPDATE places SET google_ftid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'); - const created: any[] = []; - let skipped = 0; - const insertAll = db.transaction(() => { - for (const p of places) { - 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++; - continue; - } - const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes, p.googleFtid); - const place = getPlaceWithTags(Number(result.lastInsertRowid)); - created.push(place); - trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup); - } - }); - insertAll(); - - if (opts?.enrich && opts.userId && created.length) { - void enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang); - } - - return { places: created, listName, skipped }; -} - -// --------------------------------------------------------------------------- -// Import Naver Maps list -// --------------------------------------------------------------------------- - -export async function importNaverList( - tripId: string, - url: string, - opts?: ListImportOptions, -): Promise<{ places: any[]; listName: string; skipped: number } | { error: string; status: number }> { - let resolvedUrl = url; - const limit = 20; - - // SSRF guard: validate user-supplied URL before fetching - const ssrf = await checkSsrf(url); - if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 }; - - // Resolve naver.me short links to the canonical map.naver.com folder URL. - // Redirects are followed manually so each hop is re-validated against the - // SSRF guard (a short link could otherwise 302 to an internal address). - let parsedUrl: URL; - try { parsedUrl = new URL(url); } catch { return { error: 'Invalid URL', status: 400 }; } - if (parsedUrl.hostname === 'naver.me') { - try { - const redirectRes = await safeFetchFollow(url, { signal: AbortSignal.timeout(10000) }); - resolvedUrl = redirectRes.url; - } catch (err) { - if (err instanceof SsrfBlockedError) return { error: 'URL is not allowed', status: 400 }; - throw err; - } - } - - const folderMatch = resolvedUrl.match(/favorite\/myPlace\/folder\/([A-Za-z0-9_-]+)/i); - const folderId = folderMatch?.[1] || null; - if (!folderId) { - return { error: 'Could not extract folder ID from URL. Please use a shared Naver Maps list link.', status: 400 }; - } - - const fetchPage = async (start: number) => { - const apiUrl = `https://pages.map.naver.com/save-pages/api/maps-bookmark/v3/shares/${encodeURIComponent(folderId)}/bookmarks?placeInfo=true&start=${start}&limit=${limit}&sort=lastUseTime&mcids=ALL&createIdNo=true`; - const apiRes = await fetch(apiUrl, { - headers: { - Accept: 'application/json', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - }, - signal: AbortSignal.timeout(15000), - }); - - if (!apiRes.ok) { - return { error: 'Failed to fetch list from Naver Maps', status: 502 } as const; - } - - try { - const data = await apiRes.json() as { - folder?: { bookmarkCount?: number; name?: string }; - bookmarkList?: any[]; - }; - return { data } as const; - } catch { - return { error: 'Invalid list data received from Naver Maps', status: 400 } as const; - } - }; - - const firstPage = await fetchPage(0); - if ('error' in firstPage) { - return { error: firstPage.error, status: firstPage.status }; - } - - const listName = firstPage.data.folder?.name || 'Naver Maps List'; - const totalCount = typeof firstPage.data.folder?.bookmarkCount === 'number' - ? firstPage.data.folder.bookmarkCount - : (firstPage.data.bookmarkList?.length || 0); - - const allItems: any[] = [...(firstPage.data.bookmarkList || [])]; - for (let start = limit; start < totalCount; start += limit) { - const page = await fetchPage(start); - if ('error' in page) { - return { error: page.error, status: page.status }; - } - const pageItems = page.data.bookmarkList || []; - if (!Array.isArray(pageItems) || pageItems.length === 0) break; - allItems.push(...pageItems); - } - - if (allItems.length === 0) { - return { error: 'List is empty or could not be read', status: 400 }; - } - - const places: { name: string; lat: number; lng: number; notes: string | null; address: string | null }[] = []; - for (const item of allItems) { - const lat = Number(item?.py); - const lng = Number(item?.px); - const name = typeof item?.name === 'string' && item.name.trim() - ? item.name.trim() - : (typeof item?.displayName === 'string' ? item.displayName.trim() : ''); - const note = typeof item?.memo === 'string' && item.memo.trim() ? item.memo.trim() : null; - const address = typeof item?.address === 'string' && item.address.trim() ? item.address.trim() : null; - - if (name && Number.isFinite(lat) && Number.isFinite(lng)) { - places.push({ name, lat, lng, notes: note, address }); - } - } - - if (places.length === 0) { - return { error: 'No places with coordinates found in list', status: 400 }; - } - - const dedup = buildDedupSet(tripId); - const insertStmt = db.prepare(` - INSERT INTO places (trip_id, name, lat, lng, address, notes, transport_mode) - VALUES (?, ?, ?, ?, ?, ?, 'walking') - `); - const created: any[] = []; - let skipped = 0; - const insertAll = db.transaction(() => { - for (const p of places) { - if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) { - skipped++; - continue; - } - const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.address, p.notes); - const place = getPlaceWithTags(Number(result.lastInsertRowid)); - created.push(place); - trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup); - } - }); - insertAll(); - - if (opts?.enrich && opts.userId && created.length) { - void enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang); - } - - return { places: created, listName, skipped }; -} - // --------------------------------------------------------------------------- // Search place image (Unsplash) // ---------------------------------------------------------------------------