refactor(trip): Naver List Import as Addon

This commit is contained in:
Marco Sadowski
2026-04-10 15:15:04 +02:00
parent f82f00216b
commit 6a632137ed
18 changed files with 123 additions and 28 deletions
+36 -22
View File
@@ -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<HTMLInputElement>) => {
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',
}}
>
<MapPin size={11} strokeWidth={2} /> {t('places.importList')}
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
</button>
</div>
</>}
@@ -463,22 +475,24 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 4 }}>
{t('places.importList')}
</div>
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
{(['google', 'naver'] as const).map(provider => (
<button
key={provider}
onClick={() => setListImportProvider(provider)}
style={{
padding: '6px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
fontSize: 11, fontWeight: 600, fontFamily: 'inherit',
background: listImportProvider === provider ? 'var(--accent)' : 'var(--bg-tertiary)',
color: listImportProvider === provider ? 'var(--accent-text)' : 'var(--text-muted)',
}}
>
{provider === 'google' ? 'Google Maps' : 'Naver Maps'}
</button>
))}
</div>
{hasMultipleListImportProviders && (
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
{availableListImportProviders.map(provider => (
<button
key={provider}
onClick={() => setListImportProvider(provider)}
style={{
padding: '6px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
fontSize: 11, fontWeight: 600, fontFamily: 'inherit',
background: listImportProvider === provider ? 'var(--accent)' : 'var(--bg-tertiary)',
color: listImportProvider === provider ? 'var(--accent-text)' : 'var(--text-muted)',
}}
>
{provider === 'google' ? t('places.importGoogleList') : t('places.importNaverList')}
</button>
))}
</div>
)}
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 16 }}>
{t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
</div>
+1
View File
@@ -817,6 +817,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'places.gpxError': 'فشل استيراد GPX',
'places.importList': 'استيراد قائمة',
'places.importGoogleList': 'قائمة Google',
'places.importNaverList': 'قائمة Naver',
'places.googleListHint': 'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.',
'places.googleListImported': 'تم استيراد {count} أماكن من "{list}"',
'places.googleListError': 'فشل استيراد قائمة Google Maps',
+1
View File
@@ -799,6 +799,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'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',
+1
View File
@@ -816,6 +816,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'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',
+1
View File
@@ -835,6 +835,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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',
+1
View File
@@ -814,6 +814,7 @@ const fr: Record<string, string> = {
'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',
+1
View File
@@ -816,6 +816,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'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',
+1
View File
@@ -816,6 +816,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'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',
+1
View File
@@ -814,6 +814,7 @@ const nl: Record<string, string> = {
'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',
+1
View File
@@ -1497,6 +1497,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'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',
+1
View File
@@ -814,6 +814,7 @@ const ru: Record<string, string> = {
'places.gpxError': 'Ошибка импорта GPX',
'places.importList': 'Импорт списка',
'places.importGoogleList': 'Список Google',
'places.importNaverList': 'Список Naver',
'places.googleListHint': 'Вставьте ссылку на общий список Google Maps для импорта всех мест.',
'places.googleListImported': '{count} мест импортировано из "{list}"',
'places.googleListError': 'Не удалось импортировать список Google Maps',
+1
View File
@@ -814,6 +814,7 @@ const zh: Record<string, string> = {
'places.gpxError': 'GPX 导入失败',
'places.importList': '列表导入',
'places.importGoogleList': 'Google 列表',
'places.importNaverList': 'Naver 列表',
'places.googleListHint': '粘贴共享的 Google Maps 列表链接以导入所有地点。',
'places.googleListImported': '已从"{list}"导入 {count} 个地点',
'places.googleListError': 'Google Maps 列表导入失败',
+1
View File
@@ -794,6 +794,7 @@ const zhTw: Record<string, string> = {
'places.gpxError': 'GPX 匯入失敗',
'places.importList': '列表匯入',
'places.importGoogleList': 'Google 列表',
'places.importNaverList': 'Naver 列表',
'places.googleListHint': '貼上共享的 Google Maps 列表連結以匯入所有地點。',
'places.googleListImported': '已從"{list}"匯入 {count} 個地點',
'places.googleListError': 'Google Maps 列表匯入失敗',
+50 -6
View File
@@ -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) {
+1
View File
@@ -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 (?, ?, ?, ?, ?, ?, ?)');
+4
View File
@@ -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;
+1
View File
@@ -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 },
];
+19
View File
@@ -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))