Compare commits

..

3 Commits

Author SHA1 Message Date
jubnl
a02b06042a docs: add AMAP_WEB_SERVICE_KEY to server .env.example
Some checks are pending
Security Scan / scout (push) Waiting to run
2026-07-11 18:52:44 +08:00
jubnl
9a34f1983b fix: update isGoogleMapsUrl to also detect Amap URLs
- Add amap.com and uri.amap.com to URL detection
- Keep legacy Google Maps URL patterns for backward compatibility
- Update test expectations
2026-07-11 18:50:30 +08:00
jubnl
0e6fe5bcab 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
2026-07-11 18:42:14 +08:00
14 changed files with 22 additions and 606 deletions

View File

@ -39,7 +39,6 @@ import {
type FileUpdateRequest, type FileLinkRequest, type FileUpdateRequest, type FileLinkRequest,
type CreateTagRequest, type UpdateTagRequest, type CreateTagRequest, type UpdateTagRequest,
type CreateCategoryRequest, type UpdateCategoryRequest, type CreateCategoryRequest, type UpdateCategoryRequest,
type PlaceImportListRequest,
type BookingImportPreviewItem, type BookingImportPreviewItem,
type BookingImportPreviewResponse, type BookingImportPreviewResponse,
type BookingImportConfirmResponse, type BookingImportConfirmResponse,
@ -379,10 +378,6 @@ export const placesApi = {
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths)) 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) 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[]) => bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data), apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
bulkUpdate: (tripId: number | string, ids: number[], data: Omit<PlaceBulkUpdateRequest, 'ids'>) => bulkUpdate: (tripId: number | string, ids: number[], data: Omit<PlaceBulkUpdateRequest, 'ids'>) =>

View File

