diff --git a/server/src/nest/places/places.helpers.ts b/server/src/nest/places/places.helpers.ts index 3dd5c2884..9533a78d0 100644 --- a/server/src/nest/places/places.helpers.ts +++ b/server/src/nest/places/places.helpers.ts @@ -134,6 +134,14 @@ function lineCourse(routeGeometry: string | null | undefined): string { return [first[0], first[1], last[0], last[1], south, west, north, east].map((v) => v.toFixed(3)).join(','); } +/** An imported row as the dedup set reads it: a line when it carries one. */ +export function lineCandidate( + name: string | null | undefined, + row: { lat: number | null; lng: number | null; routeGeometry?: string | null }, +): DedupCandidate { + return { name, lat: row.lat, lng: row.lng, track: !!row.routeGeometry, routeGeometry: row.routeGeometry }; +} + /** A track's entry in `DedupSet.tracks`: its normalized name and its course. */ function trackKey(name: string, routeGeometry: string | null | undefined): string { return `${name}\u0000${lineCourse(routeGeometry)}`; diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index b0514e2f1..0ac0c1678 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -58,7 +58,7 @@ import { buildCategoryNameLookup, escapeLikePattern, dedupSetOf, - isPlaceDuplicate, + isPlaceDuplicate, lineCandidate, mapWithConcurrency, pickEnrichmentMatch, reclaimPhotoCache, @@ -758,7 +758,7 @@ export class PlacesService { async gpxDuplicates(tripId: string, rows: PreparedGpxPlace[]): Promise { const dedup = await this.buildDedupSet(tripId); return rows.map((wp) => { - const candidate = { name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry, routeGeometry: wp.routeGeometry }; + const candidate = lineCandidate(wp.name, wp); if (isPlaceDuplicate(candidate, dedup)) return true; trackInsertedInDedupSet(candidate, dedup); return false; @@ -925,7 +925,7 @@ export class PlacesService { // `findWithTagsAndRatings` INSIDE the caller's transaction, so the read // sees the still-uncommitted row. for (const wp of waypoints) { - if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry, routeGeometry: wp.routeGeometry }, dedup)) { + if (isPlaceDuplicate(lineCandidate(wp.name, wp), dedup)) { skipped++; continue; } @@ -960,7 +960,7 @@ export class PlacesService { }); const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!; created.push(place); - trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng, track: !!wp.routeGeometry, routeGeometry: wp.routeGeometry }, dedup); + trackInsertedInDedupSet(lineCandidate(wp.name, wp), dedup); } return { places: created, count: created.length, skipped }; @@ -1033,7 +1033,7 @@ export class PlacesService { const fallbackName = `Placemark ${fallbackIndex}`; const name = parsedPlacemark.name || fallbackName; - if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng, track: isPath, routeGeometry: parsedPlacemark.routeGeometry }, dedup)) { + if (isPlaceDuplicate(lineCandidate(name, parsedPlacemark), dedup)) { summary.skippedCount += 1; dupCount++; fallbackIndex += 1; @@ -1079,7 +1079,7 @@ export class PlacesService { const place = (await this.placesRepo.findWithTagsAndRatings(placeId))!; created.push(place); - trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng, track: isPath, routeGeometry: parsedPlacemark.routeGeometry }, dedup); + trackInsertedInDedupSet(lineCandidate(name, parsedPlacemark), dedup); summary.createdCount += 1; fallbackIndex += 1; } diff --git a/server/tests/unit/nest/tours.gpx.atomic.test.ts b/server/tests/unit/nest/tours.gpx.atomic.test.ts index 3282ad414..6dcacf826 100644 --- a/server/tests/unit/nest/tours.gpx.atomic.test.ts +++ b/server/tests/unit/nest/tours.gpx.atomic.test.ts @@ -418,9 +418,10 @@ describe('Tours GPX atomic persistence and postcommit publication', () => { }); it('imports only the new tracks and publishes only committed rows in a mixed result', async () => { + // The same Ridge as in the mixed file, line and all: a track is a duplicate only of one that runs alike (#2870). const existing = Buffer.from(`Ridge 100150 - `); + 120`); expect((await tours.importGpxAsTour(tripId, existing))?.tours).toHaveLength(1); broadcast.mockClear();