From 1e63f5c91f0e5ff7dfc582b67d262d5939cc34bc Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Fri, 21 Aug 2026 19:02:05 +0200 Subject: Add global analytics page shell and split server.go by concern --- web/sync.go | 255 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 web/sync.go (limited to 'web/sync.go') diff --git a/web/sync.go b/web/sync.go new file mode 100644 index 0000000..4a6ba5f --- /dev/null +++ b/web/sync.go @@ -0,0 +1,255 @@ +package web + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/martinlehoux/biking_home/rides" + "github.com/martinlehoux/biking_home/strava" + "github.com/martinlehoux/kagamigo/kcore" +) + +func (s *Server) handleSyncForm(w http.ResponseWriter, r *http.Request) { + data := SyncPageData{ + From: queryOrDefault(r, "from", time.Now().AddDate(0, 0, -30).Format(dateFormat)), + To: queryOrDefault(r, "to", time.Now().Format(dateFormat)), + Notice: syncNotice(r), + HasAuth: s.hasStravaToken(), + } + if _, _, err := s.stravaClient(); err != nil { + data.Error = err.Error() + } + kcore.RenderPage(r.Context(), SyncPage(data), w) +} + +func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(10 << 20); err != nil && !errors.Is(err, http.ErrNotMultipart) { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + from, to, err := parseDateRange(r.FormValue("from"), r.FormValue("to")) + if err != nil { + s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) + return + } + appConfig, err := s.loadConfig() + if err != nil { + s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) + return + } + client, authorized, err := newStravaClient(appConfig) + if err != nil { + s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) + return + } + if !authorized { + query := url.Values{} + query.Set("return_to", "/sync?from="+r.FormValue("from")+"&to="+r.FormValue("to")) + http.Redirect(w, r, "/strava/login?"+query.Encode(), http.StatusFound) + return + } + if !s.beginSync() { + http.Error(w, "a Strava sync is already in progress", http.StatusConflict) + return + } + defer s.endSync() + + if refreshed, err := client.RefreshIfNeeded(); err != nil { + s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) + return + } else if refreshed { + if err := s.saveStravaToken(client); err != nil { + s.renderSyncError(w, r, r.FormValue("from"), r.FormValue("to"), err) + return + } + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming responses are not supported", http.StatusInternalServerError) + return + } + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Content-Type", "text/event-stream") + progress, err := s.syncRides(client, appConfig.Storage.GPXDir, from, to, func(progress SyncProgress) error { + return writeSyncEvent(w, flusher, "progress", progress) + }) + if err != nil { + _ = writeSyncEvent(w, flusher, "error", syncErrorEvent{Message: err.Error(), Progress: progress}) + return + } + slog.Info("Completed Strava sync", "from", from.Format(dateFormat), "to", to.Add(-24*time.Hour).Format(dateFormat), "total", progress.Total, "imported", progress.Imported, "skipped", progress.Skipped) + _ = writeSyncEvent(w, flusher, "complete", progress) +} + +func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, report func(SyncProgress) error) (SyncProgress, error) { + activities, err := client.List(from, to) + if err != nil { + return SyncProgress{}, err + } + progress := SyncProgress{Total: len(activities)} + if err := reportSyncProgress(report, progress); err != nil { + return progress, err + } + slog.Info("Started Strava sync", "from", from.Format(dateFormat), "to", to.Add(-24*time.Hour).Format(dateFormat), "total", progress.Total) + if err := os.MkdirAll(gpxDir, 0o755); err != nil { + return progress, fmt.Errorf("create GPX directory: %w", err) + } + for _, summary := range activities { + externalID := fmt.Sprintf("strava:%d", summary.ID) + _, exists, err := rides.GetByExternalID(s.db, externalID) + if err != nil { + return progress, err + } + if exists { + progress.Skipped++ + progress.Completed++ + if err := reportSyncProgress(report, progress); err != nil { + return progress, err + } + continue + } + activity, gpxData, err := client.Get(summary.ID) + if err != nil { + if errors.Is(err, strava.ErrNoTrackPoints) { + _ = os.Remove(filepath.Join(gpxDir, fmt.Sprintf("activity_%d.gpx", summary.ID))) + if saveErr := rides.Save(s.db, rideFromActivity(externalID, "", activity)); saveErr != nil { + if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("save metadata: %w", saveErr)); reportErr != nil { + return progress, reportErr + } + continue + } + progress.Imported++ + progress.Completed++ + if reportErr := reportSyncProgress(report, progress); reportErr != nil { + return progress, reportErr + } + slog.Info("Imported Strava activity without route", "activity", activity.ID, "name", activity.Name) + continue + } + if err := skipSyncActivity(&progress, report, summary, err); err != nil { + return progress, err + } + continue + } + gpxPath := filepath.Join(gpxDir, fmt.Sprintf("activity_%d.gpx", activity.ID)) + if err := os.WriteFile(gpxPath, gpxData, 0o644); err != nil { + _ = os.Remove(gpxPath) + if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("write GPX: %w", err)); reportErr != nil { + return progress, reportErr + } + continue + } + if err := rides.Save(s.db, rideFromActivity(externalID, gpxPath, activity)); err != nil { + _ = os.Remove(gpxPath) + if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("save activity: %w", err)); reportErr != nil { + return progress, reportErr + } + continue + } + progress.Imported++ + progress.Completed++ + if err := reportSyncProgress(report, progress); err != nil { + return progress, err + } + slog.Info("Imported Strava ride", "activity", activity.ID, "name", activity.Name) + } + return progress, nil +} + +func rideFromActivity(externalID, gpxPath string, activity strava.Activity) rides.Ride { + activityType := activity.SportType + if activityType == "" { + activityType = activity.Type + } + return rides.Ride{ + ExternalID: externalID, + GPXPath: gpxPath, + Name: activity.Name, + Type: activityType, + StartDate: activity.StartDate, + DistanceM: activity.DistanceM, + ExternalMovingTimeS: activity.MovingTimeS, + ElapsedTimeS: activity.ElapsedTimeS, + TotalElevationGainM: activity.TotalElevationGainM, + AverageSpeedMps: activity.AverageSpeedMps, + } +} + +func skipSyncActivity(progress *SyncProgress, report func(SyncProgress) error, activity strava.Activity, err error) error { + progress.Skipped++ + progress.Completed++ + slog.Warn("Skipped Strava activity", "activity", activity.ID, "name", activity.Name, "error", err) + return reportSyncProgress(report, *progress) +} + +func reportSyncProgress(report func(SyncProgress) error, progress SyncProgress) error { + if report == nil { + return nil + } + return report(progress) +} + +func (s *Server) beginSync() bool { + s.syncMu.Lock() + defer s.syncMu.Unlock() + if s.syncActive { + return false + } + s.syncActive = true + return true +} + +func (s *Server) endSync() { + s.syncMu.Lock() + s.syncActive = false + s.syncMu.Unlock() +} + +type syncErrorEvent struct { + Message string `json:"message"` + Progress SyncProgress `json:"progress"` +} + +func writeSyncEvent(w http.ResponseWriter, flusher http.Flusher, event string, data any) error { + payload, err := json.Marshal(data) + if err != nil { + return err + } + if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, payload); err != nil { + return err + } + flusher.Flush() + return nil +} + +func (s *Server) renderSyncError(w http.ResponseWriter, r *http.Request, from, to string, err error) { + if strings.Contains(r.Header.Get("Accept"), "text/event-stream") { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusBadRequest) + kcore.RenderPage(r.Context(), SyncPage(SyncPageData{From: from, To: to, Error: err.Error(), HasAuth: s.hasStravaToken()}), w) +} + +func parseDateRange(from, to string) (time.Time, time.Time, error) { + start, err := time.Parse(dateFormat, from) + if err != nil { + return time.Time{}, time.Time{}, errors.New("a valid start date is required") + } + end, err := time.Parse(dateFormat, to) + if err != nil { + return time.Time{}, time.Time{}, errors.New("a valid end date is required") + } + if end.Before(start) { + return time.Time{}, time.Time{}, errors.New("the end date must not be before the start date") + } + return start.UTC(), end.AddDate(0, 0, 1).UTC(), nil +} -- cgit v1.3