Spaces:
Sleeping
Sleeping
File size: 7,059 Bytes
57a889c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 | 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,
};
}
|