From 52714630645a688d8cd7ede10220f78ab593ce66 Mon Sep 17 00:00:00 2001 From: Yannis Biasutti Date: Mon, 6 Apr 2026 18:31:47 +0200 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 08/10] 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')} + +
+
+
, + document.body + ) +} diff --git a/client/src/components/Planner/PlacesSidebar.test.tsx b/client/src/components/Planner/PlacesSidebar.test.tsx index 79b52c17..dc25a418 100644 --- a/client/src/components/Planner/PlacesSidebar.test.tsx +++ b/client/src/components/Planner/PlacesSidebar.test.tsx @@ -433,29 +433,29 @@ describe('Mobile day-picker (portal)', () => { // ── GPX import ──────────────────────────────────────────────────────────────── describe('GPX import', () => { - it('FE-PLANNER-SIDEBAR-038: GPX import button triggers file input click', async () => { + it('FE-PLANNER-SIDEBAR-038: "Import file" button opens the file import modal', async () => { const user = userEvent.setup(); render(); - const fileInput = document.querySelector('input[type="file"][accept=".gpx"]') as HTMLInputElement; - expect(fileInput).toBeTruthy(); - const clickSpy = vi.spyOn(fileInput, 'click'); - await user.click(screen.getByText(/GPX/i)); - expect(clickSpy).toHaveBeenCalled(); + await user.click(screen.getByText(/Import file/i)); + expect(await screen.findByText(/\.gpx.*\.kml.*\.kmz/i)).toBeInTheDocument(); }); - it('FE-PLANNER-SIDEBAR-039: successful GPX import shows success toast', async () => { - // FormData POST hangs on CI — mock at the API boundary instead of MSW. + it('FE-PLANNER-SIDEBAR-039: successful GPX import via modal shows success toast', async () => { const importSpy = vi.spyOn(placesApi, 'importGpx').mockResolvedValueOnce({ count: 2, places: [{ id: 10 }, { id: 11 }] }); const loadTrip = vi.fn().mockResolvedValue(undefined); seedStore(useTripStore, { loadTrip }); const addToast = vi.fn(); (window as any).__addToast = addToast; + const user = userEvent.setup(); render(); - const fileInput = document.querySelector('input[type="file"][accept=".gpx"]') as HTMLInputElement; + await user.click(screen.getByText(/Import file/i)); + const fileInput = document.querySelector('input[type="file"][accept=".gpx,.kml,.kmz"]') as HTMLInputElement; + expect(fileInput).toBeTruthy(); const file = new File(['track data'], 'route.gpx', { type: 'application/gpx+xml' }); await act(async () => { fireEvent.change(fileInput, { target: { files: [file] } }); }); + await user.click(screen.getByRole('button', { name: /^import$/i })); await waitFor(() => { expect(addToast).toHaveBeenCalledWith( expect.stringContaining('2'), diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 6f5e3fc3..79f27b10 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -1,6 +1,6 @@ import React from 'react' import ReactDOM from 'react-dom' -import { useState, useRef, useMemo, useCallback, useEffect } from 'react' +import { useState, useMemo, useEffect, useRef } from 'react' import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react' import PlaceAvatar from '../shared/PlaceAvatar' import { getCategoryIcon } from '../shared/categoryIcons' @@ -12,14 +12,7 @@ import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useAddonStore } from '../../store/addonStore' import type { Place, Category, Day, AssignmentsMap } from '../../types' - -interface PlacesImportSummary { - totalPlacemarks: number - createdCount: number - skippedCount: number - warnings: string[] - errors: string[] -} +import FileImportModal from './FileImportModal' interface PlacesSidebarProps { tripId: number @@ -47,35 +40,43 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const { t } = useTranslation() const toast = useToast() const ctxMenu = useContextMenu() - const gpxInputRef = useRef(null) - const keyholeMarkupFileInputRef = useRef(null) const trip = useTripStore((s) => s.trip) const loadTrip = useTripStore((s) => s.loadTrip) const can = useCanDo() const canEditPlaces = can('place_edit', trip) const isNaverListImportEnabled = useAddonStore((s) => s.isEnabled('naver_list_import')) - const importFileLimitBytes = 10 * 1024 * 1024 - const handleGpxImport = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - e.target.value = '' - try { - const result = await placesApi.importGpx(tripId, file) - await loadTrip(tripId) - toast.success(t('places.gpxImported', { count: result.count })) - if (result.places?.length > 0) { - const importedIds: number[] = result.places.map((p: { id: number }) => p.id) - pushUndo?.(t('undo.importGpx'), async () => { - for (const id of importedIds) { - try { await placesApi.delete(tripId, id) } catch {} - } - await loadTrip(tripId) - }) - } - } catch (err: any) { - toast.error(err?.response?.data?.error || t('places.gpxError')) - } + const [fileImportOpen, setFileImportOpen] = useState(false) + const [sidebarDropFile, setSidebarDropFile] = useState(null) + const [sidebarDragOver, setSidebarDragOver] = useState(false) + const sidebarDragCounter = useRef(0) + + const handleSidebarDragEnter = (e: React.DragEvent) => { + if (!canEditPlaces) return + e.preventDefault() + sidebarDragCounter.current++ + setSidebarDragOver(true) + } + + const handleSidebarDragOver = (e: React.DragEvent) => { + if (!canEditPlaces) return + e.preventDefault() + } + + const handleSidebarDragLeave = () => { + sidebarDragCounter.current-- + if (sidebarDragCounter.current === 0) setSidebarDragOver(false) + } + + const handleSidebarDrop = (e: React.DragEvent) => { + e.preventDefault() + sidebarDragCounter.current = 0 + setSidebarDragOver(false) + if (!canEditPlaces) return + const f = e.dataTransfer.files[0] + if (!f) return + setSidebarDropFile(f) + setFileImportOpen(true) } const [listImportOpen, setListImportOpen] = useState(false) @@ -84,68 +85,6 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google') const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google'] const hasMultipleListImportProviders = availableListImportProviders.length > 1 - const [keyholeMarkupFileOpen, setKeyholeMarkupFileOpen] = useState(false) - const [keyholeMarkupFileLoading, setKeyholeMarkupFileLoading] = useState(false) - const [keyholeMarkupFile, setKeyholeMarkupFileFile] = useState(null) - const [keyholeMarkupFileSummary, setKeyholeMarkupFileSummary] = useState(null) - const [keyholeMarkupFileError, setKeyholeMarkupFileError] = useState('') - - const resetKeyholeMarkupFileDialog = () => { - setKeyholeMarkupFileFile(null) - setKeyholeMarkupFileSummary(null) - setKeyholeMarkupFileError('') - setKeyholeMarkupFileLoading(false) - } - - const handleKeyholeMarkupFileImport = async () => { - if (!keyholeMarkupFile) return - - const ext = keyholeMarkupFile.name.toLowerCase().split('.').pop() - if (ext !== 'kml' && ext !== 'kmz') { - setKeyholeMarkupFileError(t('places.kmlKmzInvalidType')) - return - } - if (keyholeMarkupFile.size > importFileLimitBytes) { - setKeyholeMarkupFileError(t('places.kmlKmzTooLarge', { maxMb: 10 })) - return - } - - setKeyholeMarkupFileLoading(true) - setKeyholeMarkupFileError('') - setKeyholeMarkupFileSummary(null) - - try { - const result = await placesApi.importMapFile(tripId, keyholeMarkupFile) - - await loadTrip(tripId) - setKeyholeMarkupFileSummary(result.summary || null) - toast.success(t('places.kmlKmzImported', { count: result.count })) - - if (result.summary?.errors?.length > 0) { - setKeyholeMarkupFileError(result.summary.errors.join('\n')) - } - - if (result.places?.length > 0) { - const importedIds: number[] = result.places.map((p: { id: number }) => p.id) - pushUndo?.(t('undo.importKeyholeMarkup'), 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) { - setKeyholeMarkupFileSummary(responseSummary) - } - const message = err?.response?.data?.error || t('places.kmlKmzImportError') - setKeyholeMarkupFileError(message) - toast.error(message) - } finally { - setKeyholeMarkupFileLoading(false) - } - } useEffect(() => { if (!isNaverListImportEnabled && listImportProvider === 'naver') { @@ -162,7 +101,11 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ ? await placesApi.importGoogleList(tripId, listImportUrl.trim()) : await placesApi.importNaverList(tripId, listImportUrl.trim()) await loadTrip(tripId) - toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName })) + if (result.count === 0 && result.skipped > 0) { + toast.warning(t('places.importAllSkipped')) + } else { + toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName })) + } setListImportOpen(false) setListImportUrl('') if (result.places?.length > 0) { @@ -214,7 +157,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId) return ( -
+
+ {sidebarDragOver && ( +
+ + {t('places.sidebarDrop')} +
+ )} {/* Kopfbereich */}
{canEditPlaces && } {canEditPlaces && <> -
-
, document.body )} - {keyholeMarkupFileOpen && ReactDOM.createPortal( -
{ 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 }} - > -
e.stopPropagation()} - style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 520, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }} - > -
- {t('places.importKeyholeMarkup')} -
-
- {t('places.kmlKmzHint')} -
- - { - const file = e.target.files?.[0] || null - setKeyholeMarkupFileFile(file) - setKeyholeMarkupFileSummary(null) - setKeyholeMarkupFileError('') - }} - /> - - - - {keyholeMarkupFileSummary && ( -
-
- {t('places.kmlKmzSummaryValues', { - total: keyholeMarkupFileSummary.totalPlacemarks, - created: keyholeMarkupFileSummary.createdCount, - skipped: keyholeMarkupFileSummary.skippedCount, - })} -
- {keyholeMarkupFileSummary.warnings?.length > 0 && ( -
- {keyholeMarkupFileSummary.warnings.join('\n')} -
- )} -
- )} - - {keyholeMarkupFileError && ( -
- {keyholeMarkupFileError} -
- )} - -
- {t('places.kmlKmzSizeHint', { maxMb: 10 })} -
- -
- - -
-
-
, - document.body - )} + { setFileImportOpen(false); setSidebarDropFile(null) }} + tripId={tripId} + pushUndo={pushUndo} + initialFile={sidebarDropFile} + />
) diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index b766e2b5..92c23eec 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -892,21 +892,19 @@ const ar: Record = { // Places Sidebar 'places.addPlace': 'إضافة مكان/نشاط', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'استيراد ملف', + 'places.sidebarDrop': 'أفلت للاستيراد', + 'places.importFileHint': 'استورد ملفات .gpx أو .kml أو .kmz من أدوات مثل Google My Maps وGoogle Earth أو جهاز تتبع GPS.', + 'places.importFileDropHere': 'انقر لاختيار ملف أو اسحبه وأفلته هنا', + 'places.importFileDropActive': 'أفلت الملف للاختيار', + 'places.importFileUnsupported': 'نوع الملف غير مدعوم. استخدم .gpx أو .kml أو .kmz.', + 'places.importFileTooLarge': 'الملف كبير جدًا. الحد الأقصى لحجم الرفع هو {maxMb} MB.', + 'places.importFileError': 'فشل الاستيراد', + 'places.importAllSkipped': 'جميع الأماكن موجودة بالفعل في الرحلة.', 'places.gpxImported': 'تم استيراد {count} مكان من GPX', 'places.kmlKmzImported': 'تم استيراد {count} مكان من KMZ/KML', 'places.urlResolved': 'تم استيراد المكان من الرابط', - 'places.gpxError': 'فشل استيراد GPX', 'places.importList': 'استيراد قائمة', - 'places.kmlKmzImportError': 'فشل استيراد KMZ/KML', - 'places.kmlKmzInvalidType': 'يرجى اختيار ملف .kml أو .kmz.', - 'places.kmlKmzTooLarge': 'الملف كبير جدًا. الحد الأقصى لحجم الرفع هو {maxMb} MB.', - 'places.kmlKmzHint': 'استورد ملفات الخرائط من أدوات مثل Google My Maps وGoogle Earth.', - 'places.kmlKmzSizeHint': 'الحد الأقصى لحجم الملف: {maxMb} MB', - 'places.kmlKmzSelectFile': 'اختيار ملف', - 'places.kmlKmzSelectedFile': 'الملف المحدد: {name}', - 'places.kmlKmzSummaryTitle': 'ملخص الاستيراد', 'places.kmlKmzSummaryValues': 'علامات المواضع: {total} • تم الاستيراد: {created} • تم التجاوز: {skipped}', 'places.importGoogleList': 'قائمة Google', 'places.importNaverList': 'قائمة Naver', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 0dbb37e9..346bafc9 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -862,21 +862,19 @@ const br: Record = { // Places Sidebar 'places.addPlace': 'Adicionar lugar/atividade', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Importar arquivo', + 'places.sidebarDrop': 'Solte para importar', + 'places.importFileHint': 'Importe arquivos .gpx, .kml ou .kmz de ferramentas como Google My Maps, Google Earth ou um rastreador GPS.', + 'places.importFileDropHere': 'Clique para selecionar um arquivo ou arraste e solte aqui', + 'places.importFileDropActive': 'Solte o arquivo para selecionar', + 'places.importFileUnsupported': 'Tipo de arquivo não suportado. Use .gpx, .kml ou .kmz.', + 'places.importFileTooLarge': 'O arquivo é muito grande. O tamanho máximo de upload é {maxMb} MB.', + 'places.importFileError': 'Importação falhou', + 'places.importAllSkipped': 'Todos os lugares já estavam na viagem.', 'places.gpxImported': '{count} lugares importados do GPX', 'places.kmlKmzImported': '{count} lugares importados de KMZ/KML', 'places.urlResolved': 'Lugar importado da URL', - 'places.gpxError': 'Falha ao importar GPX', 'places.importList': 'Importar lista', - 'places.kmlKmzImportError': 'Falha na importação de KMZ/KML', - 'places.kmlKmzInvalidType': 'Selecione um arquivo .kml ou .kmz.', - 'places.kmlKmzTooLarge': 'O arquivo é muito grande. O tamanho máximo de upload é {maxMb} MB.', - 'places.kmlKmzHint': 'Importe arquivos de mapa de ferramentas como Google My Maps e Google Earth.', - 'places.kmlKmzSizeHint': 'Tamanho máximo do arquivo: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Selecionar arquivo', - 'places.kmlKmzSelectedFile': 'Arquivo selecionado: {name}', - 'places.kmlKmzSummaryTitle': 'Resumo da importação', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importados: {created} • Ignorados: {skipped}', 'places.importGoogleList': 'Lista Google', 'places.importNaverList': 'Lista Naver', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index e6caf7bd..283fb7e8 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -890,21 +890,19 @@ const cs: Record = { // Boční panel míst (Places Sidebar) 'places.addPlace': 'Přidat místo/aktivitu', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Importovat soubor', + 'places.sidebarDrop': 'Pusťte pro import', + 'places.importFileHint': 'Importujte soubory .gpx, .kml nebo .kmz z nástrojů jako Google My Maps, Google Earth nebo GPS tracker.', + 'places.importFileDropHere': 'Klikněte pro výběr souboru nebo jej přetáhněte sem', + 'places.importFileDropActive': 'Přetáhněte soubor pro výběr', + 'places.importFileUnsupported': 'Nepodporovaný typ souboru. Použijte .gpx, .kml nebo .kmz.', + 'places.importFileTooLarge': 'Soubor je příliš velký. Maximální velikost nahrání je {maxMb} MB.', + 'places.importFileError': 'Import se nezdařil', + 'places.importAllSkipped': 'Všechna místa již byla v cestě.', 'places.gpxImported': '{count} míst importováno z GPX', 'places.kmlKmzImported': 'Importováno {count} míst z KMZ/KML', 'places.urlResolved': 'Místo importováno z URL', - 'places.gpxError': 'Import GPX se nezdařil', 'places.importList': 'Import seznamu', - 'places.kmlKmzImportError': 'Import KMZ/KML selhal', - 'places.kmlKmzInvalidType': 'Vyberte soubor .kml nebo .kmz.', - 'places.kmlKmzTooLarge': 'Soubor je příliš velký. Maximální velikost nahrání je {maxMb} MB.', - 'places.kmlKmzHint': 'Importujte mapové soubory z nástrojů jako Google My Maps a Google Earth.', - 'places.kmlKmzSizeHint': 'Maximální velikost souboru: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Vybrat soubor', - 'places.kmlKmzSelectedFile': 'Vybraný soubor: {name}', - 'places.kmlKmzSummaryTitle': 'Souhrn importu', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importováno: {created} • Přeskočeno: {skipped}', 'places.importGoogleList': 'Google Seznam', 'places.importNaverList': 'Naver Seznam', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index aada158a..2c9e54ee 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -893,21 +893,19 @@ const de: Record = { // Places Sidebar 'places.addPlace': 'Ort/Aktivität hinzufügen', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Datei importieren', + 'places.sidebarDrop': 'Ablegen zum Importieren', + 'places.importFileHint': '.gpx-, .kml- oder .kmz-Dateien aus Tools wie Google My Maps, Google Earth oder einem GPS-Tracker importieren.', + 'places.importFileDropHere': 'Datei auswählen oder hierher ziehen und ablegen', + 'places.importFileDropActive': 'Datei ablegen zum Auswählen', + 'places.importFileUnsupported': 'Nicht unterstützter Dateityp. Verwende .gpx, .kml oder .kmz.', + 'places.importFileTooLarge': 'Datei ist zu groß. Maximale Upload-Größe ist {maxMb} MB.', + 'places.importFileError': 'Import fehlgeschlagen', + 'places.importAllSkipped': 'Alle Orte waren bereits in der Reise.', 'places.gpxImported': '{count} Orte aus GPX importiert', 'places.kmlKmzImported': '{count} Orte aus KMZ/KML importiert', 'places.urlResolved': 'Ort aus URL importiert', - 'places.gpxError': 'GPX-Import fehlgeschlagen', 'places.importList': 'Listenimport', - 'places.kmlKmzImportError': 'KMZ/KML-Import fehlgeschlagen', - 'places.kmlKmzInvalidType': 'Bitte eine .kml- oder .kmz-Datei auswählen.', - 'places.kmlKmzTooLarge': 'Datei ist zu groß. Maximale Upload-Größe ist {maxMb} MB.', - 'places.kmlKmzHint': 'Importiere Kartendateien aus Tools wie Google My Maps und Google Earth.', - 'places.kmlKmzSizeHint': 'Max. Dateigröße: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Datei auswählen', - 'places.kmlKmzSelectedFile': 'Ausgewählte Datei: {name}', - 'places.kmlKmzSummaryTitle': 'Importzusammenfassung', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importiert: {created} • Übersprungen: {skipped}', 'places.importGoogleList': 'Google Liste', 'places.importNaverList': 'Naver Liste', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index d9f3ce5a..056e3773 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -915,21 +915,19 @@ const en: Record = { // Places Sidebar 'places.addPlace': 'Add Place/Activity', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Import file', + 'places.sidebarDrop': 'Drop to import', + 'places.importFileHint': 'Import .gpx, .kml or .kmz files from tools like Google My Maps, Google Earth, or a GPS tracker.', + 'places.importFileDropHere': 'Click to select a file or drag and drop here', + 'places.importFileDropActive': 'Drop file to select', + 'places.importFileUnsupported': 'Unsupported file type. Use .gpx, .kml or .kmz.', + 'places.importFileTooLarge': 'File is too large. Maximum upload size is {maxMb} MB.', + 'places.importFileError': 'Import failed', + 'places.importAllSkipped': 'All places were already in the trip.', '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.importList': 'List Import', - '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': 'Import map files from tools like Google My Maps and Google Earth.', - 'places.kmlKmzSizeHint': 'Max file size: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Select File', - 'places.kmlKmzSelectedFile': 'Selected file: {name}', - 'places.kmlKmzSummaryTitle': 'Import summary', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Imported: {created} • Skipped: {skipped}', 'places.importGoogleList': 'Google List', 'places.importNaverList': 'Naver List', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 971093fd..1a64e5e2 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -865,21 +865,19 @@ const es: Record = { // Places Sidebar 'places.addPlace': 'Añadir lugar/actividad', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Importar archivo', + 'places.sidebarDrop': 'Soltar para importar', + 'places.importFileHint': 'Importa archivos .gpx, .kml o .kmz de herramientas como Google My Maps, Google Earth o un rastreador GPS.', + 'places.importFileDropHere': 'Haz clic para seleccionar un archivo o arrástralo aquí', + 'places.importFileDropActive': 'Suelta el archivo para seleccionarlo', + 'places.importFileUnsupported': 'Tipo de archivo no compatible. Usa .gpx, .kml o .kmz.', + 'places.importFileTooLarge': 'El archivo es demasiado grande. El tamaño máximo de carga es {maxMb} MB.', + 'places.importFileError': 'Importación fallida', + 'places.importAllSkipped': 'Todos los lugares ya estaban en el viaje.', 'places.gpxImported': '{count} lugares importados desde GPX', 'places.kmlKmzImported': '{count} lugares importados desde KMZ/KML', 'places.urlResolved': 'Lugar importado desde URL', - 'places.gpxError': 'Error al importar GPX', 'places.importList': 'Importar lista', - 'places.kmlKmzImportError': 'La importación KMZ/KML falló', - 'places.kmlKmzInvalidType': 'Selecciona un archivo .kml o .kmz.', - 'places.kmlKmzTooLarge': 'El archivo es demasiado grande. El tamaño máximo de carga es {maxMb} MB.', - 'places.kmlKmzHint': 'Importa archivos de mapa desde herramientas como Google My Maps y Google Earth.', - 'places.kmlKmzSizeHint': 'Tamaño máximo de archivo: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Seleccionar archivo', - 'places.kmlKmzSelectedFile': 'Archivo seleccionado: {name}', - 'places.kmlKmzSummaryTitle': 'Resumen de importación', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importados: {created} • Omitidos: {skipped}', 'places.importGoogleList': 'Lista Google', 'places.importNaverList': 'Lista Naver', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 5e5d2aa6..11ce8012 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -889,21 +889,19 @@ const fr: Record = { // Places Sidebar 'places.addPlace': 'Ajouter un lieu/activité', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Importer un fichier', + 'places.sidebarDrop': 'Déposer pour importer', + 'places.importFileHint': 'Importez des fichiers .gpx, .kml ou .kmz depuis des outils comme Google My Maps, Google Earth ou un traceur GPS.', + 'places.importFileDropHere': 'Cliquez pour sélectionner un fichier ou glissez-déposez ici', + 'places.importFileDropActive': 'Déposez le fichier pour le sélectionner', + 'places.importFileUnsupported': 'Type de fichier non pris en charge. Utilisez .gpx, .kml ou .kmz.', + 'places.importFileTooLarge': 'Le fichier est trop volumineux. La taille maximale est de {maxMb} MB.', + 'places.importFileError': 'Importation échouée', + 'places.importAllSkipped': 'Tous les lieux étaient déjà dans le voyage.', 'places.gpxImported': '{count} lieux importés depuis GPX', 'places.kmlKmzImported': '{count} lieux importés depuis KMZ/KML', 'places.urlResolved': 'Lieu importé depuis l\'URL', - 'places.gpxError': 'L\'import GPX a échoué', 'places.importList': 'Import de liste', - 'places.kmlKmzImportError': 'L\'import KMZ/KML a échoué', - 'places.kmlKmzInvalidType': 'Veuillez sélectionner un fichier .kml ou .kmz.', - 'places.kmlKmzTooLarge': 'Le fichier est trop volumineux. La taille maximale est de {maxMb} MB.', - 'places.kmlKmzHint': 'Importez des fichiers de carte depuis des outils comme Google My Maps et Google Earth.', - 'places.kmlKmzSizeHint': 'Taille maximale du fichier : {maxMb} MB', - 'places.kmlKmzSelectFile': 'Sélectionner un fichier', - 'places.kmlKmzSelectedFile': 'Fichier sélectionné : {name}', - 'places.kmlKmzSummaryTitle': 'Résumé d\'import', 'places.kmlKmzSummaryValues': 'Placemarks : {total} • Importés : {created} • Ignorés : {skipped}', 'places.importGoogleList': 'Liste Google', 'places.importNaverList': 'Liste Naver', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 539509a7..2d64ae63 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -890,21 +890,19 @@ const hu: Record = { // Helyek oldalsáv 'places.addPlace': 'Hely/Tevékenység hozzáadása', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Fájl importálása', + 'places.sidebarDrop': 'Ejtse el az importáláshoz', + 'places.importFileHint': '.gpx, .kml vagy .kmz fájlok importálása olyan eszközökből, mint a Google My Maps, Google Earth vagy egy GPS tracker.', + 'places.importFileDropHere': 'Kattintson egy fájl kiválasztásához, vagy húzza ide', + 'places.importFileDropActive': 'Ejtse ide a fájlt a kiválasztáshoz', + 'places.importFileUnsupported': 'Nem támogatott fájltípus. Használjon .gpx, .kml vagy .kmz fájlt.', + 'places.importFileTooLarge': 'A fájl túl nagy. A maximális feltöltési méret {maxMb} MB.', + 'places.importFileError': 'Importálás sikertelen', + 'places.importAllSkipped': 'Minden hely már szerepel az utazásban.', 'places.gpxImported': '{count} hely importálva GPX-ből', 'places.kmlKmzImported': '{count} hely importálva KMZ/KML-ből', 'places.urlResolved': 'Hely importálva URL-ből', - 'places.gpxError': 'GPX importálás sikertelen', 'places.importList': 'Lista importálás', - 'places.kmlKmzImportError': 'A KMZ/KML importálás sikertelen', - 'places.kmlKmzInvalidType': 'Válassz egy .kml vagy .kmz fájlt.', - 'places.kmlKmzTooLarge': 'A fájl túl nagy. A maximális feltöltési méret {maxMb} MB.', - 'places.kmlKmzHint': 'Térképfájlok importálása olyan eszközökből, mint a Google My Maps és a Google Earth.', - 'places.kmlKmzSizeHint': 'Maximális fájlméret: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Fájl kiválasztása', - 'places.kmlKmzSelectedFile': 'Kiválasztott fájl: {name}', - 'places.kmlKmzSummaryTitle': 'Import összegzés', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importálva: {created} • Kihagyva: {skipped}', 'places.importGoogleList': 'Google Lista', 'places.importNaverList': 'Naver Lista', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 88b5576c..4c2716c4 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -890,21 +890,19 @@ const it: Record = { // Places Sidebar 'places.addPlace': 'Aggiungi Luogo/Attività', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Importa file', + 'places.sidebarDrop': 'Rilascia per importare', + 'places.importFileHint': 'Importa file .gpx, .kml o .kmz da strumenti come Google My Maps, Google Earth o un tracker GPS.', + 'places.importFileDropHere': 'Clicca per selezionare un file o trascina e rilascia qui', + 'places.importFileDropActive': 'Rilascia il file per selezionarlo', + 'places.importFileUnsupported': 'Tipo di file non supportato. Usa .gpx, .kml o .kmz.', + 'places.importFileTooLarge': 'Il file è troppo grande. La dimensione massima di caricamento è {maxMb} MB.', + 'places.importFileError': 'Importazione non riuscita', + 'places.importAllSkipped': 'Tutti i luoghi erano già nel viaggio.', 'places.gpxImported': '{count} luoghi importati da GPX', 'places.kmlKmzImported': '{count} luoghi importati da KMZ/KML', 'places.urlResolved': 'Luogo importato dall\'URL', - 'places.gpxError': 'Importazione GPX non riuscita', 'places.importList': 'Importa lista', - 'places.kmlKmzImportError': 'Importazione KMZ/KML non riuscita', - 'places.kmlKmzInvalidType': 'Seleziona un file .kml o .kmz.', - 'places.kmlKmzTooLarge': 'Il file è troppo grande. La dimensione massima di caricamento è {maxMb} MB.', - 'places.kmlKmzHint': 'Importa file mappa da strumenti come Google My Maps e Google Earth.', - 'places.kmlKmzSizeHint': 'Dimensione massima file: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Seleziona file', - 'places.kmlKmzSelectedFile': 'File selezionato: {name}', - 'places.kmlKmzSummaryTitle': 'Riepilogo importazione', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importati: {created} • Saltati: {skipped}', 'places.importGoogleList': 'Lista Google', 'places.importNaverList': 'Lista Naver', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 1e372ab1..72c9a6aa 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -889,21 +889,19 @@ const nl: Record = { // Places Sidebar 'places.addPlace': 'Plaats/activiteit toevoegen', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Bestand importeren', + 'places.sidebarDrop': 'Loslaten om te importeren', + 'places.importFileHint': 'Importeer .gpx-, .kml- of .kmz-bestanden uit tools zoals Google My Maps, Google Earth of een GPS-tracker.', + 'places.importFileDropHere': 'Klik om een bestand te selecteren of sleep het hier naartoe', + 'places.importFileDropActive': 'Laat het bestand los om het te selecteren', + 'places.importFileUnsupported': 'Niet-ondersteund bestandstype. Gebruik .gpx, .kml of .kmz.', + 'places.importFileTooLarge': 'Bestand is te groot. Maximale uploadgrootte is {maxMb} MB.', + 'places.importFileError': 'Importeren mislukt', + 'places.importAllSkipped': 'Alle plaatsen waren al in de reis.', 'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX', 'places.kmlKmzImported': '{count} plaatsen geïmporteerd uit KMZ/KML', 'places.urlResolved': 'Plaats geïmporteerd van URL', - 'places.gpxError': 'GPX-import mislukt', 'places.importList': 'Lijst importeren', - 'places.kmlKmzImportError': 'KMZ/KML-import mislukt', - 'places.kmlKmzInvalidType': 'Selecteer een .kml- of .kmz-bestand.', - 'places.kmlKmzTooLarge': 'Bestand is te groot. Maximale uploadgrootte is {maxMb} MB.', - 'places.kmlKmzHint': 'Importeer kaartbestanden uit tools zoals Google My Maps en Google Earth.', - 'places.kmlKmzSizeHint': 'Max. bestandsgrootte: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Bestand selecteren', - 'places.kmlKmzSelectedFile': 'Geselecteerd bestand: {name}', - 'places.kmlKmzSummaryTitle': 'Importoverzicht', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Geïmporteerd: {created} • Overgeslagen: {skipped}', 'places.importGoogleList': 'Google Lijst', 'places.importNaverList': 'Naver Lijst', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 4b81e680..41d1d899 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -856,20 +856,18 @@ const pl: Record = { // Places Sidebar 'places.addPlace': 'Dodaj miejsce/atrakcję', - 'places.importGpx': 'Importuj GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Importuj plik', + 'places.sidebarDrop': 'Upuść, aby zaimportować', + 'places.importFileHint': 'Importuj pliki .gpx, .kml lub .kmz z narzędzi takich jak Google My Maps, Google Earth lub tracker GPS.', + 'places.importFileDropHere': 'Kliknij, aby wybrać plik lub przeciągnij i upuść tutaj', + 'places.importFileDropActive': 'Upuść plik, aby go wybrać', + 'places.importFileUnsupported': 'Nieobsługiwany typ pliku. Użyj .gpx, .kml lub .kmz.', + 'places.importFileTooLarge': 'Plik jest za duży. Maksymalny rozmiar przesyłania to {maxMb} MB.', + 'places.importFileError': 'Import nie powiódł się', + 'places.importAllSkipped': 'Wszystkie miejsca były już w podróży.', 'places.gpxImported': '{count} miejsc zaimportowanych z GPX', 'places.kmlKmzImported': 'Zaimportowano {count} miejsc z KMZ/KML', 'places.urlResolved': 'Miejsce zaimportowane z URL', - 'places.gpxError': 'Nie udało się zaimportować pliku GPX', - 'places.kmlKmzImportError': 'Import KMZ/KML nie powiódł się', - 'places.kmlKmzInvalidType': 'Wybierz plik .kml lub .kmz.', - 'places.kmlKmzTooLarge': 'Plik jest za duży. Maksymalny rozmiar przesyłania to {maxMb} MB.', - 'places.kmlKmzHint': 'Importuj pliki map z narzędzi takich jak Google My Maps i Google Earth.', - 'places.kmlKmzSizeHint': 'Maksymalny rozmiar pliku: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Wybierz plik', - 'places.kmlKmzSelectedFile': 'Wybrany plik: {name}', - 'places.kmlKmzSummaryTitle': 'Podsumowanie importu', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Zaimportowano: {created} • Pominięto: {skipped}', 'places.importGoogleList': 'Lista Google', 'places.assignToDay': 'Do którego dnia dodać?', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 2db4b31e..18373f55 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -889,21 +889,19 @@ const ru: Record = { // Places Sidebar 'places.addPlace': 'Добавить место/активность', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Импортировать файл', + 'places.sidebarDrop': 'Отпустите для импорта', + 'places.importFileHint': 'Импортируйте файлы .gpx, .kml или .kmz из инструментов, таких как Google My Maps, Google Earth или GPS-трекер.', + 'places.importFileDropHere': 'Нажмите для выбора файла или перетащите его сюда', + 'places.importFileDropActive': 'Отпустите файл для выбора', + 'places.importFileUnsupported': 'Неподдерживаемый тип файла. Используйте .gpx, .kml или .kmz.', + 'places.importFileTooLarge': 'Файл слишком большой. Максимальный размер загрузки — {maxMb} MB.', + 'places.importFileError': 'Ошибка импорта', + 'places.importAllSkipped': 'Все места уже были в поездке.', 'places.gpxImported': '{count} мест импортировано из GPX', 'places.kmlKmzImported': '{count} мест импортировано из KMZ/KML', 'places.urlResolved': 'Место импортировано из URL', - 'places.gpxError': 'Ошибка импорта GPX', 'places.importList': 'Импорт списка', - 'places.kmlKmzImportError': 'Ошибка импорта KMZ/KML', - 'places.kmlKmzInvalidType': 'Выберите файл .kml или .kmz.', - 'places.kmlKmzTooLarge': 'Файл слишком большой. Максимальный размер загрузки — {maxMb} MB.', - 'places.kmlKmzHint': 'Импортируйте файлы карт из инструментов, таких как Google My Maps и Google Earth.', - 'places.kmlKmzSizeHint': 'Максимальный размер файла: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Выбрать файл', - 'places.kmlKmzSelectedFile': 'Выбранный файл: {name}', - 'places.kmlKmzSummaryTitle': 'Сводка импорта', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Импортировано: {created} • Пропущено: {skipped}', 'places.importGoogleList': 'Список Google', 'places.importNaverList': 'Список Naver', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 2b6d782f..3312da83 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -889,21 +889,19 @@ const zh: Record = { // Places Sidebar 'places.addPlace': '添加地点/活动', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': '导入文件', + 'places.sidebarDrop': '拖放以导入', + 'places.importFileHint': '从 Google My Maps、Google Earth 或 GPS 追踪器等工具导入 .gpx、.kml 或 .kmz 文件。', + 'places.importFileDropHere': '点击选择文件或拖放到此处', + 'places.importFileDropActive': '释放文件以选择', + 'places.importFileUnsupported': '不支持的文件类型,请使用 .gpx、.kml 或 .kmz。', + 'places.importFileTooLarge': '文件过大。最大上传大小为 {maxMb} MB。', + 'places.importFileError': '导入失败', + 'places.importAllSkipped': '所有地点已在行程中。', 'places.gpxImported': '已从 GPX 导入 {count} 个地点', 'places.kmlKmzImported': '已从 KMZ/KML 导入 {count} 个地点', 'places.urlResolved': '已从 URL 导入地点', - 'places.gpxError': 'GPX 导入失败', 'places.importList': '列表导入', - 'places.kmlKmzImportError': 'KMZ/KML 导入失败', - 'places.kmlKmzInvalidType': '请选择 .kml 或 .kmz 文件。', - 'places.kmlKmzTooLarge': '文件过大。最大上传大小为 {maxMb} MB。', - 'places.kmlKmzHint': '可从 Google My Maps、Google Earth 等工具导入地图文件。', - 'places.kmlKmzSizeHint': '最大文件大小:{maxMb} MB', - 'places.kmlKmzSelectFile': '选择文件', - 'places.kmlKmzSelectedFile': '已选择文件:{name}', - 'places.kmlKmzSummaryTitle': '导入摘要', 'places.kmlKmzSummaryValues': 'Placemarks:{total} • 已导入:{created} • 已跳过:{skipped}', 'places.importGoogleList': 'Google 列表', 'places.importNaverList': 'Naver 列表', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index e6b28a85..4e6acb6b 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -914,21 +914,19 @@ const zhTw: Record = { // Places Sidebar 'places.addPlace': '新增地點/活動', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': '匯入檔案', + 'places.sidebarDrop': '拖放以匯入', + 'places.importFileHint': '從 Google My Maps、Google Earth 或 GPS 追蹤器等工具匯入 .gpx、.kml 或 .kmz 檔案。', + 'places.importFileDropHere': '點選以選取檔案或拖放至此處', + 'places.importFileDropActive': '放開檔案以選取', + 'places.importFileUnsupported': '不支援的檔案類型,請使用 .gpx、.kml 或 .kmz。', + 'places.importFileTooLarge': '檔案過大。最大上傳大小為 {maxMb} MB。', + 'places.importFileError': '匯入失敗', + 'places.importAllSkipped': '所有地點已在行程中。', 'places.gpxImported': '已從 GPX 匯入 {count} 個地點', 'places.kmlKmzImported': '已從 KMZ/KML 匯入 {count} 個地點', 'places.urlResolved': '已從 URL 匯入地點', - 'places.gpxError': 'GPX 匯入失敗', 'places.importList': '列表匯入', - 'places.kmlKmzImportError': 'KMZ/KML 匯入失敗', - 'places.kmlKmzInvalidType': '請選擇 .kml 或 .kmz 檔案。', - 'places.kmlKmzTooLarge': '檔案過大。最大上傳大小為 {maxMb} MB。', - 'places.kmlKmzHint': '可從 Google My Maps、Google Earth 等工具匯入地圖檔案。', - 'places.kmlKmzSizeHint': '最大檔案大小:{maxMb} MB', - 'places.kmlKmzSelectFile': '選擇檔案', - 'places.kmlKmzSelectedFile': '已選擇檔案:{name}', - 'places.kmlKmzSummaryTitle': '匯入摘要', 'places.kmlKmzSummaryValues': 'Placemarks:{total} • 已匯入:{created} • 已略過:{skipped}', 'places.importGoogleList': 'Google 列表', 'places.importNaverList': 'Naver 列表', diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts index 48d9f4e4..72becb7c 100644 --- a/server/src/routes/places.ts +++ b/server/src/routes/places.ts @@ -66,13 +66,13 @@ router.post('/import/gpx', authenticate, requireTripAccess, uploadMulter.single( const file = req.file as Express.Multer.File | undefined; if (!file) return res.status(400).json({ error: 'No file uploaded' }); - const created = importGpx(tripId, file.buffer); - if (!created) { + const result = importGpx(tripId, file.buffer); + if (!result) { return res.status(400).json({ error: 'No waypoints found in GPX file' }); } - res.status(201).json({ places: created, count: created.length }); - for (const place of created) { + res.status(201).json({ places: result.places, count: result.count, skipped: result.skipped }); + for (const place of result.places) { broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); } }); @@ -89,7 +89,7 @@ router.post('/import/map', authenticate, requireTripAccess, uploadMulter.single( try { const result = await importMapFile(tripId, file.buffer, file.originalname); - if (result.count === 0) { + if (result.summary?.totalPlacemarks === 0) { return res.status(400).json({ error: 'No valid Placemarks found in map file', summary: result.summary }); } @@ -120,7 +120,7 @@ router.post('/import/google-list', authenticate, requireTripAccess, async (req: return res.status(result.status).json({ error: result.error }); } - res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName }); + res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped }); for (const place of result.places) { broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); } @@ -150,7 +150,7 @@ router.post('/import/naver-list', authenticate, requireTripAccess, async (req: R return res.status(result.status).json({ error: result.error }); } - res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName }); + res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped }); for (const place of result.places) { broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); } diff --git a/server/src/services/kmlImport.ts b/server/src/services/kmlImport.ts index 9c7f2790..9fe38eb2 100644 --- a/server/src/services/kmlImport.ts +++ b/server/src/services/kmlImport.ts @@ -40,6 +40,13 @@ function asArray(value: T | T[] | null | undefined): T[] { function asTrimmedString(value: unknown): string | null { if (value == null) return null; + // Parsed objects (mixed-content XML parsed without stopNodes) must not + // produce "[object Object]" — extract #text if present, else return null. + if (typeof value === 'object') { + const candidate = (value as Record)['#text']; + if (typeof candidate === 'string') return candidate.trim() || null; + return null; + } const text = String(value).trim(); return text.length > 0 ? text : null; } @@ -73,7 +80,12 @@ export function sanitizeKmlDescription(value: unknown): string | null { const raw = asTrimmedString(value); if (!raw) return null; - const withLineBreaks = raw.replace(//gi, '\n'); + // Unwrap CDATA sections — present when fast-xml-parser returns raw node text + // via stopNodes. Must happen before tag-stripping so the CDATA markers are + // not mis-parsed by the <[^>]+> regex. + const withoutCdata = raw.replace(//g, '$1'); + + const withLineBreaks = withoutCdata.replace(//gi, '\n'); const stripped = withLineBreaks.replace(/<[^>]+>/g, ''); const decoded = decodeHtmlEntities(stripped) .replace(/\r\n/g, '\n') diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts index 35e7e84c..f2305bb1 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -255,10 +255,77 @@ const kmlParser = new XMLParser({ attributeNamePrefix: '@_', removeNSPrefix: true, isArray: (name) => ['Placemark', 'Folder', 'Document'].includes(name), + // Treat as raw text so mixed-content HTML (e.g.
, ) + // is returned as a string instead of a parsed object. + stopNodes: ['*.description'], }); export const KMZ_DECOMPRESSED_SIZE_LIMIT = 50 * 1024 * 1024; // 50 MB +// --------------------------------------------------------------------------- +// Import deduplication helpers +// --------------------------------------------------------------------------- + +const COORD_DEDUP_TOLERANCE = 0.0001; // ≈ 11 m + +interface DedupSet { + names: Set; + coords: Array<{ lat: number; lng: number }>; +} + +/** Build a lookup of names/coords for places already in a trip. */ +function buildDedupSet(tripId: string): DedupSet { + const rows = db.prepare('SELECT name, lat, lng FROM places WHERE trip_id = ?').all(tripId) as Array<{ + name: string | null; + lat: number | null; + lng: number | null; + }>; + const names = new Set(); + const coords: Array<{ lat: number; lng: number }> = []; + for (const row of rows) { + if (row.name) { + names.add(row.name.trim().toLowerCase()); + } else if (row.lat != null && row.lng != null) { + coords.push({ lat: row.lat, lng: row.lng }); + } + } + return { names, coords }; +} + +/** + * Returns true if a candidate place is already represented in the dedup set. + * Named places match by case-insensitive name; unnamed places fall back to + * coordinate proximity. + */ +function isPlaceDuplicate( + candidate: { name: string | null | undefined; lat: number | null; lng: number | null }, + dedup: DedupSet, +): boolean { + const normalizedName = candidate.name?.trim().toLowerCase(); + if (normalizedName) return dedup.names.has(normalizedName); + if (candidate.lat != null && candidate.lng != null) { + return dedup.coords.some( + (c) => + Math.abs(c.lat - candidate.lat!) <= COORD_DEDUP_TOLERANCE && + Math.abs(c.lng - candidate.lng!) <= COORD_DEDUP_TOLERANCE, + ); + } + return false; +} + +/** Record a newly inserted place so subsequent candidates in the same batch are checked against it. */ +function trackInsertedInDedupSet( + place: { name: string | null | undefined; lat: number | null; lng: number | null }, + dedup: DedupSet, +): void { + const normalizedName = place.name?.trim().toLowerCase(); + if (normalizedName) { + dedup.names.add(normalizedName); + } else if (place.lat != null && place.lng != null) { + dedup.coords.push({ lat: place.lat, lng: place.lng }); + } +} + export function importGpx(tripId: string, fileBuffer: Buffer) { const parsed = gpxParser.parse(fileBuffer.toString('utf-8')); const gpx = parsed?.gpx; @@ -310,21 +377,28 @@ export function importGpx(tripId: string, fileBuffer: Buffer) { if (waypoints.length === 0) return null; + const dedup = buildDedupSet(tripId); const insertStmt = db.prepare(` INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry) VALUES (?, ?, ?, ?, ?, 'walking', ?) `); const created: any[] = []; + let skipped = 0; const insertAll = db.transaction(() => { for (const wp of waypoints) { + if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) { + skipped++; + continue; + } const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null); const place = getPlaceWithTags(Number(result.lastInsertRowid)); created.push(place); + trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup); } }); insertAll(); - return created; + return { places: created, count: created.length, skipped }; } export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImportResult { @@ -351,7 +425,9 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport const categories = db.prepare('SELECT id, name FROM categories').all() as { id: number; name: string }[]; const categoryLookup = buildCategoryNameLookup(categories); + const dedup = buildDedupSet(tripId); const created: any[] = []; + let dupCount = 0; const insertStmt = db.prepare(` INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode) @@ -373,6 +449,14 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport const fallbackName = `Placemark ${fallbackIndex}`; const name = parsedPlacemark.name || fallbackName; + + if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup)) { + summary.skippedCount += 1; + dupCount++; + fallbackIndex += 1; + continue; + } + const categoryId = resolveCategoryIdForFolder(parsedPlacemark.folderName, categoryLookup); const result = insertStmt.run( @@ -386,6 +470,7 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport const place = getPlaceWithTags(Number(result.lastInsertRowid)); created.push(place); + trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup); summary.createdCount += 1; fallbackIndex += 1; } @@ -393,6 +478,10 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport insertAll(); + if (dupCount > 0) { + summary.warnings.push(`${dupCount} place${dupCount > 1 ? 's' : ''} skipped (already in trip).`); + } + if (summary.totalPlacemarks === 0) { summary.errors.push('No Placemarks found in KML file.'); } @@ -514,30 +603,23 @@ export async function importGoogleList(tripId: string, url: string) { return { error: 'No places with coordinates found in list', status: 400 }; } - // Skip places that already exist in this trip (same name + coordinates within ~10m) - const existingPlaces = db.prepare( - 'SELECT name, lat, lng FROM places WHERE trip_id = ?' - ).all(tripId) as { name: string; lat: number; lng: number }[]; - - const isDuplicate = (p: { name: string; lat: number; lng: number }) => - existingPlaces.some(e => - e.name === p.name && Math.abs(e.lat - p.lat) < 0.0001 && Math.abs(e.lng - p.lng) < 0.0001 - ); - - const newPlaces = places.filter(p => !isDuplicate(p)); - const skipped = places.length - newPlaces.length; - - // Insert only new places into trip + const dedup = buildDedupSet(tripId); const insertStmt = db.prepare(` INSERT INTO places (trip_id, name, lat, lng, notes, transport_mode) VALUES (?, ?, ?, ?, ?, 'walking') `); const created: any[] = []; + let skipped = 0; const insertAll = db.transaction(() => { - for (const p of newPlaces) { + for (const p of places) { + if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) { + skipped++; + continue; + } const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes); const place = getPlaceWithTags(Number(result.lastInsertRowid)); created.push(place); + trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup); } }); insertAll(); @@ -643,21 +725,28 @@ export async function importNaverList( return { error: 'No places with coordinates found in list', status: 400 }; } + const dedup = buildDedupSet(tripId); const insertStmt = db.prepare(` INSERT INTO places (trip_id, name, lat, lng, address, notes, transport_mode) VALUES (?, ?, ?, ?, ?, ?, 'walking') `); const created: any[] = []; + let skipped = 0; const insertAll = db.transaction(() => { for (const p of places) { + if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) { + skipped++; + continue; + } const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.address, p.notes); const place = getPlaceWithTags(Number(result.lastInsertRowid)); created.push(place); + trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup); } }); insertAll(); - return { places: created, listName }; + return { places: created, listName, skipped }; } // --------------------------------------------------------------------------- diff --git a/server/tests/unit/services/kmlImportUtils.test.ts b/server/tests/unit/services/kmlImportUtils.test.ts index 330543d3..47cd40e2 100644 --- a/server/tests/unit/services/kmlImportUtils.test.ts +++ b/server/tests/unit/services/kmlImportUtils.test.ts @@ -16,6 +16,11 @@ describe('kmlImportUtils', () => { expect(output).toBe('Line 1\nLine 2 & more'); }); + it('unwraps CDATA sections before stripping tags', () => { + const input = 'for photos and skyline.]]>'; + expect(sanitizeKmlDescription(input)).toBe('Great spot\nfor photos and skyline.'); + }); + 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 }); @@ -65,6 +70,18 @@ describe('kmlImportUtils', () => { expect(sanitizeKmlDescription('😀')).toBe('😀'); }); + it('does not produce [object Object] when description is a parsed object with #text', () => { + // fast-xml-parser can return an object for mixed-content nodes when stopNodes + // is not configured; the fallback in asTrimmedString must extract #text. + const result = sanitizeKmlDescription({ '#text': 'Hello world' } as any); + expect(result).not.toBe('[object Object]'); + expect(result).toBe('Hello world'); + }); + + it('returns null when description object has no #text', () => { + expect(sanitizeKmlDescription({ i: 'bold' } as any)).toBeNull(); + }); + it('returns warning for non-UTF8 payload', () => { const buffer = Buffer.concat([ Buffer.from('Caf'), diff --git a/server/tests/unit/services/placeService.test.ts b/server/tests/unit/services/placeService.test.ts index 51b84c6f..4d6a8fc8 100644 --- a/server/tests/unit/services/placeService.test.ts +++ b/server/tests/unit/services/placeService.test.ts @@ -45,7 +45,12 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createPlace, createCategory, createTag } from '../../helpers/factories'; -import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, deletePlace, importGpx, importGoogleList, searchPlaceImage } from '../../../src/services/placeService'; +import path from 'path'; +import fs from 'fs'; +import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage } from '../../../src/services/placeService'; + +const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx'); +const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml'); beforeAll(() => { createTables(testDb); @@ -266,10 +271,10 @@ describe('importGpx', () => { Paris London `); - const places = importGpx(String(trip.id), gpx) as any[]; - expect(places).toHaveLength(2); - expect(places[0].name).toBe('Paris'); - expect(places[1].name).toBe('London'); + const result = importGpx(String(trip.id), gpx) as any; + expect(result.places).toHaveLength(2); + expect(result.places[0].name).toBe('Paris'); + expect(result.places[1].name).toBe('London'); }); it('PLACE-SVC-022 — falls back to route points when no elements exist', () => { @@ -281,10 +286,10 @@ describe('importGpx', () => { End `); - const places = importGpx(String(trip.id), gpx) as any[]; - expect(places).toHaveLength(2); - expect(places[0].name).toBe('Start'); - expect(places[1].name).toBe('End'); + const result = importGpx(String(trip.id), gpx) as any; + expect(result.places).toHaveLength(2); + expect(result.places[0].name).toBe('Start'); + expect(result.places[1].name).toBe('End'); }); it('PLACE-SVC-023 — imports track as a single place with routeGeometry', () => { @@ -299,10 +304,10 @@ describe('importGpx', () => { `); - const places = importGpx(String(trip.id), gpx) as any[]; - expect(places).toHaveLength(1); - expect(places[0].name).toBe('My Track'); - const geometry = JSON.parse(places[0].route_geometry); + const result = importGpx(String(trip.id), gpx) as any; + expect(result.places).toHaveLength(1); + expect(result.places[0].name).toBe('My Track'); + const geometry = JSON.parse(result.places[0].route_geometry); expect(Array.isArray(geometry)).toBe(true); expect(geometry).toHaveLength(2); }); @@ -320,10 +325,10 @@ describe('importGpx', () => { `); - const places = importGpx(String(trip.id), gpx) as any[]; + const result = importGpx(String(trip.id), gpx) as any; // 1 wpt + 1 trk - expect(places).toHaveLength(2); - const trackPlace = places.find((p: any) => p.name === 'Track') as any; + expect(result.places).toHaveLength(2); + const trackPlace = result.places.find((p: any) => p.name === 'Track') as any; expect(trackPlace).toBeDefined(); const geometry = JSON.parse(trackPlace.route_geometry); expect(geometry).toHaveLength(2); @@ -449,3 +454,74 @@ describe('searchPlaceImage', () => { expect(result.photos[0].photographer).toBe('Photographer'); }); }); + +// ── Import deduplication ────────────────────────────────────────────────────── + +describe('importGpx deduplication', () => { + it('PLACE-SVC-033 — skips waypoints already in trip by name', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const buf = fs.readFileSync(GPX_FIXTURE); + + // First import + const first = importGpx(String(trip.id), buf) as any; + expect(first.count).toBeGreaterThan(0); + + // Second import — all names already present, nothing new created + const second = importGpx(String(trip.id), buf) as any; + expect(second.count).toBe(0); + expect(second.skipped).toBe(first.count); + + // Total places in DB should equal first import count + const total = (listPlaces(String(trip.id), {}) as any[]).length; + expect(total).toBe(first.count); + }); + + it('PLACE-SVC-034 — imports new places while skipping existing ones', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const buf = fs.readFileSync(GPX_FIXTURE); + + const first = importGpx(String(trip.id), buf) as any; + // Manually add a brand-new place so total > first.count + createPlace(testDb, trip.id, { name: 'Unique Extra Place', lat: 99, lng: 99 }); + + // Re-import: the fixture places are skipped, the extra place remains untouched + const second = importGpx(String(trip.id), buf) as any; + expect(second.count).toBe(0); + + const total = (listPlaces(String(trip.id), {}) as any[]).length; + expect(total).toBe(first.count + 1); + }); +}); + +describe('importKmlPlaces deduplication', () => { + it('PLACE-SVC-035 — skips placemarks already in trip by name', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const buf = fs.readFileSync(KML_FIXTURE); + + const first = importKmlPlaces(String(trip.id), buf); + expect(first.count).toBeGreaterThan(0); + + const second = importKmlPlaces(String(trip.id), buf); + expect(second.count).toBe(0); + expect(second.summary.skippedCount).toBeGreaterThanOrEqual(first.count); + expect(second.summary.warnings.some((w: string) => w.includes('skipped'))).toBe(true); + }); + + it('PLACE-SVC-036 — deduplicates within the same file (intra-batch)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + // Craft a KML with two placemarks sharing the same name + const kml = Buffer.from(` + + Dupe Place2.0,48.0,0 + Dupe Place2.1,48.1,0 +`); + + const result = importKmlPlaces(String(trip.id), kml); + expect(result.count).toBe(1); + expect(result.summary.skippedCount).toBe(1); + }); +});