@ -2,6 +2,12 @@ import { describe, it, expect } from 'vitest'
import { isGoogleMapsUrl } from './PlaceFormModal.helpers' import { isGoogleMapsUrl } from './PlaceFormModal.helpers'
describe('isGoogleMapsUrl', () => { describe('isGoogleMapsUrl', () => {
it('accepts Amap URLs', () => {
expect(isGoogleMapsUrl('https://uri.amap.com/marker?position=1,2')).toBe(true)
expect(isGoogleMapsUrl('https://ditu.amap.com/search?query=test')).toBe(true)
expect(isGoogleMapsUrl('https://www.amap.com/dir')).toBe(true)
})
it('accepts the short share hosts', () => { it('accepts the short share hosts', () => {
expect(isGoogleMapsUrl('https://maps.app.goo.gl/abc123')).toBe(true) expect(isGoogleMapsUrl('https://maps.app.goo.gl/abc123')).toBe(true)
expect(isGoogleMapsUrl('https://goo.gl/maps/xyz')).toBe(true) expect(isGoogleMapsUrl('https://goo.gl/maps/xyz')).toBe(true)

View File

@ -21,13 +21,13 @@ export function isGoogleMapsUrl(input: string): boolean {
try { try {
const { hostname, pathname } = new URL(input.trim()) const { hostname, pathname } = new URL(input.trim())
const h = hostname.toLowerCase() const h = hostname.toLowerCase()
// maps.app.goo.gl, goo.gl/maps // AMap / Gaode URLs
if (h === 'uri.amap.com') return true
if (h.endsWith('.amap.com')) return true
// Google Maps URL patterns (legacy)
if (h === 'maps.app.goo.gl') return true if (h === 'maps.app.goo.gl') return true
if (h === 'goo.gl' && pathname.startsWith('/maps')) return true if (h === 'goo.gl' && pathname.startsWith('/maps')) return true
// maps.google.* (e.g. maps.google.com, maps.google.co.uk)
// Must be maps.google.<tld> or maps.google.<sld>.<tld> — reject maps.google.evil.com
if (/^maps\.google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(h)) return true if (/^maps\.google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(h)) return true
// google.*/maps (e.g. google.com/maps, www.google.co.uk/maps)
const bare = h.startsWith('www.') ? h.slice(4) : h const bare = h.startsWith('www.') ? h.slice(4) : h
if (/^google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(bare) && pathname.startsWith('/maps')) return true if (/^google\.[a-z]{2,3}(\.[a-z]{2})?$/.test(bare) && pathname.startsWith('/maps')) return true
return false return false

View File

@ -7,7 +7,6 @@ import { PlacesDropOverlay, PlacesHeader } from './PlacesSidebarHeader'
import { PlacesSelectionBar } from './PlacesSidebarSelectionBar' import { PlacesSelectionBar } from './PlacesSidebarSelectionBar'
import { PlacesList } from './PlacesSidebarList' import { PlacesList } from './PlacesSidebarList'
import { MobileDayPickerSheet } from './PlacesSidebarMobileDayPicker' import { MobileDayPickerSheet } from './PlacesSidebarMobileDayPicker'
import { ListImportModal } from './PlacesSidebarListImportModal'
import { PlacesBulkCategoryModal } from './PlacesBulkCategoryModal' import { PlacesBulkCategoryModal } from './PlacesBulkCategoryModal'
import SaveTripPlacesToListModal from '../Collections/SaveTripPlacesToListModal' import SaveTripPlacesToListModal from '../Collections/SaveTripPlacesToListModal'
@ -15,7 +14,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
const S = usePlacesSidebar(props) const S = usePlacesSidebar(props)
const { const {
sidebarDragOver, handleSidebarDragEnter, handleSidebarDragOver, handleSidebarDragLeave, handleSidebarDrop, sidebarDragOver, handleSidebarDragEnter, handleSidebarDragOver, handleSidebarDragLeave, handleSidebarDrop,
selectMode, filtered, t, dayPickerPlace, listImportOpen, selectMode, filtered, t, dayPickerPlace,
fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, tripId, pushUndo, fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, tripId, pushUndo,
ctxMenu, isMobile, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm, ctxMenu, isMobile, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
categories, selectedIds, exitSelectMode, onBulkChangeCategory, categoryPickerOpen, setCategoryPickerOpen, categories, selectedIds, exitSelectMode, onBulkChangeCategory, categoryPickerOpen, setCategoryPickerOpen,
@ -46,7 +45,6 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
<PlacesList {...S} /> <PlacesList {...S} />
{dayPickerPlace && <MobileDayPickerSheet {...S} />} {dayPickerPlace && <MobileDayPickerSheet {...S} />}
{listImportOpen && <ListImportModal {...S} />}
<FileImportModal <FileImportModal
isOpen={fileImportOpen} isOpen={fileImportOpen}
onClose={() => { setFileImportOpen(false); setSidebarDropFile(null) }} onClose={() => { setFileImportOpen(false); setSidebarDropFile(null) }}

View File

@ -21,7 +21,7 @@ export function PlacesDropOverlay({ t }: SidebarState) {
export function PlacesHeader(S: SidebarState) { export function PlacesHeader(S: SidebarState) {
const { const {
canEditPlaces, onAddPlace, t, setFileImportOpen, setListImportOpen, hasMultipleListImportProviders, canEditPlaces, onAddPlace, t, setFileImportOpen,
places, categories, categoryFilters, search, setSearch, plannedIds, hasTracks, places, categories, categoryFilters, search, setSearch, plannedIds, hasTracks,
filter, setFilter, onPlacesFilterChange, setSelectedIds, selectMode, setSelectMode, filter, setFilter, onPlacesFilterChange, setSelectedIds, selectMode, setSelectMode,
catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFiltersLocal, onCategoryFilterChange, catDropOpen, setCatDropOpen, toggleCategoryFilter, setCategoryFiltersLocal, onCategoryFilterChange,
@ -54,18 +54,6 @@ export function PlacesHeader(S: SidebarState) {
> >
<Upload size={11} strokeWidth={2} /> {t('places.importFile')} <Upload size={11} strokeWidth={2} /> {t('places.importFile')}
</button> </button>
<button
onClick={() => setListImportOpen(true)}
className="border border-dashed border-edge text-content-faint"
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: 1, padding: '5px 12px', borderRadius: 8,
background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
</button>
</div> </div>
<div className="bg-edge" style={{ height: 1, margin: '2px 0 10px' }} /> <div className="bg-edge" style={{ height: 1, margin: '2px 0 10px' }} />
</>} </>}

View File

@ -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(
<div
onClick={() => { 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 }}
>
<div
onClick={e => 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)' }}
>
<div className="text-content" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 700, marginBottom: 4 }}>
{t('places.importList')}
</div>
{hasMultipleListImportProviders && (
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
{availableListImportProviders.map(provider => (
<button
key={provider}
onClick={() => setListImportProvider(provider)}
className={listImportProvider === provider ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-muted'}
style={{
padding: '6px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, fontFamily: 'inherit',
}}
>
{provider === 'google' ? t('places.importGoogleList') : t('places.importNaverList')}
</button>
))}
</div>
)}
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginBottom: 16 }}>
{t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
</div>
<input
type="text"
value={listImportUrl}
onChange={e => 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 && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginTop: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('places.enrichOnImport')}</div>
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2 }}>{t('places.enrichOnImportHint')}</div>
</div>
<ToggleSwitch on={listImportEnrich} onToggle={() => setListImportEnrich(!listImportEnrich)} />
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
<button
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
className="text-content"
style={{
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
background: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('common.cancel')}
</button>
<button
onClick={handleListImport}
disabled={!listImportUrl.trim() || listImportLoading}
className={!listImportUrl.trim() || listImportLoading ? 'bg-surface-tertiary text-content-faint' : 'bg-accent text-accent-text'}
style={{
padding: '8px 16px', borderRadius: 10, border: 'none',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: !listImportUrl.trim() || listImportLoading ? 'default' : 'pointer',
fontFamily: 'inherit',
}}
>
{listImportLoading ? t('common.loading') : t('common.import')}
</button>
</div>
</div>
</div>,
document.body
)
}

View File

@ -7,7 +7,6 @@ import { useContextMenu } from '../shared/ContextMenu'
import { placesApi } from '../../api/client' import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useAuthStore } from '../../store/authStore'
import { useAddonStore } from '../../store/addonStore' import { useAddonStore } from '../../store/addonStore'
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore' import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
import { placeToSaveTarget } from '../Collections/saveTarget' import { placeToSaveTarget } from '../Collections/saveTarget'
@ -56,9 +55,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const can = useCanDo() const can = useCanDo()
const canEditPlaces = can('place_edit', trip) const canEditPlaces = can('place_edit', trip)
const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections')) 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 [fileImportOpen, setFileImportOpen] = useState(false)
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null) const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
@ -101,51 +97,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
setFileImportOpen(true) 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 [search, setSearch] = useState('')
const [filter, setFilter] = useState('all') const [filter, setFilter] = useState('all')
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set()) const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
@ -260,10 +211,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile,
sidebarDragOver, handleSidebarDragEnter, handleSidebarDragOver, handleSidebarDragLeave, handleSidebarDrop, sidebarDragOver, handleSidebarDragEnter, handleSidebarDragOver, handleSidebarDragLeave, handleSidebarDrop,
scrollContainerRef, onScrollTopChange, scrollContainerRef, onScrollTopChange,
listImportOpen, setListImportOpen, listImportUrl, setListImportUrl,
listImportLoading, listImportProvider, setListImportProvider,
listImportEnrich, setListImportEnrich, canEnrichImport,
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal, search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds, selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
categoryPickerOpen, setCategoryPickerOpen, categoryPickerOpen, setCategoryPickerOpen,

View File

@ -45,6 +45,10 @@ DEMO_MODE=false # Demo mode - resets data hourly
# UNSPLASH_ACCESS_KEY= # Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs get blocked from (#1449). Get a free key at unsplash.com/developers. Overrides any key set per-admin in Admin > Settings. Can also be configured there instead of here. # UNSPLASH_ACCESS_KEY= # Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs get blocked from (#1449). Get a free key at unsplash.com/developers. Overrides any key set per-admin in Admin > Settings. Can also be configured there instead of here.
# AMAP_WEB_SERVICE_KEY= # Required. Amap (Gaode Maps) Web Service API key for maps, places, routing, and transit services. Get one at https://console.amap.com/dev/
# OVERPASS_URL= # Deprecated — maps service uses Amap instead. Ignored when AMAP_WEB_SERVICE_KEY is set.
# Initial admin account — ONLY applied on the first boot, when the database has no # Initial admin account — ONLY applied on the first boot, when the database has no
# users yet. Adding these later has no effect (the server logs a reminder if you do); # users yet. Adding these later has no effect (the server logs a reminder if you do);
# to change an existing password sign in and use Settings, or reset the admin account. # to change an existing password sign in and use Settings, or reset the admin account.

View File

@ -1,7 +1,8 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod'; import { z } from 'zod';
import { findByIata, searchAirports } from '../../services/airportService'; 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 { getWeather, getDetailedWeather } from '../../services/weatherService';
import { import {
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_READONLY,
@ -53,14 +54,14 @@ export function registerMapsWeatherTools(server: McpServer, userId: number, scop
if (canGeo) server.registerTool( if (canGeo) server.registerTool(
'resolve_maps_url', '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: { inputSchema: {
url: z.string().describe('Google Maps share URL'), url: z.string().describe('AMap share URL'),
}, },
annotations: TOOL_ANNOTATIONS_READONLY, annotations: TOOL_ANNOTATIONS_READONLY,
}, },
async ({ url }) => { 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 }; if (!result) return { content: [{ type: 'text' as const, text: 'Could not resolve URL or maps service not configured.' }], isError: true };
return ok(result); return ok(result);
} }

View File

@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod'; import { z } from 'zod';
import { canAccessTrip, db } from '../../db/database'; import { canAccessTrip, db } from '../../db/database';
import { isDemoUser } from '../../services/authService'; 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 { createAssignment, dayExists } from '../../services/assignmentService';
import { onPlaceDeleted } from '../../services/journeyService'; import { onPlaceDeleted } from '../../services/journeyService';
import { listCategories } from '../../services/categoryService'; 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( if (W) server.registerTool(
'bulk_delete_places', 'bulk_delete_places',
{ {

View File

@ -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') @Post('bulk-delete')
@HttpCode(200) // Express answers bulk-delete with res.json (200), unlike the 201 imports. @HttpCode(200) // Express answers bulk-delete with res.json (200), unlike the 201 imports.
bulkDelete( bulkDelete(

View File

@ -68,14 +68,6 @@ export class PlacesService {
return svc.importMapFile(tripId, buffer, filename, opts); return svc.importMapFile(tripId, buffer, filename, opts);
} }
importGoogleList(tripId: string, url: string, opts?: Parameters<typeof svc.importGoogleList>[2]) {
return svc.importGoogleList(tripId, url, opts);
}
importNaverList(tripId: string, url: string, opts?: Parameters<typeof svc.importNaverList>[2]) {
return svc.importNaverList(tripId, url, opts);
}
searchImage(tripId: string, id: string, userId: number) { searchImage(tripId: string, id: string, userId: number) {
return svc.searchPlaceImage(tripId, id, userId); return svc.searchPlaceImage(tripId, id, userId);
} }

View File

@ -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; const name = data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null;
return { name, address: data.display_name || 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<Response> => {
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<string, string> };
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) };
}

View File

@ -13,7 +13,6 @@ import {
resolveCategoryIdForFolder, resolveCategoryIdForFolder,
type KmlImportSummary, type KmlImportSummary,
} from './kmlImport'; } from './kmlImport';
import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment';
import * as placePhotoCache from './placePhotoCache'; import * as placePhotoCache from './placePhotoCache';
import { searchUnsplashPhotos, getUnsplashKey } from './unsplashService'; import { searchUnsplashPhotos, getUnsplashKey } from './unsplashService';
import { type UpdateConflict, isUpdateConflict } from './conflictResult'; 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 { interface PlaceWithCategory extends Place {
category_name: string | null; category_name: string | null;
category_color: string | null; category_color: string | null;
@ -720,259 +712,6 @@ function findDuplicatePlace(
return null; 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) // Search place image (Unsplash)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------