adds upload dialog and duplicates
This commit is contained in:
195
web/src/lib/components/settings/upload_dialog.svelte
Normal file
195
web/src/lib/components/settings/upload_dialog.svelte
Normal file
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
processUploadQueue,
|
||||
uploadStore,
|
||||
type Upload,
|
||||
} from "$lib/stores/upload_store.svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
let minimized: boolean = $state(false);
|
||||
|
||||
let visibleUploads = $derived(
|
||||
uploadStore.enqueuedUploads
|
||||
.concat(uploadStore.completedUploads)
|
||||
.sort((a, b) => a.file.size - b.file.size),
|
||||
);
|
||||
|
||||
let remaining = $derived(uploadStore.enqueuedUploads.length);
|
||||
|
||||
let successfulUploads = $derived(
|
||||
uploadStore.completedUploads.reduce(
|
||||
(sum, u) => (sum += u.status == "success" ? 1 : 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
let errorUploads = $derived(
|
||||
uploadStore.completedUploads.reduce(
|
||||
(sum, u) => (sum += u.status == "error" ? 1 : 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
let duplicateUploads = $derived(
|
||||
uploadStore.completedUploads.reduce(
|
||||
(sum, u) => (sum += u.status == "duplicate" ? 1 : 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
function dismissUpload(u: Upload) {
|
||||
const index = uploadStore.completedUploads.indexOf(u);
|
||||
uploadStore.completedUploads.splice(index, 1);
|
||||
}
|
||||
|
||||
function dismissAllCompleted() {
|
||||
uploadStore.completedUploads = [];
|
||||
}
|
||||
|
||||
function cancelUpload(u: Upload) {
|
||||
const index = uploadStore.enqueuedUploads.indexOf(u);
|
||||
u.status = "cancelled";
|
||||
uploadStore.enqueuedUploads.splice(index, 1);
|
||||
uploadStore.completedUploads.push(u);
|
||||
}
|
||||
|
||||
function reUpload(u: Upload, ignoreDuplicates: boolean = false) {
|
||||
const index = uploadStore.completedUploads.indexOf(u);
|
||||
u.status = "enqueued";
|
||||
u.progress = 0;
|
||||
u.error = undefined;
|
||||
u.duplicate = undefined;
|
||||
uploadStore.completedUploads.splice(index, 1);
|
||||
uploadStore.enqueuedUploads.push(u);
|
||||
processUploadQueue(undefined, ignoreDuplicates);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visibleUploads.length}
|
||||
<div
|
||||
class="fixed bottom-4 right-4 z-10 p-4 bg-background rounded-xl border border-input-border shadow-xl"
|
||||
class:cursor-pointer={minimized}
|
||||
in:slide
|
||||
out:slide
|
||||
role="presentation"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
minimized = false;
|
||||
}}
|
||||
>
|
||||
<div class="flex gap-x-2 items-start justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
Remaining {remaining} - Processed {uploadStore
|
||||
.completedUploads.length}/{uploadStore.enqueuedUploads
|
||||
.length + uploadStore.completedUploads.length}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
Uploaded <span class="text-emerald-400"
|
||||
>{successfulUploads}</span
|
||||
>
|
||||
- Error <span class="text-red-400">{errorUploads}</span> -
|
||||
Duplicates
|
||||
<span class="text-amber-500">{duplicateUploads}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<button aria-label="Dismiss all" onclick={dismissAllCompleted}
|
||||
><i class="fa fa-ban"></i></button
|
||||
>
|
||||
<button
|
||||
aria-label="Minimize"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
minimized = true;
|
||||
}}><i class="fa fa-minus"></i></button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="max-h-96 max-w-72 mt-4 overflow-y-auto space-y-2"
|
||||
class:hidden={minimized}
|
||||
>
|
||||
{#each visibleUploads as u}
|
||||
<div class="bg-menu-item-background-hover rounded-lg py-2 px-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 shrink-0">
|
||||
{#if u.status === "enqueued" || u.status == "uploading"}
|
||||
<div class="spinner spinner-small"></div>
|
||||
{:else}
|
||||
<i
|
||||
class={{
|
||||
fa: true,
|
||||
"fa-circle-exclamation text-red-400":
|
||||
u.status == "error",
|
||||
"fa-triangle-exclamation text-amber-500":
|
||||
u.status == "duplicate",
|
||||
"fa-circle-check text-emerald-400":
|
||||
u.status == "success",
|
||||
"fa-ban text-gray-500":
|
||||
u.status == "cancelled",
|
||||
}}
|
||||
></i>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs basis-full min-w-0 break-all">
|
||||
{u.file.name}
|
||||
</p>
|
||||
{#if u.status == "error" || u.status == "cancelled"}
|
||||
<button
|
||||
aria-label="Re-upload"
|
||||
onclick={() => reUpload(u)}
|
||||
><i class="fa fa-redo text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
{#if u.status == "enqueued"}
|
||||
<button
|
||||
aria-label="Cancel upload"
|
||||
onclick={() => cancelUpload(u)}
|
||||
><i class="fa fa-stop text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
{#if u.status == "duplicate"}
|
||||
<button
|
||||
title="Force upload"
|
||||
aria-label="Force upload"
|
||||
onclick={() => reUpload(u, true)}
|
||||
><i class="fa fa-upload text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
{#if u.status != "enqueued" && u.status != "uploading"}
|
||||
<button
|
||||
aria-label="Dismiss"
|
||||
onclick={() => dismissUpload(u)}
|
||||
><i class="fa fa-close text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if u.status == "uploading"}
|
||||
<div
|
||||
class="progress-bar my-1 rounded-md"
|
||||
style="height:2px; width:{u.progress}%; background-color:#3549bb;transition: width 0.5s ease-in-out;"
|
||||
></div>
|
||||
{:else if u.error}
|
||||
<p class="text-red-400 text-xs">
|
||||
{u.error}
|
||||
</p>
|
||||
{:else if u.duplicate}
|
||||
<p class="text-amber-400 text-xs">
|
||||
{$_("duplicate")}:
|
||||
<button
|
||||
class="underline"
|
||||
onclick={() =>
|
||||
goto(`/trail/view/${u.duplicate!.id}`)}
|
||||
>{u.duplicate.name}</button
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -93,7 +93,7 @@
|
||||
throw gpxObject;
|
||||
}
|
||||
|
||||
const totals = gpxObject.getTotals();
|
||||
const totals = gpxObject.features;
|
||||
|
||||
$data.duration = totals.duration / 1000;
|
||||
$data.elevation_gain = totals.elevationGain;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"download": "Herunterladen",
|
||||
"draw-a-route": "Route zeichnen",
|
||||
"driving": "Auto",
|
||||
"duplicate": "",
|
||||
"duration": "Dauer",
|
||||
"dutch": "Niederländisch",
|
||||
"easy": "Einfach",
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"download": "Download",
|
||||
"draw-a-route": "Draw a route",
|
||||
"driving": "Driving",
|
||||
"duplicate": "Duplicate",
|
||||
"duration": "Duration",
|
||||
"dutch": "Dutch",
|
||||
"easy": "Easy",
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"download": "Descarga",
|
||||
"draw-a-route": "Dibujar una ruta",
|
||||
"driving": "Conducir",
|
||||
"duplicate": "",
|
||||
"duration": "Duración",
|
||||
"dutch": "Holandés",
|
||||
"easy": "Fácil",
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"download": "Download",
|
||||
"draw-a-route": "Tracer un itinéraire",
|
||||
"driving": "Conduire",
|
||||
"duplicate": "",
|
||||
"duration": "Durée",
|
||||
"dutch": "Néerlandais",
|
||||
"easy": "Facile",
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"download": "Download",
|
||||
"draw-a-route": "Draw a route",
|
||||
"driving": "Driving",
|
||||
"duplicate": "",
|
||||
"duration": "Duration",
|
||||
"dutch": "Holland",
|
||||
"easy": "Könnyű",
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"download": "Scarica",
|
||||
"draw-a-route": "Disegna un percorso",
|
||||
"driving": "Guida",
|
||||
"duplicate": "",
|
||||
"duration": "Durata",
|
||||
"dutch": "Olandese",
|
||||
"easy": "Facile",
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"download": "Download",
|
||||
"draw-a-route": "Draw a route",
|
||||
"driving": "Driving",
|
||||
"duplicate": "",
|
||||
"duration": "Duration",
|
||||
"dutch": "Nederlands",
|
||||
"easy": "Makkelijk",
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"download": "Pobierz",
|
||||
"draw-a-route": "Narysuj trasę",
|
||||
"driving": "Samochód",
|
||||
"duplicate": "",
|
||||
"duration": "Czas trwania",
|
||||
"dutch": "Niderlandzki",
|
||||
"easy": "Łatwy",
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"download": "Download",
|
||||
"draw-a-route": "Desenhar uma rota",
|
||||
"driving": "Conduzir",
|
||||
"duplicate": "",
|
||||
"duration": "Duração",
|
||||
"dutch": "Holandês",
|
||||
"easy": "Fácil",
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"download": "Download",
|
||||
"draw-a-route": "绘制路线",
|
||||
"driving": "驾驶",
|
||||
"duplicate": "",
|
||||
"duration": "持续时间",
|
||||
"dutch": "荷兰语",
|
||||
"easy": "简单",
|
||||
|
||||
@@ -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<GPX | Error> {
|
||||
|
||||
@@ -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<Response> = fetch): Promise<TrailFilterValues> {
|
||||
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<string, any>, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
|
||||
|
||||
57
web/src/lib/stores/upload_store.svelte.ts
Normal file
57
web/src/lib/stores/upload_store.svelte.ts
Normal file
@@ -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<unknown>
|
||||
};
|
||||
|
||||
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<unknown>[] = [];
|
||||
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;
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export async function create<T>(event: RequestEvent, schema: ZodSchema, collecti
|
||||
const data = await event.request.json();
|
||||
const safeData = schema.parse(data);
|
||||
|
||||
const r = await pb.collection(Collection[collection]).create<T>(safeData, safeSearchParams)
|
||||
const r = await pb.collection(Collection[collection]).create<T>(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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user