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
+13 -1
View File
@@ -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')