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:
jubnl
2026-04-15 06:07:26 +02:00
parent 801ffbfb7b
commit 875c91e5ff
22 changed files with 741 additions and 431 deletions
@@ -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('&#x1F600;')).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'),