* 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>
200 lines
5.7 KiB
Go
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
|
|
}
|