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/biking_home/rideanalysis" "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, rideanalysis.PassMarginM) 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. 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 } sample, err := parseSampleForm(r) if err != nil { writePreviewSaveError(w, "a valid recorded sample is required") return } created, err := official_climb.Create(s.db, official_climb.OfficialClimb{ Name: r.FormValue("name"), StartCoord: startCoord, EndCoord: endCoord, Sample: sample, }) 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}) } // parseSampleForm reads the sample the preview client computed for the selected boundaries. func parseSampleForm(r *http.Request) (climb.SegmentSample, error) { distanceM, err := strconv.ParseFloat(r.FormValue("sample_distance_m"), 64) if err != nil { return climb.SegmentSample{}, err } elevationGainM, err := strconv.ParseFloat(r.FormValue("sample_elevation_gain_m"), 64) if err != nil { return climb.SegmentSample{}, err } return climb.SegmentSample{DistanceM: distanceM, ElevationGainM: elevationGainM}, nil } 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}) }