mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
refactor(server): consolidate KML import utilities
This commit is contained in:
@@ -19,7 +19,7 @@ import {
|
|||||||
searchPlaceImage,
|
searchPlaceImage,
|
||||||
} from '../services/placeService';
|
} 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 });
|
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)
|
// 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;
|
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))
|
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' });
|
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;
|
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)) {
|
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' });
|
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;
|
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)) {
|
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' });
|
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> = {
|
|
||||||
'&': '&',
|
|
||||||
'<': '<',
|
|
||||||
'>': '>',
|
|
||||||
'"': '"',
|
|
||||||
''': "'",
|
|
||||||
' ': ' ',
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
resolveCategoryIdForFolder,
|
resolveCategoryIdForFolder,
|
||||||
stripXmlNamespaces,
|
stripXmlNamespaces,
|
||||||
type KmlImportSummary,
|
type KmlImportSummary,
|
||||||
} from './placeImport/kmlImportUtils';
|
} from './kmlImport';
|
||||||
|
|
||||||
interface PlaceWithCategory extends Place {
|
interface PlaceWithCategory extends Place {
|
||||||
category_name: string | null;
|
category_name: string | null;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
resolveCategoryIdForFolder,
|
resolveCategoryIdForFolder,
|
||||||
sanitizeKmlDescription,
|
sanitizeKmlDescription,
|
||||||
stripXmlNamespaces,
|
stripXmlNamespaces,
|
||||||
} from '../../../src/services/placeImport/kmlImportUtils';
|
} from '../../../src/services/kmlImport';
|
||||||
|
|
||||||
describe('kmlImportUtils', () => {
|
describe('kmlImportUtils', () => {
|
||||||
it('strips KML namespaces and prefixes', () => {
|
it('strips KML namespaces and prefixes', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user