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:
slothful-vassal
2026-06-22 15:00:44 +02:00
committed by GitHub
parent 2e3d8537b8
commit 485ec53f6d
196 changed files with 20947 additions and 5454 deletions

View 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

View 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
```

View 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

View 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

View 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

View 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
View 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)
}

View 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)
}

View 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 &amp; 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
View 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
View 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
}

View 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"
}
}
}
}
}

View 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
View 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