From 6a632137ede6b270a767a0e6c7b3e425a35ec444 Mon Sep 17 00:00:00 2001 From: Marco Sadowski Date: Fri, 10 Apr 2026 15:15:04 +0200 Subject: [PATCH] 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))