mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat(import): selective GPX/KML element import and performance improvements
Add type-selector UI in the file import modal letting users choose which GPX elements (waypoints, routes, tracks) or KML/KMZ elements (points, paths) to import. KML LineString placemarks are now imported as path places with route_geometry. Performance improvements: - Extract MemoPlaceRow with React.memo and contentVisibility:auto to cut unnecessary re-renders in PlacesSidebar - Add weatherQueue to cap concurrent weather fetches at 3 - Replace sequential per-place deletes with a single bulkDelete API call (new DELETE /places/bulk endpoint + deletePlacesMany service) - Memoize atlas/photo/weather service calls to avoid redundant requests - Add multi-select mode to PlacesSidebar for bulk operations Add large GPX/KML/KMZ fixtures for integration/perf testing and two profiler analysis scripts under scripts/.
This commit is contained in:
@@ -12,11 +12,13 @@ import {
|
||||
getPlace,
|
||||
updatePlace,
|
||||
deletePlace,
|
||||
deletePlacesMany,
|
||||
importGpx,
|
||||
importMapFile,
|
||||
importGoogleList,
|
||||
importNaverList,
|
||||
searchPlaceImage,
|
||||
type KmlImportOptions,
|
||||
} from '../services/placeService';
|
||||
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService';
|
||||
|
||||
@@ -65,9 +67,18 @@ router.post('/import/gpx', authenticate, requireTripAccess, uploadMulter.single(
|
||||
const file = req.file as Express.Multer.File | undefined;
|
||||
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const result = importGpx(tripId, file.buffer);
|
||||
const parseBool = (v: unknown, defaultVal: boolean) => v === undefined || v === null ? defaultVal : String(v) === 'true';
|
||||
const importWaypoints = parseBool(req.body.importWaypoints, true);
|
||||
const importRoutes = parseBool(req.body.importRoutes, true);
|
||||
const importTracks = parseBool(req.body.importTracks, true);
|
||||
|
||||
if (!importWaypoints && !importRoutes && !importTracks) {
|
||||
return res.status(400).json({ error: 'No import types selected' });
|
||||
}
|
||||
|
||||
const result = importGpx(tripId, file.buffer, { importWaypoints, importRoutes, importTracks });
|
||||
if (!result) {
|
||||
return res.status(400).json({ error: 'No waypoints found in GPX file' });
|
||||
return res.status(400).json({ error: 'No matching places found in GPX file' });
|
||||
}
|
||||
|
||||
res.status(201).json({ places: result.places, count: result.count, skipped: result.skipped });
|
||||
@@ -86,8 +97,18 @@ router.post('/import/map', authenticate, requireTripAccess, uploadMulter.single(
|
||||
const file = req.file as Express.Multer.File | undefined;
|
||||
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const parseBool = (v: unknown, defaultVal: boolean) => v === undefined || v === null ? defaultVal : String(v) === 'true';
|
||||
const importPoints = parseBool(req.body.importPoints, true);
|
||||
const importPaths = parseBool(req.body.importPaths, true);
|
||||
|
||||
if (!importPoints && !importPaths) {
|
||||
return res.status(400).json({ error: 'No import types selected' });
|
||||
}
|
||||
|
||||
const kmlOpts: KmlImportOptions = { importPoints, importPaths };
|
||||
|
||||
try {
|
||||
const result = await importMapFile(tripId, file.buffer, file.originalname);
|
||||
const result = await importMapFile(tripId, file.buffer, file.originalname, kmlOpts);
|
||||
if (result.summary?.totalPlacemarks === 0) {
|
||||
return res.status(400).json({ error: 'No valid Placemarks found in map file', summary: result.summary });
|
||||
}
|
||||
@@ -201,6 +222,30 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name
|
||||
try { onPlaceUpdated(place.id); } catch {}
|
||||
});
|
||||
|
||||
// Bulk delete (must be before /:id)
|
||||
router.post('/bulk-delete', authenticate, requireTripAccess, (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' });
|
||||
|
||||
const { tripId } = req.params;
|
||||
const { ids } = req.body as { ids?: unknown };
|
||||
if (!Array.isArray(ids) || ids.some(v => typeof v !== 'number'))
|
||||
return res.status(400).json({ error: 'ids must be an array of numbers' });
|
||||
|
||||
const idList = ids as number[];
|
||||
if (idList.length === 0) return res.json({ deleted: [], count: 0 });
|
||||
|
||||
for (const id of idList) { try { onPlaceDeleted(id); } catch {} }
|
||||
const deleted = deletePlacesMany(tripId, idList);
|
||||
|
||||
res.json({ deleted, count: deleted.length });
|
||||
const socketId = req.headers['x-socket-id'] as string;
|
||||
for (const id of deleted) {
|
||||
broadcast(tripId, 'place:deleted', { placeId: id }, socketId);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, requireTripAccess, (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))
|
||||
|
||||
@@ -264,6 +264,54 @@ function getPlacesForTrips(tripIds: number[]): Place[] {
|
||||
return db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds) as Place[];
|
||||
}
|
||||
|
||||
// ── Country resolution (batch DB cache + sync fallback + background geocoding) ──
|
||||
|
||||
function resolvePlaceCountries(places: Place[]): Map<number, string> {
|
||||
const out = new Map<number, string>();
|
||||
const geoPlaces = places.filter(p => p.lat && p.lng);
|
||||
const placeIds = geoPlaces.map(p => p.id);
|
||||
|
||||
const cached = placeIds.length > 0
|
||||
? (db.prepare(
|
||||
`SELECT place_id, country_code FROM place_regions WHERE place_id IN (${placeIds.map(() => '?').join(',')})`
|
||||
).all(...placeIds) as { place_id: number; country_code: string }[])
|
||||
: [];
|
||||
const cachedMap = new Map(cached.map(r => [r.place_id, r.country_code]));
|
||||
|
||||
const uncachedForGeocode: Place[] = [];
|
||||
for (const p of places) {
|
||||
const fromDb = cachedMap.get(p.id);
|
||||
if (fromDb) { out.set(p.id, fromDb); continue; }
|
||||
const sync = resolveCountryCodeSync(p);
|
||||
if (sync) { out.set(p.id, sync); continue; }
|
||||
if (p.lat && p.lng && !geocodingInFlight.has(p.id)) {
|
||||
uncachedForGeocode.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (uncachedForGeocode.length > 0) {
|
||||
const insertStmt = db.prepare(
|
||||
'INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
for (const p of uncachedForGeocode) geocodingInFlight.add(p.id);
|
||||
void (async () => {
|
||||
try {
|
||||
for (const place of uncachedForGeocode) {
|
||||
try {
|
||||
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
|
||||
if (info) insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
|
||||
} catch { /* continue */ }
|
||||
finally { geocodingInFlight.delete(place.id); }
|
||||
}
|
||||
} catch {
|
||||
for (const p of uncachedForGeocode) geocodingInFlight.delete(p.id);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── getStats ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getStats(userId: number) {
|
||||
@@ -279,9 +327,10 @@ export async function getStats(userId: number) {
|
||||
const places = getPlacesForTrips(tripIds);
|
||||
|
||||
interface CountryEntry { code: string; places: { id: number; name: string; lat: number | null; lng: number | null }[]; tripIds: Set<number> }
|
||||
const placeCountries = resolvePlaceCountries(places);
|
||||
const countrySet = new Map<string, CountryEntry>();
|
||||
for (const place of places) {
|
||||
const code = await resolveCountryCode(place);
|
||||
const code = placeCountries.get(place.id);
|
||||
if (code) {
|
||||
if (!countrySet.has(code)) {
|
||||
countrySet.set(code, { code, places: [], tripIds: new Set() });
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface ParsedKmlPlacemark {
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
folderName: string | null;
|
||||
routeGeometry: string | null;
|
||||
}
|
||||
|
||||
export interface KmlPlacemarkNode {
|
||||
@@ -97,6 +98,26 @@ export function sanitizeKmlDescription(value: unknown): string | null {
|
||||
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;
|
||||
@@ -167,13 +188,25 @@ export function extractKmlPlacemarkNodes(kmlRoot: any): KmlPlacemarkNode[] {
|
||||
}
|
||||
|
||||
export function parsePlacemarkNode(node: KmlPlacemarkNode): ParsedKmlPlacemark {
|
||||
const coordinates = parseKmlPointCoordinates(node.placemark?.Point?.coordinates);
|
||||
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: coordinates?.lat ?? null,
|
||||
lng: coordinates?.lng ?? null,
|
||||
lat: pointCoords?.lat ?? pathFirstPt?.lat ?? null,
|
||||
lng: pointCoords?.lng ?? pathFirstPt?.lng ?? null,
|
||||
folderName: node.folderName,
|
||||
routeGeometry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,6 +71,30 @@ const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
|
||||
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
||||
import * as placePhotoCache from './placePhotoCache';
|
||||
|
||||
// ── Concurrency limiter for outbound photo fetches ───────────────────────────
|
||||
// Caps simultaneous Wikimedia/Google photo requests so a bulk import of hundreds
|
||||
// of places cannot monopolise the event loop or trigger external API rate limits.
|
||||
const MAX_CONCURRENT_PHOTO_FETCHES = 5;
|
||||
let photoFetchActive = 0;
|
||||
const photoFetchQueue: Array<() => void> = [];
|
||||
|
||||
function acquirePhotoFetchSlot(): Promise<void> {
|
||||
if (photoFetchActive < MAX_CONCURRENT_PHOTO_FETCHES) {
|
||||
photoFetchActive++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise(resolve => photoFetchQueue.push(resolve));
|
||||
}
|
||||
|
||||
function releasePhotoFetchSlot(): void {
|
||||
const next = photoFetchQueue.shift();
|
||||
if (next) {
|
||||
next();
|
||||
} else {
|
||||
photoFetchActive--;
|
||||
}
|
||||
}
|
||||
|
||||
// ── API key retrieval ────────────────────────────────────────────────────────
|
||||
|
||||
export function getMapsKey(userId: number): string | null {
|
||||
@@ -597,6 +621,8 @@ export async function getPlacePhoto(
|
||||
}
|
||||
|
||||
const fetchPromise = (async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
||||
await acquirePhotoFetchSlot();
|
||||
try {
|
||||
const apiKey = getMapsKey(userId);
|
||||
const isCoordLookup = placeId.startsWith('coords:');
|
||||
|
||||
@@ -676,6 +702,9 @@ export async function getPlacePhoto(
|
||||
}
|
||||
|
||||
return { filePath: cached.filePath, attribution };
|
||||
} finally {
|
||||
releasePhotoFetchSlot();
|
||||
}
|
||||
})();
|
||||
|
||||
placePhotoCache.setInFlight(placeId, fetchPromise);
|
||||
|
||||
@@ -10,11 +10,14 @@ const ERROR_TTL = 5 * 60 * 1000;
|
||||
// In-flight dedup — prevents stampedes when multiple requests hit the same uncached placeId simultaneously
|
||||
const inFlight = new Map<string, Promise<{ filePath: string; attribution: string | null } | null>>();
|
||||
|
||||
function ensureDir(): void {
|
||||
if (!fs.existsSync(GOOGLE_PHOTO_DIR)) {
|
||||
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
// In-memory set of placeIds whose file is confirmed on disk this session.
|
||||
// Avoids a synchronous fs.existsSync() call on every cache hit after the first verification.
|
||||
const knownOnDisk = new Set<string>();
|
||||
|
||||
// Ensure upload dir exists once at startup — avoids sync FS calls inside put() on every write.
|
||||
try {
|
||||
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
|
||||
} catch { /* already exists */ }
|
||||
|
||||
function filePath(placeId: string): string {
|
||||
// Hash to avoid filename collisions — coords:lat:lng pseudo-IDs contain characters that
|
||||
@@ -41,10 +44,15 @@ export function get(placeId: string): CachedPhoto | null {
|
||||
if (!row) return null;
|
||||
|
||||
const fp = filePath(placeId);
|
||||
if (!fs.existsSync(fp)) {
|
||||
// File missing (e.g. volume wiped) — clear row so it refetches
|
||||
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
|
||||
return null;
|
||||
|
||||
if (!knownOnDisk.has(placeId)) {
|
||||
// First time this placeId is checked this session — verify the file exists on disk.
|
||||
// (Guards against volume wipes or manual deletion between server restarts.)
|
||||
if (!fs.existsSync(fp)) {
|
||||
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
|
||||
return null;
|
||||
}
|
||||
knownOnDisk.add(placeId);
|
||||
}
|
||||
|
||||
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution: row.attribution };
|
||||
@@ -60,19 +68,21 @@ export function getErrored(placeId: string): boolean {
|
||||
}
|
||||
|
||||
export function markError(placeId: string): void {
|
||||
knownOnDisk.delete(placeId);
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)'
|
||||
).run(placeId, Date.now(), Date.now());
|
||||
}
|
||||
|
||||
export async function put(placeId: string, bytes: Buffer, attribution: string | null): Promise<CachedPhoto> {
|
||||
ensureDir();
|
||||
const fp = filePath(placeId);
|
||||
const tmp = fp + '.tmp';
|
||||
|
||||
await fsPromises.writeFile(tmp, bytes);
|
||||
await fsPromises.rename(tmp, fp);
|
||||
|
||||
knownOnDisk.add(placeId);
|
||||
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)'
|
||||
).run(placeId, attribution, Date.now());
|
||||
@@ -90,6 +100,9 @@ export function setInFlight(placeId: string, promise: Promise<{ filePath: string
|
||||
}
|
||||
|
||||
export function serveFilePath(placeId: string): string | null {
|
||||
if (knownOnDisk.has(placeId)) return filePath(placeId);
|
||||
const fp = filePath(placeId);
|
||||
return fs.existsSync(fp) ? fp : null;
|
||||
if (!fs.existsSync(fp)) return null;
|
||||
knownOnDisk.add(placeId);
|
||||
return fp;
|
||||
}
|
||||
|
||||
@@ -240,6 +240,22 @@ export function deletePlace(tripId: string, placeId: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deletePlacesMany(tripId: string, ids: number[]): number[] {
|
||||
if (ids.length === 0) return [];
|
||||
const selectStmt = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?');
|
||||
const deleteStmt = db.prepare('DELETE FROM places WHERE id = ?');
|
||||
const deleted: number[] = [];
|
||||
const run = db.transaction((list: number[]) => {
|
||||
for (const id of list) {
|
||||
if (!selectStmt.get(id, tripId)) continue;
|
||||
deleteStmt.run(id);
|
||||
deleted.push(id);
|
||||
}
|
||||
});
|
||||
run(ids);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import GPX
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -326,7 +342,20 @@ function trackInsertedInDedupSet(
|
||||
}
|
||||
}
|
||||
|
||||
export function importGpx(tripId: string, fileBuffer: Buffer) {
|
||||
export interface GpxImportOptions {
|
||||
importWaypoints?: boolean;
|
||||
importRoutes?: boolean;
|
||||
importTracks?: boolean;
|
||||
}
|
||||
|
||||
export interface KmlImportOptions {
|
||||
importPoints?: boolean;
|
||||
importPaths?: boolean;
|
||||
}
|
||||
|
||||
export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}) {
|
||||
const { importWaypoints = true, importRoutes = true, importTracks = true } = opts;
|
||||
|
||||
const parsed = gpxParser.parse(fileBuffer.toString('utf-8'));
|
||||
const gpx = parsed?.gpx;
|
||||
if (!gpx) return null;
|
||||
@@ -338,41 +367,46 @@ export function importGpx(tripId: string, fileBuffer: Buffer) {
|
||||
const waypoints: WaypointEntry[] = [];
|
||||
|
||||
// 1) Parse <wpt> elements (named waypoints / POIs)
|
||||
for (const wpt of gpx.wpt ?? []) {
|
||||
const lat = num(wpt['@_lat']);
|
||||
const lng = num(wpt['@_lon']);
|
||||
if (lat === null || lng === null) continue;
|
||||
waypoints.push({ lat, lng, name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`, description: str(wpt.desc) });
|
||||
if (importWaypoints) {
|
||||
for (const wpt of gpx.wpt ?? []) {
|
||||
const lat = num(wpt['@_lat']);
|
||||
const lng = num(wpt['@_lon']);
|
||||
if (lat === null || lng === null) continue;
|
||||
waypoints.push({ lat, lng, name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`, description: str(wpt.desc) });
|
||||
}
|
||||
}
|
||||
|
||||
// 2) If no <wpt>, try <rte> route points as individual places
|
||||
if (waypoints.length === 0) {
|
||||
// 2) Parse <rte> routes as polyline-places (one place per route with route_geometry)
|
||||
if (importRoutes) {
|
||||
for (const rte of gpx.rte ?? []) {
|
||||
for (const rtept of rte.rtept ?? []) {
|
||||
const lat = num(rtept['@_lat']);
|
||||
const lng = num(rtept['@_lon']);
|
||||
if (lat === null || lng === null) continue;
|
||||
waypoints.push({ lat, lng, name: str(rtept.name) || `Route Point ${waypoints.length + 1}`, description: str(rtept.desc) });
|
||||
}
|
||||
const pts = (rte.rtept ?? [])
|
||||
.map((pt: Record<string, unknown>) => ({ lat: num(pt['@_lat']), lng: num(pt['@_lon']), ele: num(pt['ele']) }))
|
||||
.filter((p: { lat: number | null; lng: number | null; ele: number | null }) => p.lat !== null && p.lng !== null) as Array<{ lat: number; lng: number; ele: number | null }>;
|
||||
if (pts.length === 0) continue;
|
||||
const hasAllEle = pts.every(p => p.ele !== null);
|
||||
const routeGeometry = pts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
|
||||
waypoints.push({ lat: pts[0].lat, lng: pts[0].lng, name: str(rte.name) || 'GPX Route', description: str(rte.desc), routeGeometry: JSON.stringify(routeGeometry) });
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Extract full track geometry from <trk> (always, even if <wpt> were found)
|
||||
for (const trk of gpx.trk ?? []) {
|
||||
const trackPoints: { lat: number; lng: number; ele: number | null }[] = [];
|
||||
for (const seg of trk.trkseg ?? []) {
|
||||
for (const pt of seg.trkpt ?? []) {
|
||||
const lat = num(pt['@_lat']);
|
||||
const lng = num(pt['@_lon']);
|
||||
if (lat === null || lng === null) continue;
|
||||
trackPoints.push({ lat, lng, ele: num(pt.ele) });
|
||||
// 3) Extract full track geometry from <trk>
|
||||
if (importTracks) {
|
||||
for (const trk of gpx.trk ?? []) {
|
||||
const trackPoints: { lat: number; lng: number; ele: number | null }[] = [];
|
||||
for (const seg of trk.trkseg ?? []) {
|
||||
for (const pt of seg.trkpt ?? []) {
|
||||
const lat = num(pt['@_lat']);
|
||||
const lng = num(pt['@_lon']);
|
||||
if (lat === null || lng === null) continue;
|
||||
trackPoints.push({ lat, lng, ele: num(pt.ele) });
|
||||
}
|
||||
}
|
||||
if (trackPoints.length === 0) continue;
|
||||
const start = trackPoints[0];
|
||||
const hasAllEle = trackPoints.every(p => p.ele !== null);
|
||||
const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
|
||||
waypoints.push({ lat: start.lat, lng: start.lng, name: str(trk.name) || 'GPX Track', description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) });
|
||||
}
|
||||
if (trackPoints.length === 0) continue;
|
||||
const start = trackPoints[0];
|
||||
const hasAllEle = trackPoints.every(p => p.ele !== null);
|
||||
const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
|
||||
waypoints.push({ lat: start.lat, lng: start.lng, name: str(trk.name) || 'GPX Track', description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) });
|
||||
}
|
||||
|
||||
if (waypoints.length === 0) return null;
|
||||
@@ -401,7 +435,8 @@ export function importGpx(tripId: string, fileBuffer: Buffer) {
|
||||
return { places: created, count: created.length, skipped };
|
||||
}
|
||||
|
||||
export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImportResult {
|
||||
export function importKmlPlaces(tripId: string, fileBuffer: Buffer, opts: KmlImportOptions = {}): PlaceImportResult {
|
||||
const { importPoints = true, importPaths = true } = opts;
|
||||
const decoded = decodeUtf8WithWarning(fileBuffer);
|
||||
|
||||
const validationResult = XMLValidator.validate(decoded.text);
|
||||
@@ -430,19 +465,32 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport
|
||||
let dupCount = 0;
|
||||
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'walking')
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode, route_geometry)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'walking', ?)
|
||||
`);
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
let fallbackIndex = 1;
|
||||
for (const node of placemarkNodes) {
|
||||
const parsedPlacemark = parsePlacemarkNode(node);
|
||||
const isPath = parsedPlacemark.routeGeometry !== null;
|
||||
|
||||
// KML geometry support is intentionally limited to <Placemark><Point> coordinates.
|
||||
// Unsupported geometry type (polygon, multi-geometry, no geometry, etc.)
|
||||
if (parsedPlacemark.lat === null || parsedPlacemark.lng === null) {
|
||||
summary.skippedCount += 1;
|
||||
summary.errors.push(`Skipped Placemark ${fallbackIndex}: missing Point coordinates.`);
|
||||
summary.errors.push(`Skipped Placemark ${fallbackIndex}: unsupported geometry type.`);
|
||||
fallbackIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type filtering: respect importPoints / importPaths opts
|
||||
if (isPath && !importPaths) {
|
||||
summary.skippedCount += 1;
|
||||
fallbackIndex += 1;
|
||||
continue;
|
||||
}
|
||||
if (!isPath && !importPoints) {
|
||||
summary.skippedCount += 1;
|
||||
fallbackIndex += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -466,6 +514,7 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport
|
||||
parsedPlacemark.lat,
|
||||
parsedPlacemark.lng,
|
||||
categoryId,
|
||||
parsedPlacemark.routeGeometry,
|
||||
);
|
||||
|
||||
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
||||
@@ -514,15 +563,15 @@ export async function unpackKmzToKml(
|
||||
return preferredEntry.buffer();
|
||||
}
|
||||
|
||||
export async function importKmzPlaces(tripId: string, kmzBuffer: Buffer): Promise<PlaceImportResult> {
|
||||
export async function importKmzPlaces(tripId: string, kmzBuffer: Buffer, opts: KmlImportOptions = {}): Promise<PlaceImportResult> {
|
||||
const kmlBuffer = await unpackKmzToKml(kmzBuffer);
|
||||
return importKmlPlaces(tripId, kmlBuffer);
|
||||
return importKmlPlaces(tripId, kmlBuffer, opts);
|
||||
}
|
||||
|
||||
export async function importMapFile(tripId: string, fileBuffer: Buffer, filename: string): Promise<PlaceImportResult> {
|
||||
export async function importMapFile(tripId: string, fileBuffer: Buffer, filename: string, opts: KmlImportOptions = {}): Promise<PlaceImportResult> {
|
||||
const ext = filename.toLowerCase().split('.').pop();
|
||||
if (ext === 'kmz') return importKmzPlaces(tripId, fileBuffer);
|
||||
if (ext === 'kml') return importKmlPlaces(tripId, fileBuffer);
|
||||
if (ext === 'kmz') return importKmzPlaces(tripId, fileBuffer, opts);
|
||||
if (ext === 'kml') return importKmlPlaces(tripId, fileBuffer, opts);
|
||||
throw new Error(`Unsupported map file format: .${ext}. Please upload a .kml or .kmz file.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ const WMO_DESCRIPTION_EN: Record<number, string> = {
|
||||
// ── Cache management ────────────────────────────────────────────────────
|
||||
|
||||
const weatherCache = new Map<string, { data: WeatherResult; expiresAt: number }>();
|
||||
const inFlight = new Map<string, Promise<WeatherResult>>();
|
||||
const CACHE_MAX_ENTRIES = 1000;
|
||||
const CACHE_PRUNE_TARGET = 500;
|
||||
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -146,7 +147,7 @@ export function estimateCondition(tempAvg: number, precipMm: number): string {
|
||||
|
||||
// ── getWeather ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function getWeather(
|
||||
async function _getWeatherImpl(
|
||||
lat: string,
|
||||
lng: string,
|
||||
date: string | undefined,
|
||||
@@ -281,9 +282,27 @@ export async function getWeather(
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getWeather(
|
||||
lat: string,
|
||||
lng: string,
|
||||
date: string | undefined,
|
||||
lang: string,
|
||||
): Promise<WeatherResult> {
|
||||
const ck = cacheKey(lat, lng, date);
|
||||
const cached = getCached(ck);
|
||||
if (cached) return cached;
|
||||
|
||||
const inFlightKey = `${ck}:${lang}`;
|
||||
const existing = inFlight.get(inFlightKey);
|
||||
if (existing) return existing;
|
||||
const promise = _getWeatherImpl(lat, lng, date, lang);
|
||||
inFlight.set(inFlightKey, promise);
|
||||
try { return await promise; } finally { inFlight.delete(inFlightKey); }
|
||||
}
|
||||
|
||||
// ── getDetailedWeather ──────────────────────────────────────────────────
|
||||
|
||||
export async function getDetailedWeather(
|
||||
async function _getDetailedWeatherImpl(
|
||||
lat: string,
|
||||
lng: string,
|
||||
date: string,
|
||||
@@ -434,6 +453,24 @@ export async function getDetailedWeather(
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getDetailedWeather(
|
||||
lat: string,
|
||||
lng: string,
|
||||
date: string,
|
||||
lang: string,
|
||||
): Promise<WeatherResult> {
|
||||
const ck = `detailed_${cacheKey(lat, lng, date)}`;
|
||||
const cached = getCached(ck);
|
||||
if (cached) return cached;
|
||||
|
||||
const inFlightKey = `${ck}:${lang}`;
|
||||
const existing = inFlight.get(inFlightKey);
|
||||
if (existing) return existing;
|
||||
const promise = _getDetailedWeatherImpl(lat, lng, date, lang);
|
||||
inFlight.set(inFlightKey, promise);
|
||||
try { return await promise; } finally { inFlight.delete(inFlightKey); }
|
||||
}
|
||||
|
||||
// ── ApiError ────────────────────────────────────────────────────────────
|
||||
|
||||
export class ApiError extends Error {
|
||||
|
||||
Vendored
+17773
File diff suppressed because it is too large
Load Diff
Vendored
+3181
File diff suppressed because it is too large
Load Diff
Vendored
BIN
Binary file not shown.
@@ -771,7 +771,7 @@ describe('KML/KMZ Import', () => {
|
||||
expect(res.body.summary.totalPlacemarks).toBe(3);
|
||||
expect(res.body.summary.skippedCount).toBe(1);
|
||||
expect(Array.isArray(res.body.summary.errors)).toBe(true);
|
||||
expect(res.body.summary.errors.join(' ')).toContain('missing Point coordinates');
|
||||
expect(res.body.summary.errors.join(' ')).toContain('unsupported geometry type');
|
||||
|
||||
const nested = res.body.places.find((p: any) => p.name === 'Nested Place');
|
||||
expect(nested).toBeDefined();
|
||||
@@ -862,7 +862,7 @@ describe('GPX Import — edge cases', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', emptyGpx, { filename: 'empty.gpx', contentType: 'application/gpx+xml' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/no waypoints/i);
|
||||
expect(res.body.error).toMatch(/no matching places/i);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -277,19 +277,24 @@ describe('importGpx', () => {
|
||||
expect(result.places[1].name).toBe('London');
|
||||
});
|
||||
|
||||
it('PLACE-SVC-022 — falls back to <rte> route points when no <wpt> elements exist', () => {
|
||||
it('PLACE-SVC-022 — imports <rte> as a single polyline-place with routeGeometry', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
|
||||
<rte>
|
||||
<name>My Route</name>
|
||||
<rtept lat="48.8566" lon="2.3522"><name>Start</name></rtept>
|
||||
<rtept lat="51.5074" lon="-0.1278"><name>End</name></rtept>
|
||||
</rte>
|
||||
</gpx>`);
|
||||
const result = importGpx(String(trip.id), gpx) as any;
|
||||
expect(result.places).toHaveLength(2);
|
||||
expect(result.places[0].name).toBe('Start');
|
||||
expect(result.places[1].name).toBe('End');
|
||||
expect(result.places).toHaveLength(1);
|
||||
expect(result.places[0].name).toBe('My Route');
|
||||
expect(result.places[0].lat).toBe(48.8566);
|
||||
expect(result.places[0].lng).toBe(2.3522);
|
||||
expect(result.places[0].route_geometry).toBeTruthy();
|
||||
const coords = JSON.parse(result.places[0].route_geometry);
|
||||
expect(coords).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-023 — imports <trk> track as a single place with routeGeometry', () => {
|
||||
|
||||
Reference in New Issue
Block a user