mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge remote-tracking branch 'refs/remotes/pull/495' into feat/naver-support
This commit is contained in:
@@ -868,23 +868,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);
|
||||
}
|
||||
},
|
||||
// Migration: OAuth 2.1 clients, consents, and tokens for MCP
|
||||
() => {
|
||||
db.exec(`
|
||||
|
||||
@@ -92,6 +92,7 @@ function seedAddons(db: Database.Database): void {
|
||||
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
|
||||
{ id: '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 },
|
||||
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ import { requireTripAccess } from '../middleware/tripAccess';
|
||||
import { broadcast } from '../websocket';
|
||||
import { validateStringLengths } from '../middleware/validate';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
import { AuthRequest } from '../types';
|
||||
import {
|
||||
listPlaces,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
deletePlace,
|
||||
importGpx,
|
||||
importGoogleList,
|
||||
importNaverList,
|
||||
searchPlaceImage,
|
||||
} from '../services/placeService';
|
||||
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService';
|
||||
@@ -101,6 +103,38 @@ router.post('/import/google-list', authenticate, requireTripAccess, async (req:
|
||||
}
|
||||
});
|
||||
|
||||
// Import places from a shared Naver Maps list URL
|
||||
router.post('/import/naver-list', authenticate, requireTripAccess, async (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
if (!isAddonEnabled('naver_list_import')) {
|
||||
return res.status(403).json({ error: 'Naver list import addon is disabled' });
|
||||
}
|
||||
|
||||
const { tripId } = req.params;
|
||||
const { url } = req.body;
|
||||
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
|
||||
|
||||
try {
|
||||
const result = await importNaverList(tripId, url);
|
||||
|
||||
if ('error' in result) {
|
||||
return res.status(result.status).json({ error: result.error });
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
|
||||
@@ -405,6 +405,115 @@ export async function importGoogleList(tripId: string, url: string) {
|
||||
return { places: created, listName, skipped };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import Naver Maps list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function importNaverList(
|
||||
tripId: string,
|
||||
url: string,
|
||||
): Promise<{ places: any[]; listName: string } | { error: string; status: number }> {
|
||||
let resolvedUrl = url;
|
||||
const limit = 20;
|
||||
|
||||
// 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -120,6 +120,7 @@ const DEFAULT_ADDONS = [
|
||||
{ id: 'vacay', name: 'Vacay', description: 'Vacation day planner', type: 'global', icon: 'CalendarDays',enabled: 1, sort_order: 10 },
|
||||
{ id: '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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -511,6 +511,100 @@ describe('Categories', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Naver list import
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Naver list import', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('POST /import/naver-list returns 403 when addon is disabled', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
testDb.prepare("UPDATE addons SET enabled = 0 WHERE id = 'naver_list_import'").run();
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/naver-list`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'https://naver.me/GYDpx3Wv' });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain('addon is disabled');
|
||||
});
|
||||
|
||||
it('POST /import/naver-list resolves shortlink, paginates, and creates places', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const folderId = 'a04c3f7a8dd24d42a8eb52d710a700cc';
|
||||
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run();
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
url: `https://map.naver.com/v5/favorite/myPlace/folder/${folderId}`,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
folder: { name: 'Seoul Food', bookmarkCount: 22 },
|
||||
bookmarkList: [
|
||||
{ name: 'SINSAJEON', px: 127.0226195, py: 37.5186363, memo: null, address: 'Sinsa-dong Seoul' },
|
||||
{ name: 'Ilpyeondeungsim', px: 126.9852986, py: 37.5629334, memo: 'Try lunch set', address: 'Myeong-dong Seoul' },
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
folder: { name: 'Seoul Food', bookmarkCount: 22 },
|
||||
bookmarkList: [
|
||||
{ name: 'WAIKIKI MARKET', px: 126.8886523, py: 37.5589079, memo: null, address: 'Mapo-gu Seoul' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/naver-list`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'https://naver.me/GYDpx3Wv' });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.count).toBe(3);
|
||||
expect(res.body.listName).toBe('Seoul Food');
|
||||
expect(res.body.places[0].name).toBe('SINSAJEON');
|
||||
expect(res.body.places[1].notes).toBe('Try lunch set');
|
||||
expect(res.body.places[2].address).toBe('Mapo-gu Seoul');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock.mock.calls[1][0]).toContain(`shares/${folderId}/bookmarks?`);
|
||||
expect(fetchMock.mock.calls[1][0]).toContain('start=0');
|
||||
expect(fetchMock.mock.calls[1][0]).toContain('limit=20');
|
||||
expect(fetchMock.mock.calls[2][0]).toContain('start=20');
|
||||
});
|
||||
|
||||
it('POST /import/naver-list returns 400 for invalid URL', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run();
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/naver-list`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'https://example.com/not-a-naver-list' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('Could not extract folder ID');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GPX Import
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user