refactor(server): consolidate KML import utilities

This commit is contained in:
Yannis Biasutti
2026-04-06 21:27:10 +02:00
parent 2cc79b3d16
commit 8c8bd5bc37
4 changed files with 6 additions and 181 deletions
+4 -4
View File
@@ -19,7 +19,7 @@ import {
searchPlaceImage,
} from '../services/placeService';
const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
const uploadMulter = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
const router = express.Router({ mergeParams: true });
@@ -54,7 +54,7 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
});
// Import places from GPX file with full track geometry (must be before /:id)
router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('file'), (req: Request, res: Response) => {
router.post('/import/gpx', authenticate, requireTripAccess, uploadMulter.single('file'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
@@ -74,7 +74,7 @@ router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('fi
}
});
router.post('/import/kml', authenticate, requireTripAccess, gpxUpload.single('file'), (req: Request, res: Response) => {
router.post('/import/kml', authenticate, requireTripAccess, uploadMulter.single('file'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) {
return res.status(403).json({ error: 'No permission' });
@@ -100,7 +100,7 @@ router.post('/import/kml', authenticate, requireTripAccess, gpxUpload.single('fi
}
});
router.post('/import/kmz', authenticate, requireTripAccess, gpxUpload.single('file'), async (req: Request, res: Response) => {
router.post('/import/kmz', authenticate, requireTripAccess, uploadMulter.single('file'), async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) {
return res.status(403).json({ error: 'No permission' });
@@ -1,175 +0,0 @@
import { TextDecoder } from 'util';
export interface ParsedKmlPlacemark {
name: string | null;
description: string | null;
lat: number | null;
lng: number | null;
folderName: 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;
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) ? String.fromCharCode(code) : _;
})
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
const code = Number.parseInt(hex, 16);
return Number.isFinite(code) ? String.fromCharCode(code) : _;
});
}
export function stripXmlNamespaces(xml: string): string {
// KML exports vary heavily; stripping namespace declarations/prefixes makes parsing resilient.
return xml
.replace(/\sxmlns(:\w+)?="[^"]*"/g, '')
.replace(/\sxmlns(:\w+)?='[^']*'/g, '')
.replace(/<(\/?)\w+:/g, '<$1');
}
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;
const withLineBreaks = raw.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 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 coordinates = parseKmlPointCoordinates(node.placemark?.Point?.coordinates);
return {
name: asTrimmedString(node.placemark?.name),
description: sanitizeKmlDescription(node.placemark?.description),
lat: coordinates?.lat ?? null,
lng: coordinates?.lng ?? null,
folderName: node.folderName,
};
}
+1 -1
View File
@@ -12,7 +12,7 @@ import {
resolveCategoryIdForFolder,
stripXmlNamespaces,
type KmlImportSummary,
} from './placeImport/kmlImportUtils';
} from './kmlImport';
interface PlaceWithCategory extends Place {
category_name: string | null;
@@ -8,7 +8,7 @@ import {
resolveCategoryIdForFolder,
sanitizeKmlDescription,
stripXmlNamespaces,
} from '../../../src/services/placeImport/kmlImportUtils';
} from '../../../src/services/kmlImport';
describe('kmlImportUtils', () => {
it('strips KML namespaces and prefixes', () => {