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>
This commit is contained in:
15
plugins/strava/Makefile
Normal file
15
plugins/strava/Makefile
Normal file
@@ -0,0 +1,15 @@
|
||||
PLUGIN_ID := strava
|
||||
DIST_DIR := dist/$(PLUGIN_ID)
|
||||
|
||||
.PHONY: build manifest clean
|
||||
|
||||
build: manifest
|
||||
tinygo build -target=wasi -scheduler=none -no-debug -o $(DIST_DIR)/plugin.wasm .
|
||||
|
||||
manifest:
|
||||
mkdir -p $(DIST_DIR)
|
||||
go run github.com/open-wanderer/wanderer/plugins/sdk/cmd/manifestcheck > $(DIST_DIR)/plugin.json
|
||||
cp assets/icon.svg $(DIST_DIR)/icon.svg
|
||||
|
||||
clean:
|
||||
rm -rf dist
|
||||
11
plugins/strava/README.md
Normal file
11
plugins/strava/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# wanderer Strava WASM Plugin
|
||||
|
||||
Strava provider for the wanderer WASM plugin system.
|
||||
|
||||
```sh
|
||||
make build
|
||||
```
|
||||
|
||||
The build output is written to `dist/strava`. Copy it below `data/plugins` or
|
||||
run `make plugins-install-local` from the repository root to install all bundled
|
||||
plugins locally.
|
||||
3
plugins/strava/assets/icon.svg
Normal file
3
plugins/strava/assets/icon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 432 91"><style type="text/css">
|
||||
.st0{fill:#FC4C02;}
|
||||
</style><path class="st0" d="M74.5 49.5c1.6 2.8 2.5 6.3 2.5 10.4v0.2c0 4.2-0.8 8-2.5 11.4 -1.7 3.4-4.1 6.2-7.1 8.6 -3.1 2.3-6.8 4.1-11.2 5.4 -4.4 1.3-9.3 1.9-14.7 1.9 -8.2 0-15.9-1.1-23-3.4 -7.1-2.3-13.2-5.7-18.3-10.2l14.4-17.1c4.4 3.4 9 5.8 13.8 7.2 4.8 1.5 9.6 2.2 14.4 2.2 2.5 0 4.2-0.3 5.3-0.9 1.1-0.6 1.6-1.5 1.6-2.5v-0.2c0-1.2-0.8-2.1-2.4-2.9 -1.6-0.8-4.5-1.6-8.8-2.4 -4.5-0.9-8.8-2-12.9-3.2 -4.1-1.2-7.7-2.8-10.8-4.7 -3.1-1.9-5.6-4.3-7.4-7.2C5.4 39 4.5 35.4 4.5 31.2V31c0-3.8 0.7-7.4 2.2-10.7 1.5-3.3 3.7-6.2 6.6-8.6 2.9-2.5 6.5-4.4 10.7-5.8 4.2-1.4 9.1-2.1 14.7-2.1 7.8 0 14.7 0.9 20.5 2.8 5.9 1.8 11.1 4.6 15.8 8.3L61.9 33c-3.8-2.8-7.9-4.8-12.1-6.1 -4.3-1.3-8.3-1.9-12-1.9 -2 0-3.5 0.3-4.4 0.9 -1 0.6-1.4 1.4-1.4 2.4v0.2c0 1.1 0.7 2 2.2 2.8 1.5 0.8 4.3 1.6 8.5 2.4 5.1 0.9 9.8 2 14 3.3 4.2 1.3 7.8 3 10.9 5C70.5 44.2 72.9 46.6 74.5 49.5zM75.5 28.1h23.7v57.8h26.9V28.1h23.7V5.3H75.5V28.1zM387.9 0.3l-43.3 85.6h25.8l17.5-34.6 17.6 34.6h25.8L387.9 0.3zM267.3 0.3l43.4 85.6h-25.8l-17.5-34.6 -17.5 34.6h-17.5 -8.3 -22.4l-15.2-23h-0.2 -5.5v23h-26.9V5.3H193c7.2 0 13.1 0.8 17.8 2.5 4.6 1.6 8.4 3.9 11.2 6.7 2.5 2.4 4.3 5.2 5.5 8.3 1.2 3.1 1.8 6.7 1.8 10.8v0.2c0 5.9-1.4 10.9-4.3 14.9 -2.8 4.1-6.7 7.3-11.6 9.7l14 20.4L267.3 0.3zM202.5 35.6c0-2.6-0.9-4.5-2.8-5.8 -1.8-1.3-4.3-1.9-7.5-1.9h-11.7v15.8h11.6c3.2 0 5.8-0.7 7.6-2.1 1.8-1.4 2.8-3.3 2.8-5.8V35.6zM345.2 5.3L327.6 40 310 5.3h-25.8l43.4 85.6 43.3-85.6H345.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
9
plugins/strava/go.mod
Normal file
9
plugins/strava/go.mod
Normal file
@@ -0,0 +1,9 @@
|
||||
module github.com/open-wanderer/wanderer/plugins/strava
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/extism/go-pdk v1.1.3
|
||||
|
||||
require github.com/open-wanderer/wanderer/plugins/sdk v0.0.0
|
||||
|
||||
replace github.com/open-wanderer/wanderer/plugins/sdk => ../sdk
|
||||
2
plugins/strava/go.sum
Normal file
2
plugins/strava/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
||||
126
plugins/strava/main.go
Normal file
126
plugins/strava/main.go
Normal file
@@ -0,0 +1,126 @@
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
func main() {}
|
||||
|
||||
//export list_routes_v1
|
||||
func listRoutesV1() int32 {
|
||||
var input listInput
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
return fail("invalid_request", "invalid list_routes input: "+err.Error())
|
||||
}
|
||||
client, err := newClient(input.Auth)
|
||||
if err != nil {
|
||||
return fail("auth_failed", err.Error())
|
||||
}
|
||||
output, err := syncRoutes(client, input)
|
||||
if err != nil {
|
||||
return fail("provider_unavailable", err.Error())
|
||||
}
|
||||
if err := pdk.OutputJSON(output); err != nil {
|
||||
return fail("internal_error", err.Error())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
//export list_activities_v1
|
||||
func listActivitiesV1() int32 {
|
||||
var input listInput
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
return fail("invalid_request", "invalid list_activities input: "+err.Error())
|
||||
}
|
||||
client, err := newClient(input.Auth)
|
||||
if err != nil {
|
||||
return fail("auth_failed", err.Error())
|
||||
}
|
||||
output, err := syncActivities(client, input)
|
||||
if err != nil {
|
||||
return fail("provider_unavailable", err.Error())
|
||||
}
|
||||
if err := pdk.OutputJSON(output); err != nil {
|
||||
return fail("internal_error", err.Error())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
//export get_route_detail_v1
|
||||
func getRouteDetailV1() int32 {
|
||||
return getTrailDetail("planned")
|
||||
}
|
||||
|
||||
//export get_activity_detail_v1
|
||||
func getActivityDetailV1() int32 {
|
||||
return getTrailDetail("completed")
|
||||
}
|
||||
|
||||
func getTrailDetail(kind string) int32 {
|
||||
var input detailInput
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
return fail("invalid_request", "invalid detail input: "+err.Error())
|
||||
}
|
||||
client, err := newClient(input.Auth)
|
||||
if err != nil {
|
||||
return fail("auth_failed", err.Error())
|
||||
}
|
||||
var item trailImport
|
||||
switch kind {
|
||||
case "planned":
|
||||
route, err := client.route(input.Summary.Source.ExternalID)
|
||||
if err != nil {
|
||||
return fail("provider_unavailable", err.Error())
|
||||
}
|
||||
gpxData, err := client.routeGPX(input.Summary.Source.ExternalID)
|
||||
if err != nil {
|
||||
return fail("provider_unavailable", err.Error())
|
||||
}
|
||||
item, err = routeImport(*route, gpxData)
|
||||
if err != nil {
|
||||
return fail("provider_unavailable", err.Error())
|
||||
}
|
||||
case "completed":
|
||||
id, err := strconv.ParseInt(input.Summary.Source.ExternalID, 10, 64)
|
||||
if err != nil {
|
||||
return fail("invalid_request", "invalid activity external id")
|
||||
}
|
||||
detail, err := client.activity(id)
|
||||
if err != nil {
|
||||
return fail("provider_unavailable", err.Error())
|
||||
}
|
||||
var photos []activityPhoto
|
||||
if detail.Photos.Count > 0 {
|
||||
photos, _ = client.activityPhotos(id)
|
||||
}
|
||||
streams, err := client.activityStreams(id)
|
||||
if err != nil {
|
||||
return fail("provider_unavailable", err.Error())
|
||||
}
|
||||
item, err = activityImport(detail, streams, photos)
|
||||
if err != nil {
|
||||
return fail("provider_unavailable", err.Error())
|
||||
}
|
||||
default:
|
||||
return fail("invalid_request", "unsupported detail kind")
|
||||
}
|
||||
if err := pdk.OutputJSON(detailOutput{Item: item}); err != nil {
|
||||
return fail("internal_error", err.Error())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func fail(code string, message string) int32 {
|
||||
data, err := json.Marshal(pluginError{Code: code, Message: message})
|
||||
if err != nil {
|
||||
pdk.SetErrorString(message)
|
||||
return 1
|
||||
}
|
||||
pdk.SetErrorString(string(data))
|
||||
return 1
|
||||
}
|
||||
228
plugins/strava/mapper.go
Normal file
228
plugins/strava/mapper.go
Normal file
@@ -0,0 +1,228 @@
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx"
|
||||
)
|
||||
|
||||
func routeImport(route route, gpxData []byte) (trailImport, error) {
|
||||
if len(gpxData) == 0 {
|
||||
return trailImport{}, fmt.Errorf("route GPX is empty")
|
||||
}
|
||||
privacy := privacyFromPrivate(route.Private)
|
||||
startedAt := time.Unix(route.Timestamp, 0).UTC().Format(time.RFC3339)
|
||||
return trailImport{
|
||||
Source: trailImportSource{
|
||||
Provider: "strava",
|
||||
ExternalID: route.IDStr,
|
||||
},
|
||||
Kind: "planned",
|
||||
Name: route.Name,
|
||||
Description: route.Description,
|
||||
StartedAt: startedAt,
|
||||
ActivityType: activityTypeForRoute(route.Type),
|
||||
Privacy: &privacy,
|
||||
Track: track{
|
||||
Format: "gpx",
|
||||
ContentBase64: base64.StdEncoding.EncodeToString(gpxData),
|
||||
},
|
||||
Waypoints: routeWaypoints(route),
|
||||
Metadata: map[string]any{
|
||||
"distance": route.Distance,
|
||||
"elevationGain": route.ElevationGain,
|
||||
"duration": route.EstimatedMovingTime,
|
||||
"providerCategory": routeCategory(route.Type),
|
||||
"estimatedMovingTime": route.EstimatedMovingTime,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func activityImport(activity *detailedActivity, streams *activityStreamResponse, photos []activityPhoto) (trailImport, error) {
|
||||
if len(activity.StartLatlng) < 2 {
|
||||
return trailImport{}, fmt.Errorf("activity has no start coordinate")
|
||||
}
|
||||
gpxData, err := activityGPX(activity, streams)
|
||||
if err != nil {
|
||||
return trailImport{}, err
|
||||
}
|
||||
privacy := privacyFromPrivate(activity.Private)
|
||||
return trailImport{
|
||||
Source: trailImportSource{
|
||||
Provider: "strava",
|
||||
ExternalID: strconv.FormatInt(activity.ID, 10),
|
||||
},
|
||||
Kind: "completed",
|
||||
Name: activity.Name,
|
||||
Description: activity.Description,
|
||||
StartedAt: activity.StartDate,
|
||||
ActivityType: activityType(activity),
|
||||
Privacy: &privacy,
|
||||
Track: track{
|
||||
Format: "gpx",
|
||||
ContentBase64: base64.StdEncoding.EncodeToString(gpxData),
|
||||
},
|
||||
Photos: activityPhotos(activity, photos),
|
||||
Metadata: map[string]any{
|
||||
"distance": activity.Distance,
|
||||
"elevationGain": activity.TotalElevationGain,
|
||||
"duration": activity.ElapsedTime,
|
||||
"providerCategory": providerActivityType(activity),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func routeWaypoints(route route) []waypoint {
|
||||
points := make([]waypoint, 0, len(route.Waypoints))
|
||||
for i, wp := range route.Waypoints {
|
||||
if len(wp.Latlng) < 2 {
|
||||
continue
|
||||
}
|
||||
name := wp.Title
|
||||
if name == "" {
|
||||
name = strconv.Itoa(i)
|
||||
}
|
||||
points = append(points, waypoint{
|
||||
Name: name,
|
||||
Description: wp.Description,
|
||||
Lat: wp.Latlng[0],
|
||||
Lon: wp.Latlng[1],
|
||||
Icon: "circle",
|
||||
})
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func activityPhotos(activity *detailedActivity, apiPhotos []activityPhoto) []photo {
|
||||
photos := make([]photo, 0, len(apiPhotos))
|
||||
seen := make(map[string]bool, len(apiPhotos))
|
||||
for _, apiPhoto := range apiPhotos {
|
||||
url := apiPhoto.Urls.Num600
|
||||
if url == "" {
|
||||
url = apiPhoto.Urls.Num100
|
||||
}
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
externalID := apiPhoto.UniqueID
|
||||
if externalID == "" {
|
||||
externalID = url
|
||||
}
|
||||
if seen[externalID] {
|
||||
continue
|
||||
}
|
||||
seen[externalID] = true
|
||||
photos = append(photos, photo{
|
||||
ExternalID: externalID,
|
||||
Filename: fmt.Sprintf("strava-%s.jpg", safePhotoID(externalID)),
|
||||
Source: mediaSource{
|
||||
Type: "url",
|
||||
URL: url,
|
||||
},
|
||||
})
|
||||
}
|
||||
if len(photos) > 0 {
|
||||
return photos
|
||||
}
|
||||
if activity.Photos.Primary.Urls.Num600 == "" {
|
||||
return nil
|
||||
}
|
||||
externalID := strconv.FormatInt(activity.Photos.Primary.ID, 10)
|
||||
return []photo{{
|
||||
ExternalID: externalID,
|
||||
Filename: fmt.Sprintf("strava-%s.jpg", externalID),
|
||||
Source: mediaSource{
|
||||
Type: "url",
|
||||
URL: activity.Photos.Primary.Urls.Num600,
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func safePhotoID(value string) string {
|
||||
replacer := strings.NewReplacer("/", "-", "\\", "-", ":", "-", "?", "-", "&", "-", "=", "-")
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
|
||||
func activityGPX(activity *detailedActivity, streams *activityStreamResponse) ([]byte, error) {
|
||||
if streams == nil || len(streams.LatLng.Data) == 0 {
|
||||
return nil, fmt.Errorf("activity has no latlng stream")
|
||||
}
|
||||
startedAt, _ := time.Parse(time.RFC3339, activity.StartDate)
|
||||
|
||||
points := make([]sdkgpx.Point, 0, len(streams.LatLng.Data))
|
||||
for i, latlng := range streams.LatLng.Data {
|
||||
if len(latlng) < 2 || i >= len(streams.Time.Data) {
|
||||
continue
|
||||
}
|
||||
elevation := 0.0
|
||||
if i < len(streams.Altitude.Data) {
|
||||
elevation = streams.Altitude.Data[i]
|
||||
}
|
||||
point := sdkgpx.Point{
|
||||
Lat: latlng[0],
|
||||
Lon: latlng[1],
|
||||
Elevation: &elevation,
|
||||
}
|
||||
if !startedAt.IsZero() {
|
||||
pointTime := startedAt.Add(time.Duration(streams.Time.Data[i]) * time.Second).UTC()
|
||||
point.Time = &pointTime
|
||||
}
|
||||
points = append(points, point)
|
||||
}
|
||||
return sdkgpx.Track("wanderer Strava plugin", activity.Name, points)
|
||||
}
|
||||
|
||||
func privacyFromPrivate(private bool) string {
|
||||
if private {
|
||||
return "private"
|
||||
}
|
||||
return "public"
|
||||
}
|
||||
|
||||
func activityTypeForRoute(routeType int) string {
|
||||
switch routeType {
|
||||
case 1:
|
||||
return "biking"
|
||||
case 2:
|
||||
return "walking"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func routeCategory(routeType int) string {
|
||||
return fmt.Sprintf("route:%d", routeType)
|
||||
}
|
||||
|
||||
func providerActivityType(activity *detailedActivity) string {
|
||||
if activity.SportType != "" {
|
||||
return activity.SportType
|
||||
}
|
||||
return activity.Type
|
||||
}
|
||||
|
||||
func activityType(activity *detailedActivity) string {
|
||||
value := providerActivityType(activity)
|
||||
switch value {
|
||||
case "AlpineSki", "BackcountrySki", "IceSkate", "NordicSki", "RollerSki", "Snowboard":
|
||||
return "skiing"
|
||||
case "Canoeing", "Kayaking", "Kitesurf", "Rowing", "Sail", "StandUpPaddling", "Surfing", "Windsurf":
|
||||
return "canoeing"
|
||||
case "Hike", "Snowshoe":
|
||||
return "hiking"
|
||||
case "Run", "VirtualRun", "Walk", "Golf", "Skateboard", "Wheelchair":
|
||||
return "walking"
|
||||
case "Ride", "EBikeRide", "Handcycle", "InlineSkate", "Velomobile", "VirtualRide":
|
||||
return "biking"
|
||||
case "RockClimbing":
|
||||
return "climbing"
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
40
plugins/strava/options.go
Normal file
40
plugins/strava/options.go
Normal file
@@ -0,0 +1,40 @@
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func dateOption(options map[string]any, key string) string {
|
||||
value, _ := options[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func unixAfter(options map[string]any) int64 {
|
||||
after := dateOption(options, "after")
|
||||
if after == "" {
|
||||
return 0
|
||||
}
|
||||
parsed, err := time.Parse("2006-01-02", after)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed.UTC().Unix()
|
||||
}
|
||||
|
||||
func timeAfterDate(value string, after string) bool {
|
||||
if after == "" {
|
||||
return true
|
||||
}
|
||||
limit, err := time.Parse("2006-01-02", after)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return !parsed.Before(limit)
|
||||
}
|
||||
541
plugins/strava/plugin.json
Normal file
541
plugins/strava/plugin.json
Normal file
@@ -0,0 +1,541 @@
|
||||
{
|
||||
"manifestVersion": "1.0",
|
||||
"id": "strava",
|
||||
"type": "trails",
|
||||
"name": "Strava",
|
||||
"description": "Imports Strava routes, activities and photos into wanderer.",
|
||||
"version": "0.1.0",
|
||||
"runtime": {
|
||||
"type": "wasm",
|
||||
"entrypoint": "plugin.wasm"
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"name": "list_routes",
|
||||
"version": "v1",
|
||||
"export": "list_routes_v1"
|
||||
},
|
||||
{
|
||||
"name": "get_route_detail",
|
||||
"version": "v1",
|
||||
"export": "get_route_detail_v1"
|
||||
},
|
||||
{
|
||||
"name": "list_activities",
|
||||
"version": "v1",
|
||||
"export": "list_activities_v1"
|
||||
},
|
||||
{
|
||||
"name": "get_activity_detail",
|
||||
"version": "v1",
|
||||
"export": "get_activity_detail_v1"
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"contexts": {
|
||||
"oauth_access_token": {
|
||||
"type": "oauth2",
|
||||
"fields": [
|
||||
"clientId",
|
||||
"clientSecret"
|
||||
],
|
||||
"secretFields": [
|
||||
"clientSecret",
|
||||
"accessToken",
|
||||
"refreshToken"
|
||||
],
|
||||
"authorizationUrl": "https://www.strava.com/oauth/authorize",
|
||||
"tokenUrl": "https://www.strava.com/oauth/token",
|
||||
"scopes": [
|
||||
"read_all",
|
||||
"activity:read_all"
|
||||
],
|
||||
"scopeSeparator": ",",
|
||||
"tokenRequestFormat": "json",
|
||||
"tokenAuth": "client_secret_post",
|
||||
"authorizationParams": {
|
||||
"approval_prompt": "auto"
|
||||
},
|
||||
"refresh": {
|
||||
"mode": "host",
|
||||
"grantType": "refresh_token"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"network": {
|
||||
"connectors": [
|
||||
{
|
||||
"name": "api",
|
||||
"type": "public_api",
|
||||
"fixedBaseURL": "https://www.strava.com/api/v3",
|
||||
"allowedPathPrefixes": [
|
||||
"/"
|
||||
],
|
||||
"auth": [
|
||||
"oauth_access_token"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "api_next",
|
||||
"type": "public_api",
|
||||
"fixedBaseURL": "https://www.api-v3.strava.com",
|
||||
"allowedPathPrefixes": [
|
||||
"/"
|
||||
],
|
||||
"auth": [
|
||||
"oauth_access_token"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "oauth",
|
||||
"type": "public_api",
|
||||
"fixedBaseURL": "https://www.strava.com/oauth",
|
||||
"allowedPathPrefixes": [
|
||||
"/"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"auth": [
|
||||
"oauth_access_token"
|
||||
],
|
||||
"downloads": {
|
||||
"maxBytes": 1048576,
|
||||
"contentTypes": [
|
||||
"application/json",
|
||||
"application/gpx+xml",
|
||||
"application/xml",
|
||||
"text/xml",
|
||||
"application/octet-stream"
|
||||
]
|
||||
}
|
||||
},
|
||||
"configSchema": [
|
||||
{
|
||||
"key": "after",
|
||||
"type": "date",
|
||||
"label": "Start date",
|
||||
"labels": {
|
||||
"de": "Startdatum",
|
||||
"en": "Start date"
|
||||
},
|
||||
"description": "Ignore routes and activities before this date.",
|
||||
"descriptions": {
|
||||
"cs": "Pokud váš účet obsahuje velké množství aktivit, můžete narazit na limit API služby Strava, což znemožní synchronizaci všech aktivit najednou. Tomuto problému předejdete nastavením data \"Od\" a dále - synchronizují se tak pouze aktivity zaznamenané po tomto datu.",
|
||||
"de": "Wenn Ihr Konto eine große Anzahl von Aktivitäten enthält, kann es vorkommen, dass Sie aufgrund der API-Restriktionen von Strava nicht alle Aktivitäten auf einmal synchronisieren können. Um dieses Problem zu umgehen, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.",
|
||||
"en": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an start date below so that only activities that were recorded after this date are synced.",
|
||||
"es": "Si tu cuenta tiene una gran cantidad de actividades, es posible que alcances el límite de peticiones de la API de Strava, lo que impedirá la sincronización de todas las actividades a la vez. Para mitigar este problema, puedes establecer una fecha \"Posterior a\" a continuación, de modo que solo se sincronicen las actividades que se registraron después de esa fecha.",
|
||||
"eu": "Zure kontuak ekintza esko baditu Stravaren APIaren mugekin topo egin dezakezu eta agian ezingo dituzu zure ekintza guztiak aldi berean inportatu. Horretarako data jakin batetik aurrerako ekintzak sinkronizatzeko aukera duzu.",
|
||||
"fr": "Si votre compte a une grande quantité d'activités, vous pouvez rencontrer la limite d'utilisation de l'API de Strava vous empêchant de synchroniser toutes les activités en même temps. Pour atténuer ce problème, vous pouvez définir une date \"Après-\" ci-dessous afin que seules les activités qui ont été enregistrées après cette date soient synchronisées.",
|
||||
"it": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
|
||||
"hu": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
|
||||
"nl": "Als uw account een grote hoeveelheid activiteiten heeft, kunt u op de API-limiet van Strava botsen voorkomend dat u alle activiteiten tegelijk synchroniseert. Om dit probleem te omzeilen kunt u een \"Later\" datum hieronder instellen, zodat alleen activiteiten die na deze datum werden opgenomen worden gesynchroniseerd.",
|
||||
"no": "Hvis kontoen din har en stor mengde aktiviteter kan du støte på Stravas API-hastighetsgrense som hindrer deg i å synkronisere alle aktiviteter samtidig. For å redusere dette problemet kan du sette en \"Etter\" dato nedenfor slik at bare aktiviteter som ble registrert etter denne datoen blir synkronisert.",
|
||||
"pl": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
|
||||
"pt": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
|
||||
"ru": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
|
||||
"zh": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced."
|
||||
}
|
||||
}
|
||||
],
|
||||
"hostConfig": {
|
||||
"categoryMapping": {
|
||||
"route:1": "Biking",
|
||||
"route:2": "Walking",
|
||||
"AlpineSki": "Skiing",
|
||||
"BackcountrySki": "Skiing",
|
||||
"Badminton": "Other",
|
||||
"Canoeing": "Canoeing",
|
||||
"Crossfit": "Workout",
|
||||
"EBikeRide": "Biking",
|
||||
"EMountainBikeRide": "Biking",
|
||||
"Elliptical": "Workout",
|
||||
"Golf": "Other",
|
||||
"GravelRide": "Biking",
|
||||
"Handcycle": "Biking",
|
||||
"HighIntensityIntervalTraining": "Other",
|
||||
"Hike": "Hiking",
|
||||
"IceSkate": "Skiing",
|
||||
"InlineSkate": "Walking",
|
||||
"Kayaking": "Canoeing",
|
||||
"Kitesurf": "Canoeing",
|
||||
"MountainBikeRide": "Biking",
|
||||
"NordicSki": "Skiing",
|
||||
"Pickleball": "Other",
|
||||
"Pilates": "Other",
|
||||
"Racquetball": "Other",
|
||||
"Ride": "Biking",
|
||||
"RockClimbing": "Climbing",
|
||||
"RollerSki": "Skiing",
|
||||
"Rowing": "Canoeing",
|
||||
"Run": "Walking",
|
||||
"Sail": "Canoeing",
|
||||
"Skateboard": "Walking",
|
||||
"Snowboard": "Skiing",
|
||||
"Snowshoe": "Hiking",
|
||||
"Soccer": "Other",
|
||||
"Squash": "Other",
|
||||
"StairStepper": "Workout",
|
||||
"StandUpPaddling": "Canoeing",
|
||||
"Surfing": "Canoeing",
|
||||
"Swim": "Other",
|
||||
"TableTennis": "Other",
|
||||
"Tennis": "Other",
|
||||
"TrailRun": "Other",
|
||||
"Training": "Other",
|
||||
"Velomobile": "Biking",
|
||||
"VirtualRide": "Biking",
|
||||
"VirtualRow": "Other",
|
||||
"VirtualRun": "Walking",
|
||||
"Walk": "Walking",
|
||||
"WeightTraining": "Workout",
|
||||
"Wheelchair": "Walking",
|
||||
"Windsurf": "Canoeing",
|
||||
"Workout": "Workout",
|
||||
"Yoga": "Workout"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"descriptions": {
|
||||
"cs": "Synchronizuje vaše trasy a aktivity z aplikace Strava s Wandererem v pravidelných intervalech.",
|
||||
"de": "Importiert Strava-Routen, Aktivitäten und Fotos in wanderer.",
|
||||
"en": "Imports Strava routes, activities and photos into wanderer.",
|
||||
"es": "Sincroniza tus recorridos y actividades de Strava con Wanderer en intervalos regulares.",
|
||||
"eu": "Zure stravako ibilbideak wandererekin sinkronizatzen ditu aldian behin.",
|
||||
"fr": "Synchronisez vos itinéraires et vos activités Strava avec wanderer à intervalles réguliers.",
|
||||
"it": "Syncs your strava routes & activities with wanderer in regular intervals.",
|
||||
"hu": "Syncs your strava routes & activities with wanderer in regular intervals.",
|
||||
"nl": "Synchroniseert je Strava-routes en -activiteiten met Wanderer op regelmatige tijdstippen.",
|
||||
"no": "Synkroniserer dine Strava-ruter og aktiviteter med Wanderer med jevne mellomrom.",
|
||||
"pl": "Synchronizuje trasy i aktywność z wanderer w równych odstępach.",
|
||||
"pt": "Syncs your strava routes & activities with wanderer in regular intervals.",
|
||||
"ru": "Синхронизирует ваши данные со Strava.",
|
||||
"zh": "定期与strava同步您的wanderer路线和活动。"
|
||||
},
|
||||
"icons": {
|
||||
"light": "icon.svg"
|
||||
},
|
||||
"providerCategories": {
|
||||
"route:1": {
|
||||
"labels": {
|
||||
"de": "Strava-Route: Radfahren",
|
||||
"en": "Strava route: cycling"
|
||||
}
|
||||
},
|
||||
"route:2": {
|
||||
"labels": {
|
||||
"de": "Strava-Route: Laufen",
|
||||
"en": "Strava route: running"
|
||||
}
|
||||
},
|
||||
"AlpineSki": {
|
||||
"labels": {
|
||||
"de": "Ski alpin",
|
||||
"en": "Alpine ski"
|
||||
}
|
||||
},
|
||||
"BackcountrySki": {
|
||||
"labels": {
|
||||
"de": "Skitour",
|
||||
"en": "Backcountry ski"
|
||||
}
|
||||
},
|
||||
"Badminton": {
|
||||
"labels": {
|
||||
"de": "Badminton",
|
||||
"en": "Badminton"
|
||||
}
|
||||
},
|
||||
"Canoeing": {
|
||||
"labels": {
|
||||
"de": "Kanufahren",
|
||||
"en": "Canoeing"
|
||||
}
|
||||
},
|
||||
"Crossfit": {
|
||||
"labels": {
|
||||
"de": "Crossfit",
|
||||
"en": "Crossfit"
|
||||
}
|
||||
},
|
||||
"EBikeRide": {
|
||||
"labels": {
|
||||
"de": "E-Bike-Fahrt",
|
||||
"en": "E-bike ride"
|
||||
}
|
||||
},
|
||||
"EMountainBikeRide": {
|
||||
"labels": {
|
||||
"de": "E-Mountainbike-Fahrt",
|
||||
"en": "E-mountain bike ride"
|
||||
}
|
||||
},
|
||||
"Elliptical": {
|
||||
"labels": {
|
||||
"de": "Crosstrainer",
|
||||
"en": "Elliptical"
|
||||
}
|
||||
},
|
||||
"Golf": {
|
||||
"labels": {
|
||||
"de": "Golf",
|
||||
"en": "Golf"
|
||||
}
|
||||
},
|
||||
"GravelRide": {
|
||||
"labels": {
|
||||
"de": "Gravel-Fahrt",
|
||||
"en": "Gravel ride"
|
||||
}
|
||||
},
|
||||
"Handcycle": {
|
||||
"labels": {
|
||||
"de": "Handbike",
|
||||
"en": "Handcycle"
|
||||
}
|
||||
},
|
||||
"HighIntensityIntervalTraining": {
|
||||
"labels": {
|
||||
"de": "HIIT",
|
||||
"en": "High-intensity interval training"
|
||||
}
|
||||
},
|
||||
"Hike": {
|
||||
"labels": {
|
||||
"de": "Wandern",
|
||||
"en": "Hike"
|
||||
}
|
||||
},
|
||||
"IceSkate": {
|
||||
"labels": {
|
||||
"de": "Schlittschuhlaufen",
|
||||
"en": "Ice skate"
|
||||
}
|
||||
},
|
||||
"InlineSkate": {
|
||||
"labels": {
|
||||
"de": "Inlineskaten",
|
||||
"en": "Inline skate"
|
||||
}
|
||||
},
|
||||
"Kayaking": {
|
||||
"labels": {
|
||||
"de": "Kajakfahren",
|
||||
"en": "Kayaking"
|
||||
}
|
||||
},
|
||||
"Kitesurf": {
|
||||
"labels": {
|
||||
"de": "Kitesurfen",
|
||||
"en": "Kitesurf"
|
||||
}
|
||||
},
|
||||
"MountainBikeRide": {
|
||||
"labels": {
|
||||
"de": "Mountainbike-Fahrt",
|
||||
"en": "Mountain bike ride"
|
||||
}
|
||||
},
|
||||
"NordicSki": {
|
||||
"labels": {
|
||||
"de": "Langlauf",
|
||||
"en": "Nordic ski"
|
||||
}
|
||||
},
|
||||
"Pickleball": {
|
||||
"labels": {
|
||||
"de": "Pickleball",
|
||||
"en": "Pickleball"
|
||||
}
|
||||
},
|
||||
"Pilates": {
|
||||
"labels": {
|
||||
"de": "Pilates",
|
||||
"en": "Pilates"
|
||||
}
|
||||
},
|
||||
"Racquetball": {
|
||||
"labels": {
|
||||
"de": "Racquetball",
|
||||
"en": "Racquetball"
|
||||
}
|
||||
},
|
||||
"Ride": {
|
||||
"labels": {
|
||||
"de": "Radfahren",
|
||||
"en": "Ride"
|
||||
}
|
||||
},
|
||||
"RockClimbing": {
|
||||
"labels": {
|
||||
"de": "Felsklettern",
|
||||
"en": "Rock climbing"
|
||||
}
|
||||
},
|
||||
"RollerSki": {
|
||||
"labels": {
|
||||
"de": "Rollski",
|
||||
"en": "Roller ski"
|
||||
}
|
||||
},
|
||||
"Rowing": {
|
||||
"labels": {
|
||||
"de": "Rudern",
|
||||
"en": "Rowing"
|
||||
}
|
||||
},
|
||||
"Run": {
|
||||
"labels": {
|
||||
"de": "Laufen",
|
||||
"en": "Run"
|
||||
}
|
||||
},
|
||||
"Sail": {
|
||||
"labels": {
|
||||
"de": "Segeln",
|
||||
"en": "Sail"
|
||||
}
|
||||
},
|
||||
"Skateboard": {
|
||||
"labels": {
|
||||
"de": "Skateboard",
|
||||
"en": "Skateboard"
|
||||
}
|
||||
},
|
||||
"Snowboard": {
|
||||
"labels": {
|
||||
"de": "Snowboard",
|
||||
"en": "Snowboard"
|
||||
}
|
||||
},
|
||||
"Snowshoe": {
|
||||
"labels": {
|
||||
"de": "Schneeschuhwandern",
|
||||
"en": "Snowshoe"
|
||||
}
|
||||
},
|
||||
"Soccer": {
|
||||
"labels": {
|
||||
"de": "Fussball",
|
||||
"en": "Soccer"
|
||||
}
|
||||
},
|
||||
"Squash": {
|
||||
"labels": {
|
||||
"de": "Squash",
|
||||
"en": "Squash"
|
||||
}
|
||||
},
|
||||
"StairStepper": {
|
||||
"labels": {
|
||||
"de": "Stepper",
|
||||
"en": "Stair stepper"
|
||||
}
|
||||
},
|
||||
"StandUpPaddling": {
|
||||
"labels": {
|
||||
"de": "Stand-up-Paddling",
|
||||
"en": "Stand-up paddling"
|
||||
}
|
||||
},
|
||||
"Surfing": {
|
||||
"labels": {
|
||||
"de": "Surfen",
|
||||
"en": "Surfing"
|
||||
}
|
||||
},
|
||||
"Swim": {
|
||||
"labels": {
|
||||
"de": "Schwimmen",
|
||||
"en": "Swim"
|
||||
}
|
||||
},
|
||||
"TableTennis": {
|
||||
"labels": {
|
||||
"de": "Tischtennis",
|
||||
"en": "Table tennis"
|
||||
}
|
||||
},
|
||||
"Tennis": {
|
||||
"labels": {
|
||||
"de": "Tennis",
|
||||
"en": "Tennis"
|
||||
}
|
||||
},
|
||||
"TrailRun": {
|
||||
"labels": {
|
||||
"de": "Trailrun",
|
||||
"en": "Trail run"
|
||||
}
|
||||
},
|
||||
"Training": {
|
||||
"labels": {
|
||||
"de": "Training",
|
||||
"en": "Training"
|
||||
}
|
||||
},
|
||||
"Velomobile": {
|
||||
"labels": {
|
||||
"de": "Velomobil",
|
||||
"en": "Velomobile"
|
||||
}
|
||||
},
|
||||
"VirtualRide": {
|
||||
"labels": {
|
||||
"de": "Virtuelle Radfahrt",
|
||||
"en": "Virtual ride"
|
||||
}
|
||||
},
|
||||
"VirtualRow": {
|
||||
"labels": {
|
||||
"de": "Virtuelles Rudern",
|
||||
"en": "Virtual row"
|
||||
}
|
||||
},
|
||||
"VirtualRun": {
|
||||
"labels": {
|
||||
"de": "Virtueller Lauf",
|
||||
"en": "Virtual run"
|
||||
}
|
||||
},
|
||||
"Walk": {
|
||||
"labels": {
|
||||
"de": "Gehen",
|
||||
"en": "Walk"
|
||||
}
|
||||
},
|
||||
"WeightTraining": {
|
||||
"labels": {
|
||||
"de": "Krafttraining",
|
||||
"en": "Weight training"
|
||||
}
|
||||
},
|
||||
"Wheelchair": {
|
||||
"labels": {
|
||||
"de": "Rollstuhl",
|
||||
"en": "Wheelchair"
|
||||
}
|
||||
},
|
||||
"Windsurf": {
|
||||
"labels": {
|
||||
"de": "Windsurfen",
|
||||
"en": "Windsurf"
|
||||
}
|
||||
},
|
||||
"Workout": {
|
||||
"labels": {
|
||||
"de": "Training",
|
||||
"en": "Workout"
|
||||
}
|
||||
},
|
||||
"Yoga": {
|
||||
"labels": {
|
||||
"de": "Yoga",
|
||||
"en": "Yoga"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
199
plugins/strava/strava.go
Normal file
199
plugins/strava/strava.go
Normal file
@@ -0,0 +1,199 @@
|
||||
//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
|
||||
}
|
||||
95
plugins/strava/types.go
Normal file
95
plugins/strava/types.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import "github.com/open-wanderer/wanderer/plugins/sdk"
|
||||
|
||||
type instanceRef = sdk.InstanceRef
|
||||
type listInput = sdk.ListInput
|
||||
type listOutput = sdk.ListOutput
|
||||
type detailInput = sdk.DetailInput
|
||||
type detailOutput = sdk.DetailOutput
|
||||
type trailSummary = sdk.TrailSummary
|
||||
type trailImport = sdk.TrailImport
|
||||
type trailImportSource = sdk.TrailImportSource
|
||||
type track = sdk.Track
|
||||
type waypoint = sdk.Waypoint
|
||||
type photo = sdk.Photo
|
||||
type mediaSource = sdk.MediaSource
|
||||
|
||||
type pluginError = sdk.PluginError
|
||||
|
||||
type route struct {
|
||||
Description string `json:"description"`
|
||||
Distance float64 `json:"distance"`
|
||||
ElevationGain float64 `json:"elevation_gain"`
|
||||
IDStr string `json:"id_str"`
|
||||
Name string `json:"name"`
|
||||
Private bool `json:"private"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Type int `json:"type"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
EstimatedMovingTime int `json:"estimated_moving_time"`
|
||||
Waypoints []routeWaypoint `json:"waypoints"`
|
||||
}
|
||||
|
||||
type routeWaypoint struct {
|
||||
Latlng []float64 `json:"latlng"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type activity struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type detailedActivity struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Distance float64 `json:"distance"`
|
||||
ElapsedTime int `json:"elapsed_time"`
|
||||
TotalElevationGain float64 `json:"total_elevation_gain"`
|
||||
Private bool `json:"private"`
|
||||
StartDate string `json:"start_date"`
|
||||
StartLatlng []float64 `json:"start_latlng"`
|
||||
Type string `json:"type"`
|
||||
SportType string `json:"sport_type"`
|
||||
Photos photos `json:"photos"`
|
||||
}
|
||||
|
||||
type photos struct {
|
||||
Count int `json:"count"`
|
||||
Primary primaryPhoto `json:"primary"`
|
||||
}
|
||||
|
||||
type primaryPhoto struct {
|
||||
ID int64 `json:"id"`
|
||||
Urls photoURLs `json:"urls"`
|
||||
}
|
||||
|
||||
type photoURLs struct {
|
||||
Num100 string `json:"100"`
|
||||
Num600 string `json:"600"`
|
||||
}
|
||||
|
||||
type activityPhoto struct {
|
||||
UniqueID string `json:"unique_id"`
|
||||
Urls photoURLs `json:"urls"`
|
||||
}
|
||||
|
||||
type activityStreamResponse struct {
|
||||
LatLng streamLatLng `json:"latlng"`
|
||||
Time streamInt `json:"time"`
|
||||
Altitude streamFloat64 `json:"altitude"`
|
||||
}
|
||||
|
||||
type streamLatLng struct {
|
||||
Data [][]float64 `json:"data"`
|
||||
}
|
||||
|
||||
type streamInt struct {
|
||||
Data []int `json:"data"`
|
||||
}
|
||||
|
||||
type streamFloat64 struct {
|
||||
Data []float64 `json:"data"`
|
||||
}
|
||||
Reference in New Issue
Block a user