1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
|
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})
}
|