Name GPX routes and tracks after their source file so multiple imports stick (#1054)

Unnamed routes and tracks all fell back to the same generic 'GPX Route' /
'GPX Track' label, so the name-based import dedup dropped every one after
the first - importing several files (or one file with several tracks) only
kept a single place. Derive the default name from the source filename with
an index suffix when a file holds more than one geometry, thread the
filename down through the controller, and let the import modal take more
than one file at a time. Adds PLACE-SVC-037/038.
This commit is contained in:
Maurice
2026-05-31 22:36:15 +02:00
parent d02ecf239e
commit 39113e12de
5 changed files with 156 additions and 71 deletions
+1 -1
View File
@@ -117,7 +117,7 @@ export class PlacesController {
if (!importWaypoints && !importRoutes && !importTracks) {
throw new HttpException({ error: 'No import types selected' }, 400);
}
const result = this.places.importGpx(tripId, file.buffer, { importWaypoints, importRoutes, importTracks });
const result = this.places.importGpx(tripId, file.buffer, { importWaypoints, importRoutes, importTracks, defaultName: file.originalname });
if (!result) {
throw new HttpException({ error: 'No matching places found in GPX file' }, 400);
}
+5 -1
View File
@@ -52,7 +52,11 @@ export class PlacesService {
return svc.deletePlacesMany(tripId, ids);
}
importGpx(tripId: string, buffer: Buffer, opts: { importWaypoints: boolean; importRoutes: boolean; importTracks: boolean }) {
importGpx(
tripId: string,
buffer: Buffer,
opts: { importWaypoints: boolean; importRoutes: boolean; importTracks: boolean; defaultName?: string },
) {
return svc.importGpx(tripId, buffer, opts);
}
+19 -3
View File
@@ -346,6 +346,8 @@ export interface GpxImportOptions {
importWaypoints?: boolean;
importRoutes?: boolean;
importTracks?: boolean;
/** Source filename used to name unnamed routes/tracks (keeps multiple imports distinct). */
defaultName?: string;
}
export interface KmlImportOptions {
@@ -354,7 +356,7 @@ export interface KmlImportOptions {
}
export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}) {
const { importWaypoints = true, importRoutes = true, importTracks = true } = opts;
const { importWaypoints = true, importRoutes = true, importTracks = true, defaultName } = opts;
const parsed = gpxParser.parse(fileBuffer.toString('utf-8'));
const gpx = parsed?.gpx;
@@ -363,6 +365,20 @@ export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOpt
const str = (v: unknown) => (v != null ? String(v).trim() : null);
const num = (v: unknown) => { const n = parseFloat(String(v)); return isNaN(n) ? null : n; };
// Routes and tracks rarely carry their own <name>. Without one they all fall back to the
// same generic label, so name-based dedup drops every import after the first. Derive a
// base from the source filename (the requested behaviour) and suffix an index so multiple
// geometries from one file stay distinct.
const rawName = str(defaultName);
const baseName = rawName ? rawName.replace(/\.[^.]+$/, '').trim() || rawName : null;
let geoSeq = 0;
const geoName = (explicit: string | null, fallback: string): string => {
if (explicit) return explicit;
geoSeq++;
const base = baseName || fallback;
return geoSeq === 1 ? base : `${base} ${geoSeq}`;
};
type WaypointEntry = { name: string; lat: number; lng: number; description: string | null; routeGeometry?: string };
const waypoints: WaypointEntry[] = [];
@@ -385,7 +401,7 @@ export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOpt
if (pts.length === 0) continue;
const hasAllEle = pts.every(p => p.ele !== null);
const routeGeometry = pts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
waypoints.push({ lat: pts[0].lat, lng: pts[0].lng, name: str(rte.name) || 'GPX Route', description: str(rte.desc), routeGeometry: JSON.stringify(routeGeometry) });
waypoints.push({ lat: pts[0].lat, lng: pts[0].lng, name: geoName(str(rte.name), 'GPX Route'), description: str(rte.desc), routeGeometry: JSON.stringify(routeGeometry) });
}
}
@@ -405,7 +421,7 @@ export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOpt
const start = trackPoints[0];
const hasAllEle = trackPoints.every(p => p.ele !== null);
const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
waypoints.push({ lat: start.lat, lng: start.lng, name: str(trk.name) || 'GPX Track', description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) });
waypoints.push({ lat: start.lat, lng: start.lng, name: geoName(str(trk.name), 'GPX Track'), description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) });
}
}
@@ -346,6 +346,39 @@ describe('importGpx', () => {
const result = importGpx(String(trip.id), gpx);
expect(result).toBeNull();
});
it('PLACE-SVC-037 — multiple unnamed tracks in one file get distinct names instead of collapsing to one', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
<trk><trkseg>
<trkpt lat="48.8566" lon="2.3522"></trkpt>
<trkpt lat="48.8570" lon="2.3530"></trkpt>
</trkseg></trk>
<trk><trkseg>
<trkpt lat="40.0000" lon="-3.0000"></trkpt>
<trkpt lat="40.1000" lon="-3.1000"></trkpt>
</trkseg></trk>
</gpx>`);
const result = importGpx(String(trip.id), gpx) as any;
expect(result.places).toHaveLength(2);
const names = result.places.map((p: any) => p.name);
expect(new Set(names).size).toBe(2);
});
it('PLACE-SVC-038 — unnamed tracks fall back to the source filename', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
<trk><trkseg>
<trkpt lat="48.8566" lon="2.3522"></trkpt>
<trkpt lat="48.8570" lon="2.3530"></trkpt>
</trkseg></trk>
</gpx>`);
const result = importGpx(String(trip.id), gpx, { defaultName: 'morning-hike.gpx' }) as any;
expect(result.places).toHaveLength(1);
expect(result.places[0].name).toBe('morning-hike');
});
});
// ── importGoogleList ──────────────────────────────────────────────────────────