From 52714630645a688d8cd7ede10220f78ab593ce66 Mon Sep 17 00:00:00 2001 From: Yannis Biasutti Date: Mon, 6 Apr 2026 18:31:47 +0200 Subject: [PATCH 001/170] feat(server): add KML and KMZ place import pipeline --- server/src/routes/places.ts | 54 ++++++ .../services/placeImport/kmlImportUtils.ts | 175 ++++++++++++++++++ server/src/services/placeService.ts | 122 +++++++++++- server/tests/fixtures/test-malformed.kml | 8 + server/tests/fixtures/test-nested.kml | 26 +++ server/tests/fixtures/test.kml | 21 +++ server/tests/fixtures/test.kmz | Bin 0 -> 408 bytes server/tests/integration/places.test.ts | 123 ++++++++++++ .../unit/services/kmlImportUtils.test.ts | 78 ++++++++ 9 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 server/src/services/placeImport/kmlImportUtils.ts create mode 100644 server/tests/fixtures/test-malformed.kml create mode 100644 server/tests/fixtures/test-nested.kml create mode 100644 server/tests/fixtures/test.kml create mode 100644 server/tests/fixtures/test.kmz create mode 100644 server/tests/unit/services/kmlImportUtils.test.ts diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts index 642c95a4..59f7b8fe 100644 --- a/server/src/routes/places.ts +++ b/server/src/routes/places.ts @@ -13,6 +13,8 @@ import { updatePlace, deletePlace, importGpx, + importKmlPlaces, + importKmzPlaces, importGoogleList, searchPlaceImage, } from '../services/placeService'; @@ -72,6 +74,58 @@ router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('fi } }); +router.post('/import/kml', authenticate, requireTripAccess, gpxUpload.single('file'), (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 file = (req as any).file; + if (!file) return res.status(400).json({ error: 'No file uploaded' }); + + try { + const result = importKmlPlaces(tripId, file.buffer); + if (result.count === 0) { + return res.status(400).json({ error: 'No valid Placemarks found in KML file', summary: result.summary }); + } + + res.status(201).json(result); + for (const place of result.places) { + broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to import KML file'; + res.status(400).json({ error: message }); + } +}); + +router.post('/import/kmz', authenticate, requireTripAccess, gpxUpload.single('file'), 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 file = (req as any).file; + if (!file) return res.status(400).json({ error: 'No file uploaded' }); + + try { + const result = await importKmzPlaces(tripId, file.buffer); + if (result.count === 0) { + return res.status(400).json({ error: 'No valid Placemarks found in KMZ file', summary: result.summary }); + } + + res.status(201).json(result); + for (const place of result.places) { + broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to import KMZ file'; + res.status(400).json({ error: message }); + } +}); + // Import places from a shared Google Maps list URL router.post('/import/google-list', authenticate, requireTripAccess, async (req: Request, res: Response) => { const authReq = req as AuthRequest; diff --git a/server/src/services/placeImport/kmlImportUtils.ts b/server/src/services/placeImport/kmlImportUtils.ts new file mode 100644 index 00000000..4489d54b --- /dev/null +++ b/server/src/services/placeImport/kmlImportUtils.ts @@ -0,0 +1,175 @@ +import { TextDecoder } from 'util'; + +export interface ParsedKmlPlacemark { + name: string | null; + description: string | null; + lat: number | null; + lng: number | null; + folderName: string | null; +} + +export interface KmlPlacemarkNode { + placemark: any; + folderName: string | null; +} + +export interface KmlImportSummary { + totalPlacemarks: number; + createdCount: number; + skippedCount: number; + warnings: string[]; + errors: string[]; +} + +const UTF8_DECODER_FATAL = new TextDecoder('utf-8', { fatal: true }); +const UTF8_DECODER_LOOSE = new TextDecoder('utf-8'); + +const ENTITY_MAP: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + ' ': ' ', +}; + +function asArray(value: T | T[] | null | undefined): T[] { + if (value == null) return []; + return Array.isArray(value) ? value : [value]; +} + +function asTrimmedString(value: unknown): string | null { + if (value == null) return null; + const text = String(value).trim(); + return text.length > 0 ? text : null; +} + +function decodeHtmlEntities(value: string): string { + const withNamedEntities = value.replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (m) => ENTITY_MAP[m] || m); + + return withNamedEntities + .replace(/&#(\d+);/g, (_, dec) => { + const code = Number(dec); + return Number.isFinite(code) ? String.fromCharCode(code) : _; + }) + .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => { + const code = Number.parseInt(hex, 16); + return Number.isFinite(code) ? String.fromCharCode(code) : _; + }); +} + +export function stripXmlNamespaces(xml: string): string { + // KML exports vary heavily; stripping namespace declarations/prefixes makes parsing resilient. + return xml + .replace(/\sxmlns(:\w+)?="[^"]*"/g, '') + .replace(/\sxmlns(:\w+)?='[^']*'/g, '') + .replace(/<(\/?)\w+:/g, '<$1'); +} + +export function decodeUtf8WithWarning(fileBuffer: Buffer): { text: string; warning: string | null } { + try { + return { text: UTF8_DECODER_FATAL.decode(fileBuffer), warning: null }; + } catch { + return { + text: UTF8_DECODER_LOOSE.decode(fileBuffer), + warning: 'The uploaded file is not valid UTF-8. Some characters may be shown incorrectly.', + }; + } +} + +export function sanitizeKmlDescription(value: unknown): string | null { + const raw = asTrimmedString(value); + if (!raw) return null; + + const withLineBreaks = raw.replace(//gi, '\n'); + const stripped = withLineBreaks.replace(/<[^>]+>/g, ''); + const decoded = decodeHtmlEntities(stripped) + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(/[\t\f\v]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + return decoded || null; +} + +export function parseKmlPointCoordinates(value: unknown): { lat: number; lng: number } | null { + const coordinates = asTrimmedString(value); + if (!coordinates) return null; + + const firstCoordinate = coordinates.split(/\s+/)[0]; + const [lngRaw, latRaw] = firstCoordinate.split(','); + if (lngRaw == null || latRaw == null) return null; + + const lng = Number.parseFloat(lngRaw); + const lat = Number.parseFloat(latRaw); + + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + return { lat, lng }; +} + +export function createKmlImportSummary(totalPlacemarks: number): KmlImportSummary { + return { + totalPlacemarks, + createdCount: 0, + skippedCount: 0, + warnings: [], + errors: [], + }; +} + +export function buildCategoryNameLookup(categories: { id: number; name: string }[]): Map { + const lookup = new Map(); + for (const category of categories) { + const normalizedName = category.name.trim().toLowerCase(); + if (!normalizedName) continue; + if (!lookup.has(normalizedName)) { + lookup.set(normalizedName, category.id); + } + } + return lookup; +} + +export function resolveCategoryIdForFolder(folderName: string | null, lookup: Map): number | null { + if (!folderName) return null; + const normalizedFolder = folderName.trim().toLowerCase(); + if (!normalizedFolder) return null; + return lookup.get(normalizedFolder) ?? null; +} + +export function extractKmlPlacemarkNodes(kmlRoot: any): KmlPlacemarkNode[] { + const nodes: KmlPlacemarkNode[] = []; + + const visitNode = (node: any, currentFolderName: string | null): void => { + if (!node || typeof node !== 'object') return; + + for (const placemark of asArray(node.Placemark)) { + nodes.push({ placemark, folderName: currentFolderName }); + } + + for (const folder of asArray(node.Folder)) { + // Nested folders inherit/override folder context used for category matching. + const folderName = asTrimmedString(folder?.name) || currentFolderName; + visitNode(folder, folderName); + } + + for (const childDocument of asArray(node.Document)) { + visitNode(childDocument, currentFolderName); + } + }; + + visitNode(kmlRoot, null); + return nodes; +} + +export function parsePlacemarkNode(node: KmlPlacemarkNode): ParsedKmlPlacemark { + const coordinates = parseKmlPointCoordinates(node.placemark?.Point?.coordinates); + + return { + name: asTrimmedString(node.placemark?.name), + description: sanitizeKmlDescription(node.placemark?.description), + lat: coordinates?.lat ?? null, + lng: coordinates?.lng ?? null, + folderName: node.folderName, + }; +} diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts index 911f5ae4..b602bd0c 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -1,7 +1,18 @@ -import { XMLParser } from 'fast-xml-parser'; +import { XMLParser, XMLValidator } from 'fast-xml-parser'; +import unzipper from 'unzipper'; import { db, getPlaceWithTags } from '../db/database'; import { loadTagsByPlaceIds } from './queryHelpers'; import { Place } from '../types'; +import { + buildCategoryNameLookup, + createKmlImportSummary, + decodeUtf8WithWarning, + extractKmlPlacemarkNodes, + parsePlacemarkNode, + resolveCategoryIdForFolder, + stripXmlNamespaces, + type KmlImportSummary, +} from './placeImport/kmlImportUtils'; interface PlaceWithCategory extends Place { category_name: string | null; @@ -14,6 +25,12 @@ interface UnsplashSearchResponse { errors?: string[]; } +export interface PlaceImportResult { + places: any[]; + count: number; + summary: KmlImportSummary; +} + // --------------------------------------------------------------------------- // List places // --------------------------------------------------------------------------- @@ -223,6 +240,12 @@ const gpxParser = new XMLParser({ isArray: (name) => ['wpt', 'trkpt', 'rtept', 'trk', 'trkseg', 'rte'].includes(name), }); +const kmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + isArray: (name) => ['Placemark', 'Folder', 'Document'].includes(name), +}); + export function importGpx(tripId: string, fileBuffer: Buffer) { const parsed = gpxParser.parse(fileBuffer.toString('utf-8')); const gpx = parsed?.gpx; @@ -291,6 +314,103 @@ export function importGpx(tripId: string, fileBuffer: Buffer) { return created; } +export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImportResult { + const decoded = decodeUtf8WithWarning(fileBuffer); + const xmlWithoutNamespaces = stripXmlNamespaces(decoded.text); + + const validationResult = XMLValidator.validate(xmlWithoutNamespaces); + if (validationResult !== true) { + throw new Error('Malformed KML: invalid XML structure'); + } + + const parsed = kmlParser.parse(xmlWithoutNamespaces); + const kmlRoot = parsed?.kml ?? parsed; + + if (!kmlRoot || typeof kmlRoot !== 'object') { + throw new Error('Malformed KML: could not parse XML'); + } + + const placemarkNodes = extractKmlPlacemarkNodes(kmlRoot); + const summary = createKmlImportSummary(placemarkNodes.length); + + if (decoded.warning) { + summary.warnings.push(decoded.warning); + } + + const categories = db.prepare('SELECT id, name FROM categories').all() as { id: number; name: string }[]; + const categoryLookup = buildCategoryNameLookup(categories); + const created: any[] = []; + + const insertStmt = db.prepare(` + INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode) + VALUES (?, ?, ?, ?, ?, ?, 'walking') + `); + + const insertAll = db.transaction(() => { + let fallbackIndex = 1; + for (const node of placemarkNodes) { + const parsedPlacemark = parsePlacemarkNode(node); + + // KML geometry support is intentionally limited to coordinates. + if (parsedPlacemark.lat === null || parsedPlacemark.lng === null) { + summary.skippedCount += 1; + summary.errors.push(`Skipped Placemark ${fallbackIndex}: missing Point coordinates.`); + fallbackIndex += 1; + continue; + } + + const fallbackName = `Placemark ${fallbackIndex}`; + const name = parsedPlacemark.name || fallbackName; + const categoryId = resolveCategoryIdForFolder(parsedPlacemark.folderName, categoryLookup); + + const result = insertStmt.run( + tripId, + name, + parsedPlacemark.description, + parsedPlacemark.lat, + parsedPlacemark.lng, + categoryId, + ); + + const place = getPlaceWithTags(Number(result.lastInsertRowid)); + created.push(place); + summary.createdCount += 1; + fallbackIndex += 1; + } + }); + + insertAll(); + summary.skippedCount = summary.totalPlacemarks - summary.createdCount; + + if (summary.totalPlacemarks === 0) { + summary.errors.push('No Placemarks found in KML file.'); + } + + return { places: created, count: created.length, summary }; +} + +export async function unpackKmzToKml(kmzBuffer: Buffer): Promise { + let zip; + try { + zip = await unzipper.Open.buffer(kmzBuffer); + } catch { + throw new Error('Invalid KMZ archive.'); + } + + const kmlEntries = zip.files.filter((entry) => !entry.path.endsWith('/') && entry.path.toLowerCase().endsWith('.kml')); + if (kmlEntries.length === 0) { + throw new Error('KMZ archive does not contain a KML file.'); + } + + const preferredEntry = kmlEntries.find((entry) => entry.path.toLowerCase().endsWith('doc.kml')) || kmlEntries[0]; + return preferredEntry.buffer(); +} + +export async function importKmzPlaces(tripId: string, kmzBuffer: Buffer): Promise { + const kmlBuffer = await unpackKmzToKml(kmzBuffer); + return importKmlPlaces(tripId, kmlBuffer); +} + // --------------------------------------------------------------------------- // Import Google Maps list // --------------------------------------------------------------------------- diff --git a/server/tests/fixtures/test-malformed.kml b/server/tests/fixtures/test-malformed.kml new file mode 100644 index 00000000..6d227c24 --- /dev/null +++ b/server/tests/fixtures/test-malformed.kml @@ -0,0 +1,8 @@ + + + + + Broken Placemark + 2.1,48.1,0 + + diff --git a/server/tests/fixtures/test-nested.kml b/server/tests/fixtures/test-nested.kml new file mode 100644 index 00000000..e29b7cb8 --- /dev/null +++ b/server/tests/fixtures/test-nested.kml @@ -0,0 +1,26 @@ + + + + + Food + + Parks + + Nested Place + Nested folder placemark
line 2
+ + 13.4050,52.5200,15 + +
+
+ + Empty Placemark + + + + 13.4010,52.5210,0 + + +
+
+
diff --git a/server/tests/fixtures/test.kml b/server/tests/fixtures/test.kml new file mode 100644 index 00000000..5501fd26 --- /dev/null +++ b/server/tests/fixtures/test.kml @@ -0,0 +1,21 @@ + + + + + Museums + + Eiffel Tower View + for photos and skyline.]]> + + 2.2945,48.8584,0 + + + + Coordinates only placemark + + 2.3333,48.8600,0 + + + + + diff --git a/server/tests/fixtures/test.kmz b/server/tests/fixtures/test.kmz new file mode 100644 index 0000000000000000000000000000000000000000..347373022fa054eb75dde83d1512bb3992fd4ef8 GIT binary patch literal 408 zcmWIWW@Zs#U|`^2xID2f#^CLN7&S%)hAJkI2m?b&YH^8Pc5cqnAYXqbLxH`sf5^X@ zSGZeg##J6wmLron**`d0=cIFs3Z>o2cZucx1I+GLs3^=`9!*ZG6Ye1(I1)7Hl3T<}giQFfh2VFtwX=Tg{vU0}{=eMwll*^$W_EVTKWsmKXw>Xy4)A7V5@Em{n!o@>0 { createTables(testDb); @@ -528,3 +532,122 @@ describe('GPX Import', () => { expect(res.status).toBe(400); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// KML / KMZ Import +// ───────────────────────────────────────────────────────────────────────────── + +describe('KML/KMZ Import', () => { + it('PLACE-020 — POST /import/kml with valid KML creates places and returns summary', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + testDb.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)') + .run('Museums', '#3b82f6', 'Landmark', user.id); + + const res = await request(app) + .post(`/api/trips/${trip.id}/places/import/kml`) + .set('Cookie', authCookie(user.id)) + .attach('file', KML_FIXTURE); + + expect(res.status).toBe(201); + expect(res.body.count).toBe(2); + expect(res.body.summary).toBeDefined(); + expect(res.body.summary.totalPlacemarks).toBe(2); + expect(res.body.summary.createdCount).toBe(2); + + const first = res.body.places.find((p: any) => p.name === 'Eiffel Tower View'); + expect(first).toBeDefined(); + expect(first.description).toContain('Great spot'); + expect(first.description).toContain('\n'); + expect(first.description).not.toContain(''); + expect(first.category?.name).toBe('Museums'); + }); + + it('PLACE-021 — nested folders, empty placemark, and coordinates-only placemark are handled', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + testDb.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)') + .run('Parks', '#22c55e', 'Trees', user.id); + + const res = await request(app) + .post(`/api/trips/${trip.id}/places/import/kml`) + .set('Cookie', authCookie(user.id)) + .attach('file', KML_NESTED_FIXTURE); + + expect(res.status).toBe(201); + expect(res.body.count).toBe(2); + expect(res.body.summary.totalPlacemarks).toBe(3); + expect(res.body.summary.skippedCount).toBe(1); + expect(Array.isArray(res.body.summary.errors)).toBe(true); + expect(res.body.summary.errors.join(' ')).toContain('missing Point coordinates'); + + const nested = res.body.places.find((p: any) => p.name === 'Nested Place'); + expect(nested).toBeDefined(); + expect(nested.category?.name).toBe('Parks'); + + const fallback = res.body.places.find((p: any) => String(p.name).startsWith('Placemark')); + expect(fallback).toBeDefined(); + }); + + it('PLACE-022 — malformed KML returns 400', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const res = await request(app) + .post(`/api/trips/${trip.id}/places/import/kml`) + .set('Cookie', authCookie(user.id)) + .attach('file', KML_MALFORMED_FIXTURE); + + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + it('PLACE-023 — non-UTF8 KML continues with warning', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const prefix = Buffer.from('Caf'); + const invalidByte = Buffer.from([0xe9]); // invalid UTF-8 sequence when used standalone + const suffix = Buffer.from('2.1,48.1,0'); + const nonUtf8Kml = Buffer.concat([prefix, invalidByte, suffix]); + + const res = await request(app) + .post(`/api/trips/${trip.id}/places/import/kml`) + .set('Cookie', authCookie(user.id)) + .attach('file', nonUtf8Kml, 'non-utf8.kml'); + + expect(res.status).toBe(201); + expect(res.body.count).toBe(1); + expect(Array.isArray(res.body.summary.warnings)).toBe(true); + expect(res.body.summary.warnings.join(' ')).toContain('not valid UTF-8'); + }); + + it('PLACE-024 — POST /import/kmz with valid KMZ creates places', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const res = await request(app) + .post(`/api/trips/${trip.id}/places/import/kmz`) + .set('Cookie', authCookie(user.id)) + .attach('file', KMZ_FIXTURE); + + expect(res.status).toBe(201); + expect(res.body.count).toBeGreaterThan(0); + expect(res.body.summary).toBeDefined(); + }); + + it('PLACE-025 — invalid KMZ returns 400', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const res = await request(app) + .post(`/api/trips/${trip.id}/places/import/kmz`) + .set('Cookie', authCookie(user.id)) + .attach('file', Buffer.from('not-a-zip-archive'), 'invalid.kmz'); + + expect(res.status).toBe(400); + expect(String(res.body.error || '')).toContain('KMZ'); + }); +}); diff --git a/server/tests/unit/services/kmlImportUtils.test.ts b/server/tests/unit/services/kmlImportUtils.test.ts new file mode 100644 index 00000000..f250b696 --- /dev/null +++ b/server/tests/unit/services/kmlImportUtils.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { + buildCategoryNameLookup, + decodeUtf8WithWarning, + extractKmlPlacemarkNodes, + parseKmlPointCoordinates, + parsePlacemarkNode, + resolveCategoryIdForFolder, + sanitizeKmlDescription, + stripXmlNamespaces, +} from '../../../src/services/placeImport/kmlImportUtils'; + +describe('kmlImportUtils', () => { + it('strips KML namespaces and prefixes', () => { + const xml = ''; + const stripped = stripXmlNamespaces(xml); + expect(stripped).not.toContain('xmlns'); + expect(stripped).toContain(''); + expect(stripped).toContain(' { + const input = 'Line 1
Line 2 & more'; + const output = sanitizeKmlDescription(input); + expect(output).toBe('Line 1\nLine 2 & more'); + }); + + it('parses KML coordinate order lng,lat,alt', () => { + const parsed = parseKmlPointCoordinates('13.4050,52.5200,15'); + expect(parsed).toEqual({ lat: 52.52, lng: 13.405 }); + }); + + it('extracts placemarks from nested folders', () => { + const root = { + Document: { + Folder: { + name: 'Parent', + Folder: { + name: 'Child', + Placemark: { name: 'Nested', Point: { coordinates: '13.4,52.5,0' } }, + }, + }, + }, + }; + + const nodes = extractKmlPlacemarkNodes(root); + expect(nodes).toHaveLength(1); + expect(nodes[0].folderName).toBe('Child'); + + const parsed = parsePlacemarkNode(nodes[0]); + expect(parsed.name).toBe('Nested'); + expect(parsed.lat).toBe(52.5); + expect(parsed.lng).toBe(13.4); + }); + + it('builds exact case-insensitive category lookup', () => { + const lookup = buildCategoryNameLookup([ + { id: 3, name: 'Museums' }, + { id: 4, name: 'Parks' }, + ]); + + expect(resolveCategoryIdForFolder('museums', lookup)).toBe(3); + expect(resolveCategoryIdForFolder('Museum', lookup)).toBeNull(); + expect(resolveCategoryIdForFolder('parks', lookup)).toBe(4); + }); + + it('returns warning for non-UTF8 payload', () => { + const buffer = Buffer.concat([ + Buffer.from('Caf'), + Buffer.from([0xe9]), + Buffer.from(''), + ]); + + const decoded = decodeUtf8WithWarning(buffer); + expect(decoded.warning).toContain('not valid UTF-8'); + expect(decoded.text).toContain(''); + }); +}); From d60ab3672e4b7e224fd19701f134fe7d6d603a37 Mon Sep 17 00:00:00 2001 From: Yannis Biasutti Date: Mon, 6 Apr 2026 18:32:00 +0200 Subject: [PATCH 002/170] feat(client): add KMZ/KML places import dialog and API --- client/src/api/client.ts | 8 + .../src/components/Planner/PlacesSidebar.tsx | 188 ++++++++++++++++++ 2 files changed, 196 insertions(+) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 237d3e64..df74ae96 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -105,6 +105,14 @@ export const placesApi = { const fd = new FormData(); fd.append('file', file) return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) }, + importKml: (tripId: number | string, file: File) => { + const fd = new FormData(); fd.append('file', file) + return apiClient.post(`/trips/${tripId}/places/import/kml`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) + }, + importKmz: (tripId: number | string, file: File) => { + const fd = new FormData(); fd.append('file', file) + return apiClient.post(`/trips/${tripId}/places/import/kmz`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) + }, importGoogleList: (tripId: number | string, url: string) => apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data), } diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index ce8c6c51..40ece73e 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -14,6 +14,14 @@ import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import type { Place, Category, Day, AssignmentsMap } from '../../types' +interface PlacesImportSummary { + totalPlacemarks: number + createdCount: number + skippedCount: number + warnings: string[] + errors: string[] +} + interface PlacesSidebarProps { tripId: number places: Place[] @@ -44,6 +52,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const loadTrip = useTripStore((s) => s.loadTrip) const can = useCanDo() const canEditPlaces = can('place_edit', trip) + const importFileLimitBytes = 10 * 1024 * 1024 const handleGpxImport = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] @@ -70,6 +79,70 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const [googleListOpen, setGoogleListOpen] = useState(false) const [googleListUrl, setGoogleListUrl] = useState('') const [googleListLoading, setGoogleListLoading] = useState(false) + const [kmlKmzOpen, setKmlKmzOpen] = useState(false) + const [kmlKmzLoading, setKmlKmzLoading] = useState(false) + const [kmlKmzFile, setKmlKmzFile] = useState(null) + const [kmlKmzSummary, setKmlKmzSummary] = useState(null) + const [kmlKmzError, setKmlKmzError] = useState('') + + const resetKmlKmzDialog = () => { + setKmlKmzFile(null) + setKmlKmzSummary(null) + setKmlKmzError('') + setKmlKmzLoading(false) + } + + const handleKmlKmzImport = async () => { + if (!kmlKmzFile) return + + const ext = kmlKmzFile.name.toLowerCase().split('.').pop() + if (ext !== 'kml' && ext !== 'kmz') { + setKmlKmzError(t('places.kmlKmzInvalidType')) + return + } + if (kmlKmzFile.size > importFileLimitBytes) { + setKmlKmzError(t('places.kmlKmzTooLarge', { maxMb: 10 })) + return + } + + setKmlKmzLoading(true) + setKmlKmzError('') + setKmlKmzSummary(null) + + try { + const result = ext === 'kmz' + ? await placesApi.importKmz(tripId, kmlKmzFile) + : await placesApi.importKml(tripId, kmlKmzFile) + + await loadTrip(tripId) + setKmlKmzSummary(result.summary || null) + toast.success(t('places.kmlKmzImported', { count: result.count })) + + if (result.summary?.errors?.length > 0) { + setKmlKmzError(result.summary.errors.join('\n')) + } + + if (result.places?.length > 0) { + const importedIds: number[] = result.places.map((p: { id: number }) => p.id) + pushUndo?.(t('undo.importKmlKmz'), async () => { + for (const id of importedIds) { + try { await placesApi.delete(tripId, id) } catch {} + } + await loadTrip(tripId) + }) + } + } catch (err: any) { + const responseSummary = err?.response?.data?.summary as PlacesImportSummary | undefined + if (responseSummary) { + setKmlKmzSummary(responseSummary) + } + const message = err?.response?.data?.error || t('places.kmlKmzImportError') + setKmlKmzError(message) + toast.error(message) + } finally { + setKmlKmzLoading(false) + } + } const handleGoogleListImport = async () => { if (!googleListUrl.trim()) return @@ -159,6 +232,18 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ > {t('places.importGpx')} + + + + + , + document.body + )} ) From c671b5ff177647ca53cd5e9625abf5faf0a2b2bc Mon Sep 17 00:00:00 2001 From: Yannis Biasutti Date: Mon, 6 Apr 2026 18:32:10 +0200 Subject: [PATCH 003/170] chore(i18n): add KMZ/KML import translation keys --- client/src/i18n/translations/ar.ts | 10 ++++++++++ client/src/i18n/translations/br.ts | 10 ++++++++++ client/src/i18n/translations/cs.ts | 10 ++++++++++ client/src/i18n/translations/de.ts | 10 ++++++++++ client/src/i18n/translations/en.ts | 11 +++++++++++ client/src/i18n/translations/es.ts | 10 ++++++++++ client/src/i18n/translations/fr.ts | 10 ++++++++++ client/src/i18n/translations/hu.ts | 10 ++++++++++ client/src/i18n/translations/it.ts | 10 ++++++++++ client/src/i18n/translations/nl.ts | 10 ++++++++++ client/src/i18n/translations/pl.ts | 10 ++++++++++ client/src/i18n/translations/ru.ts | 10 ++++++++++ client/src/i18n/translations/zh.ts | 10 ++++++++++ client/src/i18n/translations/zhTw.ts | 10 ++++++++++ 14 files changed, 141 insertions(+) diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 43f29ee9..10369ec3 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -812,6 +812,16 @@ const ar: Record = { // Places Sidebar 'places.addPlace': 'إضافة مكان/نشاط', 'places.importGpx': 'GPX', + 'places.importKmlKmz': 'TODO: KMZ / KML', + + 'places.kmlKmzImportError': 'TODO: KMZ/KML import failed', + 'places.kmlKmzInvalidType': 'TODO: Please select a .kml or .kmz file.', + 'places.kmlKmzTooLarge': 'TODO: File is too large. Maximum upload size is {maxMb} MB.', + 'places.kmlKmzHint': 'TODO: KML/KMZ are common map export formats used by apps like Google My Maps and Google Earth. TREK imports Placemark name, description, and coordinates.', + 'places.kmlKmzSizeHint': 'TODO: Maximum file size: {maxMb} MB (same limit as GPX).', + 'places.kmlKmzSelectedFile': 'TODO: Selected file: {name}', + 'places.kmlKmzSummaryTitle': 'TODO: Import summary', + 'places.kmlKmzSummaryValues': 'TODO: Placemarks: {total} - Imported: {created} - Skipped: {skipped}', 'places.gpxImported': 'تم استيراد {count} مكان من GPX', 'places.gpxError': 'فشل استيراد GPX', 'places.importGoogleList': 'قائمة Google', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 12612daf..2395c75b 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -794,6 +794,16 @@ const br: Record = { // Places Sidebar 'places.addPlace': 'Adicionar lugar/atividade', 'places.importGpx': 'GPX', + 'places.importKmlKmz': 'TODO: KMZ / KML', + + 'places.kmlKmzImportError': 'TODO: KMZ/KML import failed', + 'places.kmlKmzInvalidType': 'TODO: Please select a .kml or .kmz file.', + 'places.kmlKmzTooLarge': 'TODO: File is too large. Maximum upload size is {maxMb} MB.', + 'places.kmlKmzHint': 'TODO: KML/KMZ are common map export formats used by apps like Google My Maps and Google Earth. TREK imports Placemark name, description, and coordinates.', + 'places.kmlKmzSizeHint': 'TODO: Maximum file size: {maxMb} MB (same limit as GPX).', + 'places.kmlKmzSelectedFile': 'TODO: Selected file: {name}', + 'places.kmlKmzSummaryTitle': 'TODO: Import summary', + 'places.kmlKmzSummaryValues': 'TODO: Placemarks: {total} - Imported: {created} - Skipped: {skipped}', 'places.gpxImported': '{count} lugares importados do GPX', 'places.gpxError': 'Falha ao importar GPX', 'places.importGoogleList': 'Lista Google', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index defebfb6..7d45e0ed 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -810,6 +810,16 @@ const cs: Record = { // Boční panel míst (Places Sidebar) 'places.addPlace': 'Přidat místo/aktivitu', 'places.importGpx': 'GPX', + 'places.importKmlKmz': 'TODO: KMZ / KML', + + 'places.kmlKmzImportError': 'TODO: KMZ/KML import failed', + 'places.kmlKmzInvalidType': 'TODO: Please select a .kml or .kmz file.', + 'places.kmlKmzTooLarge': 'TODO: File is too large. Maximum upload size is {maxMb} MB.', + 'places.kmlKmzHint': 'TODO: KML/KMZ are common map export formats used by apps like Google My Maps and Google Earth. TREK imports Placemark name, description, and coordinates.', + 'places.kmlKmzSizeHint': 'TODO: Maximum file size: {maxMb} MB (same limit as GPX).', + 'places.kmlKmzSelectedFile': 'TODO: Selected file: {name}', + 'places.kmlKmzSummaryTitle': 'TODO: Import summary', + 'places.kmlKmzSummaryValues': 'TODO: Placemarks: {total} - Imported: {created} - Skipped: {skipped}', 'places.gpxImported': '{count} míst importováno z GPX', 'places.urlResolved': 'Místo importováno z URL', 'places.gpxError': 'Import GPX se nezdařil', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 1c76a6c1..1b0f378b 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -810,6 +810,16 @@ const de: Record = { // Places Sidebar 'places.addPlace': 'Ort/Aktivität hinzufügen', 'places.importGpx': 'GPX', + 'places.importKmlKmz': 'TODO: KMZ / KML', + + 'places.kmlKmzImportError': 'TODO: KMZ/KML import failed', + 'places.kmlKmzInvalidType': 'TODO: Please select a .kml or .kmz file.', + 'places.kmlKmzTooLarge': 'TODO: File is too large. Maximum upload size is {maxMb} MB.', + 'places.kmlKmzHint': 'TODO: KML/KMZ are common map export formats used by apps like Google My Maps and Google Earth. TREK imports Placemark name, description, and coordinates.', + 'places.kmlKmzSizeHint': 'TODO: Maximum file size: {maxMb} MB (same limit as GPX).', + 'places.kmlKmzSelectedFile': 'TODO: Selected file: {name}', + 'places.kmlKmzSummaryTitle': 'TODO: Import summary', + 'places.kmlKmzSummaryValues': 'TODO: Placemarks: {total} - Imported: {created} - Skipped: {skipped}', 'places.gpxImported': '{count} Orte aus GPX importiert', 'places.urlResolved': 'Ort aus URL importiert', 'places.gpxError': 'GPX-Import fehlgeschlagen', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 6e6cc0b0..18e21581 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -829,9 +829,19 @@ const en: Record = { // Places Sidebar 'places.addPlace': 'Add Place/Activity', 'places.importGpx': 'GPX', + 'places.importKmlKmz': 'KMZ / KML', 'places.gpxImported': '{count} places imported from GPX', + 'places.kmlKmzImported': '{count} places imported from KMZ/KML', 'places.urlResolved': 'Place imported from URL', 'places.gpxError': 'GPX import failed', + 'places.kmlKmzImportError': 'KMZ/KML import failed', + 'places.kmlKmzInvalidType': 'Please select a .kml or .kmz file.', + 'places.kmlKmzTooLarge': 'File is too large. Maximum upload size is {maxMb} MB.', + 'places.kmlKmzHint': 'KML/KMZ are common map export formats used by apps like Google My Maps and Google Earth. TREK imports Placemark name, description, and coordinates.', + 'places.kmlKmzSizeHint': 'Maximum file size: {maxMb} MB (same limit as GPX).', + 'places.kmlKmzSelectedFile': 'Selected file: {name}', + 'places.kmlKmzSummaryTitle': 'Import summary', + 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Imported: {created} • Skipped: {skipped}', 'places.importGoogleList': 'Google List', 'places.googleListHint': 'Paste a shared Google Maps list link to import all places.', 'places.googleListImported': '{count} places imported from "{list}"', @@ -1591,6 +1601,7 @@ const en: Record = { 'undo.moveDay': 'Place moved to another day', 'undo.lock': 'Place lock toggled', 'undo.importGpx': 'GPX import', + 'undo.importKmlKmz': 'KMZ/KML import', 'undo.importGoogleList': 'Google 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..df06bdf7 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -786,6 +786,16 @@ const es: Record = { // Places Sidebar 'places.addPlace': 'Añadir lugar/actividad', 'places.importGpx': 'GPX', + 'places.importKmlKmz': 'TODO: KMZ / KML', + + 'places.kmlKmzImportError': 'TODO: KMZ/KML import failed', + 'places.kmlKmzInvalidType': 'TODO: Please select a .kml or .kmz file.', + 'places.kmlKmzTooLarge': 'TODO: File is too large. Maximum upload size is {maxMb} MB.', + 'places.kmlKmzHint': 'TODO: KML/KMZ are common map export formats used by apps like Google My Maps and Google Earth. TREK imports Placemark name, description, and coordinates.', + 'places.kmlKmzSizeHint': 'TODO: Maximum file size: {maxMb} MB (same limit as GPX).', + 'places.kmlKmzSelectedFile': 'TODO: Selected file: {name}', + 'places.kmlKmzSummaryTitle': 'TODO: Import summary', + 'places.kmlKmzSummaryValues': 'TODO: Placemarks: {total} - Imported: {created} - Skipped: {skipped}', 'places.gpxImported': '{count} lugares importados desde GPX', 'places.gpxError': 'Error al importar GPX', 'places.importGoogleList': 'Lista Google', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index b1615c9b..3d68ab82 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -809,6 +809,16 @@ const fr: Record = { // Places Sidebar 'places.addPlace': 'Ajouter un lieu/activité', 'places.importGpx': 'GPX', + 'places.importKmlKmz': 'TODO: KMZ / KML', + + 'places.kmlKmzImportError': 'TODO: KMZ/KML import failed', + 'places.kmlKmzInvalidType': 'TODO: Please select a .kml or .kmz file.', + 'places.kmlKmzTooLarge': 'TODO: File is too large. Maximum upload size is {maxMb} MB.', + 'places.kmlKmzHint': 'TODO: KML/KMZ are common map export formats used by apps like Google My Maps and Google Earth. TREK imports Placemark name, description, and coordinates.', + 'places.kmlKmzSizeHint': 'TODO: Maximum file size: {maxMb} MB (same limit as GPX).', + 'places.kmlKmzSelectedFile': 'TODO: Selected file: {name}', + 'places.kmlKmzSummaryTitle': 'TODO: Import summary', + 'places.kmlKmzSummaryValues': 'TODO: Placemarks: {total} - Imported: {created} - Skipped: {skipped}', 'places.gpxImported': '{count} lieux importés depuis GPX', 'places.gpxError': 'L\'import GPX a échoué', 'places.importGoogleList': 'Liste Google', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 3d6c6603..8f3d82e7 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -810,6 +810,16 @@ const hu: Record = { // Helyek oldalsáv 'places.addPlace': 'Hely/Tevékenység hozzáadása', 'places.importGpx': 'GPX', + 'places.importKmlKmz': 'TODO: KMZ / KML', + + 'places.kmlKmzImportError': 'TODO: KMZ/KML import failed', + 'places.kmlKmzInvalidType': 'TODO: Please select a .kml or .kmz file.', + 'places.kmlKmzTooLarge': 'TODO: File is too large. Maximum upload size is {maxMb} MB.', + 'places.kmlKmzHint': 'TODO: KML/KMZ are common map export formats used by apps like Google My Maps and Google Earth. TREK imports Placemark name, description, and coordinates.', + 'places.kmlKmzSizeHint': 'TODO: Maximum file size: {maxMb} MB (same limit as GPX).', + 'places.kmlKmzSelectedFile': 'TODO: Selected file: {name}', + 'places.kmlKmzSummaryTitle': 'TODO: Import summary', + 'places.kmlKmzSummaryValues': 'TODO: Placemarks: {total} - Imported: {created} - Skipped: {skipped}', 'places.gpxImported': '{count} hely importálva GPX-ből', 'places.urlResolved': 'Hely importálva URL-ből', 'places.gpxError': 'GPX importálás sikertelen', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 0a504f9e..33f09bf7 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -810,6 +810,16 @@ const it: Record = { // Places Sidebar 'places.addPlace': 'Aggiungi Luogo/Attività', 'places.importGpx': 'GPX', + 'places.importKmlKmz': 'TODO: KMZ / KML', + + 'places.kmlKmzImportError': 'TODO: KMZ/KML import failed', + 'places.kmlKmzInvalidType': 'TODO: Please select a .kml or .kmz file.', + 'places.kmlKmzTooLarge': 'TODO: File is too large. Maximum upload size is {maxMb} MB.', + 'places.kmlKmzHint': 'TODO: KML/KMZ are common map export formats used by apps like Google My Maps and Google Earth. TREK imports Placemark name, description, and coordinates.', + 'places.kmlKmzSizeHint': 'TODO: Maximum file size: {maxMb} MB (same limit as GPX).', + 'places.kmlKmzSelectedFile': 'TODO: Selected file: {name}', + 'places.kmlKmzSummaryTitle': 'TODO: Import summary', + 'places.kmlKmzSummaryValues': 'TODO: Placemarks: {total} - Imported: {created} - Skipped: {skipped}', 'places.gpxImported': '{count} luoghi importati da GPX', 'places.urlResolved': 'Luogo importato dall\'URL', 'places.gpxError': 'Importazione GPX non riuscita', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 93c4e780..69b9c932 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -809,6 +809,16 @@ const nl: Record = { // Places Sidebar 'places.addPlace': 'Plaats/activiteit toevoegen', 'places.importGpx': 'GPX', + 'places.importKmlKmz': 'TODO: KMZ / KML', + + 'places.kmlKmzImportError': 'TODO: KMZ/KML import failed', + 'places.kmlKmzInvalidType': 'TODO: Please select a .kml or .kmz file.', + 'places.kmlKmzTooLarge': 'TODO: File is too large. Maximum upload size is {maxMb} MB.', + 'places.kmlKmzHint': 'TODO: KML/KMZ are common map export formats used by apps like Google My Maps and Google Earth. TREK imports Placemark name, description, and coordinates.', + 'places.kmlKmzSizeHint': 'TODO: Maximum file size: {maxMb} MB (same limit as GPX).', + 'places.kmlKmzSelectedFile': 'TODO: Selected file: {name}', + 'places.kmlKmzSummaryTitle': 'TODO: Import summary', + 'places.kmlKmzSummaryValues': 'TODO: Placemarks: {total} - Imported: {created} - Skipped: {skipped}', 'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX', 'places.gpxError': 'GPX-import mislukt', 'places.importGoogleList': 'Google Lijst', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index b0202860..b5f929e6 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -773,6 +773,16 @@ const pl: Record = { // Places Sidebar 'places.addPlace': 'Dodaj miejsce/atrakcję', 'places.importGpx': 'Importuj GPX', + 'places.importKmlKmz': 'TODO: KMZ / KML', + + 'places.kmlKmzImportError': 'TODO: KMZ/KML import failed', + 'places.kmlKmzInvalidType': 'TODO: Please select a .kml or .kmz file.', + 'places.kmlKmzTooLarge': 'TODO: File is too large. Maximum upload size is {maxMb} MB.', + 'places.kmlKmzHint': 'TODO: KML/KMZ are common map export formats used by apps like Google My Maps and Google Earth. TREK imports Placemark name, description, and coordinates.', + 'places.kmlKmzSizeHint': 'TODO: Maximum file size: {maxMb} MB (same limit as GPX).', + 'places.kmlKmzSelectedFile': 'TODO: Selected file: {name}', + 'places.kmlKmzSummaryTitle': 'TODO: Import summary', + 'places.kmlKmzSummaryValues': 'TODO: Placemarks: {total} - Imported: {created} - Skipped: {skipped}', 'places.gpxImported': '{count} miejsc zaimportowanych z GPX', 'places.urlResolved': 'Miejsce zaimportowane z URL', 'places.gpxError': 'Nie udało się zaimportować pliku GPX', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 3cf4cc74..4aa14c38 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -809,6 +809,16 @@ const ru: Record = { // Places Sidebar 'places.addPlace': 'Добавить место/активность', 'places.importGpx': 'GPX', + 'places.importKmlKmz': 'TODO: KMZ / KML', + + 'places.kmlKmzImportError': 'TODO: KMZ/KML import failed', + 'places.kmlKmzInvalidType': 'TODO: Please select a .kml or .kmz file.', + 'places.kmlKmzTooLarge': 'TODO: File is too large. Maximum upload size is {maxMb} MB.', + 'places.kmlKmzHint': 'TODO: KML/KMZ are common map export formats used by apps like Google My Maps and Google Earth. TREK imports Placemark name, description, and coordinates.', + 'places.kmlKmzSizeHint': 'TODO: Maximum file size: {maxMb} MB (same limit as GPX).', + 'places.kmlKmzSelectedFile': 'TODO: Selected file: {name}', + 'places.kmlKmzSummaryTitle': 'TODO: Import summary', + 'places.kmlKmzSummaryValues': 'TODO: Placemarks: {total} - Imported: {created} - Skipped: {skipped}', 'places.gpxImported': '{count} мест импортировано из GPX', 'places.gpxError': 'Ошибка импорта GPX', 'places.importGoogleList': 'Список Google', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 5dc74216..03bb2ae9 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -809,6 +809,16 @@ const zh: Record = { // Places Sidebar 'places.addPlace': '添加地点/活动', 'places.importGpx': 'GPX', + 'places.importKmlKmz': 'TODO: KMZ / KML', + + 'places.kmlKmzImportError': 'TODO: KMZ/KML import failed', + 'places.kmlKmzInvalidType': 'TODO: Please select a .kml or .kmz file.', + 'places.kmlKmzTooLarge': 'TODO: File is too large. Maximum upload size is {maxMb} MB.', + 'places.kmlKmzHint': 'TODO: KML/KMZ are common map export formats used by apps like Google My Maps and Google Earth. TREK imports Placemark name, description, and coordinates.', + 'places.kmlKmzSizeHint': 'TODO: Maximum file size: {maxMb} MB (same limit as GPX).', + 'places.kmlKmzSelectedFile': 'TODO: Selected file: {name}', + 'places.kmlKmzSummaryTitle': 'TODO: Import summary', + 'places.kmlKmzSummaryValues': 'TODO: Placemarks: {total} - Imported: {created} - Skipped: {skipped}', 'places.gpxImported': '已从 GPX 导入 {count} 个地点', 'places.gpxError': 'GPX 导入失败', 'places.importGoogleList': 'Google 列表', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index fc35e1ab..b5d01f0b 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -789,6 +789,16 @@ const zhTw: Record = { // Places Sidebar 'places.addPlace': '新增地點/活動', 'places.importGpx': 'GPX', + 'places.importKmlKmz': 'TODO: KMZ / KML', + + 'places.kmlKmzImportError': 'TODO: KMZ/KML import failed', + 'places.kmlKmzInvalidType': 'TODO: Please select a .kml or .kmz file.', + 'places.kmlKmzTooLarge': 'TODO: File is too large. Maximum upload size is {maxMb} MB.', + 'places.kmlKmzHint': 'TODO: KML/KMZ are common map export formats used by apps like Google My Maps and Google Earth. TREK imports Placemark name, description, and coordinates.', + 'places.kmlKmzSizeHint': 'TODO: Maximum file size: {maxMb} MB (same limit as GPX).', + 'places.kmlKmzSelectedFile': 'TODO: Selected file: {name}', + 'places.kmlKmzSummaryTitle': 'TODO: Import summary', + 'places.kmlKmzSummaryValues': 'TODO: Placemarks: {total} - Imported: {created} - Skipped: {skipped}', 'places.gpxImported': '已從 GPX 匯入 {count} 個地點', 'places.gpxError': 'GPX 匯入失敗', 'places.importGoogleList': 'Google 列表', From 2cc79b3d1608c8ce4574670a5233b604fdcfd9e5 Mon Sep 17 00:00:00 2001 From: Yannis Biasutti Date: Mon, 6 Apr 2026 19:13:54 +0200 Subject: [PATCH 004/170] feat(client): refine KMZ/KML import dialog and localize all locales --- .../src/components/Planner/PlacesSidebar.tsx | 48 ++++++++++++------- client/src/i18n/translations/ar.ts | 21 ++++---- client/src/i18n/translations/br.ts | 21 ++++---- client/src/i18n/translations/cs.ts | 21 ++++---- client/src/i18n/translations/de.ts | 21 ++++---- client/src/i18n/translations/en.ts | 5 +- client/src/i18n/translations/es.ts | 21 ++++---- client/src/i18n/translations/fr.ts | 21 ++++---- client/src/i18n/translations/hu.ts | 21 ++++---- client/src/i18n/translations/it.ts | 21 ++++---- client/src/i18n/translations/nl.ts | 21 ++++---- client/src/i18n/translations/pl.ts | 21 ++++---- client/src/i18n/translations/ru.ts | 21 ++++---- client/src/i18n/translations/zh.ts | 21 ++++---- client/src/i18n/translations/zhTw.ts | 21 ++++---- 15 files changed, 177 insertions(+), 149 deletions(-) diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 40ece73e..a7595940 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -48,6 +48,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const toast = useToast() const ctxMenu = useContextMenu() const gpxInputRef = useRef(null) + const kmlKmzInputRef = useRef(null) const trip = useTripStore((s) => s.trip) const loadTrip = useTripStore((s) => s.loadTrip) const can = useCanDo() @@ -602,43 +603,52 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
{t('places.importKmlKmz')}
-
+
{t('places.kmlKmzHint')}
-
- {t('places.kmlKmzSizeHint', { maxMb: 10 })} -
{ const file = e.target.files?.[0] || null setKmlKmzFile(file) setKmlKmzSummary(null) setKmlKmzError('') }} - style={{ - width: '100%', padding: '8px 10px', borderRadius: 10, - border: '1px solid var(--border-primary)', background: 'var(--bg-tertiary)', - fontSize: 12, color: 'var(--text-primary)', boxSizing: 'border-box', marginBottom: 12, - }} /> - {kmlKmzFile && ( -
- {t('places.kmlKmzSelectedFile', { name: kmlKmzFile.name })} -
- )} + {kmlKmzSummary && (
-
- {t('places.kmlKmzSummaryTitle')} -
{t('places.kmlKmzSummaryValues', { total: kmlKmzSummary.totalPlacemarks, @@ -664,6 +674,10 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
)} +
+ {t('places.kmlKmzSizeHint', { maxMb: 10 })} +
+
, document.body )} - {kmlKmzOpen && ReactDOM.createPortal( + {keyholeMarkupFileOpen && ReactDOM.createPortal(
{ setKmlKmzOpen(false); resetKmlKmzDialog() }} + onClick={() => { setKeyholeMarkupFileOpen(false); resetKeyholeMarkupFileDialog() }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} >
{ const file = e.target.files?.[0] || null - setKmlKmzFile(file) - setKmlKmzSummary(null) - setKmlKmzError('') + setKeyholeMarkupFileFile(file) + setKeyholeMarkupFileSummary(null) + setKeyholeMarkupFileError('') }} /> - {kmlKmzSummary && ( + {keyholeMarkupFileSummary && (
{t('places.kmlKmzSummaryValues', { - total: kmlKmzSummary.totalPlacemarks, - created: kmlKmzSummary.createdCount, - skipped: kmlKmzSummary.skippedCount, + total: keyholeMarkupFileSummary.totalPlacemarks, + created: keyholeMarkupFileSummary.createdCount, + skipped: keyholeMarkupFileSummary.skippedCount, })}
- {kmlKmzSummary.warnings?.length > 0 && ( + {keyholeMarkupFileSummary.warnings?.length > 0 && (
- {kmlKmzSummary.warnings.join('\n')} + {keyholeMarkupFileSummary.warnings.join('\n')}
)}
)} - {kmlKmzError && ( + {keyholeMarkupFileError && (
- {kmlKmzError} + {keyholeMarkupFileError}
)} @@ -678,7 +676,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
From 81851d836767a7e67371a5ca2db9a88e6e013ad1 Mon Sep 17 00:00:00 2001 From: Yannis Biasutti Date: Mon, 6 Apr 2026 22:26:22 +0200 Subject: [PATCH 008/170] refactor(i18n): rename importKmlKmz to importKeyholeMarkup across all locales --- .../src/components/Planner/PlacesSidebar.tsx | 6 +- client/src/i18n/translations/ar.ts | 13 +- client/src/i18n/translations/br.ts | 13 +- client/src/i18n/translations/cs.ts | 13 +- client/src/i18n/translations/de.ts | 13 +- client/src/i18n/translations/en.ts | 6 +- client/src/i18n/translations/es.ts | 13 +- client/src/i18n/translations/fr.ts | 13 +- client/src/i18n/translations/hu.ts | 13 +- client/src/i18n/translations/it.ts | 13 +- client/src/i18n/translations/nl.ts | 13 +- client/src/i18n/translations/pl.ts | 15 +- client/src/i18n/translations/ru.ts | 13 +- client/src/i18n/translations/zh.ts | 13 +- client/src/i18n/translations/zhTw.ts | 13 +- server/src/services/kmlImport.ts | 175 ++++++++++++++++++ 16 files changed, 273 insertions(+), 85 deletions(-) create mode 100644 server/src/services/kmlImport.ts diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 37853194..4e495134 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -121,7 +121,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ if (result.places?.length > 0) { const importedIds: number[] = result.places.map((p: { id: number }) => p.id) - pushUndo?.(t('undo.importKmlKmz'), async () => { + pushUndo?.(t('undo.importKeyholeMarkup'), async () => { for (const id of importedIds) { try { await placesApi.delete(tripId, id) } catch {} } @@ -239,7 +239,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ cursor: 'pointer', fontFamily: 'inherit', }} > - {t('places.importKmlKmz')} + {t('places.importKeyholeMarkup')}
} @@ -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 cb8280249f7a2deeadb33a54131ce1215bb61759 Mon Sep 17 00:00:00 2001 From: Kessler Dev Date: Wed, 8 Apr 2026 12:45:16 +0200 Subject: [PATCH 010/170] chore(chart): use appVersion as default image tag --- chart/Chart.yaml | 2 +- chart/templates/deployment.yaml | 2 +- chart/values.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/chart/Chart.yaml b/chart/Chart.yaml index 886ba48f..28fcae00 100644 --- a/chart/Chart.yaml +++ b/chart/Chart.yaml @@ -2,4 +2,4 @@ apiVersion: v2 name: trek version: 0.1.0 description: Minimal Helm chart for TREK app -appVersion: "latest" +appVersion: "2.9.11" diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml index 0ab074ba..d79ae344 100644 --- a/chart/templates/deployment.yaml +++ b/chart/templates/deployment.yaml @@ -27,7 +27,7 @@ spec: fsGroup: 1000 containers: - name: trek - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} {{- with .Values.resources }} resources: diff --git a/chart/values.yaml b/chart/values.yaml index 47a941c7..464e6d8c 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -1,7 +1,7 @@ image: repository: mauriceboe/trek - tag: latest + # tag: latest pullPolicy: IfNotPresent # Optional image pull secrets for private registries From dba655d6e8bfc54a50bfb4ff5a8b99dbf26f1889 Mon Sep 17 00:00:00 2001 From: Kessler Dev Date: Wed, 8 Apr 2026 13:01:14 +0200 Subject: [PATCH 011/170] chore: implement helm chart release automation to gh-pages --- .github/workflows/docker.yml | 6 +++-- .github/workflows/helm-release.yml | 25 +++++++++++++++++++ {chart => charts}/README.md | 0 {chart => charts/trek}/Chart.yaml | 0 {chart => charts/trek}/templates/NOTES.txt | 0 {chart => charts/trek}/templates/_helpers.tpl | 0 .../trek}/templates/configmap.yaml | 0 .../trek}/templates/deployment.yaml | 0 {chart => charts/trek}/templates/ingress.yaml | 0 {chart => charts/trek}/templates/pvc.yaml | 0 {chart => charts/trek}/templates/secret.yaml | 0 {chart => charts/trek}/templates/service.yaml | 0 {chart => charts/trek}/values.yaml | 0 13 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/helm-release.yml rename {chart => charts}/README.md (100%) rename {chart => charts/trek}/Chart.yaml (100%) rename {chart => charts/trek}/templates/NOTES.txt (100%) rename {chart => charts/trek}/templates/_helpers.tpl (100%) rename {chart => charts/trek}/templates/configmap.yaml (100%) rename {chart => charts/trek}/templates/deployment.yaml (100%) rename {chart => charts/trek}/templates/ingress.yaml (100%) rename {chart => charts/trek}/templates/pvc.yaml (100%) rename {chart => charts/trek}/templates/secret.yaml (100%) rename {chart => charts/trek}/templates/service.yaml (100%) rename {chart => charts/trek}/values.yaml (100%) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0a7c8f38..40af54fc 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -54,14 +54,16 @@ jobs: echo "VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT echo "$CURRENT → $NEW_VERSION ($BUMP)" - # Update both package.json files + # Update package.json files and Helm chart cd server && npm version "$NEW_VERSION" --no-git-tag-version && cd .. cd client && npm version "$NEW_VERSION" --no-git-tag-version && cd .. + sed -i "s/^version: .*/version: $NEW_VERSION/" charts/trek/Chart.yaml + sed -i "s/^appVersion: .*/appVersion: \"$NEW_VERSION\"/" charts/trek/Chart.yaml # Commit and tag git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add server/package.json server/package-lock.json client/package.json client/package-lock.json + git add server/package.json server/package-lock.json client/package.json client/package-lock.json charts/trek/Chart.yaml git commit -m "chore: bump version to $NEW_VERSION [skip ci]" git tag "v$NEW_VERSION" git push origin main --follow-tags diff --git a/.github/workflows/helm-release.yml b/.github/workflows/helm-release.yml new file mode 100644 index 00000000..d3f93c86 --- /dev/null +++ b/.github/workflows/helm-release.yml @@ -0,0 +1,25 @@ +name: Release Helm Chart + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Helm Chart Releaser + uses: helm/chart-releaser-action@v1.7.0 + with: + config: | + pages_branch: gh-pages + charts_dir: charts diff --git a/chart/README.md b/charts/README.md similarity index 100% rename from chart/README.md rename to charts/README.md diff --git a/chart/Chart.yaml b/charts/trek/Chart.yaml similarity index 100% rename from chart/Chart.yaml rename to charts/trek/Chart.yaml diff --git a/chart/templates/NOTES.txt b/charts/trek/templates/NOTES.txt similarity index 100% rename from chart/templates/NOTES.txt rename to charts/trek/templates/NOTES.txt diff --git a/chart/templates/_helpers.tpl b/charts/trek/templates/_helpers.tpl similarity index 100% rename from chart/templates/_helpers.tpl rename to charts/trek/templates/_helpers.tpl diff --git a/chart/templates/configmap.yaml b/charts/trek/templates/configmap.yaml similarity index 100% rename from chart/templates/configmap.yaml rename to charts/trek/templates/configmap.yaml diff --git a/chart/templates/deployment.yaml b/charts/trek/templates/deployment.yaml similarity index 100% rename from chart/templates/deployment.yaml rename to charts/trek/templates/deployment.yaml diff --git a/chart/templates/ingress.yaml b/charts/trek/templates/ingress.yaml similarity index 100% rename from chart/templates/ingress.yaml rename to charts/trek/templates/ingress.yaml diff --git a/chart/templates/pvc.yaml b/charts/trek/templates/pvc.yaml similarity index 100% rename from chart/templates/pvc.yaml rename to charts/trek/templates/pvc.yaml diff --git a/chart/templates/secret.yaml b/charts/trek/templates/secret.yaml similarity index 100% rename from chart/templates/secret.yaml rename to charts/trek/templates/secret.yaml diff --git a/chart/templates/service.yaml b/charts/trek/templates/service.yaml similarity index 100% rename from chart/templates/service.yaml rename to charts/trek/templates/service.yaml diff --git a/chart/values.yaml b/charts/trek/values.yaml similarity index 100% rename from chart/values.yaml rename to charts/trek/values.yaml From 8c7d1f8fa69bbc01f8618e11a008d1ad2b8c329e Mon Sep 17 00:00:00 2001 From: Kessler Dev Date: Wed, 8 Apr 2026 13:28:22 +0200 Subject: [PATCH 012/170] chore: use helm-publisher action for chart release --- .github/workflows/docker.yml | 15 +++++++++++++++ .github/workflows/helm-release.yml | 25 ------------------------- 2 files changed, 15 insertions(+), 25 deletions(-) delete mode 100644 .github/workflows/helm-release.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 40af54fc..9cc0e762 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -153,3 +153,18 @@ jobs: - name: Inspect manifest run: docker buildx imagetools inspect mauriceboe/trek:latest + + release-helm: + runs-on: ubuntu-latest + needs: version-bump + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: main + + - name: Publish Helm chart + uses: stefanprodan/helm-gh-pages@v1.7.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + charts_dir: charts diff --git a/.github/workflows/helm-release.yml b/.github/workflows/helm-release.yml deleted file mode 100644 index d3f93c86..00000000 --- a/.github/workflows/helm-release.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Release Helm Chart - -on: - push: - tags: - - 'v*' - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Helm Chart Releaser - uses: helm/chart-releaser-action@v1.7.0 - with: - config: | - pages_branch: gh-pages - charts_dir: charts From 830f6c070624a2749dc369af4774209c67b11cfc Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 9 Apr 2026 22:25:58 +0200 Subject: [PATCH 013/170] feat(mcp): introduce OAuth 2.1 auth and enforce addon gating OAuth 2.1 authentication for MCP: - Add OAuth 2.1 authorization server with PKCE support (routes/oauth.ts) - Add OAuth service for client CRUD, auth-code flow, and token management (services/oauthService.ts) - Add typed scope definitions and enforcement helpers (mcp/scopes.ts) - Add OAuth consent UI page (OAuthAuthorizePage.tsx) - Add client-side scope labels and descriptions (api/oauthScopes.ts) - Integrate OAuth token auth into MCP handler alongside existing static tokens - All OAuth endpoints gated on `mcp` addon Addon gating across MCP tools, resources, and prompts: - Add typed ADDON_IDS constant (server/src/addons.ts) replacing all string literals - Gate budget tools and resources (trip-budget, per-person, settlement) on `budget` addon - Gate packing tools and resources (trip-packing, trip-packing-bags, trip-todos) on `packing` addon - Gate todos tools on `packing` addon (mirrors web UI Lists tab behavior) - Expand atlas gate to cover full tool body (bucket-list + country tools no longer leak) - Expand collab gate to cover full tool body (collab notes no longer leak) - Gate packing-list and budget-overview MCP prompts on their respective addons - Gate get_trip_summary sections per addon; blank packing/budget/collab_notes/todos when disabled - Remove trip-files resource and files field from get_trip_summary - Replace all isAddonEnabled('literal') calls with ADDON_IDS constants Co-Authored-By: Claude Sonnet 4.6 --- client/package-lock.json | 860 +++++++++--------- client/src/App.tsx | 3 + client/src/api/client.ts | 37 + client/src/api/oauthScopes.ts | 43 + .../components/Settings/IntegrationsTab.tsx | 582 +++++++++++- client/src/i18n/translations/en.ts | 41 + client/src/pages/OAuthAuthorizePage.tsx | 248 +++++ server/package-lock.json | 24 +- server/src/addons.ts | 11 + server/src/app.ts | 6 + server/src/db/migrations.ts | 42 + server/src/mcp/index.ts | 50 +- server/src/mcp/resources.ts | 39 +- server/src/mcp/scopes.ts | 89 ++ server/src/mcp/tools.ts | 32 +- server/src/mcp/tools/assignments.ts | 20 +- server/src/mcp/tools/atlas.ts | 31 +- server/src/mcp/tools/budget.ts | 19 +- server/src/mcp/tools/collab.ts | 37 +- server/src/mcp/tools/days.ts | 5 +- server/src/mcp/tools/mapsWeather.ts | 5 +- server/src/mcp/tools/notifications.ts | 16 +- server/src/mcp/tools/packing.ts | 40 +- server/src/mcp/tools/places.ts | 18 +- server/src/mcp/tools/prompts.ts | 28 +- server/src/mcp/tools/reservations.ts | 5 +- server/src/mcp/tools/tags.ts | 14 +- server/src/mcp/tools/todos.ts | 26 +- server/src/mcp/tools/trips.ts | 67 +- server/src/mcp/tools/vacay.ts | 53 +- server/src/routes/oauth.ts | 296 ++++++ server/src/services/oauthService.ts | 471 ++++++++++ 32 files changed, 2589 insertions(+), 669 deletions(-) create mode 100644 client/src/api/oauthScopes.ts create mode 100644 client/src/pages/OAuthAuthorizePage.tsx create mode 100644 server/src/addons.ts create mode 100644 server/src/mcp/scopes.ts create mode 100644 server/src/routes/oauth.ts create mode 100644 server/src/services/oauthService.ts diff --git a/client/package-lock.json b/client/package-lock.json index f1ac488c..78ad94bf 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1877,6 +1877,397 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", @@ -5373,6 +5764,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -11133,436 +11563,6 @@ } } }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, "node_modules/vitest": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", diff --git a/client/src/App.tsx b/client/src/App.tsx index 0ca00b63..06640508 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -12,6 +12,7 @@ import VacayPage from './pages/VacayPage' import AtlasPage from './pages/AtlasPage' import SharedTripPage from './pages/SharedTripPage' import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' +import OAuthAuthorizePage from './pages/OAuthAuthorizePage' import { ToastContainer } from './components/shared/Toast' import { TranslationProvider, useTranslation } from './i18n' import { authApi } from './api/client' @@ -163,6 +164,8 @@ export default function App() { } /> } /> } /> + {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */} + } /> apiClient.get('/oauth/authorize/validate', { params }).then(r => r.data), + + /** Submit user consent (approve or deny) */ + authorize: (body: { + client_id: string + redirect_uri: string + scope: string + state?: string + code_challenge: string + code_challenge_method: string + approved: boolean + }) => apiClient.post('/oauth/authorize', body).then(r => r.data), + + clients: { + list: () => apiClient.get('/oauth/clients').then(r => r.data), + create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) => + apiClient.post('/oauth/clients', data).then(r => r.data), + rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data), + delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data), + }, + + sessions: { + list: () => apiClient.get('/oauth/sessions').then(r => r.data), + revoke: (id: number) => apiClient.delete(`/oauth/sessions/${id}`).then(r => r.data), + }, +} + export const tripsApi = { list: (params?: Record) => apiClient.get('/trips', { params }).then(r => r.data), create: (data: Record) => apiClient.post('/trips', data).then(r => r.data), diff --git a/client/src/api/oauthScopes.ts b/client/src/api/oauthScopes.ts new file mode 100644 index 00000000..aacd992a --- /dev/null +++ b/client/src/api/oauthScopes.ts @@ -0,0 +1,43 @@ +// Human-readable scope definitions for the OAuth consent page. +// Must stay in sync with server/src/mcp/scopes.ts + +export interface ScopeInfo { + label: string + description: string + group: string +} + +export const SCOPE_GROUPS: Record = { + 'trips:read': { label: 'View trips & itineraries', description: 'Read trips, days, day notes, members, and share links', group: 'Trips' }, + 'trips:write': { label: 'Edit trips & itineraries', description: 'Create and update trips, days, notes, and manage members', group: 'Trips' }, + 'trips:delete': { label: 'Delete trips', description: 'Permanently delete entire trips — this action is irreversible', group: 'Trips' }, + 'places:read': { label: 'View places & map data', description: 'Read places, day assignments, tags, categories, and visited countries', group: 'Places' }, + 'places:write': { label: 'Manage places', description: 'Create, update, and delete places, assignments, tags, and atlas entries', group: 'Places' }, + 'packing:read': { label: 'View packing lists', description: 'Read packing items, bags, and category assignees', group: 'Packing' }, + 'packing:write': { label: 'Manage packing lists', description: 'Add, update, delete, toggle, and reorder packing items and bags', group: 'Packing' }, + 'budget:read': { label: 'View budget', description: 'Read budget items and expense breakdown', group: 'Budget' }, + 'budget:write': { label: 'Manage budget', description: 'Create, update, and delete budget items', group: 'Budget' }, + 'reservations:read': { label: 'View reservations', description: 'Read reservations and accommodation details', group: 'Reservations' }, + 'reservations:write': { label: 'Manage reservations', description: 'Create, update, delete, and reorder reservations', group: 'Reservations' }, + 'collab:read': { label: 'View collaboration', description: 'Read collab notes, polls, messages, and to-do items', group: 'Collaboration' }, + 'collab:write': { label: 'Manage collaboration', description: 'Create, update, and delete collab notes, todos, polls, and messages', group: 'Collaboration' }, + 'notifications:read': { label: 'View notifications', description: 'Read in-app notifications and unread counts', group: 'Notifications' }, + 'notifications:write': { label: 'Manage notifications', description: 'Mark notifications as read and respond to them', group: 'Notifications' }, + 'vacay:read': { label: 'View vacation plans', description: 'Read vacation planning data, entries, and stats', group: 'Vacation' }, + 'vacay:write': { label: 'Manage vacation plans', description: 'Create and manage vacation entries, holidays, and team plans', group: 'Vacation' }, + 'media:read': { label: 'Maps & weather data', description: 'Search locations, resolve map URLs, and fetch weather forecasts', group: 'Media' }, +} + +export const ALL_SCOPES = Object.keys(SCOPE_GROUPS) + +// Group all scopes for the client registration form +export const SCOPE_GROUP_NAMES = [...new Set(Object.values(SCOPE_GROUPS).map(s => s.group))] + +export function getScopesByGroup(): Record> { + const groups: Record> = {} + for (const [scope, info] of Object.entries(SCOPE_GROUPS)) { + if (!groups[info.group]) groups[info.group] = [] + groups[info.group].push({ scope, ...info }) + } + return groups +} diff --git a/client/src/components/Settings/IntegrationsTab.tsx b/client/src/components/Settings/IntegrationsTab.tsx index f56dbcdf..afd2c40c 100644 --- a/client/src/components/Settings/IntegrationsTab.tsx +++ b/client/src/components/Settings/IntegrationsTab.tsx @@ -2,11 +2,85 @@ import Section from './Section' import React, { useEffect, useState } from 'react' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' -import { Trash2, Copy, Terminal, Plus, Check } from 'lucide-react' -import { authApi } from '../../api/client' +import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ShieldCheck, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react' +import { authApi, oauthApi } from '../../api/client' import { useAddonStore } from '../../store/addonStore' import PhotoProvidersSection from './PhotoProvidersSection' +import { getScopesByGroup, ALL_SCOPES } from '../../api/oauthScopes' +interface OAuthPreset { + id: string + label: string + name: string + uris: string + scopes: string[] +} + +const OAUTH_PRESETS: OAuthPreset[] = [ + { + id: 'claude-web', + label: 'Claude.ai', + name: 'Claude.ai', + uris: 'https://claude.ai/api/mcp/auth_callback', + scopes: ALL_SCOPES.filter(s => !s.includes(':delete')), + }, + { + id: 'claude-desktop', + label: 'Claude Desktop', + name: 'Claude Desktop', + uris: 'http://localhost', + scopes: ALL_SCOPES.filter(s => !s.includes(':delete')), + }, + { + id: 'cursor', + label: 'Cursor', + name: 'Cursor', + uris: 'http://localhost', + scopes: ALL_SCOPES.filter(s => !s.includes(':delete')), + }, + { + id: 'vscode', + label: 'VS Code', + name: 'VS Code / Copilot', + uris: 'http://localhost', + scopes: ALL_SCOPES.filter(s => s.endsWith(':read')), + }, + { + id: 'windsurf', + label: 'Windsurf', + name: 'Windsurf', + uris: 'http://localhost', + scopes: ALL_SCOPES.filter(s => !s.includes(':delete')), + }, + { + id: 'zed', + label: 'Zed', + name: 'Zed', + uris: 'http://localhost', + scopes: ALL_SCOPES.filter(s => !s.includes(':delete')), + }, +] + + +interface OAuthClient { + id: string + name: string + client_id: string + redirect_uris: string[] + allowed_scopes: string[] + created_at: string + client_secret?: string // only present on create +} + +interface OAuthSession { + id: number + client_id: string + client_name: string + scopes: string[] + access_token_expires_at: string + refresh_token_expires_at: string + created_at: string +} interface McpToken { id: number @@ -26,6 +100,23 @@ export default function IntegrationsTab(): React.ReactElement { loadAddons() }, [loadAddons]) + // OAuth clients state + const [oauthClients, setOauthClients] = useState([]) + const [oauthSessions, setOauthSessions] = useState([]) + const [oauthCreateOpen, setOauthCreateOpen] = useState(false) + const [oauthNewName, setOauthNewName] = useState('') + const [oauthNewUris, setOauthNewUris] = useState('') + const [oauthNewScopes, setOauthNewScopes] = useState([]) + const [oauthCreating, setOauthCreating] = useState(false) + const [oauthCreatedClient, setOauthCreatedClient] = useState(null) + const [oauthDeleteId, setOauthDeleteId] = useState(null) + const [oauthRevokeId, setOauthRevokeId] = useState(null) + const [oauthRotateId, setOauthRotateId] = useState(null) + const [oauthRotatedSecret, setOauthRotatedSecret] = useState(null) + const [oauthRotating, setOauthRotating] = useState(false) + const [oauthScopesOpen, setOauthScopesOpen] = useState>({}) + const [oauthScopesExpanded, setOauthScopesExpanded] = useState>({}) + // MCP state const [mcpTokens, setMcpTokens] = useState([]) const [mcpModalOpen, setMcpModalOpen] = useState(false) @@ -89,6 +180,69 @@ export default function IntegrationsTab(): React.ReactElement { }) } + // Load OAuth clients and sessions + useEffect(() => { + if (mcpEnabled) { + oauthApi.clients.list().then(d => setOauthClients(d.clients || [])).catch(() => {}) + oauthApi.sessions.list().then(d => setOauthSessions(d.sessions || [])).catch(() => {}) + } + }, [mcpEnabled]) + + const handleCreateOAuthClient = async () => { + if (!oauthNewName.trim() || !oauthNewUris.trim()) return + setOauthCreating(true) + try { + const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean) + const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes }) + setOauthCreatedClient(d.client) + setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }]) + setOauthNewName('') + setOauthNewUris('') + setOauthNewScopes([]) + } catch { + toast.error(t('settings.oauth.toast.createError')) + } finally { + setOauthCreating(false) + } + } + + const handleDeleteOAuthClient = async (id: string) => { + try { + await oauthApi.clients.delete(id) + setOauthClients(prev => prev.filter(c => c.id !== id)) + setOauthDeleteId(null) + toast.success(t('settings.oauth.toast.deleted')) + } catch { + toast.error(t('settings.oauth.toast.deleteError')) + } + } + + const handleRotateSecret = async (id: string) => { + setOauthRotating(true) + try { + const d = await oauthApi.clients.rotate(id) + setOauthRotatedSecret(d.client_secret) + setOauthRotateId(null) + } catch { + toast.error(t('settings.oauth.toast.rotateError')) + } finally { + setOauthRotating(false) + } + } + + const handleRevokeSession = async (id: number) => { + try { + await oauthApi.sessions.revoke(id) + setOauthSessions(prev => prev.filter(s => s.id !== id)) + setOauthRevokeId(null) + toast.success(t('settings.oauth.toast.revoked')) + } catch { + toast.error(t('settings.oauth.toast.revokeError')) + } + } + + const scopesByGroup = getScopesByGroup() + return ( <> @@ -126,46 +280,146 @@ export default function IntegrationsTab(): React.ReactElement {

{t('settings.mcp.clientConfigHint')}

- {/* Token list */} + {/* OAuth Clients */}
-
- -
- {mcpTokens.length === 0 ? ( + {oauthClients.length === 0 ? (

- {t('settings.mcp.noTokens')} + {t('settings.oauth.noClients')}

) : (
- {mcpTokens.map((token, i) => ( -
-
-

{token.name}

-

- {token.token_prefix}... - {t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)} - {token.last_used_at && ( - · {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)} - )} -

+ {oauthClients.map((client, i) => ( +
+
+ +
+

{client.name}

+

+ {t('settings.oauth.clientId')}: {client.client_id} + {t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)} +

+
+ {(oauthScopesExpanded[client.id] ? client.allowed_scopes : client.allowed_scopes.slice(0, 5)).map(s => ( + {s} + ))} + {client.allowed_scopes.length > 5 && ( + + )} +
+
+ +
-
))}
)}
+ + {/* Token list — deprecated */} +
+
+
+ + + Deprecated + +
+ +
+
+ +

{t('settings.mcp.apiTokensDeprecated')}

+
+
+ {mcpTokens.length === 0 ? ( +

+ {t('settings.mcp.noTokens')} +

+ ) : ( +
+ {mcpTokens.map((token, i) => ( +
+
+

{token.name}

+

+ {token.token_prefix}... + {t('settings.mcp.tokenCreatedAt')} {new Date(token.created_at).toLocaleDateString(locale)} + {token.last_used_at && ( + · {t('settings.mcp.tokenUsedAt')} {new Date(token.last_used_at).toLocaleDateString(locale)} + )} +

+
+ +
+ ))} +
+ )} +
+
+ + {/* Active OAuth Sessions */} + {oauthSessions.length > 0 && ( +
+ +
+ {oauthSessions.map((session, i) => ( +
+
+

{session.client_name}

+

+ {t('settings.oauth.sessionScopes')}: {session.scopes.join(', ')} + {t('settings.oauth.sessionExpires')} {new Date(session.access_token_expires_at).toLocaleDateString(locale)} +

+
+ +
+ ))} +
+
+ )} )} @@ -248,6 +502,282 @@ export default function IntegrationsTab(): React.ReactElement {
)} + + {/* Create OAuth Client modal */} + {oauthCreateOpen && ( +
{ if (e.target === e.currentTarget && !oauthCreatedClient) setOauthCreateOpen(false) }}> +
+ {!oauthCreatedClient ? ( + <> +

{t('settings.oauth.modal.createTitle')}

+ +
+ +
+ {OAUTH_PRESETS.map(preset => ( + + ))} +
+
+ +
+ + setOauthNewName(e.target.value)} + placeholder={t('settings.oauth.modal.clientNamePlaceholder')} + className="w-full px-3 py-2.5 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300" + style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)' }} + autoFocus /> +
+ +
+ +