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
This commit is contained in:
parent
1922251fb3
commit
0e6fe5bcab
@ -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<PlaceBulkUpdateRequest, 'ids'>) =>
|
||||
|
||||
@ -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
|
||||
<PlacesList {...S} />
|
||||
|
||||
{dayPickerPlace && <MobileDayPickerSheet {...S} />}
|
||||
{listImportOpen && <ListImportModal {...S} />}
|
||||
<FileImportModal
|
||||
isOpen={fileImportOpen}
|
||||
onClose={() => { setFileImportOpen(false); setSidebarDropFile(null) }}
|
||||
|
||||
@ -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) {
|
||||
>
|
||||
<Upload size={11} strokeWidth={2} /> {t('places.importFile')}
|
||||
</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 className="bg-edge" style={{ height: 1, margin: '2px 0 10px' }} />
|
||||
</>}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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<File | null>(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<Set<string>>(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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
{
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -68,14 +68,6 @@ export class PlacesService {
|
||||
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) {
|
||||
return svc.searchPlaceImage(tripId, id, userId);
|
||||
}
|
||||
|
||||
@ -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<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) };
|
||||
}
|
||||
|
||||
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
Reference in New Issue
Block a user