mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge pull request #662 from mauriceboe/feat/naver-support
feat: Naver Maps list import (addon, combined modal)
This commit is contained in:
@@ -196,6 +196,8 @@ export const placesApi = {
|
|||||||
},
|
},
|
||||||
importGoogleList: (tripId: number | string, url: string) =>
|
importGoogleList: (tripId: number | string, url: string) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||||
|
importNaverList: (tripId: number | string, url: string) =>
|
||||||
|
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const assignmentsApi = {
|
export const assignmentsApi = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { useState, useRef, useMemo, useCallback } from 'react'
|
import { useState, useRef, useMemo, useCallback, useEffect } from 'react'
|
||||||
import DOM from 'react-dom'
|
import DOM from 'react-dom'
|
||||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
|
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
@@ -12,6 +12,7 @@ import { useContextMenu, ContextMenu } 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 { useAddonStore } from '../../store/addonStore'
|
||||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||||
|
|
||||||
interface PlacesSidebarProps {
|
interface PlacesSidebarProps {
|
||||||
@@ -45,6 +46,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const canEditPlaces = can('place_edit', trip)
|
const canEditPlaces = can('place_edit', trip)
|
||||||
|
const isNaverListImportEnabled = useAddonStore((s) => s.isEnabled('naver_list_import'))
|
||||||
|
|
||||||
const handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
@@ -68,22 +70,34 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [googleListOpen, setGoogleListOpen] = useState(false)
|
const [listImportOpen, setListImportOpen] = useState(false)
|
||||||
const [googleListUrl, setGoogleListUrl] = useState('')
|
const [listImportUrl, setListImportUrl] = useState('')
|
||||||
const [googleListLoading, setGoogleListLoading] = useState(false)
|
const [listImportLoading, setListImportLoading] = useState(false)
|
||||||
|
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
|
||||||
|
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
|
||||||
|
const hasMultipleListImportProviders = availableListImportProviders.length > 1
|
||||||
|
|
||||||
const handleGoogleListImport = async () => {
|
useEffect(() => {
|
||||||
if (!googleListUrl.trim()) return
|
if (!isNaverListImportEnabled && listImportProvider === 'naver') {
|
||||||
setGoogleListLoading(true)
|
setListImportProvider('google')
|
||||||
|
}
|
||||||
|
}, [isNaverListImportEnabled, listImportProvider])
|
||||||
|
|
||||||
|
const handleListImport = async () => {
|
||||||
|
if (!listImportUrl.trim()) return
|
||||||
|
setListImportLoading(true)
|
||||||
|
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
|
||||||
try {
|
try {
|
||||||
const result = await placesApi.importGoogleList(tripId, googleListUrl.trim())
|
const result = provider === 'google'
|
||||||
|
? await placesApi.importGoogleList(tripId, listImportUrl.trim())
|
||||||
|
: await placesApi.importNaverList(tripId, listImportUrl.trim())
|
||||||
await loadTrip(tripId)
|
await loadTrip(tripId)
|
||||||
toast.success(t('places.googleListImported', { count: result.count, list: result.listName }))
|
toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName }))
|
||||||
setGoogleListOpen(false)
|
setListImportOpen(false)
|
||||||
setGoogleListUrl('')
|
setListImportUrl('')
|
||||||
if (result.places?.length > 0) {
|
if (result.places?.length > 0) {
|
||||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||||
pushUndo?.(t('undo.importGoogleList'), async () => {
|
pushUndo?.(t(provider === 'google' ? 'undo.importGoogleList' : 'undo.importNaverList'), async () => {
|
||||||
for (const id of importedIds) {
|
for (const id of importedIds) {
|
||||||
try { await placesApi.delete(tripId, id) } catch {}
|
try { await placesApi.delete(tripId, id) } catch {}
|
||||||
}
|
}
|
||||||
@@ -91,9 +105,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err?.response?.data?.error || t('places.googleListError'))
|
toast.error(err?.response?.data?.error || t(provider === 'google' ? 'places.googleListError' : 'places.naverListError'))
|
||||||
} finally {
|
} finally {
|
||||||
setGoogleListLoading(false)
|
setListImportLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +174,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
|
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setGoogleListOpen(true)}
|
onClick={() => setListImportOpen(true)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||||
@@ -169,7 +183,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
cursor: 'pointer', fontFamily: 'inherit',
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MapPin size={11} strokeWidth={2} /> {t('places.importGoogleList')}
|
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>}
|
</>}
|
||||||
@@ -447,9 +461,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
{googleListOpen && ReactDOM.createPortal(
|
{listImportOpen && ReactDOM.createPortal(
|
||||||
<div
|
<div
|
||||||
onClick={() => { setGoogleListOpen(false); setGoogleListUrl('') }}
|
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
|
||||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -457,17 +471,35 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 440, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}
|
style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 440, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 4 }}>
|
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 4 }}>
|
||||||
{t('places.importGoogleList')}
|
{t('places.importList')}
|
||||||
</div>
|
</div>
|
||||||
|
{hasMultipleListImportProviders && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
|
||||||
|
{availableListImportProviders.map(provider => (
|
||||||
|
<button
|
||||||
|
key={provider}
|
||||||
|
onClick={() => setListImportProvider(provider)}
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
|
||||||
|
fontSize: 11, fontWeight: 600, fontFamily: 'inherit',
|
||||||
|
background: listImportProvider === provider ? 'var(--accent)' : 'var(--bg-tertiary)',
|
||||||
|
color: listImportProvider === provider ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{provider === 'google' ? t('places.importGoogleList') : t('places.importNaverList')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 16 }}>
|
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 16 }}>
|
||||||
{t('places.googleListHint')}
|
{t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={googleListUrl}
|
value={listImportUrl}
|
||||||
onChange={e => setGoogleListUrl(e.target.value)}
|
onChange={e => setListImportUrl(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter' && !googleListLoading) handleGoogleListImport() }}
|
onKeyDown={e => { if (e.key === 'Enter' && !listImportLoading) handleListImport() }}
|
||||||
placeholder="https://maps.app.goo.gl/..."
|
placeholder={listImportProvider === 'google' ? 'https://maps.app.goo.gl/...' : 'https://naver.me/...'}
|
||||||
autoFocus
|
autoFocus
|
||||||
style={{
|
style={{
|
||||||
width: '100%', padding: '10px 14px', borderRadius: 10,
|
width: '100%', padding: '10px 14px', borderRadius: 10,
|
||||||
@@ -478,7 +510,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
/>
|
/>
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setGoogleListOpen(false); setGoogleListUrl('') }}
|
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
|
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
|
||||||
background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500,
|
background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500,
|
||||||
@@ -488,17 +520,17 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleGoogleListImport}
|
onClick={handleListImport}
|
||||||
disabled={!googleListUrl.trim() || googleListLoading}
|
disabled={!listImportUrl.trim() || listImportLoading}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px', borderRadius: 10, border: 'none',
|
padding: '8px 16px', borderRadius: 10, border: 'none',
|
||||||
background: !googleListUrl.trim() || googleListLoading ? 'var(--bg-tertiary)' : 'var(--accent)',
|
background: !listImportUrl.trim() || listImportLoading ? 'var(--bg-tertiary)' : 'var(--accent)',
|
||||||
color: !googleListUrl.trim() || googleListLoading ? 'var(--text-faint)' : 'var(--accent-text)',
|
color: !listImportUrl.trim() || listImportLoading ? 'var(--text-faint)' : 'var(--accent-text)',
|
||||||
fontSize: 13, fontWeight: 500, cursor: !googleListUrl.trim() || googleListLoading ? 'default' : 'pointer',
|
fontSize: 13, fontWeight: 500, cursor: !listImportUrl.trim() || listImportLoading ? 'default' : 'pointer',
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{googleListLoading ? t('common.loading') : t('common.import')}
|
{listImportLoading ? t('common.loading') : t('common.import')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -895,10 +895,15 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.importGpx': 'GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
|
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
|
||||||
'places.gpxError': 'فشل استيراد GPX',
|
'places.gpxError': 'فشل استيراد GPX',
|
||||||
|
'places.importList': 'استيراد قائمة',
|
||||||
'places.importGoogleList': 'قائمة Google',
|
'places.importGoogleList': 'قائمة Google',
|
||||||
|
'places.importNaverList': 'قائمة Naver',
|
||||||
'places.googleListHint': 'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.',
|
'places.googleListHint': 'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.',
|
||||||
'places.googleListImported': 'تم استيراد {count} أماكن من "{list}"',
|
'places.googleListImported': 'تم استيراد {count} أماكن من "{list}"',
|
||||||
'places.googleListError': 'فشل استيراد قائمة Google Maps',
|
'places.googleListError': 'فشل استيراد قائمة Google Maps',
|
||||||
|
'places.naverListHint': 'الصق رابط قائمة Naver Maps مشتركة لاستيراد جميع الأماكن.',
|
||||||
|
'places.naverListImported': 'تم استيراد {count} مكان من "{list}"',
|
||||||
|
'places.naverListError': 'فشل استيراد قائمة Naver Maps',
|
||||||
'places.viewDetails': 'عرض التفاصيل',
|
'places.viewDetails': 'عرض التفاصيل',
|
||||||
'places.urlResolved': 'تم استيراد المكان من الرابط',
|
'places.urlResolved': 'تم استيراد المكان من الرابط',
|
||||||
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
|
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
|
||||||
@@ -1713,6 +1718,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'undo.lock': 'تم تبديل قفل المكان',
|
'undo.lock': 'تم تبديل قفل المكان',
|
||||||
'undo.importGpx': 'استيراد GPX',
|
'undo.importGpx': 'استيراد GPX',
|
||||||
'undo.importGoogleList': 'استيراد خرائط Google',
|
'undo.importGoogleList': 'استيراد خرائط Google',
|
||||||
|
'undo.importNaverList': 'استيراد خرائط Naver',
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
'notifications.title': 'الإشعارات',
|
'notifications.title': 'الإشعارات',
|
||||||
|
|||||||
@@ -865,10 +865,15 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.importGpx': 'GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '{count} lugares importados do GPX',
|
'places.gpxImported': '{count} lugares importados do GPX',
|
||||||
'places.gpxError': 'Falha ao importar GPX',
|
'places.gpxError': 'Falha ao importar GPX',
|
||||||
|
'places.importList': 'Importar lista',
|
||||||
'places.importGoogleList': 'Lista Google',
|
'places.importGoogleList': 'Lista Google',
|
||||||
|
'places.importNaverList': 'Lista Naver',
|
||||||
'places.googleListHint': 'Cole um link compartilhado de uma lista do Google Maps para importar todos os lugares.',
|
'places.googleListHint': 'Cole um link compartilhado de uma lista do Google Maps para importar todos os lugares.',
|
||||||
'places.googleListImported': '{count} lugares importados de "{list}"',
|
'places.googleListImported': '{count} lugares importados de "{list}"',
|
||||||
'places.googleListError': 'Falha ao importar lista do Google Maps',
|
'places.googleListError': 'Falha ao importar lista do Google Maps',
|
||||||
|
'places.naverListHint': 'Cole um link compartilhado de uma lista do Naver Maps para importar todos os lugares.',
|
||||||
|
'places.naverListImported': '{count} lugares importados de "{list}"',
|
||||||
|
'places.naverListError': 'Falha ao importar lista do Naver Maps',
|
||||||
'places.viewDetails': 'Ver detalhes',
|
'places.viewDetails': 'Ver detalhes',
|
||||||
'places.urlResolved': 'Lugar importado da URL',
|
'places.urlResolved': 'Lugar importado da URL',
|
||||||
'places.assignToDay': 'Adicionar a qual dia?',
|
'places.assignToDay': 'Adicionar a qual dia?',
|
||||||
@@ -1662,6 +1667,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'undo.lock': 'Bloqueio do local alternado',
|
'undo.lock': 'Bloqueio do local alternado',
|
||||||
'undo.importGpx': 'Importação de GPX',
|
'undo.importGpx': 'Importação de GPX',
|
||||||
'undo.importGoogleList': 'Importação do Google Maps',
|
'undo.importGoogleList': 'Importação do Google Maps',
|
||||||
|
'undo.importNaverList': 'Importação do Naver Maps',
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
'notifications.title': 'Notificações',
|
'notifications.title': 'Notificações',
|
||||||
|
|||||||
@@ -894,10 +894,15 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.gpxImported': '{count} míst importováno z GPX',
|
'places.gpxImported': '{count} míst importováno z GPX',
|
||||||
'places.urlResolved': 'Místo importováno z URL',
|
'places.urlResolved': 'Místo importováno z URL',
|
||||||
'places.gpxError': 'Import GPX se nezdařil',
|
'places.gpxError': 'Import GPX se nezdařil',
|
||||||
|
'places.importList': 'Import seznamu',
|
||||||
'places.importGoogleList': 'Google Seznam',
|
'places.importGoogleList': 'Google Seznam',
|
||||||
|
'places.importNaverList': 'Naver Seznam',
|
||||||
'places.googleListHint': 'Vložte sdílený odkaz na seznam Google Maps pro import všech míst.',
|
'places.googleListHint': 'Vložte sdílený odkaz na seznam Google Maps pro import všech míst.',
|
||||||
'places.googleListImported': '{count} míst importováno ze seznamu "{list}"',
|
'places.googleListImported': '{count} míst importováno ze seznamu "{list}"',
|
||||||
'places.googleListError': 'Import seznamu Google Maps se nezdařil',
|
'places.googleListError': 'Import seznamu Google Maps se nezdařil',
|
||||||
|
'places.naverListHint': 'Vložte sdílený odkaz na seznam Naver Maps pro import všech míst.',
|
||||||
|
'places.naverListImported': '{count} míst importováno ze seznamu "{list}"',
|
||||||
|
'places.naverListError': 'Import seznamu Naver Maps se nezdařil',
|
||||||
'places.viewDetails': 'Zobrazit detaily',
|
'places.viewDetails': 'Zobrazit detaily',
|
||||||
'places.assignToDay': 'Přidat do kterého dne?',
|
'places.assignToDay': 'Přidat do kterého dne?',
|
||||||
'places.all': 'Vše',
|
'places.all': 'Vše',
|
||||||
@@ -1665,6 +1670,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'undo.lock': 'Zámek místa přepnut',
|
'undo.lock': 'Zámek místa přepnut',
|
||||||
'undo.importGpx': 'Import GPX',
|
'undo.importGpx': 'Import GPX',
|
||||||
'undo.importGoogleList': 'Import z Google Maps',
|
'undo.importGoogleList': 'Import z Google Maps',
|
||||||
|
'undo.importNaverList': 'Import z Naver Maps',
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
'notifications.title': 'Oznámení',
|
'notifications.title': 'Oznámení',
|
||||||
|
|||||||
@@ -897,10 +897,15 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.gpxImported': '{count} Orte aus GPX importiert',
|
'places.gpxImported': '{count} Orte aus GPX importiert',
|
||||||
'places.urlResolved': 'Ort aus URL importiert',
|
'places.urlResolved': 'Ort aus URL importiert',
|
||||||
'places.gpxError': 'GPX-Import fehlgeschlagen',
|
'places.gpxError': 'GPX-Import fehlgeschlagen',
|
||||||
|
'places.importList': 'Listenimport',
|
||||||
'places.importGoogleList': 'Google Liste',
|
'places.importGoogleList': 'Google Liste',
|
||||||
|
'places.importNaverList': 'Naver Liste',
|
||||||
'places.googleListHint': 'Geteilten Google Maps Listen-Link einfügen, um alle Orte zu importieren.',
|
'places.googleListHint': 'Geteilten Google Maps Listen-Link einfügen, um alle Orte zu importieren.',
|
||||||
'places.googleListImported': '{count} Orte aus "{list}" importiert',
|
'places.googleListImported': '{count} Orte aus "{list}" importiert',
|
||||||
'places.googleListError': 'Google Maps Liste konnte nicht importiert werden',
|
'places.googleListError': 'Google Maps Liste konnte nicht importiert werden',
|
||||||
|
'places.naverListHint': 'Geteilten Naver Maps Listen-Link einfügen, um alle Orte zu importieren.',
|
||||||
|
'places.naverListImported': '{count} Orte aus "{list}" importiert',
|
||||||
|
'places.naverListError': 'Naver Maps Liste konnte nicht importiert werden',
|
||||||
'places.viewDetails': 'Details anzeigen',
|
'places.viewDetails': 'Details anzeigen',
|
||||||
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
||||||
'places.all': 'Alle',
|
'places.all': 'Alle',
|
||||||
@@ -1670,6 +1675,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'undo.lock': 'Ortssperre umgeschaltet',
|
'undo.lock': 'Ortssperre umgeschaltet',
|
||||||
'undo.importGpx': 'GPX-Import',
|
'undo.importGpx': 'GPX-Import',
|
||||||
'undo.importGoogleList': 'Google Maps-Import',
|
'undo.importGoogleList': 'Google Maps-Import',
|
||||||
|
'undo.importNaverList': 'Naver Maps-Import',
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
'notifications.title': 'Benachrichtigungen',
|
'notifications.title': 'Benachrichtigungen',
|
||||||
|
|||||||
@@ -919,10 +919,15 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.gpxImported': '{count} places imported from GPX',
|
'places.gpxImported': '{count} places imported from GPX',
|
||||||
'places.urlResolved': 'Place imported from URL',
|
'places.urlResolved': 'Place imported from URL',
|
||||||
'places.gpxError': 'GPX import failed',
|
'places.gpxError': 'GPX import failed',
|
||||||
|
'places.importList': 'List Import',
|
||||||
'places.importGoogleList': 'Google List',
|
'places.importGoogleList': 'Google List',
|
||||||
|
'places.importNaverList': 'Naver List',
|
||||||
'places.googleListHint': 'Paste a shared Google Maps list link to import all places.',
|
'places.googleListHint': 'Paste a shared Google Maps list link to import all places.',
|
||||||
'places.googleListImported': '{count} places imported from "{list}"',
|
'places.googleListImported': '{count} places imported from "{list}"',
|
||||||
'places.googleListError': 'Failed to import Google Maps list',
|
'places.googleListError': 'Failed to import Google Maps list',
|
||||||
|
'places.naverListHint': 'Paste a shared Naver Maps list link to import all places.',
|
||||||
|
'places.naverListImported': '{count} places imported from "{list}"',
|
||||||
|
'places.naverListError': 'Failed to import Naver Maps list',
|
||||||
'places.viewDetails': 'View Details',
|
'places.viewDetails': 'View Details',
|
||||||
'places.assignToDay': 'Add to which day?',
|
'places.assignToDay': 'Add to which day?',
|
||||||
'places.all': 'All',
|
'places.all': 'All',
|
||||||
@@ -1704,6 +1709,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'undo.lock': 'Place lock toggled',
|
'undo.lock': 'Place lock toggled',
|
||||||
'undo.importGpx': 'GPX import',
|
'undo.importGpx': 'GPX import',
|
||||||
'undo.importGoogleList': 'Google Maps import',
|
'undo.importGoogleList': 'Google Maps import',
|
||||||
|
'undo.importNaverList': 'Naver Maps import',
|
||||||
'undo.addPlace': 'Place added',
|
'undo.addPlace': 'Place added',
|
||||||
'undo.done': 'Undone: {action}',
|
'undo.done': 'Undone: {action}',
|
||||||
|
|
||||||
|
|||||||
@@ -868,10 +868,15 @@ const es: Record<string, string> = {
|
|||||||
'places.importGpx': 'GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '{count} lugares importados desde GPX',
|
'places.gpxImported': '{count} lugares importados desde GPX',
|
||||||
'places.gpxError': 'Error al importar GPX',
|
'places.gpxError': 'Error al importar GPX',
|
||||||
|
'places.importList': 'Importar lista',
|
||||||
'places.importGoogleList': 'Lista Google',
|
'places.importGoogleList': 'Lista Google',
|
||||||
|
'places.importNaverList': 'Lista Naver',
|
||||||
'places.googleListHint': 'Pega un enlace compartido de una lista de Google Maps para importar todos los lugares.',
|
'places.googleListHint': 'Pega un enlace compartido de una lista de Google Maps para importar todos los lugares.',
|
||||||
'places.googleListImported': '{count} lugares importados de "{list}"',
|
'places.googleListImported': '{count} lugares importados de "{list}"',
|
||||||
'places.googleListError': 'Error al importar la lista de Google Maps',
|
'places.googleListError': 'Error al importar la lista de Google Maps',
|
||||||
|
'places.naverListHint': 'Pega un enlace compartido de una lista de Naver Maps para importar todos los lugares.',
|
||||||
|
'places.naverListImported': '{count} lugares importados de "{list}"',
|
||||||
|
'places.naverListError': 'Error al importar la lista de Naver Maps',
|
||||||
'places.viewDetails': 'Ver detalles',
|
'places.viewDetails': 'Ver detalles',
|
||||||
'places.urlResolved': 'Lugar importado desde URL',
|
'places.urlResolved': 'Lugar importado desde URL',
|
||||||
'places.assignToDay': '¿A qué día añadirlo?',
|
'places.assignToDay': '¿A qué día añadirlo?',
|
||||||
@@ -1672,6 +1677,7 @@ const es: Record<string, string> = {
|
|||||||
'undo.lock': 'Bloqueo de lugar activado/desactivado',
|
'undo.lock': 'Bloqueo de lugar activado/desactivado',
|
||||||
'undo.importGpx': 'Importación GPX',
|
'undo.importGpx': 'Importación GPX',
|
||||||
'undo.importGoogleList': 'Importación de Google Maps',
|
'undo.importGoogleList': 'Importación de Google Maps',
|
||||||
|
'undo.importNaverList': 'Importación de Naver Maps',
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
'notifications.title': 'Notificaciones',
|
'notifications.title': 'Notificaciones',
|
||||||
|
|||||||
@@ -892,10 +892,15 @@ const fr: Record<string, string> = {
|
|||||||
'places.importGpx': 'GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '{count} lieux importés depuis GPX',
|
'places.gpxImported': '{count} lieux importés depuis GPX',
|
||||||
'places.gpxError': 'L\'import GPX a échoué',
|
'places.gpxError': 'L\'import GPX a échoué',
|
||||||
|
'places.importList': 'Import de liste',
|
||||||
'places.importGoogleList': 'Liste Google',
|
'places.importGoogleList': 'Liste Google',
|
||||||
|
'places.importNaverList': 'Liste Naver',
|
||||||
'places.googleListHint': 'Collez un lien de liste Google Maps partagée pour importer tous les lieux.',
|
'places.googleListHint': 'Collez un lien de liste Google Maps partagée pour importer tous les lieux.',
|
||||||
'places.googleListImported': '{count} lieux importés depuis "{list}"',
|
'places.googleListImported': '{count} lieux importés depuis "{list}"',
|
||||||
'places.googleListError': 'Impossible d\'importer la liste Google Maps',
|
'places.googleListError': 'Impossible d\'importer la liste Google Maps',
|
||||||
|
'places.naverListHint': 'Collez un lien de liste Naver Maps partagée pour importer tous les lieux.',
|
||||||
|
'places.naverListImported': '{count} lieux importés depuis "{list}"',
|
||||||
|
'places.naverListError': 'Impossible d\'importer la liste Naver Maps',
|
||||||
'places.viewDetails': 'Voir les détails',
|
'places.viewDetails': 'Voir les détails',
|
||||||
'places.urlResolved': 'Lieu importé depuis l\'URL',
|
'places.urlResolved': 'Lieu importé depuis l\'URL',
|
||||||
'places.assignToDay': 'Ajouter à quel jour ?',
|
'places.assignToDay': 'Ajouter à quel jour ?',
|
||||||
@@ -1666,6 +1671,7 @@ const fr: Record<string, string> = {
|
|||||||
'undo.lock': 'Verrouillage du lieu modifié',
|
'undo.lock': 'Verrouillage du lieu modifié',
|
||||||
'undo.importGpx': 'Import GPX',
|
'undo.importGpx': 'Import GPX',
|
||||||
'undo.importGoogleList': 'Import Google Maps',
|
'undo.importGoogleList': 'Import Google Maps',
|
||||||
|
'undo.importNaverList': 'Import Naver Maps',
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
'notifications.title': 'Notifications',
|
'notifications.title': 'Notifications',
|
||||||
|
|||||||
@@ -894,10 +894,15 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.gpxImported': '{count} hely importálva GPX-ből',
|
'places.gpxImported': '{count} hely importálva GPX-ből',
|
||||||
'places.urlResolved': 'Hely importálva URL-ből',
|
'places.urlResolved': 'Hely importálva URL-ből',
|
||||||
'places.gpxError': 'GPX importálás sikertelen',
|
'places.gpxError': 'GPX importálás sikertelen',
|
||||||
|
'places.importList': 'Lista importálás',
|
||||||
'places.importGoogleList': 'Google Lista',
|
'places.importGoogleList': 'Google Lista',
|
||||||
|
'places.importNaverList': 'Naver Lista',
|
||||||
'places.googleListHint': 'Illessz be egy megosztott Google Maps lista linket az osszes hely importalasahoz.',
|
'places.googleListHint': 'Illessz be egy megosztott Google Maps lista linket az osszes hely importalasahoz.',
|
||||||
'places.googleListImported': '{count} hely importalva a(z) "{list}" listabol',
|
'places.googleListImported': '{count} hely importalva a(z) "{list}" listabol',
|
||||||
'places.googleListError': 'Google Maps lista importalasa sikertelen',
|
'places.googleListError': 'Google Maps lista importalasa sikertelen',
|
||||||
|
'places.naverListHint': 'Illessz be egy megosztott Naver Maps lista linket az összes hely importálásához.',
|
||||||
|
'places.naverListImported': '{count} hely importálva a(z) "{list}" listából',
|
||||||
|
'places.naverListError': 'Naver Maps lista importálása sikertelen',
|
||||||
'places.viewDetails': 'Részletek megtekintése',
|
'places.viewDetails': 'Részletek megtekintése',
|
||||||
'places.assignToDay': 'Melyik naphoz adod?',
|
'places.assignToDay': 'Melyik naphoz adod?',
|
||||||
'places.all': 'Összes',
|
'places.all': 'Összes',
|
||||||
@@ -1664,6 +1669,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'undo.lock': 'Hely zárolása váltva',
|
'undo.lock': 'Hely zárolása váltva',
|
||||||
'undo.importGpx': 'GPX importálás',
|
'undo.importGpx': 'GPX importálás',
|
||||||
'undo.importGoogleList': 'Google Maps importálás',
|
'undo.importGoogleList': 'Google Maps importálás',
|
||||||
|
'undo.importNaverList': 'Naver Maps importálás',
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
'notifications.title': 'Értesítések',
|
'notifications.title': 'Értesítések',
|
||||||
|
|||||||
@@ -894,10 +894,15 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'places.gpxImported': '{count} luoghi importati da GPX',
|
'places.gpxImported': '{count} luoghi importati da GPX',
|
||||||
'places.urlResolved': 'Luogo importato dall\'URL',
|
'places.urlResolved': 'Luogo importato dall\'URL',
|
||||||
'places.gpxError': 'Importazione GPX non riuscita',
|
'places.gpxError': 'Importazione GPX non riuscita',
|
||||||
|
'places.importList': 'Importa lista',
|
||||||
'places.importGoogleList': 'Lista Google',
|
'places.importGoogleList': 'Lista Google',
|
||||||
|
'places.importNaverList': 'Lista Naver',
|
||||||
'places.googleListHint': 'Incolla un link condiviso di una lista Google Maps per importare tutti i luoghi.',
|
'places.googleListHint': 'Incolla un link condiviso di una lista Google Maps per importare tutti i luoghi.',
|
||||||
'places.googleListImported': '{count} luoghi importati da "{list}"',
|
'places.googleListImported': '{count} luoghi importati da "{list}"',
|
||||||
'places.googleListError': 'Importazione lista Google Maps non riuscita',
|
'places.googleListError': 'Importazione lista Google Maps non riuscita',
|
||||||
|
'places.naverListHint': 'Incolla un link condiviso di una lista Naver Maps per importare tutti i luoghi.',
|
||||||
|
'places.naverListImported': '{count} luoghi importati da "{list}"',
|
||||||
|
'places.naverListError': 'Importazione lista Naver Maps non riuscita',
|
||||||
'places.viewDetails': 'Visualizza dettagli',
|
'places.viewDetails': 'Visualizza dettagli',
|
||||||
'places.assignToDay': 'A quale giorno aggiungere?',
|
'places.assignToDay': 'A quale giorno aggiungere?',
|
||||||
'places.all': 'Tutti',
|
'places.all': 'Tutti',
|
||||||
@@ -1668,6 +1673,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'undo.lock': 'Blocco luogo modificato',
|
'undo.lock': 'Blocco luogo modificato',
|
||||||
'undo.importGpx': 'Importazione GPX',
|
'undo.importGpx': 'Importazione GPX',
|
||||||
'undo.importGoogleList': 'Importazione Google Maps',
|
'undo.importGoogleList': 'Importazione Google Maps',
|
||||||
|
'undo.importNaverList': 'Importazione Naver Maps',
|
||||||
'undo.addPlace': 'Luogo aggiunto',
|
'undo.addPlace': 'Luogo aggiunto',
|
||||||
'undo.done': 'Annullato: {action}',
|
'undo.done': 'Annullato: {action}',
|
||||||
// Notifications
|
// Notifications
|
||||||
|
|||||||
@@ -892,10 +892,15 @@ const nl: Record<string, string> = {
|
|||||||
'places.importGpx': 'GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
|
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
|
||||||
'places.gpxError': 'GPX-import mislukt',
|
'places.gpxError': 'GPX-import mislukt',
|
||||||
|
'places.importList': 'Lijst importeren',
|
||||||
'places.importGoogleList': 'Google Lijst',
|
'places.importGoogleList': 'Google Lijst',
|
||||||
|
'places.importNaverList': 'Naver Lijst',
|
||||||
'places.googleListHint': 'Plak een gedeelde Google Maps lijstlink om alle plaatsen te importeren.',
|
'places.googleListHint': 'Plak een gedeelde Google Maps lijstlink om alle plaatsen te importeren.',
|
||||||
'places.googleListImported': '{count} plaatsen geimporteerd uit "{list}"',
|
'places.googleListImported': '{count} plaatsen geimporteerd uit "{list}"',
|
||||||
'places.googleListError': 'Google Maps lijst importeren mislukt',
|
'places.googleListError': 'Google Maps lijst importeren mislukt',
|
||||||
|
'places.naverListHint': 'Plak een gedeelde Naver Maps lijstlink om alle plaatsen te importeren.',
|
||||||
|
'places.naverListImported': '{count} plaatsen geimporteerd uit "{list}"',
|
||||||
|
'places.naverListError': 'Naver Maps lijst importeren mislukt',
|
||||||
'places.viewDetails': 'Details bekijken',
|
'places.viewDetails': 'Details bekijken',
|
||||||
'places.urlResolved': 'Plaats geïmporteerd van URL',
|
'places.urlResolved': 'Plaats geïmporteerd van URL',
|
||||||
'places.assignToDay': 'Aan welke dag toevoegen?',
|
'places.assignToDay': 'Aan welke dag toevoegen?',
|
||||||
@@ -1666,6 +1671,7 @@ const nl: Record<string, string> = {
|
|||||||
'undo.lock': 'Vergrendeling locatie gewijzigd',
|
'undo.lock': 'Vergrendeling locatie gewijzigd',
|
||||||
'undo.importGpx': 'GPX-import',
|
'undo.importGpx': 'GPX-import',
|
||||||
'undo.importGoogleList': 'Google Maps-import',
|
'undo.importGoogleList': 'Google Maps-import',
|
||||||
|
'undo.importNaverList': 'Naver Maps-import',
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
'notifications.title': 'Meldingen',
|
'notifications.title': 'Meldingen',
|
||||||
|
|||||||
@@ -1609,9 +1609,14 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'atlas.searchCountry': 'Szukaj kraju...',
|
'atlas.searchCountry': 'Szukaj kraju...',
|
||||||
'trip.loadingPhotos': 'Ładowanie zdjęć...',
|
'trip.loadingPhotos': 'Ładowanie zdjęć...',
|
||||||
'places.importGoogleList': 'Lista Google',
|
'places.importGoogleList': 'Lista Google',
|
||||||
|
'places.importNaverList': 'Lista Naver',
|
||||||
|
'places.importList': 'Import listy',
|
||||||
'places.googleListHint': 'Wklej link do listy Google Maps.',
|
'places.googleListHint': 'Wklej link do listy Google Maps.',
|
||||||
'places.googleListImported': 'Zaimportowano {count} miejsc',
|
'places.googleListImported': 'Zaimportowano {count} miejsc',
|
||||||
'places.googleListError': 'Nie udało się zaimportować listy',
|
'places.googleListError': 'Nie udało się zaimportować listy',
|
||||||
|
'places.naverListHint': 'Wklej link do udostępnionej listy Naver Maps, aby zaimportować wszystkie miejsca.',
|
||||||
|
'places.naverListImported': 'Zaimportowano {count} miejsc z "{list}"',
|
||||||
|
'places.naverListError': 'Nie udało się zaimportować listy Naver Maps',
|
||||||
'places.viewDetails': 'Zobacz szczegóły',
|
'places.viewDetails': 'Zobacz szczegóły',
|
||||||
'inspector.trackStats': 'Statystyki trasy',
|
'inspector.trackStats': 'Statystyki trasy',
|
||||||
'budget.exportCsv': 'Eksportuj CSV',
|
'budget.exportCsv': 'Eksportuj CSV',
|
||||||
@@ -1691,6 +1696,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'undo.lock': 'Blokada przełączona',
|
'undo.lock': 'Blokada przełączona',
|
||||||
'undo.importGpx': 'Import GPX',
|
'undo.importGpx': 'Import GPX',
|
||||||
'undo.importGoogleList': 'Import Google Maps',
|
'undo.importGoogleList': 'Import Google Maps',
|
||||||
|
'undo.importNaverList': 'Import Naver Maps',
|
||||||
'undo.addPlace': 'Miejsce dodane',
|
'undo.addPlace': 'Miejsce dodane',
|
||||||
'undo.done': 'Cofnięto: {action}',
|
'undo.done': 'Cofnięto: {action}',
|
||||||
'notifications.title': 'Powiadomienia',
|
'notifications.title': 'Powiadomienia',
|
||||||
|
|||||||
@@ -892,10 +892,15 @@ const ru: Record<string, string> = {
|
|||||||
'places.importGpx': 'GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '{count} мест импортировано из GPX',
|
'places.gpxImported': '{count} мест импортировано из GPX',
|
||||||
'places.gpxError': 'Ошибка импорта GPX',
|
'places.gpxError': 'Ошибка импорта GPX',
|
||||||
|
'places.importList': 'Импорт списка',
|
||||||
'places.importGoogleList': 'Список Google',
|
'places.importGoogleList': 'Список Google',
|
||||||
|
'places.importNaverList': 'Список Naver',
|
||||||
'places.googleListHint': 'Вставьте ссылку на общий список Google Maps для импорта всех мест.',
|
'places.googleListHint': 'Вставьте ссылку на общий список Google Maps для импорта всех мест.',
|
||||||
'places.googleListImported': '{count} мест импортировано из "{list}"',
|
'places.googleListImported': '{count} мест импортировано из "{list}"',
|
||||||
'places.googleListError': 'Не удалось импортировать список Google Maps',
|
'places.googleListError': 'Не удалось импортировать список Google Maps',
|
||||||
|
'places.naverListHint': 'Вставьте ссылку на общий список Naver Maps для импорта всех мест.',
|
||||||
|
'places.naverListImported': '{count} мест импортировано из "{list}"',
|
||||||
|
'places.naverListError': 'Не удалось импортировать список Naver Maps',
|
||||||
'places.viewDetails': 'Подробности',
|
'places.viewDetails': 'Подробности',
|
||||||
'places.urlResolved': 'Место импортировано из URL',
|
'places.urlResolved': 'Место импортировано из URL',
|
||||||
'places.assignToDay': 'Добавить в какой день?',
|
'places.assignToDay': 'Добавить в какой день?',
|
||||||
@@ -1663,6 +1668,7 @@ const ru: Record<string, string> = {
|
|||||||
'undo.lock': 'Блокировка места изменена',
|
'undo.lock': 'Блокировка места изменена',
|
||||||
'undo.importGpx': 'Импорт GPX',
|
'undo.importGpx': 'Импорт GPX',
|
||||||
'undo.importGoogleList': 'Импорт из Google Maps',
|
'undo.importGoogleList': 'Импорт из Google Maps',
|
||||||
|
'undo.importNaverList': 'Импорт из Naver Maps',
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
'notifications.title': 'Уведомления',
|
'notifications.title': 'Уведомления',
|
||||||
|
|||||||
@@ -892,10 +892,15 @@ const zh: Record<string, string> = {
|
|||||||
'places.importGpx': 'GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
|
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
|
||||||
'places.gpxError': 'GPX 导入失败',
|
'places.gpxError': 'GPX 导入失败',
|
||||||
|
'places.importList': '列表导入',
|
||||||
'places.importGoogleList': 'Google 列表',
|
'places.importGoogleList': 'Google 列表',
|
||||||
|
'places.importNaverList': 'Naver 列表',
|
||||||
'places.googleListHint': '粘贴共享的 Google Maps 列表链接以导入所有地点。',
|
'places.googleListHint': '粘贴共享的 Google Maps 列表链接以导入所有地点。',
|
||||||
'places.googleListImported': '已从"{list}"导入 {count} 个地点',
|
'places.googleListImported': '已从"{list}"导入 {count} 个地点',
|
||||||
'places.googleListError': 'Google Maps 列表导入失败',
|
'places.googleListError': 'Google Maps 列表导入失败',
|
||||||
|
'places.naverListHint': '粘贴共享的 Naver Maps 列表链接以导入所有地点。',
|
||||||
|
'places.naverListImported': '已从"{list}"导入 {count} 个地点',
|
||||||
|
'places.naverListError': 'Naver Maps 列表导入失败',
|
||||||
'places.viewDetails': '查看详情',
|
'places.viewDetails': '查看详情',
|
||||||
'places.urlResolved': '已从 URL 导入地点',
|
'places.urlResolved': '已从 URL 导入地点',
|
||||||
'places.assignToDay': '添加到哪一天?',
|
'places.assignToDay': '添加到哪一天?',
|
||||||
@@ -1663,6 +1668,7 @@ const zh: Record<string, string> = {
|
|||||||
'undo.lock': '地点锁定已切换',
|
'undo.lock': '地点锁定已切换',
|
||||||
'undo.importGpx': 'GPX 导入',
|
'undo.importGpx': 'GPX 导入',
|
||||||
'undo.importGoogleList': 'Google 地图导入',
|
'undo.importGoogleList': 'Google 地图导入',
|
||||||
|
'undo.importNaverList': 'Naver 地图导入',
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
'notifications.title': '通知',
|
'notifications.title': '通知',
|
||||||
|
|||||||
@@ -917,10 +917,15 @@ const zhTw: Record<string, string> = {
|
|||||||
'places.importGpx': 'GPX',
|
'places.importGpx': 'GPX',
|
||||||
'places.gpxImported': '已從 GPX 匯入 {count} 個地點',
|
'places.gpxImported': '已從 GPX 匯入 {count} 個地點',
|
||||||
'places.gpxError': 'GPX 匯入失敗',
|
'places.gpxError': 'GPX 匯入失敗',
|
||||||
|
'places.importList': '列表匯入',
|
||||||
'places.importGoogleList': 'Google 列表',
|
'places.importGoogleList': 'Google 列表',
|
||||||
|
'places.importNaverList': 'Naver 列表',
|
||||||
'places.googleListHint': '貼上共享的 Google Maps 列表連結以匯入所有地點。',
|
'places.googleListHint': '貼上共享的 Google Maps 列表連結以匯入所有地點。',
|
||||||
'places.googleListImported': '已從"{list}"匯入 {count} 個地點',
|
'places.googleListImported': '已從"{list}"匯入 {count} 個地點',
|
||||||
'places.googleListError': 'Google Maps 列表匯入失敗',
|
'places.googleListError': 'Google Maps 列表匯入失敗',
|
||||||
|
'places.naverListHint': '貼上共享的 Naver Maps 列表連結以匯入所有地點。',
|
||||||
|
'places.naverListImported': '已從"{list}"匯入 {count} 個地點',
|
||||||
|
'places.naverListError': 'Naver Maps 列表匯入失敗',
|
||||||
'places.viewDetails': '檢視詳情',
|
'places.viewDetails': '檢視詳情',
|
||||||
'places.urlResolved': '已從 URL 匯入地點',
|
'places.urlResolved': '已從 URL 匯入地點',
|
||||||
'places.assignToDay': '新增到哪一天?',
|
'places.assignToDay': '新增到哪一天?',
|
||||||
@@ -1688,6 +1693,7 @@ const zhTw: Record<string, string> = {
|
|||||||
'undo.lock': '地點鎖定已切換',
|
'undo.lock': '地點鎖定已切換',
|
||||||
'undo.importGpx': 'GPX 匯入',
|
'undo.importGpx': 'GPX 匯入',
|
||||||
'undo.importGoogleList': 'Google 地圖匯入',
|
'undo.importGoogleList': 'Google 地圖匯入',
|
||||||
|
'undo.importNaverList': 'Naver 地圖匯入',
|
||||||
|
|
||||||
// Todo
|
// Todo
|
||||||
'todo.subtab.packing': '行李清單',
|
'todo.subtab.packing': '行李清單',
|
||||||
|
|||||||
@@ -885,6 +885,17 @@ function runMigrations(db: Database.Database): void {
|
|||||||
ins.run(r.trip_id, r.category, idx++);
|
ins.run(r.trip_id, r.category, idx++);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Migration: Naver list import addon (default off)
|
||||||
|
() => {
|
||||||
|
try {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run('naver_list_import', 'Naver List Import', 'Import places from shared Naver Maps lists', 'trip', 'Link2', 0, 13);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.warn('[migrations] Non-fatal migration step failed:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
// Migration: OAuth 2.1 clients, consents, and tokens for MCP
|
// Migration: OAuth 2.1 clients, consents, and tokens for MCP
|
||||||
() => {
|
() => {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ function seedAddons(db: Database.Database): void {
|
|||||||
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
|
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
|
||||||
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
||||||
{ id: 'mcp', name: 'MCP', description: 'Model Context Protocol for AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
|
{ id: 'mcp', name: 'MCP', description: 'Model Context Protocol for AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
|
||||||
|
{ id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 0, sort_order: 13 },
|
||||||
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
||||||
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
|
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { requireTripAccess } from '../middleware/tripAccess';
|
|||||||
import { broadcast } from '../websocket';
|
import { broadcast } from '../websocket';
|
||||||
import { validateStringLengths } from '../middleware/validate';
|
import { validateStringLengths } from '../middleware/validate';
|
||||||
import { checkPermission } from '../services/permissions';
|
import { checkPermission } from '../services/permissions';
|
||||||
|
import { isAddonEnabled } from '../services/adminService';
|
||||||
import { AuthRequest } from '../types';
|
import { AuthRequest } from '../types';
|
||||||
import {
|
import {
|
||||||
listPlaces,
|
listPlaces,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
deletePlace,
|
deletePlace,
|
||||||
importGpx,
|
importGpx,
|
||||||
importGoogleList,
|
importGoogleList,
|
||||||
|
importNaverList,
|
||||||
searchPlaceImage,
|
searchPlaceImage,
|
||||||
} from '../services/placeService';
|
} from '../services/placeService';
|
||||||
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService';
|
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService';
|
||||||
@@ -101,6 +103,36 @@ router.post('/import/google-list', authenticate, requireTripAccess, async (req:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Import places from a shared Naver Maps list URL
|
||||||
|
router.post('/import/naver-list', authenticate, requireTripAccess, async (req: Request, res: Response) => {
|
||||||
|
const authReq = req as AuthRequest;
|
||||||
|
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||||
|
return res.status(403).json({ error: 'No permission' });
|
||||||
|
if (!isAddonEnabled('naver_list_import')) {
|
||||||
|
return res.status(403).json({ error: 'Naver list import addon is disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tripId } = req.params;
|
||||||
|
const { url } = req.body;
|
||||||
|
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await importNaverList(tripId, url);
|
||||||
|
|
||||||
|
if ('error' in result) {
|
||||||
|
return res.status(result.status).json({ error: result.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName });
|
||||||
|
for (const place of result.places) {
|
||||||
|
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('[Places] Naver list import error:', err instanceof Error ? err.message : err);
|
||||||
|
res.status(400).json({ error: 'Failed to import Naver Maps list. Make sure the list is shared publicly.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||||
const { tripId, id } = req.params;
|
const { tripId, id } = req.params;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { XMLParser } from 'fast-xml-parser';
|
import { XMLParser } from 'fast-xml-parser';
|
||||||
import { db, getPlaceWithTags } from '../db/database';
|
import { db, getPlaceWithTags } from '../db/database';
|
||||||
import { loadTagsByPlaceIds } from './queryHelpers';
|
import { loadTagsByPlaceIds } from './queryHelpers';
|
||||||
|
import { checkSsrf } from '../utils/ssrfGuard';
|
||||||
import { Place } from '../types';
|
import { Place } from '../types';
|
||||||
|
|
||||||
interface PlaceWithCategory extends Place {
|
interface PlaceWithCategory extends Place {
|
||||||
@@ -309,6 +310,10 @@ export async function importGoogleList(tripId: string, url: string) {
|
|||||||
let listId: string | null = null;
|
let listId: string | null = null;
|
||||||
let resolvedUrl = url;
|
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)
|
// Follow redirects for short URLs (maps.app.goo.gl, goo.gl)
|
||||||
if (url.includes('goo.gl') || url.includes('maps.app')) {
|
if (url.includes('goo.gl') || url.includes('maps.app')) {
|
||||||
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
|
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
|
||||||
@@ -405,6 +410,121 @@ export async function importGoogleList(tripId: string, url: string) {
|
|||||||
return { places: created, listName, skipped };
|
return { places: created, listName, skipped };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Import Naver Maps list
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function importNaverList(
|
||||||
|
tripId: string,
|
||||||
|
url: string,
|
||||||
|
): Promise<{ places: any[]; listName: string } | { 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.
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try { parsedUrl = new URL(url); } catch { return { error: 'Invalid URL', status: 400 }; }
|
||||||
|
if (parsedUrl.hostname === 'naver.me') {
|
||||||
|
const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) });
|
||||||
|
resolvedUrl = redirectRes.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 insertStmt = db.prepare(`
|
||||||
|
INSERT INTO places (trip_id, name, lat, lng, address, notes, transport_mode)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 'walking')
|
||||||
|
`);
|
||||||
|
const created: any[] = [];
|
||||||
|
const insertAll = db.transaction(() => {
|
||||||
|
for (const p of places) {
|
||||||
|
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.address, p.notes);
|
||||||
|
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
||||||
|
created.push(place);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
insertAll();
|
||||||
|
|
||||||
|
return { places: created, listName };
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Search place image (Unsplash)
|
// Search place image (Unsplash)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ const DEFAULT_ADDONS = [
|
|||||||
{ id: 'vacay', name: 'Vacay', description: 'Vacation day planner', type: 'global', icon: 'CalendarDays',enabled: 1, sort_order: 10 },
|
{ id: 'vacay', name: 'Vacay', description: 'Vacation day planner', type: 'global', icon: 'CalendarDays',enabled: 1, sort_order: 10 },
|
||||||
{ id: 'atlas', name: 'Atlas', description: 'Visited countries map', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
{ id: 'atlas', name: 'Atlas', description: 'Visited countries map', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
||||||
{ id: 'mcp', name: 'MCP', description: 'AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
|
{ id: 'mcp', name: 'MCP', description: 'AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
|
||||||
|
{ id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 0, sort_order: 13 },
|
||||||
{ id: 'collab', name: 'Collab', description: 'Notes, polls, live chat', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
{ id: 'collab', name: 'Collab', description: 'Notes, polls, live chat', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* - PLACE-014: reordering within a day is tested in assignments.test.ts
|
* - PLACE-014: reordering within a day is tested in assignments.test.ts
|
||||||
* - PLACE-019: GPX bulk import tested here using the test fixture
|
* - PLACE-019: GPX bulk import tested here using the test fixture
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import type { Application } from 'express';
|
import type { Application } from 'express';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -511,6 +511,200 @@ describe('Categories', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Naver list import
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Naver list import', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /import/naver-list returns 403 when addon is disabled', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
testDb.prepare("UPDATE addons SET enabled = 0 WHERE id = 'naver_list_import'").run();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places/import/naver-list`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ url: 'https://naver.me/GYDpx3Wv' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.error).toContain('addon is disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /import/naver-list resolves shortlink, paginates, and creates places', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const folderId = 'a04c3f7a8dd24d42a8eb52d710a700cc';
|
||||||
|
|
||||||
|
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run();
|
||||||
|
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
url: `https://map.naver.com/v5/favorite/myPlace/folder/${folderId}`,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
folder: { name: 'Seoul Food', bookmarkCount: 22 },
|
||||||
|
bookmarkList: [
|
||||||
|
{ name: 'SINSAJEON', px: 127.0226195, py: 37.5186363, memo: null, address: 'Sinsa-dong Seoul' },
|
||||||
|
{ name: 'Ilpyeondeungsim', px: 126.9852986, py: 37.5629334, memo: 'Try lunch set', address: 'Myeong-dong Seoul' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
folder: { name: 'Seoul Food', bookmarkCount: 22 },
|
||||||
|
bookmarkList: [
|
||||||
|
{ name: 'WAIKIKI MARKET', px: 126.8886523, py: 37.5589079, memo: null, address: 'Mapo-gu Seoul' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places/import/naver-list`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ url: 'https://naver.me/GYDpx3Wv' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.count).toBe(3);
|
||||||
|
expect(res.body.listName).toBe('Seoul Food');
|
||||||
|
expect(res.body.places[0].name).toBe('SINSAJEON');
|
||||||
|
expect(res.body.places[1].notes).toBe('Try lunch set');
|
||||||
|
expect(res.body.places[2].address).toBe('Mapo-gu Seoul');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||||
|
expect(fetchMock.mock.calls[1][0]).toContain(`shares/${folderId}/bookmarks?`);
|
||||||
|
expect(fetchMock.mock.calls[1][0]).toContain('start=0');
|
||||||
|
expect(fetchMock.mock.calls[1][0]).toContain('limit=20');
|
||||||
|
expect(fetchMock.mock.calls[2][0]).toContain('start=20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /import/naver-list returns 400 for invalid URL', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
|
||||||
|
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places/import/naver-list`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ url: 'https://example.com/not-a-naver-list' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain('Could not extract folder ID');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /import/naver-list returns 502 when Naver API is unavailable', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const folderId = 'abc123';
|
||||||
|
|
||||||
|
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run();
|
||||||
|
|
||||||
|
const fetchMock = vi.fn()
|
||||||
|
.mockResolvedValueOnce({ ok: false });
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places/import/naver-list`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ url: `https://map.naver.com/v5/favorite/myPlace/folder/${folderId}` });
|
||||||
|
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
expect(res.body.error).toContain('Failed to fetch list from Naver Maps');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /import/naver-list returns 400 when list is empty', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const folderId = 'abc123';
|
||||||
|
|
||||||
|
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run();
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ folder: { name: 'Empty List', bookmarkCount: 0 }, bookmarkList: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places/import/naver-list`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ url: `https://map.naver.com/v5/favorite/myPlace/folder/${folderId}` });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain('List is empty or could not be read');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /import/naver-list returns 400 when all items lack valid coordinates', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const folderId = 'abc123';
|
||||||
|
|
||||||
|
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run();
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
folder: { name: 'No Coords', bookmarkCount: 2 },
|
||||||
|
bookmarkList: [
|
||||||
|
{ name: 'Place A', px: undefined, py: undefined },
|
||||||
|
{ name: 'Place B', px: 'not-a-number', py: 'not-a-number' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places/import/naver-list`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ url: `https://map.naver.com/v5/favorite/myPlace/folder/${folderId}` });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain('No places with coordinates found in list');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /import/naver-list accepts canonical map.naver.com URL without redirect fetch', async () => {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
const trip = createTrip(testDb, user.id);
|
||||||
|
const folderId = 'abc123';
|
||||||
|
|
||||||
|
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run();
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
folder: { name: 'Seoul', bookmarkCount: 1 },
|
||||||
|
bookmarkList: [{ name: 'Gyeongbokgung', px: 126.9770, py: 37.5796, memo: null, address: 'Sejongno Seoul' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/trips/${trip.id}/places/import/naver-list`)
|
||||||
|
.set('Cookie', authCookie(user.id))
|
||||||
|
.send({ url: `https://map.naver.com/v5/favorite/myPlace/folder/${folderId}` });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.count).toBe(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// GPX Import
|
// GPX Import
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user