From d9d389d09022ab6709adad123223782f31109d42 Mon Sep 17 00:00:00 2001 From: Marco Sadowski Date: Tue, 7 Apr 2026 14:04:27 +0200 Subject: [PATCH 1/2] feat: naver list import Added Naver List Import in a similar style like the Google List Import. To keep the frontend clean I combined both list options. --- client/src/api/client.ts | 2 + .../src/components/Planner/PlacesSidebar.tsx | 79 ++++++++----- client/src/i18n/translations/ar.ts | 5 + client/src/i18n/translations/br.ts | 5 + client/src/i18n/translations/cs.ts | 5 + client/src/i18n/translations/de.ts | 6 +- client/src/i18n/translations/en.ts | 6 +- client/src/i18n/translations/es.ts | 5 + client/src/i18n/translations/fr.ts | 5 + client/src/i18n/translations/hu.ts | 5 + client/src/i18n/translations/it.ts | 5 + client/src/i18n/translations/nl.ts | 5 + client/src/i18n/translations/pl.ts | 5 + client/src/i18n/translations/ru.ts | 5 + client/src/i18n/translations/zh.ts | 5 + client/src/i18n/translations/zhTw.ts | 5 + server/src/routes/places.ts | 30 +++++ server/src/services/placeService.ts | 109 ++++++++++++++++++ server/tests/integration/places.test.ts | 77 ++++++++++++- 19 files changed, 336 insertions(+), 33 deletions(-) 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 // ───────────────────────────────────────────────────────────────────────────── From 6a632137ede6b270a767a0e6c7b3e425a35ec444 Mon Sep 17 00:00:00 2001 From: Marco Sadowski Date: Fri, 10 Apr 2026 15:15:04 +0200 Subject: [PATCH 2/2] refactor(trip): Naver List Import as Addon --- .../src/components/Planner/PlacesSidebar.tsx | 58 ++++++++++++------- client/src/i18n/translations/ar.ts | 1 + client/src/i18n/translations/br.ts | 1 + client/src/i18n/translations/cs.ts | 1 + client/src/i18n/translations/en.ts | 1 + client/src/i18n/translations/fr.ts | 1 + client/src/i18n/translations/hu.ts | 1 + client/src/i18n/translations/it.ts | 1 + client/src/i18n/translations/nl.ts | 1 + client/src/i18n/translations/pl.ts | 1 + client/src/i18n/translations/ru.ts | 1 + client/src/i18n/translations/zh.ts | 1 + client/src/i18n/translations/zhTw.ts | 1 + server/src/db/migrations.ts | 56 ++++++++++++++++-- server/src/db/seeds.ts | 1 + server/src/routes/places.ts | 4 ++ server/tests/helpers/test-db.ts | 1 + server/tests/integration/places.test.ts | 19 ++++++ 18 files changed, 123 insertions(+), 28 deletions(-) diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 537dd71a..a822f042 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -1,6 +1,6 @@ import React from 'react' 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 { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react' import PlaceAvatar from '../shared/PlaceAvatar' @@ -12,6 +12,7 @@ import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import { placesApi } from '../../api/client' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' +import { useAddonStore } from '../../store/addonStore' import type { Place, Category, Day, AssignmentsMap } from '../../types' interface PlacesSidebarProps { @@ -45,6 +46,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const loadTrip = useTripStore((s) => s.loadTrip) const can = useCanDo() const canEditPlaces = can('place_edit', trip) + const isNaverListImportEnabled = useAddonStore((s) => s.isEnabled('naver_list_import')) const handleGpxImport = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] @@ -72,21 +74,30 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const [listImportUrl, setListImportUrl] = useState('') 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 + + useEffect(() => { + if (!isNaverListImportEnabled && listImportProvider === 'naver') { + setListImportProvider('google') + } + }, [isNaverListImportEnabled, listImportProvider]) const handleListImport = async () => { if (!listImportUrl.trim()) return setListImportLoading(true) try { - const result = listImportProvider === 'google' + const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google' + const result = provider === 'google' ? await placesApi.importGoogleList(tripId, listImportUrl.trim()) : await placesApi.importNaverList(tripId, listImportUrl.trim()) await loadTrip(tripId) - toast.success(t(listImportProvider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName })) + toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName })) setListImportOpen(false) setListImportUrl('') if (result.places?.length > 0) { const importedIds: number[] = result.places.map((p: { id: number }) => p.id) - pushUndo?.(t(listImportProvider === 'google' ? 'undo.importGoogleList' : 'undo.importNaverList'), async () => { + pushUndo?.(t(provider === 'google' ? 'undo.importGoogleList' : 'undo.importNaverList'), async () => { for (const id of importedIds) { try { await placesApi.delete(tripId, id) } catch {} } @@ -94,7 +105,8 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ }) } } catch (err: any) { - toast.error(err?.response?.data?.error || t(listImportProvider === 'google' ? 'places.googleListError' : 'places.naverListError')) + const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google' + toast.error(err?.response?.data?.error || t(provider === 'google' ? 'places.googleListError' : 'places.naverListError')) } finally { setListImportLoading(false) } @@ -173,7 +185,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ cursor: 'pointer', fontFamily: 'inherit', }} > - {t('places.importList')} + {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
} @@ -463,22 +475,24 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
{t('places.importList')}
-
- {(['google', 'naver'] as const).map(provider => ( - - ))} -
+ {hasMultipleListImportProviders && ( +
+ {availableListImportProviders.map(provider => ( + + ))} +
+ )}
{t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index af379d79..9d054568 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -817,6 +817,7 @@ const ar: Record = { 'places.gpxError': 'فشل استيراد GPX', 'places.importList': 'استيراد قائمة', 'places.importGoogleList': 'قائمة Google', + 'places.importNaverList': 'قائمة Naver', 'places.googleListHint': 'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.', 'places.googleListImported': 'تم استيراد {count} أماكن من "{list}"', 'places.googleListError': 'فشل استيراد قائمة Google Maps', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 967a90d9..60b61836 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -799,6 +799,7 @@ const br: Record = { 'places.gpxError': 'Falha ao importar GPX', 'places.importList': 'Importar lista', '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.googleListImported': '{count} lugares importados de "{list}"', 'places.googleListError': 'Falha ao importar lista do Google Maps', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 9dc540ed..5dc7feec 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -816,6 +816,7 @@ const cs: Record = { 'places.gpxError': 'Import GPX se nezdařil', 'places.importList': 'Import seznamu', '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.googleListImported': '{count} míst importováno ze seznamu "{list}"', 'places.googleListError': 'Import seznamu Google Maps se nezdařil', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 787f9aad..c75beb7e 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -835,6 +835,7 @@ const en: Record = { 'places.gpxError': 'GPX import failed', 'places.importList': 'List Import', 'places.importGoogleList': 'Google List', + 'places.importNaverList': 'Naver 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', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 19f7b235..0c5c936a 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -814,6 +814,7 @@ const fr: Record = { 'places.gpxError': 'L\'import GPX a échoué', 'places.importList': 'Import de liste', '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.googleListImported': '{count} lieux importés depuis "{list}"', 'places.googleListError': 'Impossible d\'importer la liste Google Maps', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 005dd84c..61c004fb 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -816,6 +816,7 @@ const hu: Record = { 'places.gpxError': 'GPX importálás sikertelen', 'places.importList': 'Lista importálás', 'places.importGoogleList': 'Google Lista', + 'places.importNaverList': 'Naver 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', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 8d144abe..215cfadf 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -816,6 +816,7 @@ const it: Record = { 'places.gpxError': 'Importazione GPX non riuscita', 'places.importList': 'Importa lista', '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.googleListImported': '{count} luoghi importati da "{list}"', 'places.googleListError': 'Importazione lista Google Maps non riuscita', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 41b59fa2..b21a87ba 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -814,6 +814,7 @@ const nl: Record = { 'places.gpxError': 'GPX-import mislukt', 'places.importList': 'Lijst importeren', 'places.importGoogleList': 'Google Lijst', + 'places.importNaverList': 'Naver 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', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index f94f951b..4ee343f0 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -1497,6 +1497,7 @@ const pl: Record = { 'atlas.searchCountry': 'Szukaj kraju...', 'trip.loadingPhotos': 'Ładowanie zdjęć...', 'places.importGoogleList': 'Lista Google', + 'places.importNaverList': 'Lista Naver', 'places.importList': 'Import listy', 'places.googleListHint': 'Wklej link do listy Google Maps.', 'places.googleListImported': 'Zaimportowano {count} miejsc', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 40b6bfdc..43112615 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -814,6 +814,7 @@ const ru: Record = { 'places.gpxError': 'Ошибка импорта GPX', 'places.importList': 'Импорт списка', 'places.importGoogleList': 'Список Google', + 'places.importNaverList': 'Список Naver', 'places.googleListHint': 'Вставьте ссылку на общий список Google Maps для импорта всех мест.', 'places.googleListImported': '{count} мест импортировано из "{list}"', 'places.googleListError': 'Не удалось импортировать список Google Maps', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 741bd3c5..546ee445 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -814,6 +814,7 @@ const zh: Record = { 'places.gpxError': 'GPX 导入失败', 'places.importList': '列表导入', 'places.importGoogleList': 'Google 列表', + 'places.importNaverList': 'Naver 列表', 'places.googleListHint': '粘贴共享的 Google Maps 列表链接以导入所有地点。', 'places.googleListImported': '已从"{list}"导入 {count} 个地点', 'places.googleListError': 'Google Maps 列表导入失败', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index cacac99a..260d4411 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -794,6 +794,7 @@ const zhTw: Record = { 'places.gpxError': 'GPX 匯入失敗', 'places.importList': '列表匯入', 'places.importGoogleList': 'Google 列表', + 'places.importNaverList': 'Naver 列表', 'places.googleListHint': '貼上共享的 Google Maps 列表連結以匯入所有地點。', 'places.googleListImported': '已從"{list}"匯入 {count} 個地點', 'places.googleListError': 'Google Maps 列表匯入失敗', diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index cb3edb96..936a92fb 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -867,23 +867,67 @@ function runMigrations(db: Database.Database): void { // Migration: Budget category ordering () => { db.exec(` - CREATE TABLE IF NOT EXISTS budget_category_order ( - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + CREATE TABLE IF NOT EXISTS budget_category_order + ( + trip_id + INTEGER + NOT + NULL + REFERENCES + trips + ( + id + ) ON DELETE CASCADE, category TEXT NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (trip_id, category) - ); + PRIMARY KEY + ( + trip_id, + category + ) + ); `); // Seed existing categories with alphabetical order - const rows = db.prepare('SELECT DISTINCT trip_id, category FROM budget_items ORDER BY trip_id, category').all() as { trip_id: number; category: string }[]; + const rows = db.prepare('SELECT DISTINCT trip_id, category FROM budget_items ORDER BY trip_id, category').all() as { + trip_id: number; + category: string + }[]; const ins = db.prepare('INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)'); let lastTripId = -1; let idx = 0; for (const r of rows) { - if (r.trip_id !== lastTripId) { lastTripId = r.trip_id; idx = 0; } + if (r.trip_id !== lastTripId) { + lastTripId = r.trip_id; + idx = 0; + } ins.run(r.trip_id, r.category, idx++); } }, + // Migration: Naver list import addon (default off) + () => { + try { + db.prepare(` + INSERT INTO addons (id, name, description, type, icon, enabled, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + type = excluded.type, + icon = excluded.icon, + sort_order = excluded.sort_order + `).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); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index fe02892e..156d94a7 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -88,6 +88,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: '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: '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 }, ]; const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts index 828493cc..1553da3a 100644 --- a/server/src/routes/places.ts +++ b/server/src/routes/places.ts @@ -5,6 +5,7 @@ import { requireTripAccess } from '../middleware/tripAccess'; import { broadcast } from '../websocket'; import { validateStringLengths } from '../middleware/validate'; import { checkPermission } from '../services/permissions'; +import { isAddonEnabled } from '../services/adminService'; import { AuthRequest } from '../types'; import { listPlaces, @@ -105,6 +106,9 @@ router.post('/import/naver-list', authenticate, requireTripAccess, async (req: R 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; diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts index 1a6d9819..1d238111 100644 --- a/server/tests/helpers/test-db.ts +++ b/server/tests/helpers/test-db.ts @@ -113,6 +113,7 @@ const DEFAULT_ADDONS = [ { 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: '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 }, ]; diff --git a/server/tests/integration/places.test.ts b/server/tests/integration/places.test.ts index d55bf025..6bf3ad2f 100644 --- a/server/tests/integration/places.test.ts +++ b/server/tests/integration/places.test.ts @@ -521,11 +521,28 @@ describe('Naver list import', () => { 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, @@ -576,6 +593,8 @@ describe('Naver list import', () => { 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))