mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
feat(tours): import KML and KMZ routes as tours, with a preview first
A route planned in Google My Maps or Google Earth can now become a tour without converting it to GPX first. The import button and a file dropped on the tours workspace take .kml and .kmz files beside .gpx, through the same preview, the same import routes and the same transaction as a GPX file. The preview shows every line of the file on its own, numbered, with its distance and heights; two lines of one placemark are numbered after its name and never joined. Heights count only where they are heights: kept for an absolute line, dropped for any other altitude mode and for the all-zero placeholder My Maps writes, so a missing height stays unknown. What the file holds besides its lines (pins, areas, overlays, links to other files) is counted in a dashed card, and nothing is fetched from elsewhere. The KML reading reuses the place import's codec. A KMZ only ever has its KML entry unpacked, counted byte by byte against the decompressed cap whatever its header claims. A file that cannot be read is refused with its reason (damaged, an archive without KML, too large unpacked), and the toast names the file and the reason. A KML or KMZ import keeps the gpx origin of a file import; its provenance names the real format from the stored file name.
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* The names an import gives the lines of one file. Routes and tracks rarely
|
||||
* carry their own name. Without one they would all fall back to the same
|
||||
* generic label, so name-based dedup would drop every import after the first.
|
||||
* The base is the source file's name (without its extension) and an index
|
||||
* keeps several unnamed lines of one file distinct: `walk`, `walk 2`, `walk 3`.
|
||||
*/
|
||||
type GeometryNamer = (explicit: string | null, fallback: string) => string;
|
||||
|
||||
export function createGeometryNamer(defaultName: string | null | undefined): GeometryNamer {
|
||||
const rawName = defaultName != null ? String(defaultName).trim() : null;
|
||||
const baseName = rawName ? rawName.replace(/\.[^.]+$/, '').trim() || rawName : null;
|
||||
let unnamed = 0;
|
||||
return (explicit, fallback) => {
|
||||
if (explicit) return explicit;
|
||||
unnamed++;
|
||||
const base = baseName || fallback;
|
||||
return unnamed === 1 ? base : `${base} ${unnamed}`;
|
||||
};
|
||||
}
|
||||
@@ -8,12 +8,24 @@
|
||||
* either a point or a path's first point plus the path as route geometry.
|
||||
* Which placemarks become places, and under which category, is the importing
|
||||
* domain's decision.
|
||||
*
|
||||
* The Tours import reads the same document for its lines instead (IO-02):
|
||||
* every LineString and gx:Track, also inside a MultiGeometry or gx:MultiTrack,
|
||||
* as a line of its own, and a count of what it leaves out.
|
||||
*/
|
||||
import { stripHtmlTags } from '../common/stripHtmlTags';
|
||||
import type { KmlDocumentRead, KmlImportSummary, ParsedKmlPlacemark } from './place-import.types';
|
||||
import {
|
||||
KmlReadError,
|
||||
type KmlDocumentRead,
|
||||
type KmlImportSummary,
|
||||
type KmlLeftOut,
|
||||
type KmlLineRead,
|
||||
type KmlLinesRead,
|
||||
type ParsedKmlPlacemark,
|
||||
} from './place-import.types';
|
||||
|
||||
import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import unzipper from 'unzipper';
|
||||
import unzipper, { type File as ZipEntry } from 'unzipper';
|
||||
import { TextDecoder } from 'util';
|
||||
|
||||
export type { ParsedKmlPlacemark } from './place-import.types';
|
||||
@@ -52,6 +64,15 @@ function asArray<T>(value: T | T[] | null | undefined): T[] {
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
/** One parsed element: its children and attributes by name. */
|
||||
type KmlNode = Readonly<Record<string, unknown>>;
|
||||
|
||||
const isKmlNode = (value: unknown): value is KmlNode =>
|
||||
value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
/** A child of a parsed element by name; nothing for text or an empty element. */
|
||||
const field = (value: unknown, name: string): unknown => (isKmlNode(value) ? value[name] : undefined);
|
||||
|
||||
function asTrimmedString(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
// Parsed objects (mixed-content XML parsed without stopNodes) must not
|
||||
@@ -111,7 +132,9 @@ export function sanitizeKmlDescription(value: unknown): string | null {
|
||||
return decoded || null;
|
||||
}
|
||||
|
||||
function parseKmlLineStringCoordinates(value: unknown): Array<{ lat: number; lng: number; ele: number | null }> | null {
|
||||
type LinePoint = { lat: number; lng: number; ele: number | null };
|
||||
|
||||
function parseKmlLineStringCoordinates(value: unknown): LinePoint[] | null {
|
||||
const coordinates = asTrimmedString(value);
|
||||
if (!coordinates) return null;
|
||||
|
||||
@@ -126,7 +149,7 @@ function parseKmlLineStringCoordinates(value: unknown): Array<{ lat: number; lng
|
||||
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);
|
||||
.filter((p): p is LinePoint => p !== null);
|
||||
|
||||
return points.length >= 2 ? points : null;
|
||||
}
|
||||
@@ -156,19 +179,15 @@ function createKmlImportSummary(totalPlacemarks: number): KmlImportSummary {
|
||||
};
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
/** Every container of a document (the root, its Documents and Folders), each before its own, with its folder name. */
|
||||
function visitKmlContainers(kmlRoot: unknown, visit: (node: KmlNode, folderName: string | null) => void): void {
|
||||
const visitNode = (node: unknown, currentFolderName: string | null): void => {
|
||||
if (!isKmlNode(node)) return;
|
||||
visit(node, currentFolderName);
|
||||
|
||||
for (const folder of asArray(node.Folder)) {
|
||||
// Nested folders inherit/override folder context used for category matching.
|
||||
const folderName = asTrimmedString(folder?.name) || currentFolderName;
|
||||
const folderName = asTrimmedString(field(folder, 'name')) || currentFolderName;
|
||||
visitNode(folder, folderName);
|
||||
}
|
||||
|
||||
@@ -178,6 +197,15 @@ export function extractKmlPlacemarkNodes(kmlRoot: any): KmlPlacemarkNode[] {
|
||||
};
|
||||
|
||||
visitNode(kmlRoot, null);
|
||||
}
|
||||
|
||||
export function extractKmlPlacemarkNodes(kmlRoot: any): KmlPlacemarkNode[] {
|
||||
const nodes: KmlPlacemarkNode[] = [];
|
||||
visitKmlContainers(kmlRoot, (node, folderName) => {
|
||||
for (const placemark of asArray(node.Placemark)) {
|
||||
nodes.push({ placemark, folderName });
|
||||
}
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
@@ -206,39 +234,165 @@ export function parsePlacemarkNode(node: KmlPlacemarkNode): ParsedKmlPlacemark {
|
||||
}
|
||||
|
||||
/**
|
||||
* A KML document as its placemarks (each level's own before its folders'), with the summary the
|
||||
* import fills in. Throws the two messages the import route has always
|
||||
* answered for a broken file.
|
||||
* A KML file as its parsed root element. Throws the two messages the import
|
||||
* route has always answered for a broken file.
|
||||
*/
|
||||
export function readKmlDocument(fileBuffer: Buffer): KmlDocumentRead {
|
||||
function parseKmlRoot(fileBuffer: Buffer): { kmlRoot: KmlNode; warning: string | null } {
|
||||
const decoded = decodeUtf8WithWarning(fileBuffer);
|
||||
|
||||
const validationResult = XMLValidator.validate(decoded.text);
|
||||
if (validationResult !== true) {
|
||||
throw new Error('Malformed KML: invalid XML structure');
|
||||
throw new KmlReadError('malformed', 'Malformed KML: invalid XML structure');
|
||||
}
|
||||
|
||||
const parsed = kmlParser.parse(decoded.text);
|
||||
const kmlRoot = parsed?.kml ?? parsed;
|
||||
const parsed: unknown = kmlParser.parse(decoded.text);
|
||||
const kmlRoot = field(parsed, 'kml') ?? parsed;
|
||||
|
||||
if (!kmlRoot || typeof kmlRoot !== 'object') {
|
||||
throw new Error('Malformed KML: could not parse XML');
|
||||
if (!isKmlNode(kmlRoot)) {
|
||||
throw new KmlReadError('malformed', 'Malformed KML: could not parse XML');
|
||||
}
|
||||
|
||||
return { kmlRoot, warning: decoded.warning };
|
||||
}
|
||||
|
||||
/**
|
||||
* A KML document as its placemarks (each level's own before its folders'), with the summary the
|
||||
* import fills in.
|
||||
*/
|
||||
export function readKmlDocument(fileBuffer: Buffer): KmlDocumentRead {
|
||||
const { kmlRoot, warning } = parseKmlRoot(fileBuffer);
|
||||
|
||||
const placemarkNodes = extractKmlPlacemarkNodes(kmlRoot);
|
||||
const summary = createKmlImportSummary(placemarkNodes.length);
|
||||
|
||||
if (decoded.warning) {
|
||||
summary.warnings.push(decoded.warning);
|
||||
if (warning) {
|
||||
summary.warnings.push(warning);
|
||||
}
|
||||
|
||||
return { placemarks: placemarkNodes.map(parsePlacemarkNode), summary };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lines for the Tours import (IO-02)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** What a document holds besides placemarks that the Tours import never reads: overlays, and links it never follows. */
|
||||
const LEFT_OUT_FEATURES = ['NetworkLink', 'GroundOverlay', 'ScreenOverlay', 'PhotoOverlay'] as const;
|
||||
|
||||
/** The points of a gx:Track, each `<gx:coord>` written `lng lat alt` with spaces. */
|
||||
function parseKmlTrackCoordinates(value: unknown): LinePoint[] | null {
|
||||
const points = asArray(value).flatMap((coord): LinePoint[] => {
|
||||
const [lng = Number.NaN, lat = Number.NaN, ele = Number.NaN] = (asTrimmedString(coord) ?? '')
|
||||
.split(/\s+/)
|
||||
.map((part) => Number.parseFloat(part));
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return [];
|
||||
return [{ lat, lng, ele: Number.isFinite(ele) ? ele : null }];
|
||||
});
|
||||
return points.length >= 2 ? points : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A line as route geometry, `[lat, lng, ele]` only where its heights can be
|
||||
* trusted as heights. KML's default altitude mode is clampToGround, under
|
||||
* which an altitude means nothing, and Google My Maps writes a 0 for every
|
||||
* point. So the heights are kept when the line says `absolute`, dropped when
|
||||
* it names any other mode, and without a mode kept unless every one is 0. A
|
||||
* line with a point lacking one keeps none, as a GPX track does.
|
||||
*/
|
||||
export function kmlLineGeometry(points: LinePoint[], altitudeMode: string | null): number[][] {
|
||||
const complete = points.every((p) => p.ele !== null);
|
||||
const meaningful = altitudeMode === null ? points.some((p) => p.ele !== 0) : altitudeMode === 'absolute';
|
||||
return points.map((p) => (complete && meaningful && p.ele !== null ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]));
|
||||
}
|
||||
|
||||
/** One geometry element of a placemark taken apart into its lines; what is not a line is counted as left out. */
|
||||
function collectKmlLines(node: KmlNode, inheritedMode: string | null, lines: number[][][], leftOut: KmlLeftOut): void {
|
||||
const mode = asTrimmedString(node.altitudeMode) ?? inheritedMode;
|
||||
const shapes = [
|
||||
...asArray(node.LineString).map((line) => ({
|
||||
line,
|
||||
points: parseKmlLineStringCoordinates(field(line, 'coordinates')),
|
||||
})),
|
||||
...asArray(node.Track).map((line) => ({ line, points: parseKmlTrackCoordinates(field(line, 'coord')) })),
|
||||
];
|
||||
for (const { line, points } of shapes) {
|
||||
if (points) lines.push(kmlLineGeometry(points, asTrimmedString(field(line, 'altitudeMode')) ?? mode));
|
||||
else leftOut.other += 1;
|
||||
}
|
||||
for (const group of [...asArray(node.MultiGeometry), ...asArray(node.MultiTrack)]) {
|
||||
if (isKmlNode(group)) collectKmlLines(group, mode, lines, leftOut);
|
||||
}
|
||||
leftOut.points += asArray(node.Point).length;
|
||||
leftOut.areas += asArray(node.Polygon).length;
|
||||
leftOut.other += asArray(node.Model).length + asArray(node.LinearRing).length;
|
||||
}
|
||||
|
||||
const leftOutTotal = (leftOut: KmlLeftOut) => leftOut.points + leftOut.areas + leftOut.other;
|
||||
|
||||
/**
|
||||
* A placemark's lines, each kept apart: two lines of one placemark are never
|
||||
* joined into one, they are numbered after its name instead.
|
||||
*/
|
||||
function placemarkLines(placemark: unknown, leftOut: KmlLeftOut): KmlLineRead[] {
|
||||
const geometries: number[][][] = [];
|
||||
const before = leftOutTotal(leftOut);
|
||||
if (isKmlNode(placemark)) collectKmlLines(placemark, null, geometries, leftOut);
|
||||
// A placemark that held nothing at all, not even something left out, is counted once.
|
||||
if (geometries.length === 0 && leftOutTotal(leftOut) === before) leftOut.other += 1;
|
||||
|
||||
const name = asTrimmedString(field(placemark, 'name'));
|
||||
const description = sanitizeKmlDescription(field(placemark, 'description'));
|
||||
return geometries.map((geometry, index) => ({
|
||||
name: name && geometries.length > 1 ? `${name} (${index + 1}/${geometries.length})` : name,
|
||||
description,
|
||||
geometry,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A KML document as the lines a tour can be made of, in document order, and a
|
||||
* count of what it holds besides: single points, areas, and everything else
|
||||
* (models, overlays, links to other files, which are never followed, and
|
||||
* lines too short to be one).
|
||||
*/
|
||||
export function readKmlLines(fileBuffer: Buffer): KmlLinesRead {
|
||||
const { kmlRoot, warning } = parseKmlRoot(fileBuffer);
|
||||
const leftOut: KmlLeftOut = { points: 0, areas: 0, other: 0 };
|
||||
const lines = extractKmlPlacemarkNodes(kmlRoot).flatMap(({ placemark }) => placemarkLines(placemark, leftOut));
|
||||
visitKmlContainers(kmlRoot, (node) => {
|
||||
for (const feature of LEFT_OUT_FEATURES) leftOut.other += asArray(node[feature]).length;
|
||||
});
|
||||
return { lines, leftOut, warnings: warning ? [warning] : [] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KMZ unpacking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tooLarge = () =>
|
||||
new KmlReadError('archive_too_large', 'KMZ archive exceeds the maximum allowed decompressed size.');
|
||||
|
||||
/**
|
||||
* An archive entry decompressed, stopped as soon as it passes the limit. The
|
||||
* size an entry's header states is only a claim, so the bytes themselves are
|
||||
* counted: a crafted archive cannot unpack more than the limit by lying.
|
||||
*/
|
||||
export async function readKmzEntryCapped(entry: Pick<ZipEntry, 'stream'>, limit: number): Promise<Buffer> {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
for await (const chunk of entry.stream()) {
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
||||
size += bytes.length;
|
||||
if (size > limit) throw tooLarge();
|
||||
chunks.push(bytes);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
/**
|
||||
* The KML a KMZ archive carries. Only that one entry is read: images and
|
||||
* other files beside it are never unpacked, run or fetched.
|
||||
*/
|
||||
export async function unpackKmzToKml(
|
||||
kmzBuffer: Buffer,
|
||||
decompressedSizeLimit = KMZ_DECOMPRESSED_SIZE_LIMIT,
|
||||
@@ -247,21 +401,27 @@ export async function unpackKmzToKml(
|
||||
try {
|
||||
zip = await unzipper.Open.buffer(kmzBuffer);
|
||||
} catch {
|
||||
throw new Error('Invalid KMZ archive.');
|
||||
throw new KmlReadError('archive_invalid', 'Invalid KMZ archive.');
|
||||
}
|
||||
|
||||
const kmlEntries = zip.files.filter(
|
||||
(entry) => !entry.path.endsWith('/') && entry.path.toLowerCase().endsWith('.kml'),
|
||||
);
|
||||
if (kmlEntries.length === 0) {
|
||||
throw new Error('KMZ archive does not contain a KML file.');
|
||||
throw new KmlReadError('archive_without_kml', 'KMZ archive does not contain a KML file.');
|
||||
}
|
||||
|
||||
const preferredEntry = kmlEntries.find((entry) => entry.path.toLowerCase().endsWith('doc.kml')) || kmlEntries[0];
|
||||
|
||||
if (preferredEntry.uncompressedSize > decompressedSizeLimit) {
|
||||
throw new Error('KMZ archive exceeds the maximum allowed decompressed size.');
|
||||
throw tooLarge();
|
||||
}
|
||||
|
||||
return preferredEntry.buffer();
|
||||
try {
|
||||
return await readKmzEntryCapped(preferredEntry, decompressedSizeLimit);
|
||||
} catch (err) {
|
||||
if (err instanceof KmlReadError) throw err;
|
||||
// A damaged entry fails while it inflates, not while the directory is read.
|
||||
throw new KmlReadError('archive_invalid', 'Invalid KMZ archive.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
GoogleListPlace,
|
||||
GpxImportOptions,
|
||||
KmlDocumentRead,
|
||||
KmlTracksRead,
|
||||
ListImportError,
|
||||
ListRead,
|
||||
NaverListPlace,
|
||||
@@ -109,6 +110,11 @@ export class PlaceImportService {
|
||||
return this.kml.readKml(fileBuffer);
|
||||
}
|
||||
|
||||
/** IO-02: the lines of a .kml or .kmz upload as tour rows, and what it holds besides. Throws KmlReadError. */
|
||||
readMapFileTracks(fileBuffer: Buffer, filename: string): Promise<KmlTracksRead> {
|
||||
return this.kml.readTracks(fileBuffer, filename);
|
||||
}
|
||||
|
||||
// ── Shared lists ───────────────────────────────────────────────────────────
|
||||
|
||||
/** A Google Maps list, or the directions link a short link turned out to be. */
|
||||
|
||||
@@ -56,6 +56,53 @@ export interface KmlDocumentRead {
|
||||
summary: KmlImportSummary;
|
||||
}
|
||||
|
||||
/** What a KML document holds that a line import leaves out, counted by kind. */
|
||||
export interface KmlLeftOut {
|
||||
/** Single points (pins). */
|
||||
points: number;
|
||||
/** Polygons. */
|
||||
areas: number;
|
||||
/** Everything else: models, overlays, links to other files, placemarks without a geometry, too short lines. */
|
||||
other: number;
|
||||
}
|
||||
|
||||
/** One line of a KML document: a LineString or a gx:Track, as route geometry `[lat, lng]` or `[lat, lng, ele]`. */
|
||||
export interface KmlLineRead {
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
geometry: number[][];
|
||||
}
|
||||
|
||||
/** A KML document read for its lines (IO-02). */
|
||||
export interface KmlLinesRead {
|
||||
lines: KmlLineRead[];
|
||||
leftOut: KmlLeftOut;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** The lines of a KML or KMZ file as the rows a Tours import stores, and what it leaves out. */
|
||||
export interface KmlTracksRead {
|
||||
tracks: PreparedGpxPlace[];
|
||||
leftOut: KmlLeftOut;
|
||||
}
|
||||
|
||||
/** Why a KML or KMZ file could not be read. */
|
||||
export type KmlReadFailure = 'malformed' | 'archive_invalid' | 'archive_without_kml' | 'archive_too_large';
|
||||
|
||||
/**
|
||||
* A KML or KMZ file that could not be read, with the message the place import
|
||||
* route has always answered and the reason a caller can tell apart.
|
||||
*/
|
||||
export class KmlReadError extends Error {
|
||||
constructor(
|
||||
readonly reason: KmlReadFailure,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'KmlReadError';
|
||||
}
|
||||
}
|
||||
|
||||
/** An import that could not happen, with the status and the exact message the route answers. */
|
||||
export interface ListImportError {
|
||||
error: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createGeometryNamer } from '../geometry-names';
|
||||
import { coord, gpxGeometryParser, writeGpx } from '../gpx.codec';
|
||||
import type { GpxImportOptions, PreparedGpxPlace } from '../place-import.types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
@@ -249,19 +250,7 @@ function prepareGpxRows(fileBuffer: Buffer, opts: GpxImportOptions = {}): Prepar
|
||||
return Number.isNaN(n) ? null : n;
|
||||
};
|
||||
|
||||
// Routes and tracks rarely carry their own <name>. Without one they all fall back to the
|
||||
// same generic label, so name-based dedup drops every import after the first. Derive a
|
||||
// base from the source filename (the requested behaviour) and suffix an index so multiple
|
||||
// geometries from one file stay distinct.
|
||||
const rawName = str(defaultName);
|
||||
const baseName = rawName ? rawName.replace(/\.[^.]+$/, '').trim() || rawName : null;
|
||||
let geoSeq = 0;
|
||||
const geoName = (explicit: string | null, fallback: string): string => {
|
||||
if (explicit) return explicit;
|
||||
geoSeq++;
|
||||
const base = baseName || fallback;
|
||||
return geoSeq === 1 ? base : `${base} ${geoSeq}`;
|
||||
};
|
||||
const geoName = createGeometryNamer(defaultName);
|
||||
|
||||
const waypoints: PreparedGpxPlace[] = [];
|
||||
|
||||
|
||||
@@ -2,15 +2,22 @@
|
||||
* A KML or KMZ map file (Google My Maps, Google Earth), read through kml.codec
|
||||
* into its placemarks. The file type is the upload's extension, as it has
|
||||
* always been; anything else is refused with the route's own message.
|
||||
*
|
||||
* The Tours import reads the same file for its lines (IO-02): each one a row
|
||||
* the GPX import's persistence takes as it takes a track, named the way a GPX
|
||||
* track without a name is.
|
||||
*/
|
||||
import { readKmlDocument, unpackKmzToKml } from '../kml.codec';
|
||||
import type { KmlDocumentRead } from '../place-import.types';
|
||||
import { createGeometryNamer } from '../geometry-names';
|
||||
import { readKmlDocument, readKmlLines, unpackKmzToKml } from '../kml.codec';
|
||||
import type { KmlDocumentRead, KmlTracksRead, PreparedGpxPlace } from '../place-import.types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
const extensionOf = (filename: string) => filename.toLowerCase().split('.').pop();
|
||||
|
||||
@Injectable()
|
||||
export class KmlProvider {
|
||||
async readMapFile(fileBuffer: Buffer, filename: string): Promise<KmlDocumentRead> {
|
||||
const ext = filename.toLowerCase().split('.').pop();
|
||||
const ext = extensionOf(filename);
|
||||
if (ext === 'kmz') return this.readKmz(fileBuffer);
|
||||
if (ext === 'kml') return this.readKml(fileBuffer);
|
||||
throw new Error(`Unsupported map file format: .${ext}. Please upload a .kml or .kmz file.`);
|
||||
@@ -23,4 +30,23 @@ export class KmlProvider {
|
||||
readKml(fileBuffer: Buffer): KmlDocumentRead {
|
||||
return readKmlDocument(fileBuffer);
|
||||
}
|
||||
|
||||
/** The lines of a .kml or, by its extension, a .kmz file as tour rows, and what the file holds besides. */
|
||||
async readTracks(fileBuffer: Buffer, filename: string): Promise<KmlTracksRead> {
|
||||
const kml = extensionOf(filename) === 'kmz' ? await unpackKmzToKml(fileBuffer) : fileBuffer;
|
||||
const { lines, leftOut } = readKmlLines(kml);
|
||||
const nameOf = createGeometryNamer(filename);
|
||||
const tracks = lines.map((line): PreparedGpxPlace => {
|
||||
// A line has at least two points, so its start is always there.
|
||||
const [lat, lng] = line.geometry[0];
|
||||
return {
|
||||
name: nameOf(line.name, 'KML Track'),
|
||||
lat,
|
||||
lng,
|
||||
description: line.description,
|
||||
routeGeometry: JSON.stringify(line.geometry),
|
||||
};
|
||||
});
|
||||
return { tracks, leftOut };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* IO-02: the file a Tours import reads, whichever of the three formats it is.
|
||||
* A GPX file gives its tracks and routes, a KML or KMZ file its lines; both
|
||||
* come back as the same rows, so the preview, the duplicate rule and the one
|
||||
* transaction that stores them are the GPX import's own. Loose points are
|
||||
* never a tour: a GPX file's waypoints are skipped as they always were, and a
|
||||
* KML file's pins, areas and the rest are counted for the preview to name.
|
||||
*/
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import type { PlaceImportService } from '../place-import/place-import.service';
|
||||
import { KmlReadError, type PreparedGpxPlace } from '../place-import/place-import.types';
|
||||
import type { TourFileLeftOut } from '@trek/shared';
|
||||
|
||||
type TourFileFormat = 'gpx' | 'kml' | 'kmz';
|
||||
|
||||
/** A file read for a Tours import: the rows it would store, and for a KML or KMZ file what it holds besides. */
|
||||
export interface TourFileRead {
|
||||
rows: PreparedGpxPlace[];
|
||||
leftOut?: TourFileLeftOut;
|
||||
}
|
||||
|
||||
/** The format by the upload's extension; anything else is read as GPX, as every Tours import was before. */
|
||||
function tourFileFormat(fileName: string | undefined): TourFileFormat {
|
||||
const ext = fileName?.toLowerCase().split('.').pop();
|
||||
return ext === 'kml' || ext === 'kmz' ? ext : 'gpx';
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal for a file without a single line. A GPX file is answered with
|
||||
* the body the route has always sent; a KML or KMZ file names its own format
|
||||
* and carries the code the client tells the case by.
|
||||
*/
|
||||
export function noTrackFound(fileName: string | undefined): DomainError {
|
||||
const format = tourFileFormat(fileName);
|
||||
if (format === 'gpx') return new DomainError(400, 'No track or route found in GPX file', { code: 'no_track' });
|
||||
return new DomainError(400, `No line found in ${format.toUpperCase()} file`, {
|
||||
code: 'no_track',
|
||||
details: { code: 'no_track' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows a Tours import stores from this file. A KML or KMZ file that cannot
|
||||
* be read is a 400 whose `code` says why (malformed, archive_invalid,
|
||||
* archive_without_kml, archive_too_large).
|
||||
*/
|
||||
export async function readTourFile(
|
||||
placeImport: PlaceImportService,
|
||||
fileBuffer: Buffer,
|
||||
fileName: string | undefined,
|
||||
): Promise<TourFileRead> {
|
||||
if (tourFileFormat(fileName) === 'gpx') {
|
||||
const rows = placeImport.readGpx(fileBuffer, {
|
||||
importWaypoints: false,
|
||||
importRoutes: true,
|
||||
importTracks: true,
|
||||
defaultName: fileName,
|
||||
});
|
||||
return { rows };
|
||||
}
|
||||
try {
|
||||
const { tracks, leftOut } = await placeImport.readMapFileTracks(fileBuffer, fileName ?? '');
|
||||
return { rows: tracks, leftOut };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof KmlReadError) {
|
||||
throw new DomainError(400, err.message, { code: 'tour_file_unreadable', details: { code: err.reason } });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { TourGpxImportFieldsDto } from './dto/tour-gpx-import.dto';
|
||||
import { noTrackFound } from './tour-file-import.helpers';
|
||||
import { ToursService } from './tours.service';
|
||||
import {
|
||||
Body,
|
||||
@@ -25,11 +26,12 @@ import { tourGpxPreviewResponseSchema, tourImportGpxResponseSchema } from '@trek
|
||||
import { memoryStorage } from 'multer';
|
||||
|
||||
const UPLOAD = { storage: memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } };
|
||||
const NO_TRACK = 'No track or route found in GPX file';
|
||||
|
||||
/**
|
||||
* POST /api/trips/:tripId/tours/import/gpx: the tours-mode GPX import, and
|
||||
* POST /api/trips/:tripId/tours/import/gpx: the tours-mode file import, and
|
||||
* POST .../import/gpx/preview: the same file read and shown, nothing stored.
|
||||
* Both read a GPX file, or by its extension a KML or KMZ file (IO-02); the
|
||||
* paths keep the name they were published under.
|
||||
*
|
||||
* A controller of its own because it must not sit behind ToursController's
|
||||
* AddonGuard and TripAccessGuard: a guard answers before multer has read the
|
||||
@@ -78,7 +80,7 @@ export class ToursImportController {
|
||||
const buffer = await this.checkedFile(user, tripId, file);
|
||||
const preview = await this.tours.previewGpx(tripId, buffer, file?.originalname);
|
||||
if (!preview) {
|
||||
throw new HttpException({ error: NO_TRACK }, 400);
|
||||
throw noTrackFound(file?.originalname);
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
@@ -97,7 +99,7 @@ export class ToursImportController {
|
||||
const buffer = await this.checkedFile(user, tripId, file);
|
||||
const result = await this.tours.importGpxAsTour(tripId, buffer, file?.originalname, socketId, fields);
|
||||
if (!result) {
|
||||
throw new HttpException({ error: NO_TRACK }, 400);
|
||||
throw noTrackFound(file?.originalname);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ export class ToursMcp {
|
||||
@Tool({
|
||||
name: 'list_tours',
|
||||
description:
|
||||
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski track; a ride has no riding time worked out, so its duration is null), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day) and the stops they run between (stage_stops, in route order with key and coordinates: stage n runs from stop n-1 to stop n, and is known by the key of the stop it starts at), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), and the days of the trip each tour is on (tour_stage_key names the stage a day holds by its starting stop, null where the day holds the whole tour; a stage may also be on no day yet).',
|
||||
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski track; a ride has no riding time worked out, so its duration is null), where it came from (origin: trek, gpx for an imported GPX, KML or KMZ file, or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), for a multi-day tour the number of stages its route is split into (stage_count: one route planned whole, each stage ending at a stop marked as its end; null for a tour of one day) and the stops they run between (stage_stops, in route order with key and coordinates: stage n runs from stop n-1 to stop n, and is known by the key of the stop it starts at), the revision (raised by every saved change of the tour), the recorded tracks assigned to a tour (activity_tracks) and, for a recorded track, the tour it documents (activity_of), when an imported track was recorded (track_started_at, track_ended_at; null when the file carried no times), and the days of the trip each tour is on (tour_stage_key names the stage a day holds by its starting stop, null where the day holds the whole tour; a stage may also be on no day yet).',
|
||||
inputSchema: { tripId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
|
||||
@@ -30,6 +30,7 @@ import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { PlaceImportService } from '../place-import/place-import.service';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { activityFacts, toActivity, trackTimesFor } from './tour-activities.helpers';
|
||||
import { readTourFile } from './tour-file-import.helpers';
|
||||
import { TourPaddleCheckService } from './tour-paddle-check.service';
|
||||
import { advanceTourRevision } from './tour-revision';
|
||||
import { toVisit } from './tour-visits.service';
|
||||
@@ -88,7 +89,7 @@ export interface ImportGpxAsTourResult {
|
||||
/**
|
||||
* Tours domain: the `tours` facet table (place_id PK/FK) carries tour-specific
|
||||
* metadata while existing places and day assignments are reused.
|
||||
* GPX parsing goes through PlaceImportService.readGpx, persistence through
|
||||
* A GPX, KML or KMZ file is read through PlaceImportService (readTourFile), persistence goes through
|
||||
* PlacesService.importPreparedGpx. Places and facet rows are persisted in one transaction,
|
||||
* with tour metrics derived from each place's route_geometry.
|
||||
*/
|
||||
@@ -949,10 +950,11 @@ export class ToursService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The tours-mode GPX import reads rows through PlaceImportService.readGpx
|
||||
* with waypoints excluded. PlacesService.importPreparedGpx persists the places
|
||||
* in the same transaction as their `tours` facet rows, whose metrics are
|
||||
* derived from route_geometry.
|
||||
* The tours-mode file import reads rows through readTourFile: a GPX file's
|
||||
* tracks and routes with its waypoints excluded, a KML or KMZ file's lines
|
||||
* (IO-02). PlacesService.importPreparedGpx persists the places in the same
|
||||
* transaction as their `tours` facet rows, whose metrics are derived from
|
||||
* route_geometry, so a failure leaves no tour half stored.
|
||||
*
|
||||
* Missing or implausible metrics do not block import:
|
||||
* a track without elevation still imports, just flagged with a low
|
||||
@@ -965,7 +967,7 @@ export class ToursService {
|
||||
socketId?: string,
|
||||
fields: TourGpxImportFields = HIKE_IMPORT,
|
||||
): Promise<ImportGpxAsTourResult | null> {
|
||||
const rows = this.readGpxTracks(fileBuffer, fileName);
|
||||
const { rows } = await readTourFile(this.placeImport, fileBuffer, fileName);
|
||||
if (rows.length === 0) return null;
|
||||
await this.assertTourTypeEnabled(fields.tour_type);
|
||||
// What the type adds to each imported tour: a bike tour and a run their profile, a paddle tour its boat and water,
|
||||
@@ -1004,7 +1006,7 @@ export class ToursService {
|
||||
match_confidence: matchConfidence,
|
||||
max_hiking_difficulty: null,
|
||||
origin: 'gpx',
|
||||
origin_file_name: gpxFileName(fileName),
|
||||
origin_file_name: importFileName(fileName),
|
||||
track_started_at: recorded?.started ?? null,
|
||||
track_ended_at: recorded?.ended ?? null,
|
||||
});
|
||||
@@ -1063,12 +1065,13 @@ export class ToursService {
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-02: what a GPX file would import, read with the import's own parser
|
||||
* and duplicate rule and written nowhere. Null when the file holds no track
|
||||
* or route, the import's own refusal.
|
||||
* PAD-02: what a file would import, read with the import's own parser and
|
||||
* duplicate rule and written nowhere. Null when the file holds no track or
|
||||
* route, the import's own refusal. A KML or KMZ file also says what it holds
|
||||
* besides its lines (IO-02).
|
||||
*/
|
||||
async previewGpx(tripId: string, fileBuffer: Buffer, fileName?: string): Promise<TourGpxPreviewResponse | null> {
|
||||
const rows = this.readGpxTracks(fileBuffer, fileName);
|
||||
const { rows, leftOut } = await readTourFile(this.placeImport, fileBuffer, fileName);
|
||||
if (rows.length === 0) return null;
|
||||
const duplicates = await this.places.gpxDuplicates(tripId, rows);
|
||||
return {
|
||||
@@ -1087,18 +1090,9 @@ export class ToursService {
|
||||
duplicate: duplicates[index] ?? false,
|
||||
};
|
||||
}),
|
||||
...(leftOut ? { left_out: leftOut } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The tracks and routes of a GPX file, never its loose waypoints: the reading every Tours import shares. */
|
||||
private readGpxTracks(fileBuffer: Buffer, fileName?: string) {
|
||||
return this.placeImport.readGpx(fileBuffer, {
|
||||
importWaypoints: false,
|
||||
importRoutes: true,
|
||||
importTracks: true,
|
||||
defaultName: fileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a tour without a line starts (CW-11): at its first point. */
|
||||
@@ -1107,8 +1101,8 @@ function firstPointOf(points: readonly { lat: number; lng: number }[]): [number,
|
||||
return point ? [point.lat, point.lng] : undefined;
|
||||
}
|
||||
|
||||
/** The file name a GPX import keeps: the name as uploaded, without a path, cut to what the column shows. */
|
||||
function gpxFileName(name: string | undefined): string | null {
|
||||
/** The file name a file import keeps: the name as uploaded, without a path, cut to what the column shows. */
|
||||
function importFileName(name: string | undefined): string | null {
|
||||
const base = name?.split('/').pop()?.trim();
|
||||
return base ? base.slice(0, 255) : null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user