diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index 237d3e64..bdc249bc 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -107,6 +107,8 @@ export const placesApi = {
},
importGoogleList: (tripId: number | string, url: string) =>
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 = {
diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx
index ce8c6c51..9a516520 100644
--- a/client/src/components/Planner/PlacesSidebar.tsx
+++ b/client/src/components/Planner/PlacesSidebar.tsx
@@ -67,22 +67,25 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
}
}
- const [googleListOpen, setGoogleListOpen] = useState(false)
- const [googleListUrl, setGoogleListUrl] = useState('')
- const [googleListLoading, setGoogleListLoading] = useState(false)
+ const [listImportOpen, setListImportOpen] = useState(false)
+ const [listImportUrl, setListImportUrl] = useState('')
+ const [listImportLoading, setListImportLoading] = useState(false)
+ const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
- const handleGoogleListImport = async () => {
- if (!googleListUrl.trim()) return
- setGoogleListLoading(true)
+ const handleListImport = async () => {
+ if (!listImportUrl.trim()) return
+ setListImportLoading(true)
try {
- const result = await placesApi.importGoogleList(tripId, googleListUrl.trim())
+ const result = listImportProvider === 'google'
+ ? await placesApi.importGoogleList(tripId, listImportUrl.trim())
+ : await placesApi.importNaverList(tripId, listImportUrl.trim())
await loadTrip(tripId)
- toast.success(t('places.googleListImported', { count: result.count, list: result.listName }))
- setGoogleListOpen(false)
- setGoogleListUrl('')
+ toast.success(t(listImportProvider === '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('undo.importGoogleList'), async () => {
+ pushUndo?.(t(listImportProvider === 'google' ? 'undo.importGoogleList' : 'undo.importNaverList'), async () => {
for (const id of importedIds) {
try { await placesApi.delete(tripId, id) } catch {}
}
@@ -90,9 +93,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
})
}
} catch (err: any) {
- toast.error(err?.response?.data?.error || t('places.googleListError'))
+ toast.error(err?.response?.data?.error || t(listImportProvider === 'google' ? 'places.googleListError' : 'places.naverListError'))
} finally {
- setGoogleListLoading(false)
+ setListImportLoading(false)
}
}
@@ -160,7 +163,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
{t('places.importGpx')}
>}
@@ -447,9 +450,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
,
document.body
)}
- {googleListOpen && ReactDOM.createPortal(
+ {listImportOpen && ReactDOM.createPortal(
{ 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 }}
>
- {t('places.importGoogleList')}
+ {t('places.importList')}
+
+
+ {(['google', 'naver'] as const).map(provider => (
+
+ ))}
- {t('places.googleListHint')}
+ {t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
setGoogleListUrl(e.target.value)}
- onKeyDown={e => { if (e.key === 'Enter' && !googleListLoading) handleGoogleListImport() }}
- placeholder="https://maps.app.goo.gl/..."
+ 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
style={{
width: '100%', padding: '10px 14px', borderRadius: 10,
@@ -478,7 +497,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
/>
diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts
index 43f29ee9..e486dd9b 100644
--- a/client/src/i18n/translations/ar.ts
+++ b/client/src/i18n/translations/ar.ts
@@ -814,10 +814,14 @@ const ar: Record
= {
'places.importGpx': 'GPX',
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
'places.gpxError': 'فشل استيراد GPX',
+ 'places.importList': 'استيراد قائمة',
'places.importGoogleList': 'قائمة Google',
'places.googleListHint': 'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.',
'places.googleListImported': 'تم استيراد {count} أماكن من "{list}"',
'places.googleListError': 'فشل استيراد قائمة Google Maps',
+ 'places.naverListHint': 'الصق رابط قائمة Naver Maps مشتركة لاستيراد جميع الأماكن.',
+ 'places.naverListImported': 'تم استيراد {count} مكان من "{list}"',
+ 'places.naverListError': 'فشل استيراد قائمة Naver Maps',
'places.viewDetails': 'عرض التفاصيل',
'places.urlResolved': 'تم استيراد المكان من الرابط',
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
@@ -1553,6 +1557,7 @@ const ar: Record = {
'undo.lock': 'تم تبديل قفل المكان',
'undo.importGpx': 'استيراد GPX',
'undo.importGoogleList': 'استيراد خرائط Google',
+ 'undo.importNaverList': 'استيراد خرائط Naver',
// Notifications
'notifications.title': 'الإشعارات',
diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts
index 12612daf..60579398 100644
--- a/client/src/i18n/translations/br.ts
+++ b/client/src/i18n/translations/br.ts
@@ -796,10 +796,14 @@ const br: Record = {
'places.importGpx': 'GPX',
'places.gpxImported': '{count} lugares importados do GPX',
'places.gpxError': 'Falha ao importar GPX',
+ 'places.importList': 'Importar lista',
'places.importGoogleList': 'Lista Google',
'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.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.urlResolved': 'Lugar importado da URL',
'places.assignToDay': 'Adicionar a qual dia?',
@@ -1548,6 +1552,7 @@ const br: Record = {
'undo.lock': 'Bloqueio do local alternado',
'undo.importGpx': 'Importação de GPX',
'undo.importGoogleList': 'Importação do Google Maps',
+ 'undo.importNaverList': 'Importação do Naver Maps',
// Notifications
'notifications.title': 'Notificações',
diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts
index defebfb6..50b961d4 100644
--- a/client/src/i18n/translations/cs.ts
+++ b/client/src/i18n/translations/cs.ts
@@ -813,10 +813,14 @@ const cs: Record = {
'places.gpxImported': '{count} míst importováno z GPX',
'places.urlResolved': 'Místo importováno z URL',
'places.gpxError': 'Import GPX se nezdařil',
+ 'places.importList': 'Import seznamu',
'places.importGoogleList': 'Google Seznam',
'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.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.assignToDay': 'Přidat do kterého dne?',
'places.all': 'Vše',
@@ -1551,6 +1555,7 @@ const cs: Record = {
'undo.lock': 'Zámek místa přepnut',
'undo.importGpx': 'Import GPX',
'undo.importGoogleList': 'Import z Google Maps',
+ 'undo.importNaverList': 'Import z Naver Maps',
// Notifications
'notifications.title': 'Oznámení',
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index 1c76a6c1..c0742d99 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -813,10 +813,14 @@ const de: Record = {
'places.gpxImported': '{count} Orte aus GPX importiert',
'places.urlResolved': 'Ort aus URL importiert',
'places.gpxError': 'GPX-Import fehlgeschlagen',
+ 'places.importList': 'Listenimport',
'places.importGoogleList': 'Google Liste',
'places.googleListHint': 'Geteilten Google Maps Listen-Link einfügen, um alle Orte zu importieren.',
'places.googleListImported': '{count} Orte aus "{list}" importiert',
'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.assignToDay': 'Zu welchem Tag hinzufügen?',
'places.all': 'Alle',
@@ -1096,7 +1100,6 @@ const de: Record = {
'packing.menuCheckAll': 'Alle abhaken',
'packing.menuUncheckAll': 'Alle Haken entfernen',
'packing.menuDeleteCat': 'Kategorie löschen',
- 'packing.assignUser': 'Benutzer zuweisen',
'packing.noMembers': 'Keine Mitglieder',
'packing.addItem': 'Eintrag hinzufügen',
'packing.addItemPlaceholder': 'Artikelname...',
@@ -1555,6 +1558,7 @@ const de: Record = {
'undo.lock': 'Ortssperre umgeschaltet',
'undo.importGpx': 'GPX-Import',
'undo.importGoogleList': 'Google Maps-Import',
+ 'undo.importNaverList': 'Naver Maps-Import',
// Notifications
'notifications.title': 'Benachrichtigungen',
diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts
index 6e6cc0b0..278e039c 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -832,10 +832,14 @@ const en: Record = {
'places.gpxImported': '{count} places imported from GPX',
'places.urlResolved': 'Place imported from URL',
'places.gpxError': 'GPX import failed',
+ 'places.importList': 'List Import',
'places.importGoogleList': 'Google List',
'places.googleListHint': 'Paste a shared Google Maps list link to import all places.',
'places.googleListImported': '{count} places imported from "{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.assignToDay': 'Add to which day?',
'places.all': 'All',
@@ -1115,7 +1119,6 @@ const en: Record = {
'packing.menuCheckAll': 'Check All',
'packing.menuUncheckAll': 'Uncheck All',
'packing.menuDeleteCat': 'Delete Category',
- 'packing.assignUser': 'Assign user',
'packing.noMembers': 'No trip members',
'packing.addItem': 'Add item',
'packing.addItemPlaceholder': 'Item name...',
@@ -1592,6 +1595,7 @@ const en: Record = {
'undo.lock': 'Place lock toggled',
'undo.importGpx': 'GPX import',
'undo.importGoogleList': 'Google Maps import',
+ 'undo.importNaverList': 'Naver Maps import',
'undo.addPlace': 'Place added',
'undo.done': 'Undone: {action}',
diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts
index c487b25e..9988f37a 100644
--- a/client/src/i18n/translations/es.ts
+++ b/client/src/i18n/translations/es.ts
@@ -788,10 +788,14 @@ const es: Record = {
'places.importGpx': 'GPX',
'places.gpxImported': '{count} lugares importados desde GPX',
'places.gpxError': 'Error al importar GPX',
+ 'places.importList': 'Importar lista',
'places.importGoogleList': 'Lista Google',
'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.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.urlResolved': 'Lugar importado desde URL',
'places.assignToDay': '¿A qué día añadirlo?',
@@ -1555,6 +1559,7 @@ const es: Record = {
'undo.lock': 'Bloqueo de lugar activado/desactivado',
'undo.importGpx': 'Importación GPX',
'undo.importGoogleList': 'Importación de Google Maps',
+ 'undo.importNaverList': 'Importación de Naver Maps',
// Notifications
'notifications.title': 'Notificaciones',
diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts
index b1615c9b..ce7038cb 100644
--- a/client/src/i18n/translations/fr.ts
+++ b/client/src/i18n/translations/fr.ts
@@ -811,10 +811,14 @@ const fr: Record = {
'places.importGpx': 'GPX',
'places.gpxImported': '{count} lieux importés depuis GPX',
'places.gpxError': 'L\'import GPX a échoué',
+ 'places.importList': 'Import de liste',
'places.importGoogleList': 'Liste Google',
'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.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.urlResolved': 'Lieu importé depuis l\'URL',
'places.assignToDay': 'Ajouter à quel jour ?',
@@ -1549,6 +1553,7 @@ const fr: Record = {
'undo.lock': 'Verrouillage du lieu modifié',
'undo.importGpx': 'Import GPX',
'undo.importGoogleList': 'Import Google Maps',
+ 'undo.importNaverList': 'Import Naver Maps',
// Notifications
'notifications.title': 'Notifications',
diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts
index 3d6c6603..5806187b 100644
--- a/client/src/i18n/translations/hu.ts
+++ b/client/src/i18n/translations/hu.ts
@@ -813,10 +813,14 @@ const hu: Record = {
'places.gpxImported': '{count} hely importálva GPX-ből',
'places.urlResolved': 'Hely importálva URL-ből',
'places.gpxError': 'GPX importálás sikertelen',
+ 'places.importList': 'Lista importálás',
'places.importGoogleList': 'Google Lista',
'places.googleListHint': 'Illessz be egy megosztott Google Maps lista linket az osszes hely importalasahoz.',
'places.googleListImported': '{count} hely importalva a(z) "{list}" listabol',
'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.assignToDay': 'Melyik naphoz adod?',
'places.all': 'Összes',
@@ -1550,6 +1554,7 @@ const hu: Record = {
'undo.lock': 'Hely zárolása váltva',
'undo.importGpx': 'GPX importálás',
'undo.importGoogleList': 'Google Maps importálás',
+ 'undo.importNaverList': 'Naver Maps importálás',
// Notifications
'notifications.title': 'Értesítések',
diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts
index 0a504f9e..ee294e69 100644
--- a/client/src/i18n/translations/it.ts
+++ b/client/src/i18n/translations/it.ts
@@ -813,10 +813,14 @@ const it: Record = {
'places.gpxImported': '{count} luoghi importati da GPX',
'places.urlResolved': 'Luogo importato dall\'URL',
'places.gpxError': 'Importazione GPX non riuscita',
+ 'places.importList': 'Importa lista',
'places.importGoogleList': 'Lista Google',
'places.googleListHint': 'Incolla un link condiviso di una lista Google Maps per importare tutti i luoghi.',
'places.googleListImported': '{count} luoghi importati da "{list}"',
'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.assignToDay': 'A quale giorno aggiungere?',
'places.all': 'Tutti',
@@ -1551,6 +1555,7 @@ const it: Record = {
'undo.lock': 'Blocco luogo modificato',
'undo.importGpx': 'Importazione GPX',
'undo.importGoogleList': 'Importazione Google Maps',
+ 'undo.importNaverList': 'Importazione Naver Maps',
'undo.addPlace': 'Luogo aggiunto',
'undo.done': 'Annullato: {action}',
// Notifications
diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts
index 93c4e780..60d0b7a8 100644
--- a/client/src/i18n/translations/nl.ts
+++ b/client/src/i18n/translations/nl.ts
@@ -811,10 +811,14 @@ const nl: Record = {
'places.importGpx': 'GPX',
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
'places.gpxError': 'GPX-import mislukt',
+ 'places.importList': 'Lijst importeren',
'places.importGoogleList': 'Google Lijst',
'places.googleListHint': 'Plak een gedeelde Google Maps lijstlink om alle plaatsen te importeren.',
'places.googleListImported': '{count} plaatsen geimporteerd uit "{list}"',
'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.urlResolved': 'Plaats geïmporteerd van URL',
'places.assignToDay': 'Aan welke dag toevoegen?',
@@ -1549,6 +1553,7 @@ const nl: Record = {
'undo.lock': 'Vergrendeling locatie gewijzigd',
'undo.importGpx': 'GPX-import',
'undo.importGoogleList': 'Google Maps-import',
+ 'undo.importNaverList': 'Naver Maps-import',
// Notifications
'notifications.title': 'Meldingen',
diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts
index b0202860..faa7819d 100644
--- a/client/src/i18n/translations/pl.ts
+++ b/client/src/i18n/translations/pl.ts
@@ -1496,9 +1496,13 @@ const pl: Record = {
'atlas.searchCountry': 'Szukaj kraju...',
'trip.loadingPhotos': 'Ładowanie zdjęć...',
'places.importGoogleList': 'Lista Google',
+ 'places.importList': 'Import listy',
'places.googleListHint': 'Wklej link do listy Google Maps.',
'places.googleListImported': 'Zaimportowano {count} miejsc',
'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',
'inspector.trackStats': 'Statystyki trasy',
'budget.exportCsv': 'Eksportuj CSV',
@@ -1577,6 +1581,7 @@ const pl: Record = {
'undo.lock': 'Blokada przełączona',
'undo.importGpx': 'Import GPX',
'undo.importGoogleList': 'Import Google Maps',
+ 'undo.importNaverList': 'Import Naver Maps',
'undo.addPlace': 'Miejsce dodane',
'undo.done': 'Cofnięto: {action}',
'notifications.title': 'Powiadomienia',
diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts
index 3cf4cc74..f29dd158 100644
--- a/client/src/i18n/translations/ru.ts
+++ b/client/src/i18n/translations/ru.ts
@@ -811,10 +811,14 @@ const ru: Record = {
'places.importGpx': 'GPX',
'places.gpxImported': '{count} мест импортировано из GPX',
'places.gpxError': 'Ошибка импорта GPX',
+ 'places.importList': 'Импорт списка',
'places.importGoogleList': 'Список Google',
'places.googleListHint': 'Вставьте ссылку на общий список Google Maps для импорта всех мест.',
'places.googleListImported': '{count} мест импортировано из "{list}"',
'places.googleListError': 'Не удалось импортировать список Google Maps',
+ 'places.naverListHint': 'Вставьте ссылку на общий список Naver Maps для импорта всех мест.',
+ 'places.naverListImported': '{count} мест импортировано из "{list}"',
+ 'places.naverListError': 'Не удалось импортировать список Naver Maps',
'places.viewDetails': 'Подробности',
'places.urlResolved': 'Место импортировано из URL',
'places.assignToDay': 'Добавить в какой день?',
@@ -1549,6 +1553,7 @@ const ru: Record = {
'undo.lock': 'Блокировка места изменена',
'undo.importGpx': 'Импорт GPX',
'undo.importGoogleList': 'Импорт из Google Maps',
+ 'undo.importNaverList': 'Импорт из Naver Maps',
// Notifications
'notifications.title': 'Уведомления',
diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts
index 5dc74216..fa22a8b4 100644
--- a/client/src/i18n/translations/zh.ts
+++ b/client/src/i18n/translations/zh.ts
@@ -811,10 +811,14 @@ const zh: Record = {
'places.importGpx': 'GPX',
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
'places.gpxError': 'GPX 导入失败',
+ 'places.importList': '列表导入',
'places.importGoogleList': 'Google 列表',
'places.googleListHint': '粘贴共享的 Google Maps 列表链接以导入所有地点。',
'places.googleListImported': '已从"{list}"导入 {count} 个地点',
'places.googleListError': 'Google Maps 列表导入失败',
+ 'places.naverListHint': '粘贴共享的 Naver Maps 列表链接以导入所有地点。',
+ 'places.naverListImported': '已从"{list}"导入 {count} 个地点',
+ 'places.naverListError': 'Naver Maps 列表导入失败',
'places.viewDetails': '查看详情',
'places.urlResolved': '已从 URL 导入地点',
'places.assignToDay': '添加到哪一天?',
@@ -1549,6 +1553,7 @@ const zh: Record = {
'undo.lock': '地点锁定已切换',
'undo.importGpx': 'GPX 导入',
'undo.importGoogleList': 'Google 地图导入',
+ 'undo.importNaverList': 'Naver 地图导入',
// Notifications
'notifications.title': '通知',
diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts
index fc35e1ab..6cbf245a 100644
--- a/client/src/i18n/translations/zhTw.ts
+++ b/client/src/i18n/translations/zhTw.ts
@@ -791,10 +791,14 @@ const zhTw: Record = {
'places.importGpx': 'GPX',
'places.gpxImported': '已從 GPX 匯入 {count} 個地點',
'places.gpxError': 'GPX 匯入失敗',
+ 'places.importList': '列表匯入',
'places.importGoogleList': 'Google 列表',
'places.googleListHint': '貼上共享的 Google Maps 列表連結以匯入所有地點。',
'places.googleListImported': '已從"{list}"匯入 {count} 個地點',
'places.googleListError': 'Google Maps 列表匯入失敗',
+ 'places.naverListHint': '貼上共享的 Naver Maps 列表連結以匯入所有地點。',
+ 'places.naverListImported': '已從"{list}"匯入 {count} 個地點',
+ 'places.naverListError': 'Naver Maps 列表匯入失敗',
'places.viewDetails': '檢視詳情',
'places.urlResolved': '已從 URL 匯入地點',
'places.assignToDay': '新增到哪一天?',
@@ -1503,6 +1507,7 @@ const zhTw: Record = {
'undo.lock': '地點鎖定已切換',
'undo.importGpx': 'GPX 匯入',
'undo.importGoogleList': 'Google 地圖匯入',
+ 'undo.importNaverList': 'Naver 地圖匯入',
// Notifications
'notifications.title': '通知',
diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts
index 642c95a4..828493cc 100644
--- a/server/src/routes/places.ts
+++ b/server/src/routes/places.ts
@@ -14,6 +14,7 @@ import {
deletePlace,
importGpx,
importGoogleList,
+ importNaverList,
searchPlaceImage,
} from '../services/placeService';
@@ -99,6 +100,35 @@ 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' });
+
+ 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 });
+ }
+
+ const successResult = result as { places: any[]; listName: string };
+
+ res.status(201).json({ places: successResult.places, count: successResult.places.length, listName: successResult.listName });
+ for (const place of successResult.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) => {
const { tripId, id } = req.params;
diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts
index 911f5ae4..56b9c3fb 100644
--- a/server/src/services/placeService.ts
+++ b/server/src/services/placeService.ts
@@ -382,6 +382,115 @@ export async function importGoogleList(tripId: string, url: string) {
return { places: created, listName };
}
+// ---------------------------------------------------------------------------
+// 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;
+
+ // Resolve naver.me short links to the canonical map.naver.com folder URL.
+ if (url.includes('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)
// ---------------------------------------------------------------------------
diff --git a/server/tests/integration/places.test.ts b/server/tests/integration/places.test.ts
index 3f5bf5a9..bf4ac9c6 100644
--- a/server/tests/integration/places.test.ts
+++ b/server/tests/integration/places.test.ts
@@ -7,7 +7,7 @@
* - PLACE-014: reordering within a day is tested in assignments.test.ts
* - 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 type { Application } from 'express';
import path from 'path';
@@ -500,6 +500,81 @@ describe('Categories', () => {
});
});
+// ─────────────────────────────────────────────────────────────────────────────
+// Naver list import
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('Naver list import', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ 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';
+
+ 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);
+
+ 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');
+ });
+});
+
// ─────────────────────────────────────────────────────────────────────────────
// GPX Import
// ─────────────────────────────────────────────────────────────────────────────