aboutsummaryrefslogtreecommitdiff
path: root/web/sync.go
diff options
context:
space:
mode:
Diffstat (limited to 'web/sync.go')
-rw-r--r--web/sync.go255
1 files changed, 255 insertions, 0 deletions
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
+}