diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-11 14:19:01 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-08-11 14:19:01 +0200 |
| commit | 7d13374eec8c33c4e14cc5b0c3de36e4f620ce0e (patch) | |
| tree | 98a20ee73f34863ddb5938aa5eb29346e4a5db56 /web | |
| parent | 1da94931188c35c06dd032aadce2799fc13db1dd (diff) | |
fix: Continue Strava import after invalid activities
Diffstat (limited to 'web')
| -rw-r--r-- | web/server.go | 33 | ||||
| -rw-r--r-- | web/server_test.go | 62 | ||||
| -rw-r--r-- | web/templates.templ | 2 | ||||
| -rw-r--r-- | web/templates_templ.go | 2 |
4 files changed, 92 insertions, 7 deletions
diff --git a/web/server.go b/web/server.go index 53374ce..6f222ef 100644 --- a/web/server.go +++ b/web/server.go @@ -37,6 +37,11 @@ type Server struct { syncActive bool } +type syncClient interface { + List(from, to time.Time) ([]strava.Activity, error) + Get(id int64) (strava.Activity, []byte, error) +} + func NewServer(db *sql.DB, configPath string) *Server { return &Server{db: db, configPath: configPath} } @@ -263,7 +268,7 @@ func (s *Server) handleStravaCallback(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, returnTo, http.StatusSeeOther) } -func (s *Server) syncRides(client *strava.Client, gpxDir string, from, to time.Time, report func(SyncProgress) error) (SyncProgress, error) { +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 @@ -292,11 +297,18 @@ func (s *Server) syncRides(client *strava.Client, gpxDir string, from, to time.T } activity, gpxData, err := client.Get(summary.ID) if err != nil { - return progress, err + 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 { - return progress, fmt.Errorf("write GPX for activity %d: %w", activity.ID, err) + _ = os.Remove(gpxPath) + if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("write GPX: %w", err)); reportErr != nil { + return progress, reportErr + } + continue } activityType := activity.SportType if activityType == "" { @@ -314,7 +326,11 @@ func (s *Server) syncRides(client *strava.Client, gpxDir string, from, to time.T TotalElevationGainM: activity.TotalElevationGainM, AverageSpeedMps: activity.AverageSpeedMps, }); err != nil { - return progress, fmt.Errorf("save activity %d: %w", activity.ID, err) + _ = 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++ @@ -326,6 +342,13 @@ func (s *Server) syncRides(client *strava.Client, gpxDir string, from, to time.T return progress, nil } +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 @@ -469,7 +492,7 @@ func syncNotice(r *http.Request) string { if imported == "" { return "" } - return fmt.Sprintf("Sync complete: %s imported, %s already stored.", imported, r.URL.Query().Get("skipped")) + return fmt.Sprintf("Sync complete: %s imported, %s skipped.", imported, r.URL.Query().Get("skipped")) } func safeReturnTo(value string) string { diff --git a/web/server_test.go b/web/server_test.go index 32ab448..46828d1 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -16,6 +16,7 @@ import ( "github.com/martinlehoux/biking_home/config" "github.com/martinlehoux/biking_home/rides" + "github.com/martinlehoux/biking_home/strava" _ "github.com/mattn/go-sqlite3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -61,6 +62,32 @@ func testGPXPath(t *testing.T, name string) string { return path } +type fakeSyncClient struct { + activities []strava.Activity + results map[int64]fakeSyncResult +} + +type fakeSyncResult struct { + activity strava.Activity + data []byte + err error +} + +func (c fakeSyncClient) List(time.Time, time.Time) ([]strava.Activity, error) { + return c.activities, nil +} + +func (c fakeSyncClient) Get(id int64) (strava.Activity, []byte, error) { + result, ok := c.results[id] + if !ok { + return strava.Activity{}, nil, fmt.Errorf("missing fake activity %d", id) + } + return result.activity, result.data, result.err +} + +const emptyTrackGPX = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"><trk><trkseg></trkseg></trk></gpx>` +const validTrackGPX = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"><trk><trkseg><trkpt lat="43.0" lon="5.0"><ele>100</ele></trkpt><trkpt lat="43.001" lon="5.001"><ele>200</ele></trkpt></trkseg></trk></gpx>` + func TestHandlerRendersRidesPage(t *testing.T) { server, db := newWebTestServer(t) require.NoError(t, rides.Save(db, rides.Ride{ @@ -224,6 +251,41 @@ func TestSyncParsesMultipartDateRange(t *testing.T) { assert.NotContains(t, response.Body.String(), "<!doctype html>") } +func TestSyncRidesContinuesAfterInvalidActivity(t *testing.T) { + server, db := newWebTestServer(t) + invalidID := int64(14701658670) + validID := int64(14701658671) + startDate := time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC) + client := fakeSyncClient{ + activities: []strava.Activity{ + {ID: invalidID, Name: "Empty activity", Type: "Ride", StartDate: startDate}, + {ID: validID, Name: "Valid activity", Type: "Ride", StartDate: startDate}, + }, + results: map[int64]fakeSyncResult{ + invalidID: {activity: strava.Activity{ID: invalidID, Name: "Empty activity", Type: "Ride", StartDate: startDate}, data: []byte(emptyTrackGPX)}, + validID: {activity: strava.Activity{ID: validID, Name: "Valid activity", Type: "Ride", StartDate: startDate}, data: []byte(validTrackGPX)}, + }, + } + var updates []SyncProgress + gpxDir := t.TempDir() + + progress, err := server.syncRides(client, gpxDir, startDate, startDate.AddDate(0, 0, 1), func(progress SyncProgress) error { + updates = append(updates, progress) + return nil + }) + + require.NoError(t, err) + assert.Equal(t, SyncProgress{Total: 2, Completed: 2, Imported: 1, Skipped: 1}, progress) + assert.Len(t, updates, 3) + _, found, err := rides.GetByExternalID(db, "strava:14701658670") + require.NoError(t, err) + assert.False(t, found) + _, found, err = rides.GetByExternalID(db, "strava:14701658671") + require.NoError(t, err) + assert.True(t, found) + assert.NoFileExists(t, filepath.Join(gpxDir, "activity_14701658670.gpx")) +} + func TestSyncRejectsConcurrentImport(t *testing.T) { server, _ := newWebTestServer(t) appConfig, err := config.Load(server.configPath) diff --git a/web/templates.templ b/web/templates.templ index 74b0365..78591e1 100644 --- a/web/templates.templ +++ b/web/templates.templ @@ -208,7 +208,7 @@ templ SyncContent(data SyncPageData) { } if (eventName === "complete") { updateProgress(data); - notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} already stored.`; + notice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.`; notice.hidden = false; return true; } diff --git a/web/templates_templ.go b/web/templates_templ.go index ba6910b..b66e732 100644 --- a/web/templates_templ.go +++ b/web/templates_templ.go @@ -630,7 +630,7 @@ func SyncContent(data SyncPageData) templ.Component { return templ_7745c5c3_Err } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section><script>\n\t\t(() => {\n\t\t\tconst form = document.getElementById(\"sync-form\");\n\t\t\tif (!form) return;\n\t\t\tconst button = form.querySelector(\"button[type=submit]\");\n\t\t\tconst progressPanel = document.getElementById(\"sync-progress\");\n\t\t\tconst progressBar = document.getElementById(\"sync-progress-bar\");\n\t\t\tconst progressCount = document.getElementById(\"sync-progress-count\");\n\t\t\tconst progressDetails = document.getElementById(\"sync-progress-details\");\n\t\t\tconst notice = document.getElementById(\"sync-notice\");\n\t\t\tconst error = document.getElementById(\"sync-error\");\n\n\t\t\tconst showError = (message) => {\n\t\t\t\terror.textContent = message;\n\t\t\t\terror.hidden = false;\n\t\t\t};\n\t\t\tconst updateProgress = (progress) => {\n\t\t\t\tprogressPanel.hidden = false;\n\t\t\t\tprogressBar.max = Math.max(progress.total, 1);\n\t\t\t\tprogressBar.value = progress.completed;\n\t\t\t\tprogressCount.textContent = `${progress.completed} / ${progress.total}`;\n\t\t\t\tprogressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped`;\n\t\t\t};\n\t\t\tconst handleEvent = (eventName, data) => {\n\t\t\t\tif (eventName === \"progress\") {\n\t\t\t\t\tupdateProgress(data);\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t\tif (eventName === \"complete\") {\n\t\t\t\t\tupdateProgress(data);\n\t\t\t\t\tnotice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} already stored.`;\n\t\t\t\t\tnotice.hidden = false;\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t\tif (eventName === \"error\") {\n\t\t\t\t\tif (data.progress) updateProgress(data.progress);\n\t\t\t\t\tthrow new Error(data.message || \"Strava sync failed\");\n\t\t\t\t}\n\t\t\t\treturn false;\n\t\t\t};\n\t\t\tconst consumeEvents = async (response) => {\n\t\t\t\tconst reader = response.body.getReader();\n\t\t\t\tconst decoder = new TextDecoder();\n\t\t\t\tlet buffer = \"\";\n\t\t\t\tlet completed = false;\n\t\t\t\tconst consumeBlock = (block) => {\n\t\t\t\t\tlet eventName = \"message\";\n\t\t\t\t\tlet data = \"\";\n\t\t\t\t\tfor (const line of block.split(/\\r?\\n/)) {\n\t\t\t\t\t\tif (line.startsWith(\"event: \")) eventName = line.slice(7);\n\t\t\t\t\t\tif (line.startsWith(\"data: \")) data += line.slice(6);\n\t\t\t\t\t}\n\t\t\t\t\tif (data) completed = handleEvent(eventName, JSON.parse(data)) || completed;\n\t\t\t\t};\n\t\t\t\twhile (true) {\n\t\t\t\t\tconst { value, done } = await reader.read();\n\t\t\t\t\tbuffer += decoder.decode(value || new Uint8Array(), { stream: !done });\n\t\t\t\t\tconst blocks = buffer.split(/\\r?\\n\\r?\\n/);\n\t\t\t\t\tbuffer = blocks.pop();\n\t\t\t\t\tfor (const block of blocks) consumeBlock(block);\n\t\t\t\t\tif (done) break;\n\t\t\t\t}\n\t\t\t\tif (buffer.trim()) consumeBlock(buffer);\n\t\t\t\tif (!completed) throw new Error(\"Strava sync ended before completion\");\n\t\t\t};\n\n\t\t\tform.addEventListener(\"submit\", async (event) => {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tbutton.disabled = true;\n\t\t\t\tbutton.textContent = \"Syncing...\";\n\t\t\t\tnotice.hidden = true;\n\t\t\t\terror.hidden = true;\n\t\t\t\tprogressPanel.hidden = false;\n\t\t\t\tprogressCount.textContent = \"0 / 0\";\n\t\t\t\tprogressDetails.textContent = \"Preparing import...\";\n\t\t\t\ttry {\n\t\t\t\t\tconst response = await fetch(form.action, {\n\t\t\t\t\t\tmethod: \"POST\",\n\t\t\t\t\t\tbody: new FormData(form),\n\t\t\t\t\t\theaders: { Accept: \"text/event-stream\" },\n\t\t\t\t\t});\n\t\t\t\t\tif (response.redirected) {\n\t\t\t\t\t\twindow.location.assign(response.url);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tif (!response.ok) throw new Error(await response.text() || `Sync failed (HTTP ${response.status})`);\n\t\t\t\t\tif (!response.body) throw new Error(\"The browser does not support streaming responses\");\n\t\t\t\t\tawait consumeEvents(response);\n\t\t\t\t} catch (caught) {\n\t\t\t\t\tshowError(caught instanceof Error ? caught.message : \"Strava sync failed\");\n\t\t\t\t} finally {\n\t\t\t\t\tbutton.disabled = false;\n\t\t\t\t\tbutton.textContent = \"Sync rides\";\n\t\t\t\t}\n\t\t\t});\n\t\t})();\n\t</script>") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</section><script>\n\t\t(() => {\n\t\t\tconst form = document.getElementById(\"sync-form\");\n\t\t\tif (!form) return;\n\t\t\tconst button = form.querySelector(\"button[type=submit]\");\n\t\t\tconst progressPanel = document.getElementById(\"sync-progress\");\n\t\t\tconst progressBar = document.getElementById(\"sync-progress-bar\");\n\t\t\tconst progressCount = document.getElementById(\"sync-progress-count\");\n\t\t\tconst progressDetails = document.getElementById(\"sync-progress-details\");\n\t\t\tconst notice = document.getElementById(\"sync-notice\");\n\t\t\tconst error = document.getElementById(\"sync-error\");\n\n\t\t\tconst showError = (message) => {\n\t\t\t\terror.textContent = message;\n\t\t\t\terror.hidden = false;\n\t\t\t};\n\t\t\tconst updateProgress = (progress) => {\n\t\t\t\tprogressPanel.hidden = false;\n\t\t\t\tprogressBar.max = Math.max(progress.total, 1);\n\t\t\t\tprogressBar.value = progress.completed;\n\t\t\t\tprogressCount.textContent = `${progress.completed} / ${progress.total}`;\n\t\t\t\tprogressDetails.textContent = `${progress.imported} imported · ${progress.skipped} skipped`;\n\t\t\t};\n\t\t\tconst handleEvent = (eventName, data) => {\n\t\t\t\tif (eventName === \"progress\") {\n\t\t\t\t\tupdateProgress(data);\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t\tif (eventName === \"complete\") {\n\t\t\t\t\tupdateProgress(data);\n\t\t\t\t\tnotice.textContent = `Sync complete: ${data.imported} imported, ${data.skipped} skipped.`;\n\t\t\t\t\tnotice.hidden = false;\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t\tif (eventName === \"error\") {\n\t\t\t\t\tif (data.progress) updateProgress(data.progress);\n\t\t\t\t\tthrow new Error(data.message || \"Strava sync failed\");\n\t\t\t\t}\n\t\t\t\treturn false;\n\t\t\t};\n\t\t\tconst consumeEvents = async (response) => {\n\t\t\t\tconst reader = response.body.getReader();\n\t\t\t\tconst decoder = new TextDecoder();\n\t\t\t\tlet buffer = \"\";\n\t\t\t\tlet completed = false;\n\t\t\t\tconst consumeBlock = (block) => {\n\t\t\t\t\tlet eventName = \"message\";\n\t\t\t\t\tlet data = \"\";\n\t\t\t\t\tfor (const line of block.split(/\\r?\\n/)) {\n\t\t\t\t\t\tif (line.startsWith(\"event: \")) eventName = line.slice(7);\n\t\t\t\t\t\tif (line.startsWith(\"data: \")) data += line.slice(6);\n\t\t\t\t\t}\n\t\t\t\t\tif (data) completed = handleEvent(eventName, JSON.parse(data)) || completed;\n\t\t\t\t};\n\t\t\t\twhile (true) {\n\t\t\t\t\tconst { value, done } = await reader.read();\n\t\t\t\t\tbuffer += decoder.decode(value || new Uint8Array(), { stream: !done });\n\t\t\t\t\tconst blocks = buffer.split(/\\r?\\n\\r?\\n/);\n\t\t\t\t\tbuffer = blocks.pop();\n\t\t\t\t\tfor (const block of blocks) consumeBlock(block);\n\t\t\t\t\tif (done) break;\n\t\t\t\t}\n\t\t\t\tif (buffer.trim()) consumeBlock(buffer);\n\t\t\t\tif (!completed) throw new Error(\"Strava sync ended before completion\");\n\t\t\t};\n\n\t\t\tform.addEventListener(\"submit\", async (event) => {\n\t\t\t\tevent.preventDefault();\n\t\t\t\tbutton.disabled = true;\n\t\t\t\tbutton.textContent = \"Syncing...\";\n\t\t\t\tnotice.hidden = true;\n\t\t\t\terror.hidden = true;\n\t\t\t\tprogressPanel.hidden = false;\n\t\t\t\tprogressCount.textContent = \"0 / 0\";\n\t\t\t\tprogressDetails.textContent = \"Preparing import...\";\n\t\t\t\ttry {\n\t\t\t\t\tconst response = await fetch(form.action, {\n\t\t\t\t\t\tmethod: \"POST\",\n\t\t\t\t\t\tbody: new FormData(form),\n\t\t\t\t\t\theaders: { Accept: \"text/event-stream\" },\n\t\t\t\t\t});\n\t\t\t\t\tif (response.redirected) {\n\t\t\t\t\t\twindow.location.assign(response.url);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\tif (!response.ok) throw new Error(await response.text() || `Sync failed (HTTP ${response.status})`);\n\t\t\t\t\tif (!response.body) throw new Error(\"The browser does not support streaming responses\");\n\t\t\t\t\tawait consumeEvents(response);\n\t\t\t\t} catch (caught) {\n\t\t\t\t\tshowError(caught instanceof Error ? caught.message : \"Strava sync failed\");\n\t\t\t\t} finally {\n\t\t\t\t\tbutton.disabled = false;\n\t\t\t\t\tbutton.textContent = \"Sync rides\";\n\t\t\t\t}\n\t\t\t});\n\t\t})();\n\t</script>") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } |