Files
wanderer/plugins/strava/strava.go
2026-06-30 20:24:27 +02:00

202 lines
5.8 KiB
Go

//go:build tinygo
package main
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"time"
"github.com/open-wanderer/wanderer/plugins/sdk"
)
const stravaDownloadMaxBytes int64 = 16 * 1024 * 1024
// Strava is migrating its API host: the new host "https://www.api-v3.strava.com"
// is available from 2027-01-04 and the old one is retired on 2027-06-01 (June
// 2026 Developer Program update). We cut over on 2027-03-01 — after the new host
// has had time to stabilize, well before the old one disappears — so no manual
// change or release is needed at the deadline.
func stravaConnector() string {
return pickStravaConnector(time.Now())
}
func pickStravaConnector(now time.Time) string {
cutover := time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC)
if now.Before(cutover) {
return "api"
}
return "api_next"
}
type stravaClient struct {
accessToken string
}
func newClient(auth map[string]any) (*stravaClient, error) {
token := sdk.StringField(auth, "accessToken")
if token == "" {
return nil, fmt.Errorf("accessToken is required")
}
return &stravaClient{accessToken: token}, nil
}
func (c *stravaClient) routes(page int, perPage int) ([]route, error) {
var routes []route
err := c.getJSON("/athlete/routes", []sdk.QueryParam{
{Name: "page", Value: strconv.Itoa(page)},
{Name: "per_page", Value: strconv.Itoa(perPage)},
}, &routes)
return routes, err
}
func (c *stravaClient) route(id string) (*route, error) {
var route route
err := c.getJSON("/routes/"+url.PathEscape(id), nil, &route)
return &route, err
}
func (c *stravaClient) routeGPX(id string) ([]byte, error) {
return c.getBytes("/routes/" + url.PathEscape(id) + "/export_gpx")
}
func (c *stravaClient) activities(page int, perPage int, after int64) ([]activity, error) {
var activities []activity
err := c.getJSON("/athlete/activities", []sdk.QueryParam{
{Name: "page", Value: strconv.Itoa(page)},
{Name: "per_page", Value: strconv.Itoa(perPage)},
{Name: "after", Value: strconv.FormatInt(after, 10)},
}, &activities)
return activities, err
}
func (c *stravaClient) activity(id int64) (*detailedActivity, error) {
var activity detailedActivity
err := c.getJSON(fmt.Sprintf("/activities/%d", id), nil, &activity)
return &activity, err
}
func (c *stravaClient) activityStreams(id int64) (*activityStreamResponse, error) {
var streams activityStreamResponse
err := c.getJSON(fmt.Sprintf("/activities/%d/streams", id), []sdk.QueryParam{
{Name: "keys", Value: "latlng,time,altitude"},
{Name: "key_by_type", Value: "true"},
}, &streams)
return &streams, err
}
func (c *stravaClient) activityPhotos(id int64) ([]activityPhoto, error) {
var photos []activityPhoto
err := c.getJSON(fmt.Sprintf("/activities/%d/photos", id), []sdk.QueryParam{{Name: "size", Value: "600"}}, &photos)
return photos, err
}
func (c *stravaClient) getJSON(path string, query []sdk.QueryParam, out any) error {
response, body, err := c.request(path, query, []string{"application/json"})
if err != nil {
return err
}
if response.Status < 200 || response.Status >= 300 {
return fmt.Errorf("strava request failed (%d): %s", response.Status, string(body))
}
return json.Unmarshal(body, out)
}
func (c *stravaClient) getBytes(path string) ([]byte, error) {
response, body, err := c.request(path, nil, []string{"application/gpx+xml", "application/octet-stream", "text/xml", "application/xml"})
if err != nil {
return nil, err
}
if response.Status < 200 || response.Status >= 300 {
return nil, fmt.Errorf("strava request failed (%d): %s", response.Status, string(body))
}
return body, nil
}
func (c *stravaClient) request(path string, query []sdk.QueryParam, contentTypes []string) (sdk.HostResponse, []byte, error) {
accept := "application/json"
if len(contentTypes) > 0 {
accept = contentTypes[0]
}
return sdk.HostRequest(sdk.HostRequestSpec{
Method: "GET",
Target: sdk.RequestTarget{
Type: "connector",
Connector: stravaConnector(),
Path: path,
Query: query,
},
Headers: map[string]string{
sdk.AuthHeaderAuthorization: sdk.AuthSchemeBearer + " " + c.accessToken,
"Accept": accept,
},
Expect: sdk.ResponseExpect{
ContentTypes: contentTypes,
MaxBytes: stravaDownloadMaxBytes,
},
})
}
func syncRoutes(client *stravaClient, input listInput) (listOutput, error) {
page := sdk.IntState(input.State, "page", 1)
if page <= 0 {
page = 1
}
rows, err := client.routes(page, sdk.SyncLimit(input))
if err != nil {
return listOutput{}, err
}
after := dateOption(input.Options, "after")
items := make([]trailSummary, 0, sdk.SyncLimit(input))
for _, row := range rows {
if !timeAfterDate(row.CreatedAt, after) {
continue
}
items = append(items, trailSummary{
Source: trailImportSource{Provider: "strava", ExternalID: row.IDStr},
Kind: "planned",
})
if len(items) >= sdk.SyncLimit(input) {
break
}
}
nextPage := page + 1
hasMore := len(rows) >= sdk.SyncLimit(input)
return listOutput{
Items: items,
State: sdk.NextPageState(nextPage, hasMore),
HasMore: hasMore,
}, nil
}
func syncActivities(client *stravaClient, input listInput) (listOutput, error) {
page := sdk.IntState(input.State, "page", 1)
if page <= 0 {
page = 1
}
rows, err := client.activities(page, sdk.SyncLimit(input), unixAfter(input.Options))
if err != nil {
return listOutput{}, err
}
items := make([]trailSummary, 0, sdk.SyncLimit(input))
for _, row := range rows {
externalID := strconv.FormatInt(row.ID, 10)
items = append(items, trailSummary{
Source: trailImportSource{Provider: "strava", ExternalID: externalID},
Kind: "completed",
})
if len(items) >= sdk.SyncLimit(input) {
break
}
}
nextPage := page + 1
hasMore := len(rows) >= sdk.SyncLimit(input)
return listOutput{
Items: items,
State: sdk.NextPageState(nextPage, hasMore),
HasMore: hasMore,
}, nil
}