Spaces:
Sleeping
Sleeping
| 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> = { | |
| '&': '&', | |
| '<': '<', | |
| '>': '>', | |
| '"': '"', | |
| ''': "'", | |
| ' ': ' ', | |
| }; | |
| 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, | |
| }; | |
| } | |