135 lines
4.5 KiB
TypeScript
135 lines
4.5 KiB
TypeScript
import { fail } from '@sveltejs/kit';
|
|
import { db, peaksNearBounds, recordAscent } from '$lib/server/db';
|
|
import { matchPeaks, parseGpx } from '$lib/server/gpx';
|
|
import type { Actions } from './$types';
|
|
|
|
/** A "name" that is really just a date or timestamp, e.g. "2023-10-03 17:39:35". */
|
|
const DATE_ONLY_NAME =
|
|
/^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}([ T](\d{1,2}[:.]\d{2}([:.]\d{2})?|\d{4,6}))?Z?$/;
|
|
|
|
/** Infer an activity type from average moving speed when the GPX doesn't say. */
|
|
function inferType(gpx: { distance_m: number; moving_s: number | null }): string | null {
|
|
if (!gpx.moving_s || gpx.moving_s < 60) return null;
|
|
const kmh = (gpx.distance_m / gpx.moving_s) * 3.6;
|
|
if (kmh >= 13) return 'ride';
|
|
if (kmh >= 7) return 'run';
|
|
return 'hike';
|
|
}
|
|
|
|
/** "Morning Hike", "Evening Run", or "Hike on 3 Oct 2023" when there's no start time. */
|
|
function generateName(type: string, start: string | null, date: string | null): string {
|
|
const capitalized = type.charAt(0).toUpperCase() + type.slice(1);
|
|
if (start) {
|
|
const hour = new Date(start).getUTCHours();
|
|
const period =
|
|
hour >= 4 && hour < 11
|
|
? 'Morning'
|
|
: hour >= 11 && hour < 14
|
|
? 'Lunch'
|
|
: hour >= 14 && hour < 18
|
|
? 'Afternoon'
|
|
: hour >= 18 && hour < 22
|
|
? 'Evening'
|
|
: 'Night';
|
|
return `${period} ${capitalized}`;
|
|
}
|
|
if (date) {
|
|
const nice = new Date(date + 'T12:00:00').toLocaleDateString('en-GB', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric'
|
|
});
|
|
return `${capitalized} on ${nice}`;
|
|
}
|
|
return capitalized;
|
|
}
|
|
|
|
/**
|
|
* Parse export-style filenames like
|
|
* "2018-01-11 141322 - Run - Afternoon Run.gpx" (Date - Type - Name).
|
|
*/
|
|
function parseFilename(
|
|
filename: string
|
|
): { date: string; type: string; name: string } | null {
|
|
const base = filename.replace(/\.gpx$/i, '');
|
|
const parts = base.split(' - ');
|
|
if (parts.length < 3) return null;
|
|
const dateMatch = parts[0].match(/^(\d{4}-\d{2}-\d{2})(\s+\d{4,6})?$/);
|
|
if (!dateMatch) return null;
|
|
return {
|
|
date: dateMatch[1],
|
|
type: parts[1].trim().toLowerCase(),
|
|
name: parts.slice(2).join(' - ').trim()
|
|
};
|
|
}
|
|
|
|
export const actions: Actions = {
|
|
default: async ({ request, locals }) => {
|
|
const userId = locals.user!.id;
|
|
const form = await request.formData();
|
|
const files = form.getAll('gpx').filter((f): f is File => f instanceof File && f.size > 0);
|
|
if (files.length === 0) return fail(400, { error: 'No GPX files selected.' });
|
|
|
|
const uploaded: { id: number; name: string; newPeaks: string[] }[] = [];
|
|
const errors: string[] = [];
|
|
|
|
const insert = db.prepare(`
|
|
INSERT INTO activities
|
|
(user_id, name, type, date, distance_m, duration_s, moving_s,
|
|
elev_gain_m, elev_loss_m, elev_min_m, elev_max_m, points, bounds)
|
|
VALUES
|
|
(@user_id, @name, @type, @date, @distance_m, @duration_s, @moving_s,
|
|
@elev_gain_m, @elev_loss_m, @elev_min_m, @elev_max_m, @points, @bounds)
|
|
`);
|
|
|
|
for (const file of files) {
|
|
try {
|
|
const gpx = parseGpx(await file.text());
|
|
const parsed = parseFilename(file.name);
|
|
|
|
// a missing, "outdoor", or numeric (old Strava code) type gets inferred from pace
|
|
let type = parsed?.type ?? gpx.type ?? '';
|
|
if (!type || type === 'outdoor' || /^\d+$/.test(type)) {
|
|
type = inferType(gpx) ?? 'outdoor';
|
|
}
|
|
// a name that is just a date/timestamp gets a friendly generated one
|
|
let name = parsed?.name ?? gpx.name ?? file.name.replace(/\.gpx$/i, '');
|
|
if (!name.trim() || DATE_ONLY_NAME.test(name.trim())) {
|
|
name = generateName(type, gpx.start, gpx.date ?? parsed?.date ?? null);
|
|
}
|
|
|
|
const result = insert.run({
|
|
user_id: userId,
|
|
name,
|
|
type,
|
|
date: gpx.date ?? parsed?.date ?? null,
|
|
distance_m: gpx.distance_m,
|
|
duration_s: gpx.duration_s,
|
|
moving_s: gpx.moving_s,
|
|
elev_gain_m: gpx.elev_gain_m,
|
|
elev_loss_m: gpx.elev_loss_m,
|
|
elev_min_m: gpx.elev_min_m,
|
|
elev_max_m: gpx.elev_max_m,
|
|
points: JSON.stringify(gpx.points),
|
|
bounds: JSON.stringify(gpx.bounds)
|
|
});
|
|
const activityId = Number(result.lastInsertRowid);
|
|
|
|
const nearby = peaksNearBounds(gpx.bounds);
|
|
const summited = matchPeaks(gpx.points, nearby);
|
|
const newPeaks: string[] = [];
|
|
for (const peak of summited) {
|
|
if (recordAscent(userId, peak.id, activityId, gpx.date)) newPeaks.push(peak.name);
|
|
}
|
|
|
|
uploaded.push({ id: activityId, name, newPeaks });
|
|
} catch (err) {
|
|
errors.push(`${file.name}: ${err instanceof Error ? err.message : 'could not parse'}`);
|
|
}
|
|
}
|
|
|
|
if (uploaded.length === 0) return fail(422, { error: errors.join(' ') });
|
|
return { uploaded, errors };
|
|
}
|
|
};
|