From 52714630645a688d8cd7ede10220f78ab593ce66 Mon Sep 17 00:00:00 2001 From: Yannis Biasutti Date: Mon, 6 Apr 2026 18:31:47 +0200 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 8/8] 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')}