Files
TREK/server/src/services/kmlImport.ts
T
jubnl 6a718fccea feat(import): selective GPX/KML element import and performance improvements
Add type-selector UI in the file import modal letting users choose which
GPX elements (waypoints, routes, tracks) or KML/KMZ elements (points,
paths) to import. KML LineString placemarks are now imported as path
places with route_geometry.

Performance improvements:
- Extract MemoPlaceRow with React.memo and contentVisibility:auto to cut
  unnecessary re-renders in PlacesSidebar
- Add weatherQueue to cap concurrent weather fetches at 3
- Replace sequential per-place deletes with a single bulkDelete API call
  (new DELETE /places/bulk endpoint + deletePlacesMany service)
- Memoize atlas/photo/weather service calls to avoid redundant requests
- Add multi-select mode to PlacesSidebar for bulk operations

Add large GPX/KML/KMZ fixtures for integration/perf testing and two
profiler analysis scripts under scripts/.
2026-04-18 01:28:37 +02:00

213 lines
6.9 KiB
TypeScript

import { TextDecoder } from 'util';
export interface ParsedKmlPlacemark {
name: string | null;
description: string | null;
lat: number | null;
lng: number | null;
folderName: string | null;
routeGeometry: string | null;
}
export interface KmlPlacemarkNode {
placemark: any;
folderName: string | null;
}
export interface KmlImportSummary {
totalPlacemarks: number;
createdCount: number;
skippedCount: number;
warnings: string[];
errors: string[];
}
const UTF8_DECODER_FATAL = new TextDecoder('utf-8', { fatal: true });
const UTF8_DECODER_LOOSE = new TextDecoder('utf-8');
const ENTITY_MAP: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
};
function asArray<T>(value: T | T[] | null | undefined): T[] {
if (value == null) return [];
return Array.isArray(value) ? value : [value];
}
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;
}
function decodeHtmlEntities(value: string): string {
const withNamedEntities = value.replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (m) => ENTITY_MAP[m] || m);
return withNamedEntities
.replace(/&#(\d+);/g, (_, dec) => {
const code = Number(dec);
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : _;
})
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
const code = Number.parseInt(hex, 16);
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : _;
});
}
export function decodeUtf8WithWarning(fileBuffer: Buffer): { text: string; warning: string | null } {
try {
return { text: UTF8_DECODER_FATAL.decode(fileBuffer), warning: null };
} catch {
return {
text: UTF8_DECODER_LOOSE.decode(fileBuffer),
warning: 'The uploaded file is not valid UTF-8. Some characters may be shown incorrectly.',
};
}
}
export function sanitizeKmlDescription(value: unknown): string | null {
const raw = asTrimmedString(value);
if (!raw) return null;
// 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')
.replace(/\r/g, '\n')
.replace(/[\t\f\v]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
return decoded || null;
}
export function parseKmlLineStringCoordinates(value: unknown): Array<{ lat: number; lng: number; ele: number | null }> | null {
const coordinates = asTrimmedString(value);
if (!coordinates) return null;
const points = coordinates
.trim()
.split(/\s+/)
.map(coord => {
const parts = coord.split(',');
const lng = Number.parseFloat(parts[0] ?? '');
const lat = Number.parseFloat(parts[1] ?? '');
const eleRaw = parts[2] != null ? Number.parseFloat(parts[2]) : NaN;
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return { lat, lng, ele: Number.isFinite(eleRaw) ? eleRaw : null };
})
.filter((p): p is { lat: number; lng: number; ele: number | null } => p !== null);
return points.length >= 2 ? points : null;
}
export function parseKmlPointCoordinates(value: unknown): { lat: number; lng: number } | null {
const coordinates = asTrimmedString(value);
if (!coordinates) return null;
const firstCoordinate = coordinates.split(/\s+/)[0];
const [lngRaw, latRaw] = firstCoordinate.split(',');
if (lngRaw == null || latRaw == null) return null;
const lng = Number.parseFloat(lngRaw);
const lat = Number.parseFloat(latRaw);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
return { lat, lng };
}
export function createKmlImportSummary(totalPlacemarks: number): KmlImportSummary {
return {
totalPlacemarks,
createdCount: 0,
skippedCount: 0,
warnings: [],
errors: [],
};
}
export function buildCategoryNameLookup(categories: { id: number; name: string }[]): Map<string, number> {
const lookup = new Map<string, number>();
for (const category of categories) {
const normalizedName = category.name.trim().toLowerCase();
if (!normalizedName) continue;
if (!lookup.has(normalizedName)) {
lookup.set(normalizedName, category.id);
}
}
return lookup;
}
export function resolveCategoryIdForFolder(folderName: string | null, lookup: Map<string, number>): number | null {
if (!folderName) return null;
const normalizedFolder = folderName.trim().toLowerCase();
if (!normalizedFolder) return null;
return lookup.get(normalizedFolder) ?? null;
}
export function extractKmlPlacemarkNodes(kmlRoot: any): KmlPlacemarkNode[] {
const nodes: KmlPlacemarkNode[] = [];
const visitNode = (node: any, currentFolderName: string | null): void => {
if (!node || typeof node !== 'object') return;
for (const placemark of asArray(node.Placemark)) {
nodes.push({ placemark, folderName: currentFolderName });
}
for (const folder of asArray(node.Folder)) {
// Nested folders inherit/override folder context used for category matching.
const folderName = asTrimmedString(folder?.name) || currentFolderName;
visitNode(folder, folderName);
}
for (const childDocument of asArray(node.Document)) {
visitNode(childDocument, currentFolderName);
}
};
visitNode(kmlRoot, null);
return nodes;
}
export function parsePlacemarkNode(node: KmlPlacemarkNode): ParsedKmlPlacemark {
const pointCoords = parseKmlPointCoordinates(node.placemark?.Point?.coordinates);
let routeGeometry: string | null = null;
let pathFirstPt: { lat: number; lng: number } | null = null;
if (!pointCoords) {
const linePts = parseKmlLineStringCoordinates(node.placemark?.LineString?.coordinates);
if (linePts) {
pathFirstPt = { lat: linePts[0].lat, lng: linePts[0].lng };
const hasAllEle = linePts.every(p => p.ele !== null);
routeGeometry = JSON.stringify(linePts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]));
}
}
return {
name: asTrimmedString(node.placemark?.name),
description: sanitizeKmlDescription(node.placemark?.description),
lat: pointCoords?.lat ?? pathFirstPt?.lat ?? null,
lng: pointCoords?.lng ?? pathFirstPt?.lng ?? null,
folderName: node.folderName,
routeGeometry,
};
}