From 0cf17cbd8662c8e033107c90b30b83daaad57fdf Mon Sep 17 00:00:00 2001
From: Christian Beutel <>
Date: Sat, 1 Feb 2025 18:06:27 +0100
Subject: [PATCH] adds upload dialog and duplicates
---
web/package-lock.json | 14 +-
web/package.json | 1 +
web/src/hooks.server.ts | 14 +-
.../components/settings/upload_dialog.svelte | 195 ++++++++++++++++++
.../summit_log/summit_log_modal.svelte | 2 +-
.../components/trail/trail_info_panel.svelte | 4 +-
web/src/lib/i18n/locales/de.json | 1 +
web/src/lib/i18n/locales/en.json | 1 +
web/src/lib/i18n/locales/es.json | 1 +
web/src/lib/i18n/locales/fr.json | 1 +
web/src/lib/i18n/locales/hu.json | 1 +
web/src/lib/i18n/locales/it.json | 1 +
web/src/lib/i18n/locales/nl.json | 1 +
web/src/lib/i18n/locales/pl.json | 1 +
web/src/lib/i18n/locales/pt.json | 1 +
web/src/lib/i18n/locales/zh.json | 1 +
web/src/lib/models/gpx/gpx.ts | 65 +++++-
web/src/lib/stores/trail_store.ts | 43 ++--
web/src/lib/stores/upload_store.svelte.ts | 57 +++++
web/src/lib/util/api_util.ts | 4 +-
web/src/lib/util/gpx_util.ts | 4 +-
web/src/routes/+layout.svelte | 9 +-
web/src/routes/api/v1/trail/upload/+server.ts | 72 +++++--
web/src/routes/settings/export/+page.svelte | 150 +++++---------
web/src/routes/trail/edit/[id]/+page.svelte | 2 +-
25 files changed, 499 insertions(+), 147 deletions(-)
create mode 100644 web/src/lib/components/settings/upload_dialog.svelte
create mode 100644 web/src/lib/stores/upload_store.svelte.ts
diff --git a/web/package-lock.json b/web/package-lock.json
index c22f33bd..7dcce3e9 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "wanderer",
- "version": "0.13.2",
+ "version": "0.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wanderer",
- "version": "0.13.2",
+ "version": "0.14.0",
"dependencies": {
"@felte/validator-zod": "^1.0.18",
"@fortawesome/fontawesome-free": "^6.5.1",
@@ -32,6 +32,7 @@
"jszip": "^3.10.1",
"maplibre-gl": "^4.7.1",
"meilisearch": "^0.37.0",
+ "ngeohash": "^0.6.3",
"nouislider": "^15.7.1",
"pdfkit": "^0.15.0",
"photoswipe": "^5.4.3",
@@ -4372,6 +4373,15 @@
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
"license": "ISC"
},
+ "node_modules/ngeohash": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/ngeohash/-/ngeohash-0.6.3.tgz",
+ "integrity": "sha512-kltF0cOxgx1AbmVzKxYZaoB0aj7mOxZeHaerEtQV0YaqnkXNq26WWqMmJ6lTqShYxVRWZ/mwvvTrNeOwdslWiw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=v0.2.0"
+ }
+ },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
diff --git a/web/package.json b/web/package.json
index a4868da3..1c92a05b 100644
--- a/web/package.json
+++ b/web/package.json
@@ -54,6 +54,7 @@
"jszip": "^3.10.1",
"maplibre-gl": "^4.7.1",
"meilisearch": "^0.37.0",
+ "ngeohash": "^0.6.3",
"nouislider": "^15.7.1",
"pdfkit": "^0.15.0",
"photoswipe": "^5.4.3",
diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts
index d12206fe..31e36987 100644
--- a/web/src/hooks.server.ts
+++ b/web/src/hooks.server.ts
@@ -67,7 +67,7 @@ const auth: Handle = async ({ event, resolve }) => {
try {
// get an up-to-date auth store state by verifying and refreshing the loaded auth model (if any)
if (pb.authStore.isValid) {
- await pb.collection('users').authRefresh()
+ await pb.collection('users').authRefresh({requestKey: null})
}
} catch (_) {
// clear the auth store on failed refresh
@@ -78,7 +78,7 @@ const auth: Handle = async ({ event, resolve }) => {
let settings: Settings | undefined;
if (pb.authStore.model) {
meiliApiKey = pb.authStore.model.token
- settings = await pb.collection('settings').getFirstListItem(`user="${pb.authStore.model.id}"`)
+ settings = await pb.collection('settings').getFirstListItem(`user="${pb.authStore.model.id}"`, {requestKey: null})
} else {
const r = await event.fetch(pb.buildUrl("/public/search/token"));
const response = await r.json();
@@ -111,4 +111,12 @@ const auth: Handle = async ({ event, resolve }) => {
return response
}
-export const handle = sequence(csrf(['/api/v1']), auth)
\ No newline at end of file
+const removeLinkFromHeaders: Handle =
+ async ({ event, resolve }) => {
+ const response = await resolve(event);
+ response.headers.delete('link');
+ return response;
+ }
+
+
+export const handle = sequence(csrf(['/api/v1']), auth, removeLinkFromHeaders)
\ No newline at end of file
diff --git a/web/src/lib/components/settings/upload_dialog.svelte b/web/src/lib/components/settings/upload_dialog.svelte
new file mode 100644
index 00000000..dd7aefb6
--- /dev/null
+++ b/web/src/lib/components/settings/upload_dialog.svelte
@@ -0,0 +1,195 @@
+
+
+{#if visibleUploads.length}
+
+{/if}
diff --git a/web/src/lib/components/summit_log/summit_log_modal.svelte b/web/src/lib/components/summit_log/summit_log_modal.svelte
index a211e778..99952093 100644
--- a/web/src/lib/components/summit_log/summit_log_modal.svelte
+++ b/web/src/lib/components/summit_log/summit_log_modal.svelte
@@ -93,7 +93,7 @@
throw gpxObject;
}
- const totals = gpxObject.getTotals();
+ const totals = gpxObject.features;
$data.duration = totals.duration / 1000;
$data.elevation_gain = totals.elevationGain;
diff --git a/web/src/lib/components/trail/trail_info_panel.svelte b/web/src/lib/components/trail/trail_info_panel.svelte
index 37596b9d..86a8265e 100644
--- a/web/src/lib/components/trail/trail_info_panel.svelte
+++ b/web/src/lib/components/trail/trail_info_panel.svelte
@@ -70,7 +70,7 @@
trail: trail.id ?? "",
});
- let commentsLoading: boolean = $state(activeTab == 4);
+ let commentsLoading: boolean = $state(activeTab == 2);
let commentCreateLoading: boolean = $state(false);
let commentDeleteLoading: boolean = false;
@@ -150,7 +150,7 @@
: emptyStateTrailDark,
);
$effect(() => {
- if (browser && activeTab == 4) {
+ if (browser && activeTab == 2) {
fetchComments();
}
});
diff --git a/web/src/lib/i18n/locales/de.json b/web/src/lib/i18n/locales/de.json
index 3fded2de..e59b2687 100644
--- a/web/src/lib/i18n/locales/de.json
+++ b/web/src/lib/i18n/locales/de.json
@@ -74,6 +74,7 @@
"download": "Herunterladen",
"draw-a-route": "Route zeichnen",
"driving": "Auto",
+ "duplicate": "",
"duration": "Dauer",
"dutch": "Niederländisch",
"easy": "Einfach",
diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json
index fb45eb95..a6a1b8c6 100644
--- a/web/src/lib/i18n/locales/en.json
+++ b/web/src/lib/i18n/locales/en.json
@@ -74,6 +74,7 @@
"download": "Download",
"draw-a-route": "Draw a route",
"driving": "Driving",
+ "duplicate": "Duplicate",
"duration": "Duration",
"dutch": "Dutch",
"easy": "Easy",
diff --git a/web/src/lib/i18n/locales/es.json b/web/src/lib/i18n/locales/es.json
index 01407b06..19f909b1 100644
--- a/web/src/lib/i18n/locales/es.json
+++ b/web/src/lib/i18n/locales/es.json
@@ -74,6 +74,7 @@
"download": "Descarga",
"draw-a-route": "Dibujar una ruta",
"driving": "Conducir",
+ "duplicate": "",
"duration": "Duración",
"dutch": "Holandés",
"easy": "Fácil",
diff --git a/web/src/lib/i18n/locales/fr.json b/web/src/lib/i18n/locales/fr.json
index b663da32..c21a011d 100644
--- a/web/src/lib/i18n/locales/fr.json
+++ b/web/src/lib/i18n/locales/fr.json
@@ -74,6 +74,7 @@
"download": "Download",
"draw-a-route": "Tracer un itinéraire",
"driving": "Conduire",
+ "duplicate": "",
"duration": "Durée",
"dutch": "Néerlandais",
"easy": "Facile",
diff --git a/web/src/lib/i18n/locales/hu.json b/web/src/lib/i18n/locales/hu.json
index 7b627b99..0a4cfe2a 100644
--- a/web/src/lib/i18n/locales/hu.json
+++ b/web/src/lib/i18n/locales/hu.json
@@ -74,6 +74,7 @@
"download": "Download",
"draw-a-route": "Draw a route",
"driving": "Driving",
+ "duplicate": "",
"duration": "Duration",
"dutch": "Holland",
"easy": "Könnyű",
diff --git a/web/src/lib/i18n/locales/it.json b/web/src/lib/i18n/locales/it.json
index 3bab4931..ff6e1154 100644
--- a/web/src/lib/i18n/locales/it.json
+++ b/web/src/lib/i18n/locales/it.json
@@ -74,6 +74,7 @@
"download": "Scarica",
"draw-a-route": "Disegna un percorso",
"driving": "Guida",
+ "duplicate": "",
"duration": "Durata",
"dutch": "Olandese",
"easy": "Facile",
diff --git a/web/src/lib/i18n/locales/nl.json b/web/src/lib/i18n/locales/nl.json
index 9c7b3af6..59ad8446 100644
--- a/web/src/lib/i18n/locales/nl.json
+++ b/web/src/lib/i18n/locales/nl.json
@@ -74,6 +74,7 @@
"download": "Download",
"draw-a-route": "Draw a route",
"driving": "Driving",
+ "duplicate": "",
"duration": "Duration",
"dutch": "Nederlands",
"easy": "Makkelijk",
diff --git a/web/src/lib/i18n/locales/pl.json b/web/src/lib/i18n/locales/pl.json
index 3dd2da2b..7687a2c7 100644
--- a/web/src/lib/i18n/locales/pl.json
+++ b/web/src/lib/i18n/locales/pl.json
@@ -74,6 +74,7 @@
"download": "Pobierz",
"draw-a-route": "Narysuj trasę",
"driving": "Samochód",
+ "duplicate": "",
"duration": "Czas trwania",
"dutch": "Niderlandzki",
"easy": "Łatwy",
diff --git a/web/src/lib/i18n/locales/pt.json b/web/src/lib/i18n/locales/pt.json
index c8cac202..2ae966d1 100644
--- a/web/src/lib/i18n/locales/pt.json
+++ b/web/src/lib/i18n/locales/pt.json
@@ -74,6 +74,7 @@
"download": "Download",
"draw-a-route": "Desenhar uma rota",
"driving": "Conduzir",
+ "duplicate": "",
"duration": "Duração",
"dutch": "Holandês",
"easy": "Fácil",
diff --git a/web/src/lib/i18n/locales/zh.json b/web/src/lib/i18n/locales/zh.json
index f16f88f2..6fa9201b 100644
--- a/web/src/lib/i18n/locales/zh.json
+++ b/web/src/lib/i18n/locales/zh.json
@@ -74,6 +74,7 @@
"download": "Download",
"draw-a-route": "绘制路线",
"driving": "驾驶",
+ "duplicate": "",
"duration": "持续时间",
"dutch": "荷兰语",
"easy": "简单",
diff --git a/web/src/lib/models/gpx/gpx.ts b/web/src/lib/models/gpx/gpx.ts
index d318768b..eb7e7076 100644
--- a/web/src/lib/models/gpx/gpx.ts
+++ b/web/src/lib/models/gpx/gpx.ts
@@ -4,6 +4,8 @@ import Route from './route';
import Track from './track';
import { allDatesToISOString, haversineDistance, removeEmpty } from './utils';
import Waypoint from './waypoint';
+//@ts-ignore
+import geohash from "ngeohash"
const defaultAttributes = {
version: '1.1',
@@ -14,6 +16,17 @@ const defaultAttributes = {
'http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd'
}
+
+type GPXFeature = {
+ centroid: { lat: number; lon: number };
+ boundingBox: { minLat: number; maxLat: number; minLon: number; maxLon: number };
+ distance: number;
+ elevationGain?: number;
+ elevationLoss?: number;
+ duration: number;
+ hash: string; // MinHash or Geohash for track shape
+};
+
export default class GPX {
$: {
version: string;
@@ -27,6 +40,7 @@ export default class GPX {
wpt?: Waypoint[];
rte?: Route[];
trk?: Track[];
+ features: GPXFeature
constructor(object: {
$?: {
@@ -69,18 +83,26 @@ export default class GPX {
this.trk = object.trk.map(trk => new Track(trk))
}
+ this.features = this.getTotals();
+
removeEmpty(this);
}
- getTotals() {
+ getTotals(): GPXFeature {
let totalElevationGain = 0;
let totalElevationLoss = 0;
let totalDuration = 0;
let totalDistance = 0;
+ let totalLat = 0
+ let totalLon = 0
+ let minLat = Infinity, maxLat = -Infinity, minLon = Infinity, maxLon = -Infinity;
+
+ const allPoints: Waypoint[] = []
for (const track of this.trk ?? []) {
for (const segment of track.trkseg ?? []) {
const points = segment.trkpt ?? [];
+ allPoints.push(...points);
if (points.length >= 2) {
const startTime = points[0].time;
@@ -110,12 +132,51 @@ export default class GPX {
point.$.lat ?? 0,
point.$.lon ?? 0,
);
+
+ totalLat += point.$.lat ?? 0;
+ totalLon += point.$.lon ?? 0;
+
+ minLat = Math.min(minLat, point.$.lat ?? Infinity);
+ maxLat = Math.max(maxLat, point.$.lat ?? -Infinity);
+ minLon = Math.min(minLon, point.$.lon ?? Infinity);
+ maxLon = Math.max(maxLon, point.$.lon ?? -Infinity);
totalDistance += distance;
}
}
}
- return { distance: totalDistance, elevationGain: totalElevationGain, elevationLoss: totalElevationLoss, duration: totalDuration }
+ const boundingBox = { minLat, maxLat, minLon, maxLon };
+ const centroid = { lat: totalLat / allPoints.length, lon: totalLon / allPoints.length };
+
+ return {
+ centroid,
+ boundingBox,
+ distance: totalDistance,
+ elevationGain: totalElevationGain,
+ elevationLoss: totalElevationLoss,
+ duration: totalDuration,
+ hash: this.generateMinHash(allPoints)
+ }
+ }
+
+ private generateMinHash(points: Waypoint[]): string {
+ const hashes = points.map(pt => geohash.encode(pt.$.lat, pt.$.lon));
+ return hashes.sort().join('').slice(0, 10);
+ }
+
+ isSimilar(other: GPX, distanceThreshold = 50): boolean {
+ const f1 = this.features;
+ const f2 = other.features;
+ const centroidDistance = haversineDistance(f1.centroid.lat, f1.centroid.lon, f2.centroid.lat, f2.centroid.lon);
+ const boundingBoxOverlap =
+ f1.boundingBox.minLat <= f2.boundingBox.maxLat && f1.boundingBox.maxLat >= f2.boundingBox.minLat &&
+ f1.boundingBox.minLon <= f2.boundingBox.maxLon && f1.boundingBox.maxLon >= f2.boundingBox.minLon;
+
+ const lengthDifference = Math.abs(f1.distance - f2.distance);
+ const hashSimilarity = f1.hash === f2.hash;
+
+ return centroidDistance < distanceThreshold || boundingBoxOverlap || (lengthDifference < 100 && hashSimilarity);
+
}
static parse(gpxString: string): Promise {
diff --git a/web/src/lib/stores/trail_store.ts b/web/src/lib/stores/trail_store.ts
index a7796224..d53fa30b 100644
--- a/web/src/lib/stores/trail_store.ts
+++ b/web/src/lib/stores/trail_store.ts
@@ -401,23 +401,40 @@ export async function trails_get_bounding_box(f: (url: RequestInfo | URL, config
}
-export async function trails_upload(file: File, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch): Promise {
- const fd = new FormData()
+export async function trails_upload(file: File, ignoreDuplicates: boolean = false, onProgress?: (progress: number) => void) {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ const fd = new FormData();
+ fd.append("name", file.name);
+ fd.append("file", file);
+ fd.append("ignoreDuplicates", ignoreDuplicates ? "true" : "false")
- fd.append("name", file.name),
- fd.append("file", file)
+ xhr.open("PUT", "/api/v1/trail/upload", true);
- const r = await f('/api/v1/trail/upload', {
- method: 'PUT',
- body: fd
- })
- if (!r.ok) {
- const response = await r.json();
- throw new APIError(r.status, response.message, response.detail)
- }
+ xhr.upload.onprogress = function (event) {
+ if (event.lengthComputable) {
+ const percentComplete = (event.loaded / event.total) * 100;
+ onProgress?.(percentComplete)
+ }
+ };
- return await r.json();
+ xhr.onload = async () => {
+ const responseText = xhr.responseText;
+ const response = responseText ? JSON.parse(responseText) : null;
+ if (xhr.status >= 200 && xhr.status < 300) {
+ resolve(response);
+ } else {
+ reject(new APIError(xhr.status, response?.message || "Upload failed", response));
+ }
+ };
+
+ xhr.onerror = () => {
+ reject(new APIError(xhr.status, xhr.statusText));
+ };
+
+ xhr.send(fd);
+ });
}
export async function fetchGPX(trail: { gpx?: string } & Record, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) {
diff --git a/web/src/lib/stores/upload_store.svelte.ts b/web/src/lib/stores/upload_store.svelte.ts
new file mode 100644
index 00000000..b35b690d
--- /dev/null
+++ b/web/src/lib/stores/upload_store.svelte.ts
@@ -0,0 +1,57 @@
+import { APIError } from "$lib/util/api_util";
+
+export type Upload = {
+ file: File;
+ status: "enqueued" | "uploading" | "cancelled" | "success" | "error" | "duplicate";
+ error?: string;
+ duplicate?: { id: string, name: string };
+ progress: number;
+ function: (f: File, ignoreDuplicates?: boolean, onProgress?: (p: number) => void) => Promise
+};
+
+class UploadStore {
+ enqueuedUploads: Upload[] = $state([]);
+ completedUploads: Upload[] = $state([]);
+ uploading: boolean = $state(false);
+}
+
+export const uploadStore = new UploadStore();
+
+
+export async function processUploadQueue(batchSize: number = 3, ignoreDuplicates: boolean = false) {
+ if (uploadStore.uploading) {
+ return;
+ }
+ uploadStore.uploading = true;
+
+ while (uploadStore.enqueuedUploads.length > 0) {
+ const batch = uploadStore.enqueuedUploads.slice(0, batchSize);
+ const uploadPromises: Promise[] = [];
+ for (const b of batch) {
+ b.status = "uploading";
+ uploadPromises.push(
+ b.function(b.file, ignoreDuplicates, (p: number) => {
+ b.progress = p
+ })
+ );
+ }
+ const results = await Promise.all(
+ uploadPromises.map((p) => p.catch((e) => e)),
+ );
+ results.forEach((r, i) => {
+ const u = batch[i];
+ if (r instanceof APIError && r.message == "Duplicate trail") {
+ u.status = "duplicate"
+ u.duplicate = { id: r.detail.id, name: r.detail.name };
+ } else if (r instanceof APIError) {
+ u.status = "error"
+ u.error = r.message
+ } else {
+ u.status = "success"
+ }
+ uploadStore.completedUploads.push(u);
+ });
+ uploadStore.enqueuedUploads.splice(0, batchSize)
+ }
+ uploadStore.uploading = false;
+}
\ No newline at end of file
diff --git a/web/src/lib/util/api_util.ts b/web/src/lib/util/api_util.ts
index d34eae61..7ca5a526 100644
--- a/web/src/lib/util/api_util.ts
+++ b/web/src/lib/util/api_util.ts
@@ -80,7 +80,7 @@ export async function create(event: RequestEvent, schema: ZodSchema, collecti
const data = await event.request.json();
const safeData = schema.parse(data);
- const r = await pb.collection(Collection[collection]).create(safeData, safeSearchParams)
+ const r = await pb.collection(Collection[collection]).create(safeData, {...safeSearchParams, requestKey: null})
return r
}
@@ -124,7 +124,7 @@ export function handleError(e: any) {
if (e instanceof ZodError) {
return error(400, { message: "invalid_params", detail: e.issues } as any)
} else if (e instanceof ClientResponseError && e.status > 0) {
- return error(e.status as NumericRange<400, 599>, { message: e.message, detail: e.originalError.data } as any)
+ return error(e.status as NumericRange<400, 599>, {...e.response, message: e.message, detail: e.originalError.data } as any)
} else if (e instanceof SyntaxError) {
return error(400, "invalid_json")
} else {
diff --git a/web/src/lib/util/gpx_util.ts b/web/src/lib/util/gpx_util.ts
index 9ccd03e1..54be39b8 100644
--- a/web/src/lib/util/gpx_util.ts
+++ b/web/src/lib/util/gpx_util.ts
@@ -11,7 +11,7 @@ import Track from "$lib/models/gpx/track";
import TrackSegment from "$lib/models/gpx/track-segment";
import GPXWaypoint from "$lib/models/gpx/waypoint";
import EasyFit from "$lib/vendor/easy-fit/easy-fit";
-import type { GeoJSON, Feature, FeatureCollection, GeoJsonProperties, Position } from 'geojson';
+import type { Feature, FeatureCollection, GeoJSON, GeoJsonProperties, Position } from 'geojson';
import * as xmldom from 'xmldom';
import { bbox, splitMultiLineStringToLineStrings } from "./geojson_util";
@@ -38,7 +38,7 @@ export async function gpx2trail(gpxString: string, fallbackName?: string) {
trail.expand!.waypoints.push(wp);
}
- const totals = gpx.getTotals()
+ const totals = gpx.features
const trackPoints = gpx.trk?.at(0)?.trkseg?.at(0)?.trkpt
const routePoints = gpx.rte?.at(0)?.rtept;
diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte
index 5b710102..76db674f 100644
--- a/web/src/routes/+layout.svelte
+++ b/web/src/routes/+layout.svelte
@@ -5,6 +5,8 @@
import Toast from "$lib/components/base/toast.svelte";
import Footer from "$lib/components/footer.svelte";
import NavBar from "$lib/components/nav_bar.svelte";
+ import PageLoadingBar from "$lib/components/page_loading_bar.svelte";
+ import UploadDialog from "$lib/components/settings/upload_dialog.svelte";
import { currentUser } from "$lib/stores/user_store";
import { isRouteProtected } from "$lib/util/authorization_util";
import "@fortawesome/fontawesome-free/css/all.min.css";
@@ -13,7 +15,6 @@
import "../css/app.css";
import "../css/components.css";
import "../css/theme.css";
- import PageLoadingBar from "$lib/components/page_loading_bar.svelte";
interface Props {
children?: Snippet;
}
@@ -71,8 +72,9 @@
may cause errors.
@@ -81,6 +83,7 @@
+
{@render children?.()}
diff --git a/web/src/routes/api/v1/trail/upload/+server.ts b/web/src/routes/api/v1/trail/upload/+server.ts
index c1437146..4e5165bc 100644
--- a/web/src/routes/api/v1/trail/upload/+server.ts
+++ b/web/src/routes/api/v1/trail/upload/+server.ts
@@ -1,6 +1,9 @@
+import GPX from "$lib/models/gpx/gpx";
+import { haversineDistance } from "$lib/models/gpx/utils";
import { SummitLog } from "$lib/models/summit_log";
import type { Trail } from "$lib/models/trail";
-import { trails_create } from "$lib/stores/trail_store";
+import { pb } from "$lib/pocketbase";
+import { fetchGPX, trails_create } from "$lib/stores/trail_store";
import { handleError } from "$lib/util/api_util";
import { fromFile, gpx2trail } from "$lib/util/gpx_util";
import { json, type RequestEvent } from "@sveltejs/kit";
@@ -15,29 +18,45 @@ export async function PUT(event: RequestEvent) {
if (!gpxData.length) {
throw new ClientResponseError({ status: 400, response: { message: "Empty file" } })
}
- let trail: Trail;
+ let parseResult: { trail: Trail, gpx: GPX };
try {
- trail = (await gpx2trail(gpxData, data.get("name") as string | undefined)).trail;
-
- const log = new SummitLog(trail.date as string, {
- distance: trail.distance,
- elevation_gain: trail.elevation_gain,
- elevation_loss: trail.elevation_loss,
- duration: trail.duration ? trail.duration * 60 : undefined,
- })
- log.expand!.gpx_data = gpxData;
- const fileName = (data.get("name") as string | null)?.length ? data.get("name") as string : "file"
- log._gpx = new File([gpxFile], fileName);
-
- trail.expand!.summit_logs.push(log);
+ parseResult = (await gpx2trail(gpxData, data.get("name") as string | undefined));
} catch (e: any) {
console.error(e)
throw new ClientResponseError({ status: 400, response: { message: "Invalid file" } })
}
+ let trail = parseResult.trail;
+
+ const ignoreDuplicates = data.get("ignoreDuplicates") === "true"
+ if (!ignoreDuplicates) {
+ let duplicate: Trail | null = null;
+ try {
+ duplicate = await findDuplicate(trail)
+ } catch (e: any) {
+ throw new ClientResponseError({ status: 500, response: { message: "Error checking for duplicates" } })
+ }
+ if (duplicate !== null) {
+ throw new ClientResponseError({ status: 400, response: { message: `Duplicate trail`, id: duplicate.id, name: duplicate.name }, })
+ }
+ }
+
+ const log = new SummitLog(trail.date as string, {
+ distance: trail.distance,
+ elevation_gain: trail.elevation_gain,
+ elevation_loss: trail.elevation_loss,
+ duration: trail.duration ? trail.duration * 60 : undefined,
+ })
+ log.expand!.gpx_data = gpxData;
+ const fileName = (data.get("name") as string | null)?.length ? data.get("name") as string : "file"
+ log._gpx = new File([gpxFile], fileName);
+
+ trail.expand!.summit_logs.push(log);
+
try {
trail = await trails_create(trail, [], gpxFile, event.fetch);
} catch (e: any) {
+ console.error(e)
throw handleError(e)
}
return json(trail);
@@ -45,4 +64,25 @@ export async function PUT(event: RequestEvent) {
} catch (e: any) {
throw handleError(e)
}
-}
\ No newline at end of file
+}
+
+async function findDuplicate(t1: Trail) {
+ const trails: Trail[] = await pb.collection('trails').getFullList({ requestKey: null });
+
+ const distanceThreshold = 100;
+ const elevationThreshhold = 50;
+ const lengthThreshhold = 50;
+
+ for (const t2 of trails) {
+ const lengthDifference = Math.abs((t1.distance ?? 0) - (t2.distance ?? 0));
+ const elevationGainDifference = Math.abs((t1.elevation_gain ?? 0) - (t2.elevation_gain ?? 0));
+ const elevationLossDifference = Math.abs((t1.elevation_loss ?? 0) - (t2.elevation_loss ?? 0));
+ const startpointDifference = haversineDistance(t1.lat ?? 0, t1.lon ?? 0, t2.lat ?? 0, t2.lon ?? 0)
+
+ if (lengthDifference < lengthThreshhold && elevationGainDifference < elevationThreshhold && elevationLossDifference < elevationThreshhold && startpointDifference < distanceThreshold) {
+ return t2
+ }
+ }
+
+ return null
+}
diff --git a/web/src/routes/settings/export/+page.svelte b/web/src/routes/settings/export/+page.svelte
index 9c378ef4..15b06ce1 100644
--- a/web/src/routes/settings/export/+page.svelte
+++ b/web/src/routes/settings/export/+page.svelte
@@ -1,45 +1,23 @@