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:
63
plugins/sdk/README.md
Normal file
63
plugins/sdk/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Wanderer Plugin SDK for Go
|
||||
|
||||
TinyGo-compatible helpers for Wanderer WASM plugins.
|
||||
|
||||
```go
|
||||
import "github.com/open-wanderer/wanderer/plugins/sdk"
|
||||
```
|
||||
|
||||
The SDK contains only plugin-side protocol types and host-function helpers. It
|
||||
does not depend on Wanderer core or PocketBase.
|
||||
|
||||
Common protocol types:
|
||||
|
||||
- `ListInput`, `ListOutput`, `TrailImport`, `Track`, `Waypoint`, `Photo`
|
||||
- `RefreshSessionInput`, `RefreshSessionOutput`
|
||||
- `TrailSendInput`, `TrailSendPlan`
|
||||
- `HostRequestSpec`, `HostResponse`, `PluginError`
|
||||
|
||||
Provider HTTP requests use connector targets. Plugins provide a connector name,
|
||||
a relative path, and ordered query parameters; the host owns the final base URL,
|
||||
path scope, redirects, TLS, and private-network policy. Public external media
|
||||
URLs remain available only through `MediaSource{Type: "url"}`.
|
||||
|
||||
Host HTTP request bodies support JSON, `application/x-www-form-urlencoded`, and
|
||||
multipart. Use `PostJSON` for JSON and `PostForm` for ordered form fields. Any
|
||||
request body, including a login form POST, is governed by manifest
|
||||
`permissions.uploads.maxBytes` and `permissions.uploads.contentTypes`; in this
|
||||
contract "uploads" means plugin-to-provider request bodies, not only media/file
|
||||
uploads.
|
||||
|
||||
Set `HostRequestSpec.FollowRedirects` to `sdk.Bool(false)` when a plugin needs
|
||||
to inspect a redirect response itself, for example to collect `Location` and
|
||||
`Set-Cookie` during a provider login flow. `HostResponse.HeaderValues` is the
|
||||
only response-header representation and preserves all values. Prefer
|
||||
`FirstHeader` for scalar headers and `HeaderValuesFor` for headers that can
|
||||
appear more than once.
|
||||
|
||||
Plugins can emit host-visible logs with `LogDebug`, `LogInfo`, `LogWarn`, and
|
||||
`LogError`. Log levels are strict (`debug`, `info`, `warn`, `error`) and
|
||||
messages must be non-empty. Use logs for short diagnostics and timing markers;
|
||||
they are best-effort and should not be part of plugin control flow.
|
||||
|
||||
Small sync helpers are included for the repeated mechanics that every provider
|
||||
needs:
|
||||
|
||||
- `StringField` / `StringOption`
|
||||
- `IntState`
|
||||
- `IntOption`
|
||||
- `KnownIDs`
|
||||
- `SyncLimit`
|
||||
- `NextPageState`
|
||||
|
||||
Additional TinyGo-compatible helper packages:
|
||||
|
||||
```go
|
||||
import sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx"
|
||||
import "github.com/open-wanderer/wanderer/plugins/sdk/polyline"
|
||||
```
|
||||
|
||||
- `gpx` writes simple GPX 1.1 track documents from provider track points.
|
||||
- `polyline` decodes Google-style encoded polylines and provides small helpers
|
||||
for coordinate scale normalization, coordinate swap detection, and mapping
|
||||
shorter elevation arrays onto track points.
|
||||
19
plugins/sdk/cmd/manifestcheck/main.go
Normal file
19
plugins/sdk/cmd/manifestcheck/main.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/open-wanderer/wanderer/plugins/sdk/manifestcheck"
|
||||
)
|
||||
|
||||
func main() {
|
||||
path := "plugin.json"
|
||||
if len(os.Args) > 1 {
|
||||
path = os.Args[1]
|
||||
}
|
||||
if err := manifestcheck.PrintFile(os.Stdout, path); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "manifestcheck: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
5
plugins/sdk/go.mod
Normal file
5
plugins/sdk/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module github.com/open-wanderer/wanderer/plugins/sdk
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/extism/go-pdk v1.1.3
|
||||
2
plugins/sdk/go.sum
Normal file
2
plugins/sdk/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=
|
||||
58
plugins/sdk/gpx/gpx.go
Normal file
58
plugins/sdk/gpx/gpx.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package gpx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Point struct {
|
||||
Lat float64
|
||||
Lon float64
|
||||
Elevation *float64
|
||||
Time *time.Time
|
||||
}
|
||||
|
||||
func Track(creator string, name string, points []Point) ([]byte, error) {
|
||||
if len(points) == 0 {
|
||||
return nil, fmt.Errorf("track has no points")
|
||||
}
|
||||
if creator == "" {
|
||||
creator = "wanderer plugin"
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(xml.Header)
|
||||
buf.WriteString(`<gpx version="1.1" creator="`)
|
||||
_ = xml.EscapeText(&buf, []byte(creator))
|
||||
buf.WriteString(`" xmlns="http://www.topografix.com/GPX/1/1">`)
|
||||
buf.WriteString("<trk>")
|
||||
buf.WriteString("<name>")
|
||||
_ = xml.EscapeText(&buf, []byte(name))
|
||||
buf.WriteString("</name>")
|
||||
buf.WriteString("<trkseg>")
|
||||
for _, point := range points {
|
||||
buf.WriteString(`<trkpt lat="`)
|
||||
buf.WriteString(strconv.FormatFloat(point.Lat, 'f', 8, 64))
|
||||
buf.WriteString(`" lon="`)
|
||||
buf.WriteString(strconv.FormatFloat(point.Lon, 'f', 8, 64))
|
||||
buf.WriteString(`">`)
|
||||
if point.Elevation != nil {
|
||||
buf.WriteString("<ele>")
|
||||
buf.WriteString(strconv.FormatFloat(*point.Elevation, 'f', 2, 64))
|
||||
buf.WriteString("</ele>")
|
||||
}
|
||||
if point.Time != nil {
|
||||
buf.WriteString("<time>")
|
||||
buf.WriteString(point.Time.UTC().Format(time.RFC3339))
|
||||
buf.WriteString("</time>")
|
||||
}
|
||||
buf.WriteString("</trkpt>")
|
||||
}
|
||||
buf.WriteString("</trkseg>")
|
||||
buf.WriteString("</trk>")
|
||||
buf.WriteString("</gpx>")
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
39
plugins/sdk/gpx/gpx_test.go
Normal file
39
plugins/sdk/gpx/gpx_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package gpx
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTrackEscapesFields(t *testing.T) {
|
||||
elevation := 123.456
|
||||
timestamp := time.Date(2026, 6, 1, 10, 30, 0, 0, time.UTC)
|
||||
data, err := Track("creator & test", "A & B", []Point{{
|
||||
Lat: 46.1,
|
||||
Lon: 8.2,
|
||||
Elevation: &elevation,
|
||||
Time: ×tamp,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
gpx := string(data)
|
||||
for _, want := range []string{
|
||||
`creator="creator & test"`,
|
||||
"<name>A & B</name>",
|
||||
`lat="46.10000000" lon="8.20000000"`,
|
||||
"<ele>123.46</ele>",
|
||||
"<time>2026-06-01T10:30:00Z</time>",
|
||||
} {
|
||||
if !strings.Contains(gpx, want) {
|
||||
t.Fatalf("expected %q in %s", want, gpx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackRejectsEmptyPoints(t *testing.T) {
|
||||
if _, err := Track("", "empty", nil); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
154
plugins/sdk/host_http.go
Normal file
154
plugins/sdk/host_http.go
Normal file
@@ -0,0 +1,154 @@
|
||||
//go:build tinygo
|
||||
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
//go:wasmimport wanderer http_request
|
||||
func wandererHTTPRequest(uint64) uint64
|
||||
|
||||
//go:wasmimport wanderer log
|
||||
func wandererLog(uint64)
|
||||
|
||||
func Log(level LogLevel, message string) {
|
||||
entry := HostLogEntry{
|
||||
Level: level,
|
||||
Message: message,
|
||||
}
|
||||
memory, err := pdk.AllocateJSON(entry)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer memory.Free()
|
||||
wandererLog(memory.Offset())
|
||||
}
|
||||
|
||||
func LogDebug(message string) {
|
||||
Log(LogLevelDebug, message)
|
||||
}
|
||||
|
||||
func LogInfo(message string) {
|
||||
Log(LogLevelInfo, message)
|
||||
}
|
||||
|
||||
func LogWarn(message string) {
|
||||
Log(LogLevelWarn, message)
|
||||
}
|
||||
|
||||
func LogError(message string) {
|
||||
Log(LogLevelError, message)
|
||||
}
|
||||
|
||||
func HostRequest(spec HostRequestSpec) (HostResponse, []byte, error) {
|
||||
requestMemory, err := pdk.AllocateJSON(spec)
|
||||
if err != nil {
|
||||
return HostResponse{}, nil, err
|
||||
}
|
||||
defer requestMemory.Free()
|
||||
|
||||
responsePointer := wandererHTTPRequest(requestMemory.Offset())
|
||||
if responsePointer == 0 {
|
||||
return HostResponse{}, nil, fmt.Errorf("host http request returned no response")
|
||||
}
|
||||
responseMemory := pdk.FindMemory(responsePointer)
|
||||
var response HostResponse
|
||||
if err := json.Unmarshal(responseMemory.ReadBytes(), &response); err != nil {
|
||||
return HostResponse{}, nil, err
|
||||
}
|
||||
if response.Error != nil {
|
||||
return response, nil, fmt.Errorf("%s: %s", response.Error.Code, response.Error.Message)
|
||||
}
|
||||
body, err := base64.StdEncoding.DecodeString(response.BodyBase64)
|
||||
if err != nil {
|
||||
return response, nil, err
|
||||
}
|
||||
return response, body, nil
|
||||
}
|
||||
|
||||
func ConnectorRequest(method string, connector string, path string, query []QueryParam, headers map[string]string, expect ResponseExpect) (HostResponse, []byte, error) {
|
||||
return HostRequest(HostRequestSpec{
|
||||
Method: method,
|
||||
Target: RequestTarget{
|
||||
Type: "connector",
|
||||
Connector: connector,
|
||||
Path: path,
|
||||
Query: query,
|
||||
},
|
||||
Headers: headers,
|
||||
Expect: expect,
|
||||
})
|
||||
}
|
||||
|
||||
func Get(connector string, path string, query []QueryParam, headers map[string]string, expect ResponseExpect) (HostResponse, []byte, error) {
|
||||
return ConnectorRequest("GET", connector, path, query, headers, expect)
|
||||
}
|
||||
|
||||
func PostJSON(connector string, path string, query []QueryParam, headers map[string]string, body any, expect ResponseExpect) (HostResponse, []byte, error) {
|
||||
return HostRequest(HostRequestSpec{
|
||||
Method: "POST",
|
||||
Target: RequestTarget{
|
||||
Type: "connector",
|
||||
Connector: connector,
|
||||
Path: path,
|
||||
Query: query,
|
||||
},
|
||||
Headers: headers,
|
||||
Body: &HostRequestBody{
|
||||
Type: HostRequestBodyTypeJSON,
|
||||
JSON: body,
|
||||
},
|
||||
Expect: expect,
|
||||
})
|
||||
}
|
||||
|
||||
func PostForm(connector string, path string, query []QueryParam, headers map[string]string, form []FormField, expect ResponseExpect) (HostResponse, []byte, error) {
|
||||
return HostRequest(HostRequestSpec{
|
||||
Method: "POST",
|
||||
Target: RequestTarget{
|
||||
Type: "connector",
|
||||
Connector: connector,
|
||||
Path: path,
|
||||
Query: query,
|
||||
},
|
||||
Headers: headers,
|
||||
Body: &HostRequestBody{
|
||||
Type: HostRequestBodyTypeForm,
|
||||
Form: form,
|
||||
},
|
||||
Expect: expect,
|
||||
})
|
||||
}
|
||||
|
||||
func (r HostResponse) FirstHeader(name string) string {
|
||||
values := r.HeaderValuesFor(name)
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
func (r HostResponse) HeaderValuesFor(name string) []string {
|
||||
if r.HeaderValues == nil {
|
||||
return nil
|
||||
}
|
||||
if values, ok := r.HeaderValues[name]; ok {
|
||||
return values
|
||||
}
|
||||
for key, values := range r.HeaderValues {
|
||||
if strings.EqualFold(key, name) {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Bool(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
26
plugins/sdk/manifestcheck/manifestcheck.go
Normal file
26
plugins/sdk/manifestcheck/manifestcheck.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package manifestcheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func PrintFile(w io.Writer, path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var manifest map[string]any
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(w, string(encoded))
|
||||
return err
|
||||
}
|
||||
125
plugins/sdk/polyline/polyline.go
Normal file
125
plugins/sdk/polyline/polyline.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package polyline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
func Decode(encoded string, precision float64) ([][2]float64, error) {
|
||||
if precision == 0 {
|
||||
return nil, fmt.Errorf("precision must not be zero")
|
||||
}
|
||||
var coords [][2]float64
|
||||
index := 0
|
||||
lat := 0
|
||||
lon := 0
|
||||
for index < len(encoded) {
|
||||
dlat, next, err := decodeValue(encoded, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index = next
|
||||
dlon, next, err := decodeValue(encoded, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index = next
|
||||
lat += dlat
|
||||
lon += dlon
|
||||
coords = append(coords, [2]float64{float64(lat) / precision, float64(lon) / precision})
|
||||
}
|
||||
return coords, nil
|
||||
}
|
||||
|
||||
func DecodeValues(encoded string, precision float64) ([]float64, error) {
|
||||
if precision == 0 {
|
||||
return nil, fmt.Errorf("precision must not be zero")
|
||||
}
|
||||
var values []float64
|
||||
index := 0
|
||||
value := 0
|
||||
for index < len(encoded) {
|
||||
delta, next, err := decodeValue(encoded, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index = next
|
||||
value += delta
|
||||
values = append(values, float64(value)/precision)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func NormalizeCoordinateScale(coords [][2]float64) {
|
||||
if len(coords) == 0 {
|
||||
return
|
||||
}
|
||||
maxLat := 0.0
|
||||
maxLon := 0.0
|
||||
for _, coord := range coords {
|
||||
if abs := math.Abs(coord[0]); abs > maxLat {
|
||||
maxLat = abs
|
||||
}
|
||||
if abs := math.Abs(coord[1]); abs > maxLon {
|
||||
maxLon = abs
|
||||
}
|
||||
}
|
||||
for (maxLat > 90 || maxLon > 180) && maxLat > 0 && maxLon > 0 {
|
||||
for i := range coords {
|
||||
coords[i][0] /= 10
|
||||
coords[i][1] /= 10
|
||||
}
|
||||
maxLat /= 10
|
||||
maxLon /= 10
|
||||
}
|
||||
}
|
||||
|
||||
func ShouldSwapCoordinates(coords [][2]float64) bool {
|
||||
validAsLat := 0
|
||||
validAsLon := 0
|
||||
for _, coord := range coords {
|
||||
if validLatLon(coord[0], coord[1]) {
|
||||
validAsLat++
|
||||
}
|
||||
if validLatLon(coord[1], coord[0]) {
|
||||
validAsLon++
|
||||
}
|
||||
}
|
||||
return validAsLon > validAsLat
|
||||
}
|
||||
|
||||
func ProportionalIndex(i int, sourceLen int, targetLen int) int {
|
||||
if targetLen <= 1 || sourceLen <= 1 {
|
||||
return 0
|
||||
}
|
||||
j := int(math.Round(float64(i) * float64(targetLen-1) / float64(sourceLen-1)))
|
||||
if j < 0 {
|
||||
return 0
|
||||
}
|
||||
if j >= targetLen {
|
||||
return targetLen - 1
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
func validLatLon(lat float64, lon float64) bool {
|
||||
return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180
|
||||
}
|
||||
|
||||
func decodeValue(encoded string, index int) (int, int, error) {
|
||||
result := 0
|
||||
shift := uint(0)
|
||||
for {
|
||||
if index >= len(encoded) {
|
||||
return 0, index, fmt.Errorf("invalid polyline encoding")
|
||||
}
|
||||
b := int(encoded[index]) - 63
|
||||
index++
|
||||
result |= (b & 0x1F) << shift
|
||||
shift += 5
|
||||
if b < 0x20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return (result >> 1) ^ (-(result & 1)), index, nil
|
||||
}
|
||||
40
plugins/sdk/polyline/polyline_test.go
Normal file
40
plugins/sdk/polyline/polyline_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package polyline
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDecode(t *testing.T) {
|
||||
points, err := 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 TestNormalizeCoordinateScale(t *testing.T) {
|
||||
points := [][2]float64{{385, -1202}}
|
||||
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 !ShouldSwapCoordinates(coords) {
|
||||
t.Fatal("expected coordinates to be detected as swapped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProportionalIndex(t *testing.T) {
|
||||
if got := ProportionalIndex(2, 5, 3); got != 1 {
|
||||
t.Fatalf("got %d, want 1", got)
|
||||
}
|
||||
if got := ProportionalIndex(4, 5, 3); got != 2 {
|
||||
t.Fatalf("got %d, want 2", got)
|
||||
}
|
||||
}
|
||||
83
plugins/sdk/sync.go
Normal file
83
plugins/sdk/sync.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func StringField(values map[string]any, key string) string {
|
||||
value, _ := values[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func StringOption(options map[string]any, key string) string {
|
||||
return StringField(options, key)
|
||||
}
|
||||
|
||||
func IntOption(options map[string]any, key string, fallback int) int {
|
||||
return intValue(options, key, fallback)
|
||||
}
|
||||
|
||||
func BoolOption(options map[string]any, key string, fallback bool) bool {
|
||||
return boolValue(options, key, fallback)
|
||||
}
|
||||
|
||||
func IntState(state map[string]any, key string, fallback int) int {
|
||||
return intValue(state, key, fallback)
|
||||
}
|
||||
|
||||
func intValue(values map[string]any, key string, fallback int) int {
|
||||
switch value := values[key].(type) {
|
||||
case float64:
|
||||
return int(value)
|
||||
case int:
|
||||
return value
|
||||
case json.Number:
|
||||
parsed, err := value.Int64()
|
||||
if err == nil {
|
||||
return int(parsed)
|
||||
}
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boolValue(values map[string]any, key string, fallback bool) bool {
|
||||
switch value := values[key].(type) {
|
||||
case bool:
|
||||
return value
|
||||
case string:
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func KnownIDs(ids []string) map[string]bool {
|
||||
known := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
known[id] = true
|
||||
}
|
||||
return known
|
||||
}
|
||||
|
||||
func SyncLimit(input ListInput) int {
|
||||
if input.Limits.MaxItems > 0 {
|
||||
return input.Limits.MaxItems
|
||||
}
|
||||
return 10
|
||||
}
|
||||
|
||||
func NextPageState(nextPage int, hasMore bool) map[string]any {
|
||||
if !hasMore {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"page": nextPage}
|
||||
}
|
||||
209
plugins/sdk/types.go
Normal file
209
plugins/sdk/types.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package sdk
|
||||
|
||||
const (
|
||||
HostRequestBodyTypeJSON = "json"
|
||||
HostRequestBodyTypeForm = "form"
|
||||
HostRequestBodyTypeMultipart = "multipart"
|
||||
MultipartSourceTrail = "trail"
|
||||
MultipartSourceTrailGPX = "trail.gpx"
|
||||
|
||||
AuthHeaderAuthorization = "Authorization"
|
||||
AuthSchemeBearer = "Bearer"
|
||||
)
|
||||
|
||||
type HostRequestSpec struct {
|
||||
Method string `json:"method"`
|
||||
Target RequestTarget `json:"target"`
|
||||
Auth string `json:"auth,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Body *HostRequestBody `json:"body,omitempty"`
|
||||
Expect ResponseExpect `json:"expect,omitempty"`
|
||||
FollowRedirects *bool `json:"followRedirects,omitempty"`
|
||||
}
|
||||
|
||||
type RequestTarget struct {
|
||||
Type string `json:"type"`
|
||||
Connector string `json:"connector,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Query []QueryParam `json:"query,omitempty"`
|
||||
}
|
||||
|
||||
type QueryParam struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type HostRequestBody struct {
|
||||
Type string `json:"type"`
|
||||
JSON any `json:"json,omitempty"`
|
||||
Form []FormField `json:"form,omitempty"`
|
||||
Parts []MultipartPart `json:"parts,omitempty"`
|
||||
}
|
||||
|
||||
type FormField struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type MultipartPart struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
JSON any `json:"json,omitempty"`
|
||||
}
|
||||
|
||||
type ResponseExpect struct {
|
||||
ContentTypes []string `json:"contentTypes,omitempty"`
|
||||
MaxBytes int64 `json:"maxBytes,omitempty"`
|
||||
}
|
||||
|
||||
type HostResponse struct {
|
||||
Status int `json:"status"`
|
||||
HeaderValues map[string][]string `json:"headerValues,omitempty"`
|
||||
BodyBase64 string `json:"bodyBase64,omitempty"`
|
||||
Error *PluginError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type PluginError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type LogLevel string
|
||||
|
||||
const (
|
||||
LogLevelDebug LogLevel = "debug"
|
||||
LogLevelInfo LogLevel = "info"
|
||||
LogLevelWarn LogLevel = "warn"
|
||||
LogLevelError LogLevel = "error"
|
||||
)
|
||||
|
||||
type HostLogEntry struct {
|
||||
Level LogLevel `json:"level"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type InstanceRef struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"pluginId"`
|
||||
}
|
||||
|
||||
type RefreshSessionInput struct {
|
||||
Instance InstanceRef `json:"instance"`
|
||||
Auth map[string]any `json:"auth,omitempty"`
|
||||
Config map[string]any `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
type RefreshSessionOutput struct {
|
||||
Token string `json:"token"`
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
type SyncLimits struct {
|
||||
MaxItems int `json:"maxItems,omitempty"`
|
||||
}
|
||||
|
||||
type ListInput struct {
|
||||
Instance InstanceRef `json:"instance"`
|
||||
Auth map[string]any `json:"auth,omitempty"`
|
||||
State map[string]any `json:"state,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
Limits SyncLimits `json:"limits,omitempty"`
|
||||
}
|
||||
|
||||
type ListOutput struct {
|
||||
Items []TrailSummary `json:"items"`
|
||||
State map[string]any `json:"state,omitempty"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
Error *PluginError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type DetailInput struct {
|
||||
Instance InstanceRef `json:"instance"`
|
||||
Auth map[string]any `json:"auth,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
Summary TrailSummary `json:"summary"`
|
||||
}
|
||||
|
||||
type DetailOutput struct {
|
||||
Item TrailImport `json:"item"`
|
||||
Error *PluginError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type TrailSummary struct {
|
||||
Source TrailImportSource `json:"source"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
|
||||
type TrailImport struct {
|
||||
Source TrailImportSource `json:"source"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
StartedAt string `json:"startedAt,omitempty"`
|
||||
ActivityType string `json:"activityType,omitempty"`
|
||||
Privacy *string `json:"privacy,omitempty"`
|
||||
Track Track `json:"track"`
|
||||
Waypoints []Waypoint `json:"waypoints,omitempty"`
|
||||
Photos []Photo `json:"photos,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type TrailImportSource struct {
|
||||
Provider string `json:"provider"`
|
||||
ExternalID string `json:"externalId"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
Format string `json:"format"`
|
||||
ContentBase64 string `json:"contentBase64"`
|
||||
}
|
||||
|
||||
type Waypoint struct {
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Ele *float64 `json:"ele,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Photos []Photo `json:"photos,omitempty"`
|
||||
}
|
||||
|
||||
type Photo struct {
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Lon *float64 `json:"lon,omitempty"`
|
||||
Source MediaSource `json:"source"`
|
||||
}
|
||||
|
||||
type MediaSource struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url,omitempty"`
|
||||
MediaRef *MediaRef `json:"mediaRef,omitempty"`
|
||||
}
|
||||
|
||||
type MediaRef struct {
|
||||
Connector string `json:"connector"`
|
||||
Auth string `json:"auth,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Query []QueryParam `json:"query,omitempty"`
|
||||
AssetID string `json:"assetId,omitempty"`
|
||||
}
|
||||
|
||||
type TrailSendInput struct {
|
||||
Instance InstanceRef `json:"instance"`
|
||||
Auth map[string]any `json:"auth,omitempty"`
|
||||
Config map[string]any `json:"config,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Trail Track `json:"trail"`
|
||||
}
|
||||
|
||||
type TrailSendPlan struct {
|
||||
Request HostRequestSpec `json:"request"`
|
||||
}
|
||||
Reference in New Issue
Block a user