mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat(places): unified file import modal with drag-and-drop and deduplication
- Replace separate GPX and KML/KMZ import buttons with a single "Import file" modal accepting all three formats, with a drag-and-drop drop zone - Support dragging files directly onto the Places sidebar panel; overlay appears on hover and pre-loads the file into the modal on drop - Fix [object Object] description bug in KML imports caused by fast-xml-parser returning mixed-content nodes as objects; add stopNodes config and object guard in asTrimmedString - Fix CDATA sections leaking into descriptions (e.g. "text.]]>") by unwrapping CDATA markers before tag stripping - Add import deduplication across all import paths (GPX, KML/KMZ, Google list, Naver list): reimporting skips places already in the trip by name (case-insensitive) or by coordinates (within ~11 m tolerance), with intra-batch dedup so duplicate placemarks within the same file are also collapsed - Fix KML route returning 400 "No valid Placemarks found" when all placemarks were valid but deduplicated; 400 now only fires when the file contains zero placemarks - Show a warning toast "All places were already in the trip" instead of a misleading success toast when a reimport produces zero new places (GPX, KML/KMZ, Google list, Naver list) - Add 8 new i18n keys across all 14 locales; remove 11 keys made unused by the modal consolidation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,13 @@ function asArray<T>(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<string, unknown>)['#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(/<br\s*\/?>/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(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1');
|
||||
|
||||
const withLineBreaks = withoutCdata.replace(/<br\s*\/?>/gi, '\n');
|
||||
const stripped = withLineBreaks.replace(/<[^>]+>/g, '');
|
||||
const decoded = decodeHtmlEntities(stripped)
|
||||
.replace(/\r\n/g, '\n')
|
||||
|
||||
@@ -255,10 +255,77 @@ const kmlParser = new XMLParser({
|
||||
attributeNamePrefix: '@_',
|
||||
removeNSPrefix: true,
|
||||
isArray: (name) => ['Placemark', 'Folder', 'Document'].includes(name),
|
||||
// Treat <description> as raw text so mixed-content HTML (e.g. <br/>, <i>)
|
||||
// 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<string>;
|
||||
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<string>();
|
||||
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 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -16,6 +16,11 @@ describe('kmlImportUtils', () => {
|
||||
expect(output).toBe('Line 1\nLine 2 & more');
|
||||
});
|
||||
|
||||
it('unwraps CDATA sections before stripping tags', () => {
|
||||
const input = '<![CDATA[Great spot<br>for photos <b>and</b> 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 <b>world</b>' } 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('<?xml version="1.0"?><kml><Document><Placemark><name>Caf'),
|
||||
|
||||
@@ -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', () => {
|
||||
<wpt lat="48.8566" lon="2.3522"><name>Paris</name></wpt>
|
||||
<wpt lat="51.5074" lon="-0.1278"><name>London</name></wpt>
|
||||
</gpx>`);
|
||||
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 <rte> route points when no <wpt> elements exist', () => {
|
||||
@@ -281,10 +286,10 @@ describe('importGpx', () => {
|
||||
<rtept lat="51.5074" lon="-0.1278"><name>End</name></rtept>
|
||||
</rte>
|
||||
</gpx>`);
|
||||
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 <trk> track as a single place with routeGeometry', () => {
|
||||
@@ -299,10 +304,10 @@ describe('importGpx', () => {
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>`);
|
||||
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', () => {
|
||||
</trkseg>
|
||||
</trk>
|
||||
</gpx>`);
|
||||
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(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
|
||||
<Placemark><name>Dupe Place</name><Point><coordinates>2.0,48.0,0</coordinates></Point></Placemark>
|
||||
<Placemark><name>Dupe Place</name><Point><coordinates>2.1,48.1,0</coordinates></Point></Placemark>
|
||||
</Document></kml>`);
|
||||
|
||||
const result = importKmlPlaces(String(trip.id), kml);
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.summary.skippedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user