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:
16
plugins/hammerhead/Makefile
Normal file
16
plugins/hammerhead/Makefile
Normal file
@@ -0,0 +1,16 @@
|
||||
PLUGIN_ID := hammerhead
|
||||
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
|
||||
cp assets/icon_dark.svg $(DIST_DIR)/icon_dark.svg
|
||||
|
||||
clean:
|
||||
rm -rf dist
|
||||
29
plugins/hammerhead/README.md
Normal file
29
plugins/hammerhead/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# wanderer Hammerhead WASM plugin
|
||||
|
||||
WASM/Extism version of the Hammerhead provider for wanderer.
|
||||
|
||||
This plugin exports the wanderer plugin-system ABI:
|
||||
|
||||
- `list_routes_v1`
|
||||
- `list_activities_v1`
|
||||
- `refresh_session_v1`
|
||||
- `prepare_trail_send_v1`
|
||||
|
||||
## Build
|
||||
|
||||
Install TinyGo, then run:
|
||||
|
||||
```sh
|
||||
make build
|
||||
```
|
||||
|
||||
The plugin bundle is written to `dist/hammerhead/`. Copy it below
|
||||
`data/plugins` or run `make plugins-install-local` from the repository root to
|
||||
install all bundled plugins locally.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
GOCACHE=/tmp/wanderer-go-cache go test ./...
|
||||
make manifest
|
||||
```
|
||||
15
plugins/hammerhead/assets/icon.svg
Normal file
15
plugins/hammerhead/assets/icon.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg id="hammerhead" data-name="hammerhead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 150">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: none;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_3" data-name="Layer 3">
|
||||
<path id="Layer_3-2" data-name="Layer 3" class="cls-1" d="M0,.2H150v150H0Z" transform="translate(0 -0.2)" />
|
||||
</g>
|
||||
<path
|
||||
d="M145.64,74.71a8.05,8.05,0,0,1-2.55,5.87q-6.36,6.33-12.71,12.69l-49,49a8.16,8.16,0,0,1-11.74,0Q38.91,111.4,8,80.68a8.27,8.27,0,0,1-2.63-5.74,8,8,0,0,1,2.48-6.06L22.08,54.69Q45.72,31.07,69.35,7.43a8.51,8.51,0,0,1,5.31-2.76,7.92,7.92,0,0,1,6.67,2.42L94.72,20.47q24.11,24.09,48.21,48.18A8.36,8.36,0,0,1,145.64,74.71ZM88.88,39.61c0,7.58.05,15.13,0,22.7A2,2,0,0,1,87,64.12c-7.94,0-15.89,0-23.83,0a2,2,0,0,1-2-1.9c0-7.54,0-15.07,0-22.62h-18a2.39,2.39,0,0,0-2.7,2.7v64.5a2.35,2.35,0,0,0,2.65,2.65c6-.06,12,.13,18-.08,0-7.41,0-14.81,0-22.23A2,2,0,0,1,63.35,85q11.79,0,23.57,0a2,2,0,0,1,2,2c0,7.49,0,15,0,22.49,6.06.14,12.12,0,18.18.06a2.46,2.46,0,0,0,2.55-2.67q0-32.25,0-64.51a2.53,2.53,0,0,0-2.71-2.7C100.88,39.64,94.92,39.61,88.88,39.61ZM66.81,90.77c0,7.55,0,15.09,0,22.63A1.9,1.9,0,0,1,65,115.22c-4.74,0-9.48,0-14.22,0l-.09.15C58.53,123,66.13,130.91,74,138.56a2.51,2.51,0,0,0,3.29-.13c7.73-7.66,15.35-15.43,23.13-23l-.09-.17c-5.07,0-10.14,0-15.2,0a2,2,0,0,1-1.83-1.8c0-7.54,0-15.09,0-22.64ZM50.87,33.87c4.66,0,9.24,0,13.89,0a2,2,0,0,1,2,2q0,11.21,0,22.4H83.24q0-11,0-22a2.45,2.45,0,0,1,.45-1.66,2.19,2.19,0,0,1,1.87-.74c4.84,0,9.68.11,14.5-.07-7.59-7.62-15-15.07-22.6-22.64a2.6,2.6,0,0,0-2.27-.88,2.92,2.92,0,0,0-1.7,1C65.9,18.86,58.48,26.28,50.87,33.87Zm64.53,66.32c8-7.76,15.87-15.85,23.83-23.72a2.36,2.36,0,0,0,0-3.52C131.3,65,123.43,57,115.4,49.15Zm-80.76-50c-7.72,7.42-15.18,15.17-22.8,22.71a2.36,2.36,0,0,0,0,3.64c7.62,7.51,15,15.26,22.76,22.63Z"
|
||||
transform="translate(0 -0.2)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
15
plugins/hammerhead/assets/icon_dark.svg
Normal file
15
plugins/hammerhead/assets/icon_dark.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg id="hammerhead" data-name="hammerhead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 150">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: none;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_3" data-name="Layer 3">
|
||||
<path id="Layer_3-2" data-name="Layer 3" class="cls-1" d="M0,.2H150v150H0Z" transform="translate(0 -0.2)" />
|
||||
</g>
|
||||
<path fill="white"
|
||||
d="M145.64,74.71a8.05,8.05,0,0,1-2.55,5.87q-6.36,6.33-12.71,12.69l-49,49a8.16,8.16,0,0,1-11.74,0Q38.91,111.4,8,80.68a8.27,8.27,0,0,1-2.63-5.74,8,8,0,0,1,2.48-6.06L22.08,54.69Q45.72,31.07,69.35,7.43a8.51,8.51,0,0,1,5.31-2.76,7.92,7.92,0,0,1,6.67,2.42L94.72,20.47q24.11,24.09,48.21,48.18A8.36,8.36,0,0,1,145.64,74.71ZM88.88,39.61c0,7.58.05,15.13,0,22.7A2,2,0,0,1,87,64.12c-7.94,0-15.89,0-23.83,0a2,2,0,0,1-2-1.9c0-7.54,0-15.07,0-22.62h-18a2.39,2.39,0,0,0-2.7,2.7v64.5a2.35,2.35,0,0,0,2.65,2.65c6-.06,12,.13,18-.08,0-7.41,0-14.81,0-22.23A2,2,0,0,1,63.35,85q11.79,0,23.57,0a2,2,0,0,1,2,2c0,7.49,0,15,0,22.49,6.06.14,12.12,0,18.18.06a2.46,2.46,0,0,0,2.55-2.67q0-32.25,0-64.51a2.53,2.53,0,0,0-2.71-2.7C100.88,39.64,94.92,39.61,88.88,39.61ZM66.81,90.77c0,7.55,0,15.09,0,22.63A1.9,1.9,0,0,1,65,115.22c-4.74,0-9.48,0-14.22,0l-.09.15C58.53,123,66.13,130.91,74,138.56a2.51,2.51,0,0,0,3.29-.13c7.73-7.66,15.35-15.43,23.13-23l-.09-.17c-5.07,0-10.14,0-15.2,0a2,2,0,0,1-1.83-1.8c0-7.54,0-15.09,0-22.64ZM50.87,33.87c4.66,0,9.24,0,13.89,0a2,2,0,0,1,2,2q0,11.21,0,22.4H83.24q0-11,0-22a2.45,2.45,0,0,1,.45-1.66,2.19,2.19,0,0,1,1.87-.74c4.84,0,9.68.11,14.5-.07-7.59-7.62-15-15.07-22.6-22.64a2.6,2.6,0,0,0-2.27-.88,2.92,2.92,0,0,0-1.7,1C65.9,18.86,58.48,26.28,50.87,33.87Zm64.53,66.32c8-7.76,15.87-15.85,23.83-23.72a2.36,2.36,0,0,0,0-3.52C131.3,65,123.43,57,115.4,49.15Zm-80.76-50c-7.72,7.42-15.18,15.17-22.8,22.71a2.36,2.36,0,0,0,0,3.64c7.62,7.51,15,15.26,22.76,22.63Z"
|
||||
transform="translate(0 -0.2)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
9
plugins/hammerhead/go.mod
Normal file
9
plugins/hammerhead/go.mod
Normal file
@@ -0,0 +1,9 @@
|
||||
module github.com/open-wanderer/wanderer/plugins/hammerhead
|
||||
|
||||
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/hammerhead/go.sum
Normal file
2
plugins/hammerhead/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=
|
||||
66
plugins/hammerhead/gpx.go
Normal file
66
plugins/hammerhead/gpx.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx"
|
||||
"github.com/open-wanderer/wanderer/plugins/sdk/polyline"
|
||||
)
|
||||
|
||||
func activityGPX(activity *activity) ([]byte, error) {
|
||||
points := make([]sdkgpx.Point, 0, len(activity.RecordData.Timestamp))
|
||||
const zeroEps = 1e-4
|
||||
for i, timestamp := range activity.RecordData.Timestamp {
|
||||
if i >= len(activity.RecordData.Lat) || i >= len(activity.RecordData.Lng) {
|
||||
continue
|
||||
}
|
||||
lat := activity.RecordData.Lat[i]
|
||||
lng := activity.RecordData.Lng[i]
|
||||
if math.Abs(lat) < zeroEps && math.Abs(lng) < zeroEps {
|
||||
continue
|
||||
}
|
||||
elevation := 0.0
|
||||
if i < len(activity.RecordData.Elevation) {
|
||||
elevation = activity.RecordData.Elevation[i] / 1000.0
|
||||
}
|
||||
pointTime := time.Unix(int64(timestamp), 0).UTC()
|
||||
points = append(points, sdkgpx.Point{
|
||||
Lat: lat,
|
||||
Lon: lng,
|
||||
Elevation: &elevation,
|
||||
Time: &pointTime,
|
||||
})
|
||||
}
|
||||
return sdkgpx.Track("wanderer Hammerhead plugin", activity.ActivityData.Name, points)
|
||||
}
|
||||
|
||||
func tourGPX(tour *tour) ([]byte, error) {
|
||||
coords, err := polyline.Decode(tour.RoutePolyline, 1e5)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
polyline.NormalizeCoordinateScale(coords)
|
||||
elevations, _ := polyline.DecodeValues(tour.Elevation.Polyline, 100000)
|
||||
points := make([]sdkgpx.Point, 0, len(coords))
|
||||
swap := polyline.ShouldSwapCoordinates(coords)
|
||||
for i, coord := range coords {
|
||||
lat := coord[0]
|
||||
lon := coord[1]
|
||||
if swap {
|
||||
lat, lon = coord[1], coord[0]
|
||||
}
|
||||
var elevation *float64
|
||||
if len(elevations) == len(coords) {
|
||||
elevation = &elevations[i]
|
||||
} else if len(elevations) > 0 {
|
||||
elevation = &elevations[polyline.ProportionalIndex(i, len(coords), len(elevations))]
|
||||
}
|
||||
points = append(points, sdkgpx.Point{
|
||||
Lat: lat,
|
||||
Lon: lon,
|
||||
Elevation: elevation,
|
||||
})
|
||||
}
|
||||
return sdkgpx.Track("wanderer Hammerhead plugin", tour.Name, points)
|
||||
}
|
||||
163
plugins/hammerhead/hammerhead.go
Normal file
163
plugins/hammerhead/hammerhead.go
Normal file
@@ -0,0 +1,163 @@
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/extism/go-pdk"
|
||||
"github.com/open-wanderer/wanderer/plugins/sdk"
|
||||
)
|
||||
|
||||
type hammerheadClient struct {
|
||||
userID string
|
||||
token string
|
||||
}
|
||||
|
||||
func login(email string, password string) (string, error) {
|
||||
spec := sdk.HostRequestSpec{
|
||||
Method: "POST",
|
||||
Target: sdk.RequestTarget{
|
||||
Type: "connector",
|
||||
Connector: "api",
|
||||
Path: "/v1/auth/token",
|
||||
},
|
||||
Headers: map[string]string{
|
||||
"Accept": "application/json",
|
||||
},
|
||||
Body: &sdk.HostRequestBody{
|
||||
Type: sdk.HostRequestBodyTypeJSON,
|
||||
JSON: map[string]string{
|
||||
"grant_type": "password",
|
||||
"username": email,
|
||||
"password": password,
|
||||
},
|
||||
},
|
||||
Expect: sdk.ResponseExpect{
|
||||
ContentTypes: []string{"application/json"},
|
||||
MaxBytes: 1048576,
|
||||
},
|
||||
}
|
||||
response, body, err := sdk.HostRequest(spec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if response.Status != 200 {
|
||||
return "", fmt.Errorf("hammerhead login failed (%d): %s", response.Status, string(body))
|
||||
}
|
||||
|
||||
var parsed loginResponse
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parsed.Token == "" {
|
||||
return "", fmt.Errorf("hammerhead login returned no access token")
|
||||
}
|
||||
|
||||
pdk.SetVar("hammerhead_access_token", []byte(parsed.Token))
|
||||
return parsed.Token, nil
|
||||
}
|
||||
|
||||
func loginClient(auth map[string]any) (hammerheadClient, error) {
|
||||
email := sdk.StringField(auth, "email")
|
||||
password := sdk.StringField(auth, "password")
|
||||
if email == "" || password == "" {
|
||||
return hammerheadClient{}, fmt.Errorf("email and password are required")
|
||||
}
|
||||
token, err := login(email, password)
|
||||
if err != nil {
|
||||
return hammerheadClient{}, err
|
||||
}
|
||||
userID, err := userIDFromJWT(token)
|
||||
if err != nil {
|
||||
return hammerheadClient{}, err
|
||||
}
|
||||
return hammerheadClient{userID: userID, token: token}, nil
|
||||
}
|
||||
|
||||
func (c hammerheadClient) get(path string, query []sdk.QueryParam, out any) error {
|
||||
response, body, err := sdk.HostRequest(sdk.HostRequestSpec{
|
||||
Method: "GET",
|
||||
Target: sdk.RequestTarget{
|
||||
Type: "connector",
|
||||
Connector: "api",
|
||||
Path: "/v1/users/" + c.userID + path,
|
||||
Query: query,
|
||||
},
|
||||
Headers: map[string]string{
|
||||
sdk.AuthHeaderAuthorization: sdk.AuthSchemeBearer + " " + c.token,
|
||||
"Accept": "application/json",
|
||||
},
|
||||
Expect: sdk.ResponseExpect{
|
||||
ContentTypes: []string{"application/json"},
|
||||
MaxBytes: 1048576,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.Status != 200 {
|
||||
return fmt.Errorf("hammerhead request failed (%d): %s", response.Status, string(body))
|
||||
}
|
||||
return json.Unmarshal(body, out)
|
||||
}
|
||||
|
||||
func (c hammerheadClient) activities(page int, perPage int) ([]activityResponse, int, error) {
|
||||
var data activitiesResponse
|
||||
err := c.get("/activities", hammerheadListQuery(page, perPage), &data)
|
||||
return data.Data, data.TotalPages, err
|
||||
}
|
||||
|
||||
func (c hammerheadClient) tours(page int, perPage int) ([]tourResponse, int, error) {
|
||||
var data toursResponse
|
||||
err := c.get("/routes", hammerheadListQuery(page, perPage), &data)
|
||||
return data.Data, data.TotalPages, err
|
||||
}
|
||||
|
||||
func (c hammerheadClient) activity(id string) (*activity, error) {
|
||||
var data activity
|
||||
err := c.get("/activities/"+id+"/details", nil, &data)
|
||||
return &data, err
|
||||
}
|
||||
|
||||
func (c hammerheadClient) tour(id string) (*tour, error) {
|
||||
var data tour
|
||||
err := c.get("/routes/"+id, nil, &data)
|
||||
return &data, err
|
||||
}
|
||||
|
||||
func hammerheadListQuery(page int, perPage int) []sdk.QueryParam {
|
||||
return []sdk.QueryParam{
|
||||
{Name: "page", Value: strconv.Itoa(page)},
|
||||
{Name: "perPage", Value: strconv.Itoa(perPage)},
|
||||
{Name: "orderBy", Value: "NEWEST"},
|
||||
{Name: "ascending", Value: "true"},
|
||||
}
|
||||
}
|
||||
|
||||
func userIDForUpload(auth map[string]any) (string, error) {
|
||||
token := string(pdk.GetVar("hammerhead_access_token"))
|
||||
if token == "" {
|
||||
email := sdk.StringField(auth, "email")
|
||||
password := sdk.StringField(auth, "password")
|
||||
if email == "" || password == "" {
|
||||
return "", fmt.Errorf("email and password are required")
|
||||
}
|
||||
var err error
|
||||
token, err = login(email, password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return userIDFromJWT(token)
|
||||
}
|
||||
|
||||
func userIDFromSession() (string, error) {
|
||||
token := string(pdk.GetVar("hammerhead_access_token"))
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("session token is not available")
|
||||
}
|
||||
return userIDFromJWT(token)
|
||||
}
|
||||
97
plugins/hammerhead/hammerhead_test.go
Normal file
97
plugins/hammerhead/hammerhead_test.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx"
|
||||
"github.com/open-wanderer/wanderer/plugins/sdk/polyline"
|
||||
)
|
||||
|
||||
func TestUserIDFromJWT(t *testing.T) {
|
||||
token := "header.eyJzdWIiOiJ1c2VyLTEyMyJ9.signature"
|
||||
got, err := userIDFromJWT(token)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "user-123" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserIDFromJWTRejectsInvalidToken(t *testing.T) {
|
||||
if _, err := userIDFromJWT("not-a-jwt"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePolyline(t *testing.T) {
|
||||
points, err := polyline.Decode("_p~iF~ps|U_ulLnnqC_mqNvxq`@", 1e5)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(points) != 3 {
|
||||
t.Fatalf("expected 3 points, got %d", len(points))
|
||||
}
|
||||
if points[0][0] != 38.5 || points[0][1] != -120.2 {
|
||||
t.Fatalf("unexpected first point: %#v", points[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodePolylineNormalizesOutOfRangeScale(t *testing.T) {
|
||||
points, err := polyline.Decode("_p~iF~ps|U", 1e5)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
points[0][0] *= 10
|
||||
points[0][1] *= 10
|
||||
polyline.NormalizeCoordinateScale(points)
|
||||
if points[0][0] != 38.5 || points[0][1] != -120.2 {
|
||||
t.Fatalf("expected normalized point, got %#v", points[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSwapCoordinates(t *testing.T) {
|
||||
coords := [][2]float64{{120.2, 38.5}, {121.0, 39.0}}
|
||||
if !polyline.ShouldSwapCoordinates(coords) {
|
||||
t.Fatal("expected coordinates to be detected as swapped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProportionalIndex(t *testing.T) {
|
||||
if got := polyline.ProportionalIndex(2, 5, 3); got != 1 {
|
||||
t.Fatalf("got %d, want 1", got)
|
||||
}
|
||||
if got := polyline.ProportionalIndex(4, 5, 3); got != 2 {
|
||||
t.Fatalf("got %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPXBytesEscapesTrackName(t *testing.T) {
|
||||
data, err := sdkgpx.Track("wanderer Hammerhead plugin", "A & B", []sdkgpx.Point{{Lat: 46.1, Lon: 8.2}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
gpx := string(data)
|
||||
if !strings.Contains(gpx, "<name>A & B</name>") {
|
||||
t.Fatalf("expected escaped name, got %s", gpx)
|
||||
}
|
||||
if !strings.Contains(gpx, `lat="46.10000000" lon="8.20000000"`) {
|
||||
t.Fatalf("expected track point, got %s", gpx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrailGPXFilename(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"": "trail.gpx",
|
||||
"My Route": "My Route.gpx",
|
||||
"My Route.gpx": "My Route.gpx",
|
||||
"../Bad/Route\\Name ": "Bad-Route-Name.gpx",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := trailGPXFilename(input); got != want {
|
||||
t.Fatalf("trailGPXFilename(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
28
plugins/hammerhead/jwt.go
Normal file
28
plugins/hammerhead/jwt.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func userIDFromJWT(token string) (string, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("token is not a JWT")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sub, _ := claims["sub"].(string)
|
||||
if sub == "" {
|
||||
return "", fmt.Errorf("token has no sub claim")
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
350
plugins/hammerhead/main.go
Normal file
350
plugins/hammerhead/main.go
Normal file
@@ -0,0 +1,350 @@
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/extism/go-pdk"
|
||||
"github.com/open-wanderer/wanderer/plugins/sdk"
|
||||
)
|
||||
|
||||
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 := loginClient(input.Auth)
|
||||
if err != nil {
|
||||
return fail("auth_failed", err.Error())
|
||||
}
|
||||
output, err := listRoutes(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 := loginClient(input.Auth)
|
||||
if err != nil {
|
||||
return fail("auth_failed", err.Error())
|
||||
}
|
||||
output, err := listActivities(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")
|
||||
}
|
||||
|
||||
//export refresh_session_v1
|
||||
func refreshSessionV1() int32 {
|
||||
var input refreshSessionInput
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
return fail("invalid_request", "invalid refresh_session input: "+err.Error())
|
||||
}
|
||||
|
||||
email := sdk.StringField(input.Auth, "email")
|
||||
password := sdk.StringField(input.Auth, "password")
|
||||
if email == "" || password == "" {
|
||||
return fail("auth_failed", "email and password are required")
|
||||
}
|
||||
|
||||
token, err := login(email, password)
|
||||
if err != nil {
|
||||
return fail("auth_failed", err.Error())
|
||||
}
|
||||
|
||||
if err := pdk.OutputJSON(refreshSessionOutput{
|
||||
Token: token,
|
||||
Scheme: sdk.AuthSchemeBearer,
|
||||
}); err != nil {
|
||||
return fail("internal_error", err.Error())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
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 := loginClient(input.Auth)
|
||||
if err != nil {
|
||||
return fail("auth_failed", err.Error())
|
||||
}
|
||||
var item trailImport
|
||||
switch kind {
|
||||
case "planned":
|
||||
detail, err := client.tour(input.Summary.Source.ExternalID)
|
||||
if err != nil {
|
||||
return fail("provider_unavailable", err.Error())
|
||||
}
|
||||
item, err = tourImport(detail)
|
||||
if err != nil {
|
||||
return fail("provider_unavailable", err.Error())
|
||||
}
|
||||
case "completed":
|
||||
detail, err := client.activity(input.Summary.Source.ExternalID)
|
||||
if err != nil {
|
||||
return fail("provider_unavailable", err.Error())
|
||||
}
|
||||
item, err = activityImport(detail)
|
||||
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
|
||||
}
|
||||
|
||||
//export prepare_trail_send_v1
|
||||
func prepareTrailSendV1() int32 {
|
||||
var input trailSendInput
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
return fail("invalid_request", "invalid prepare_trail_send input: "+err.Error())
|
||||
}
|
||||
if input.Trail.Format != "gpx" || input.Trail.ContentBase64 == "" {
|
||||
return fail("invalid_request", "a GPX trail is required")
|
||||
}
|
||||
|
||||
userID, err := userIDForUpload(input.Auth)
|
||||
if err != nil {
|
||||
return fail("auth_failed", err.Error())
|
||||
}
|
||||
|
||||
plan := trailSendPlan{
|
||||
Request: sdk.HostRequestSpec{
|
||||
Method: "POST",
|
||||
Target: sdk.RequestTarget{
|
||||
Type: "connector",
|
||||
Connector: "api",
|
||||
Path: fmt.Sprintf("/v1/users/%s/routes/import/file", userID),
|
||||
},
|
||||
Auth: "provider_session",
|
||||
Body: &sdk.HostRequestBody{
|
||||
Type: sdk.HostRequestBodyTypeMultipart,
|
||||
Parts: []sdk.MultipartPart{
|
||||
{
|
||||
Name: "file",
|
||||
Source: sdk.MultipartSourceTrail,
|
||||
Filename: trailGPXFilename(input.Name),
|
||||
ContentType: "application/gpx+xml",
|
||||
},
|
||||
},
|
||||
},
|
||||
Expect: sdk.ResponseExpect{
|
||||
ContentTypes: []string{"application/json"},
|
||||
MaxBytes: 1048576,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := pdk.OutputJSON(plan); 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
|
||||
}
|
||||
|
||||
func listRoutes(client hammerheadClient, input listInput) (listOutput, error) {
|
||||
page := sdk.IntState(input.State, "page", 1)
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
limit := sdk.SyncLimit(input)
|
||||
rows, totalPages, err := client.tours(page, limit)
|
||||
if err != nil {
|
||||
return listOutput{}, err
|
||||
}
|
||||
|
||||
after := sdk.StringField(input.Options, "after")
|
||||
items := make([]trailSummary, 0, min(limit, len(rows)))
|
||||
for _, row := range rows {
|
||||
if after != "" && row.CreatedAt < after {
|
||||
return listOutput{Items: items}, nil
|
||||
}
|
||||
items = append(items, trailSummary{
|
||||
Source: trailImportSource{Provider: "hammerhead", ExternalID: row.ID},
|
||||
Kind: "planned",
|
||||
})
|
||||
if len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
nextPage := page + 1
|
||||
hasMore := nextPage <= totalPages
|
||||
return listOutput{
|
||||
Items: items,
|
||||
State: sdk.NextPageState(nextPage, hasMore),
|
||||
HasMore: hasMore,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func listActivities(client hammerheadClient, input listInput) (listOutput, error) {
|
||||
page := sdk.IntState(input.State, "page", 1)
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
limit := sdk.SyncLimit(input)
|
||||
rows, totalPages, err := client.activities(page, limit)
|
||||
if err != nil {
|
||||
return listOutput{}, err
|
||||
}
|
||||
|
||||
after := sdk.StringField(input.Options, "after")
|
||||
items := make([]trailSummary, 0, min(limit, len(rows)))
|
||||
for _, row := range rows {
|
||||
if after != "" && row.CreatedAt < after {
|
||||
return listOutput{Items: items}, nil
|
||||
}
|
||||
items = append(items, trailSummary{
|
||||
Source: trailImportSource{Provider: "hammerhead", ExternalID: row.ID},
|
||||
Kind: "completed",
|
||||
})
|
||||
if len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
nextPage := page + 1
|
||||
hasMore := nextPage <= totalPages
|
||||
return listOutput{
|
||||
Items: items,
|
||||
State: sdk.NextPageState(nextPage, hasMore),
|
||||
HasMore: hasMore,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func tourImport(tour *tour) (trailImport, error) {
|
||||
gpxData, err := tourGPX(tour)
|
||||
if err != nil {
|
||||
return trailImport{}, err
|
||||
}
|
||||
privacy := privacyFromPublic(tour.IsPublic)
|
||||
return trailImport{
|
||||
Source: trailImportSource{
|
||||
Provider: "hammerhead",
|
||||
ExternalID: tour.ID,
|
||||
},
|
||||
Kind: "planned",
|
||||
Name: tour.Name,
|
||||
StartedAt: tour.CreatedAt,
|
||||
ActivityType: "biking",
|
||||
Privacy: &privacy,
|
||||
Track: track{
|
||||
Format: "gpx",
|
||||
ContentBase64: base64.StdEncoding.EncodeToString(gpxData),
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"distance": tour.Distance,
|
||||
"elevationGain": tour.Elevation.Gain,
|
||||
"elevationLoss": tour.Elevation.Loss,
|
||||
"providerCategory": "biking",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func activityImport(activity *activity) (trailImport, error) {
|
||||
gpxData, err := activityGPX(activity)
|
||||
if err != nil {
|
||||
return trailImport{}, err
|
||||
}
|
||||
privacy := "private"
|
||||
return trailImport{
|
||||
Source: trailImportSource{
|
||||
Provider: "hammerhead",
|
||||
ExternalID: activity.ActivityData.ID,
|
||||
},
|
||||
Kind: "completed",
|
||||
Name: activity.ActivityData.Name,
|
||||
StartedAt: activity.ActivityData.CreatedAt,
|
||||
ActivityType: "biking",
|
||||
Privacy: &privacy,
|
||||
Track: track{
|
||||
Format: "gpx",
|
||||
ContentBase64: base64.StdEncoding.EncodeToString(gpxData),
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"distance": infoValueOrZero(activity, "TYPE_DISTANCE_ID"),
|
||||
"elevationGain": infoValueOrZero(activity, "TYPE_ELEVATION_GAIN_ID"),
|
||||
"elevationLoss": infoValueOrZero(activity, "TYPE_ELEVATION_LOSS_ID"),
|
||||
"duration": activityDurationSeconds(activity),
|
||||
"providerCategory": "biking",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func privacyFromPublic(public bool) string {
|
||||
if public {
|
||||
return "public"
|
||||
}
|
||||
return "private"
|
||||
}
|
||||
|
||||
func activityInfoValue(activity *activity, key string) (float64, bool) {
|
||||
for _, info := range activity.ActivityData.ActivityInfo {
|
||||
if info.Key == key {
|
||||
return info.Value.Value, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func infoValueOrZero(activity *activity, key string) float64 {
|
||||
value, _ := activityInfoValue(activity, key)
|
||||
return value
|
||||
}
|
||||
|
||||
func activityDurationSeconds(activity *activity) float64 {
|
||||
var total int
|
||||
for _, lap := range activity.ActivityData.Laps {
|
||||
total += lap.ActiveTime
|
||||
}
|
||||
if total > 0 {
|
||||
return float64(total) / 1000
|
||||
}
|
||||
if activity.ActivityData.Duration.ElapsedTime > 0 {
|
||||
return float64(activity.ActivityData.Duration.ElapsedTime) / 1000
|
||||
}
|
||||
return 0
|
||||
}
|
||||
131
plugins/hammerhead/plugin.json
Normal file
131
plugins/hammerhead/plugin.json
Normal file
@@ -0,0 +1,131 @@
|
||||
{
|
||||
"manifestVersion": "1.0",
|
||||
"id": "hammerhead",
|
||||
"type": "trails",
|
||||
"name": "Hammerhead",
|
||||
"description": "Imports Hammerhead routes and activities, and can send wanderer routes to Hammerhead.",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"name": "prepare_trail_send",
|
||||
"version": "v1",
|
||||
"export": "prepare_trail_send_v1"
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"contexts": {
|
||||
"provider_session": {
|
||||
"type": "session",
|
||||
"fields": [
|
||||
"email",
|
||||
"password"
|
||||
],
|
||||
"secretFields": [
|
||||
"password"
|
||||
],
|
||||
"refresh": {
|
||||
"mode": "plugin",
|
||||
"function": "refresh_session_v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"network": {
|
||||
"connectors": [
|
||||
{
|
||||
"name": "api",
|
||||
"type": "public_api",
|
||||
"fixedBaseURL": "https://dashboard.hammerhead.io",
|
||||
"allowedPathPrefixes": [
|
||||
"/v1"
|
||||
],
|
||||
"auth": [
|
||||
"provider_session"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"auth": [
|
||||
"provider_session"
|
||||
],
|
||||
"uploads": {
|
||||
"maxBytes": 25000000,
|
||||
"contentTypes": [
|
||||
"application/json",
|
||||
"multipart/form-data"
|
||||
]
|
||||
},
|
||||
"downloads": {
|
||||
"maxBytes": 1048576,
|
||||
"contentTypes": [
|
||||
"application/json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"configSchema": [
|
||||
{
|
||||
"key": "after",
|
||||
"type": "date",
|
||||
"label": "Start date",
|
||||
"labels": {
|
||||
"de": "Startdatum",
|
||||
"en": "Start date"
|
||||
},
|
||||
"description": "Ignore routes and activities before this date.",
|
||||
"descriptions": {
|
||||
"de": "Wenn Ihr Hammerhead Konto bereits mit anderen Trail-Datenbanken wie komoot oder Strava synchronisiert ist, kann die zusätzliche Synchronisierung Ihrer Hammerhead-Daten zu Duplikaten führen. Um dies zu vermeiden, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.",
|
||||
"en": "If your hammerhead account is already synced with other trail databases, such as komoot or Strava, start syncing your Hammerhead data may result in duplicates. To avoid this, you can set an start date below, meaning only activities recorded after this date will be synced.",
|
||||
"no": "Hvis Hammerhead-kontoen din allerede er synkronisert med andre stidatabaser, som Komoot eller Strava, kan synkronisering av Hammerhead-data føre til duplikater. For å unngå dette kan du angi en startdato nedenfor, slik at bare aktiviteter registrert etter denne datoen vil bli synkronisert."
|
||||
}
|
||||
}
|
||||
],
|
||||
"hostConfig": {
|
||||
"categoryMapping": {
|
||||
"biking": "Biking"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"descriptions": {
|
||||
"de": "Importiert Hammerhead-Routen und Aktivitäten und kann wanderer-Routen an Hammerhead senden.",
|
||||
"en": "Imports Hammerhead routes and activities, and can send wanderer routes to Hammerhead.",
|
||||
"no": "Synkroniserer Hammerhead-turene dine med Wanderer med jevne mellomrom."
|
||||
},
|
||||
"icons": {
|
||||
"light": "icon.svg",
|
||||
"dark": "icon_dark.svg"
|
||||
},
|
||||
"providerCategories": {
|
||||
"biking": {
|
||||
"labels": {
|
||||
"de": "Radfahren",
|
||||
"en": "Biking"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
plugins/hammerhead/send.go
Normal file
24
plugins/hammerhead/send.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
func trailGPXFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "trail.gpx"
|
||||
}
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == '/' || r == '\\' {
|
||||
return '-'
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
name = strings.Trim(name, ". -")
|
||||
if name == "" {
|
||||
return "trail.gpx"
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(name), ".gpx") {
|
||||
return name
|
||||
}
|
||||
return name + ".gpx"
|
||||
}
|
||||
106
plugins/hammerhead/types.go
Normal file
106
plugins/hammerhead/types.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
import "github.com/open-wanderer/wanderer/plugins/sdk"
|
||||
|
||||
type instanceRef = sdk.InstanceRef
|
||||
type refreshSessionInput = sdk.RefreshSessionInput
|
||||
type refreshSessionOutput = sdk.RefreshSessionOutput
|
||||
type trailSendInput = sdk.TrailSendInput
|
||||
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 trailSendPlan = sdk.TrailSendPlan
|
||||
|
||||
type loginResponse struct {
|
||||
Token string `json:"access_token"`
|
||||
}
|
||||
|
||||
type toursResponse struct {
|
||||
TotalPages int `json:"totalPages"`
|
||||
Data []tourResponse `json:"data"`
|
||||
}
|
||||
|
||||
type tourResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type activitiesResponse struct {
|
||||
TotalPages int `json:"totalPages"`
|
||||
Data []activityResponse `json:"data"`
|
||||
}
|
||||
|
||||
type activityResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type tour struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Name string `json:"name"`
|
||||
Distance float64 `json:"distance"`
|
||||
Elevation elevation `json:"elevation"`
|
||||
StartLocation location `json:"startLocation"`
|
||||
RoutePolyline string `json:"routePolyline"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
}
|
||||
|
||||
type elevation struct {
|
||||
Gain float64 `json:"gain"`
|
||||
Loss float64 `json:"loss"`
|
||||
Polyline string `json:"polyline"`
|
||||
}
|
||||
|
||||
type location struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
}
|
||||
|
||||
type activity struct {
|
||||
ActivityData activityData `json:"activityData"`
|
||||
RecordData recordData `json:"recordData"`
|
||||
}
|
||||
|
||||
type activityData struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Duration duration `json:"duration"`
|
||||
ActivityInfo []info `json:"activityInfo"`
|
||||
Laps []lapDetail `json:"laps"`
|
||||
ActivityType string `json:"activityType"`
|
||||
}
|
||||
|
||||
type duration struct {
|
||||
ElapsedTime int `json:"elapsedTime"`
|
||||
}
|
||||
|
||||
type info struct {
|
||||
Key string `json:"key"`
|
||||
Value infoValue `json:"value"`
|
||||
}
|
||||
|
||||
type infoValue struct {
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
type lapDetail struct {
|
||||
ActiveTime int `json:"activeTime"`
|
||||
}
|
||||
|
||||
type recordData struct {
|
||||
Timestamp []int `json:"timestamp"`
|
||||
Elevation []float64 `json:"elevation"`
|
||||
Lat []float64 `json:"lat"`
|
||||
Lng []float64 `json:"lng"`
|
||||
}
|
||||
|
||||
type pluginError = sdk.PluginError
|
||||
Reference in New Issue
Block a user