Files
wanderer/plugins/strava/strava.go
slothful-vassal 485ec53f6d feat: add plugin system (#1034)
* feat: add plugin system

* fix db docker build

* fix hammerhead readme, add strava subscription news to docs

* fixes and sdk improvements

* fix: reduce Meilisearch load, debounce federation sync (#1012)

* optimize meili trail index

* several fixes

---------

Co-authored-by: Flomp <Flomp@users.noreply.github.com>

* Bump svelte from 5.55.5 to 5.56.0 in /docs (#1032)

Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.55.5 to 5.56.0.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.56.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>

* Release v0.19.2 (#1035)

* chore: release v0.19.2

* add changelog

---------

Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
Co-authored-by: Christian Beutel <>

* speed up plugin sync and several small fixes

* concepts for security improvements and process stability

* improve concept

* security concept implemented

* remove insecure TLS

* worker concept implemented

* fixes and cleanup

* fixes

* docu

* mermaid, namings

* WASM plugin host improvements, plugin logging

* fix db migration

* Improve plugin config and category mapping UI

* fixes

* further fixes

* remove manual test sync

* fix db migration and strava mapping

* type added, UI improvements

* fix plugin card toggle clickable area

* optimize synch status card layout

* plugin type 'trails' instead of 'integration'

* session auth validation in UI

* fix komoot date and waypoints

* improve category mapping

* fix send to hammerhead: trail name

* plugin setup error handling improved

* fix review findings

* re-mapping added

* rename remote_category

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
2026-06-22 15:00:44 +02:00

200 lines
5.7 KiB
Go

//go:build tinygo
package main
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"time"
"github.com/open-wanderer/wanderer/plugins/sdk"
)
// 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: 1048576,
},
})
}
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
}