Files
streba/src/routes/upload/+page.server.ts
T

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 };
}
};