From 8e0c55230ef59869bda1ca19ca8697b447d57698 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Sun, 6 Sep 2026 12:38:16 +0200 Subject: Preview: analyze a raw GPX or FIT file without persisting it --- web/preview.go | 185 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 web/preview.go (limited to 'web/preview.go') diff --git a/web/preview.go b/web/preview.go new file mode 100644 index 0000000..2c36b71 --- /dev/null +++ b/web/preview.go @@ -0,0 +1,185 @@ +package web + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/climb" + "github.com/martinlehoux/biking_home/mountain_pass" + "github.com/martinlehoux/biking_home/official_climb" + "github.com/martinlehoux/biking_home/ride" + "github.com/martinlehoux/kagamigo/kcore" +) + +const previewOfficialClimbURL = "/preview/official-climbs" + +type PreviewPageData struct { + Error string +} + +func (s *Server) handlePreviewForm(w http.ResponseWriter, r *http.Request) { + kcore.RenderPage(r.Context(), PreviewPage(PreviewPageData{}), w) +} + +func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) { + // ponytail: hard 32MB cap; raise if real-world GPX exports exceed it + r.Body = http.MaxBytesReader(w, r.Body, 32<<20) + if err := r.ParseMultipartForm(8 << 20); err != nil { + slog.Info("Rejected preview upload", "error", err) + s.renderPreviewForm(w, r, "The upload was rejected. Please try a smaller file.") + return + } + file, header, err := r.FormFile("file") + if err != nil { + s.renderPreviewForm(w, r, "A ride file is required.") + return + } + defer file.Close() + parser, err := rideParserFor(header.Filename) + if err != nil { + s.renderPreviewForm(w, r, err.Error()) + return + } + parsed, err := parser.Parse(file) + if err != nil { + slog.Info("Failed to parse preview file", "filename", header.Filename, "error", err) + s.renderPreviewForm(w, r, "The file could not be parsed as a ride (GPX without elevation or an unsupported format?).") + return + } + passes, err := mountain_pass.LoadMountainPassesAroundRide(s.db, parsed.Coords, rideDetailMountainPassMarginM) + if err != nil { + slog.Warn("Failed to load nearby mountain passes for preview", "error", err) + } + officialClimbs, err := official_climb.List(s.db) + if err != nil { + slog.Warn("Failed to load official climbs for preview", "error", err) + } + view := buildRideDetailView(buildPreviewRideView(header.Filename, parsed), parsed, passes, officialClimbs, s.officialClimbMatchPolicy()) + view.Preview = true + view.OfficialClimbFormURL = previewOfficialClimbURL + kcore.RenderPage(r.Context(), RideDetailPage(view), w) +} + +func (s *Server) renderPreviewForm(w http.ResponseWriter, r *http.Request, message string) { + kcore.RenderPage(r.Context(), PreviewPage(PreviewPageData{Error: message}), w) +} + +func rideParserFor(filename string) (ride.RideParser, error) { + switch strings.ToLower(filepath.Ext(filename)) { + case ".gpx": + return ride.GPXRideParser{}, nil + case ".fit": + return ride.FITRideParser{}, nil + default: + return nil, errors.New("unsupported file type: upload a .gpx or .fit file") + } +} + +// buildPreviewRideView derives the ride summary metrics from the parsed route, +// the same values ride.Save persists for imported rides. +func buildPreviewRideView(filename string, parsed ride.Ride) RideView { + distanceM := parsed.Distances[len(parsed.Distances)-1] + elevationGainM := 0.0 + for i := 1; i < len(parsed.Elevations); i++ { + if delta := parsed.Elevations[i] - parsed.Elevations[i-1]; delta > 0 { + elevationGainM += delta + } + } + movingTimeS := int64(ride.MovingTime(parsed) / time.Second) + averageSpeedMps := 0.0 + if movingTimeS > 0 { + averageSpeedMps = distanceM / float64(movingTimeS) + } + timestamps := parsed.Timestamps() + var startDate time.Time + if len(timestamps) > 0 { + startDate = timestamps[0] + } + view := RideView{ + Ride: ride.StoredRide{ + Name: strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename)), + StartDate: startDate, + DistanceM: distanceM, + TotalElevationGainM: elevationGainM, + AverageSpeedMps: averageSpeedMps, + }, + Cotacol: "-", + CotacolPer100Km: "-", + MovingTime: formatDuration(movingTimeS), + } + if distanceM > 0 { + cotacol := climb.Cotacol(parsed.Distances, parsed.Elevations) + view.Cotacol = formatCotacol(cotacol) + view.CotacolPer100Km = formatCotacolPer100Km(cotacol, distanceM) + } + return view +} + +func (s *Server) handlePreviewOfficialClimb(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + startIndex, err := parseRouteIndex(r.FormValue("start_index")) + if err != nil { + writePreviewSaveError(w, "a valid start route point is required") + return + } + endIndex, err := parseRouteIndex(r.FormValue("end_index")) + if err != nil { + writePreviewSaveError(w, "a valid end route point is required") + return + } + if startIndex >= endIndex { + writePreviewSaveError(w, "the official climb boundaries must be ordered route points") + return + } + startCoord, err := parseCoordForm(r, "start") + if err != nil { + writePreviewSaveError(w, "valid start coordinates are required") + return + } + endCoord, err := parseCoordForm(r, "end") + if err != nil { + writePreviewSaveError(w, "valid end coordinates are required") + return + } + created, err := official_climb.Create(s.db, official_climb.OfficialClimb{ + Name: r.FormValue("name"), + StartCoord: startCoord, + EndCoord: endCoord, + }) + if err != nil { + slog.Warn("Failed to create official climb from preview", "error", err) + writePreviewSaveError(w, err.Error()) + return + } + slog.Info("Created official climb from preview", "official_climb_id", created.ID) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "name": created.Name}) +} + +func parseCoordForm(r *http.Request, prefix string) (coord geodist.Coord, err error) { + lat, err := strconv.ParseFloat(r.FormValue(prefix+"_lat"), 64) + if err != nil { + return coord, err + } + lon, err := strconv.ParseFloat(r.FormValue(prefix+"_lon"), 64) + if err != nil { + return coord, err + } + return geodist.Coord{Lat: lat, Lon: lon}, nil +} + +func writePreviewSaveError(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{"error": message}) +} -- cgit v1.3