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

15
plugins/komoot/Makefile Normal file
View File

@@ -0,0 +1,15 @@
PLUGIN_ID := komoot
DIST_DIR := dist/$(PLUGIN_ID)
.PHONY: build manifest clean
build: manifest
tinygo build -target=wasi -scheduler=none -no-debug -o $(DIST_DIR)/plugin.wasm .
manifest:
mkdir -p $(DIST_DIR)
go run github.com/open-wanderer/wanderer/plugins/sdk/cmd/manifestcheck > $(DIST_DIR)/plugin.json
cp assets/icon.svg $(DIST_DIR)/icon.svg
clean:
rm -rf dist

11
plugins/komoot/README.md Normal file
View File

@@ -0,0 +1,11 @@
# wanderer Komoot WASM Plugin
Komoot provider for the wanderer WASM plugin system.
```sh
make build
```
The build output is written to `dist/komoot`. Copy it below `data/plugins` or
run `make plugins-install-local` from the repository root to install all bundled
plugins locally.

View File

@@ -0,0 +1,197 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="295.82901mm"
height="78.07637mm"
fill="none"
version="1.1"
viewBox="0 0 1118.0939 295.09179"
id="svg882"
sodipodi:docname="komoot-logo-type.svg"
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview884"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
units="mm"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:zoom="0.98784722"
inkscape:cx="456.04218"
inkscape:cy="390.74868"
inkscape:window-width="1920"
inkscape:window-height="996"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg882"
inkscape:document-units="mm" />
<path
d="m 442.20776,107.91206 h 37.51116 l -44.92654,49.82164 49.33677,55.2986 h -36.83341 c -0.8717,0 -39.20823,-44.92654 -39.20823,-44.92654 v 44.87805 H 378.47554 V 77.282918 h 29.61197 v 72.794032 l 34.12025,-42.16382 z"
fill="url(#paint0_linear)"
style="font-variation-settings:normal;fill:url(#linearGradient966);stroke-width:10.775;-inkscape-stroke:none"
id="path855" />
<g
clip-rule="evenodd"
fill="url(#paint0_linear)"
fill-rule="evenodd"
id="g863"
style="fill:url(#linearGradient974)"
transform="matrix(10.775043,0,0,10.775043,-24.847249,-24.836474)">
<path
d="m 47.003,17.173 c 0,-2.8382 2.2984,-5.1411 5.1321,-5.1411 2.8337,0 5.1321,2.3029 5.1321,5.1411 0,2.8382 -2.2984,5.1411 -5.1321,5.1411 -2.8337,0 -5.1321,-2.2984 -5.1321,-5.1411 z m 2.6987,0.0045 c 0,1.4168 1.0885,2.5638 2.4334,2.5638 1.3449,0 2.4333,-1.147 2.4333,-2.5638 0,-1.4168 -1.0884,-2.5638 -2.4333,-2.5638 -1.3449,0 -2.4334,1.147 -2.4334,2.5638 z"
style="font-variation-settings:normal;fill:url(#linearGradient968);-inkscape-stroke:none"
id="path857" />
<path
d="m 75.965,17.173 c 0,-2.8382 2.2984,-5.1411 5.1321,-5.1411 2.8337,0 5.1321,2.3029 5.1321,5.1411 0,2.8382 -2.2984,5.1411 -5.1321,5.1411 -2.8337,0 -5.1321,-2.2984 -5.1321,-5.1411 z m 2.6987,0.0045 c 0,1.4258 1.0885,2.5818 2.4334,2.5818 1.3449,0 2.4334,-1.156 2.4334,-2.5818 0,-1.4258 -1.0885,-2.5818 -2.4334,-2.5818 -1.3449,0 -2.4334,1.156 -2.4334,2.5818 z"
style="font-variation-settings:normal;fill:url(#linearGradient970);-inkscape-stroke:none"
id="path859" />
<path
d="m 92.819,12.032 c -2.8337,0 -5.1321,2.3029 -5.1321,5.1411 0,2.8427 2.2984,5.1411 5.1321,5.1411 2.8337,0 5.1321,-2.3029 5.1321,-5.1411 0,-2.8382 -2.2984,-5.1411 -5.1321,-5.1411 z m 0,7.7274 c -1.3448,0 -2.4333,-1.156 -2.4333,-2.5818 0,-1.4258 1.0885,-2.5818 2.4333,-2.5818 1.3449,0 2.4334,1.156 2.4334,2.5818 0,1.4258 -1.0885,2.5818 -2.4334,2.5818 z"
style="font-variation-settings:normal;fill:url(#linearGradient972);-inkscape-stroke:none"
id="path861" />
</g>
<g
fill="url(#paint0_linear)"
id="g873"
style="fill:url(#linearGradient984)"
transform="matrix(10.775043,0,0,10.775043,-24.847249,-24.836474)">
<path
d="m 105.52,19.89 c -1.03,0.1755 -1.615,-0.2384 -1.858,-0.4902 -0.252,-0.2609 -0.436,-0.6837 -0.436,-0.9581 v -3.8052 h 2.564 v -2.2984 h -2.564 V 9.6843 h -2.717 v 2.6538 h -1.8575 v 2.2984 h 1.8575 v 3.8052 c 0,2.1365 1.736,3.8727 3.873,3.8727 0,0 0.994,0.036 1.691,-0.2519 z"
style="font-variation-settings:normal;fill:url(#linearGradient976);-inkscape-stroke:none"
id="path865" />
<path
d="m 61.788,17.016 c -0.009,1.2279 0,5.0421 0,5.0421 v 0.0045 h -2.7348 v -9.72 h 2.2805 l 0.3328,1.1965 c 0.4768,-0.7422 1.0795,-1.201 1.9746,-1.3629 0.9985,-0.1799 1.8621,-0.0674 2.6358,0.3374 0.4948,0.2608 0.8861,0.6702 1.1649,1.2189 0.0045,-0.009 0.0135,-0.0225 0.0225,-0.0315 0.1124,-0.1528 0.225,-0.3059 0.3464,-0.4498 0.4812,-0.5667 1.1109,-0.922 1.8711,-1.066 0.7556,-0.1394 1.4798,-0.1124 2.159,0.09 0.7781,0.2294 1.3763,0.7151 1.7722,1.4393 0.2968,0.5398 0.4677,1.156 0.5262,1.8756 0.027,0.3509 0.0405,0.6657 0.0405,0.9716 v 5.5054 h -2.7212 c 0,0 0.009,-4.246 0,-5.6269 -0.0045,-0.4228 -0.1035,-0.8006 -0.2834,-1.1244 -0.1889,-0.3329 -0.4543,-0.5353 -0.8186,-0.6252 -0.4498,-0.1125 -0.8276,-0.0945 -1.2235,0.0584 -0.5082,0.1934 -0.8501,0.5848 -1.048,1.192 -0.1034,0.3148 -0.1529,0.6702 -0.1529,1.075 v 5.0421 h -2.6583 v -5.5414 c 0,-0.3328 -0.0179,-0.7242 -0.1709,-1.084 -0.2114,-0.5083 -0.5937,-0.7736 -1.1739,-0.8096 -0.4363,-0.027 -0.8051,0.036 -1.129,0.1889 -0.5173,0.2474 -0.7601,0.6747 -0.9041,1.2819 -0.0674,0.2924 -0.1079,0.6027 -0.1079,0.9221 z"
style="font-variation-settings:normal;fill:url(#linearGradient978);-inkscape-stroke:none"
id="path867" />
<path
d="m 2.3064,15.998 c 0,-7.5486 6.1446,-13.693 13.693,-13.693 7.5532,0 13.693,6.1446 13.693,13.693 0,3.0384 -0.9752,5.9053 -2.8172,8.3116 l -6.8669,-6.8669 c 0.1715,-0.4695 0.2573,-0.9571 0.2573,-1.4492 0,-2.3522 -1.9142,-4.2664 -4.2664,-4.2664 -2.3522,0 -4.2664,1.9142 -4.2664,4.2664 0,0.4921 0.0858,0.9797 0.2573,1.4492 L 5.1232,24.3096 C 3.2812,21.9078 2.306,19.0364 2.306,15.998 Z"
id="path869"
style="fill:url(#linearGradient980)" />
<path
d="m 13.489,19.231 2.5102,-3.9143 2.5102,3.9097 6.8037,6.8038 c -2.5418,2.3612 -5.8421,3.6614 -9.3139,3.6614 -3.4718,0 -6.7721,-1.3002 -9.3139,-3.6614 z"
id="path871"
style="fill:url(#linearGradient982)" />
</g>
<defs
id="defs880">
<linearGradient
id="paint0_linear"
x1="16"
x2="16"
y1="2.3069999"
y2="29.691999"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#8FCE3C"
offset="0"
id="stop875" />
<stop
stop-color="#64A322"
offset="1"
id="stop877" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#paint0_linear"
id="linearGradient966"
gradientUnits="userSpaceOnUse"
x1="16"
y1="2.3069999"
x2="16"
y2="29.691999"
gradientTransform="matrix(10.775043,0,0,10.775043,-24.847249,-24.836474)" />
<linearGradient
inkscape:collect="always"
xlink:href="#paint0_linear"
id="linearGradient968"
gradientUnits="userSpaceOnUse"
x1="16"
y1="2.3069999"
x2="16"
y2="29.691999" />
<linearGradient
inkscape:collect="always"
xlink:href="#paint0_linear"
id="linearGradient970"
gradientUnits="userSpaceOnUse"
x1="16"
y1="2.3069999"
x2="16"
y2="29.691999" />
<linearGradient
inkscape:collect="always"
xlink:href="#paint0_linear"
id="linearGradient972"
gradientUnits="userSpaceOnUse"
x1="16"
y1="2.3069999"
x2="16"
y2="29.691999" />
<linearGradient
inkscape:collect="always"
xlink:href="#paint0_linear"
id="linearGradient974"
gradientUnits="userSpaceOnUse"
x1="16"
y1="2.3069999"
x2="16"
y2="29.691999" />
<linearGradient
inkscape:collect="always"
xlink:href="#paint0_linear"
id="linearGradient976"
gradientUnits="userSpaceOnUse"
x1="16"
y1="2.3069999"
x2="16"
y2="29.691999" />
<linearGradient
inkscape:collect="always"
xlink:href="#paint0_linear"
id="linearGradient978"
gradientUnits="userSpaceOnUse"
x1="16"
y1="2.3069999"
x2="16"
y2="29.691999" />
<linearGradient
inkscape:collect="always"
xlink:href="#paint0_linear"
id="linearGradient980"
gradientUnits="userSpaceOnUse"
x1="16"
y1="2.3069999"
x2="16"
y2="29.691999" />
<linearGradient
inkscape:collect="always"
xlink:href="#paint0_linear"
id="linearGradient982"
gradientUnits="userSpaceOnUse"
x1="16"
y1="2.3069999"
x2="16"
y2="29.691999" />
<linearGradient
inkscape:collect="always"
xlink:href="#paint0_linear"
id="linearGradient984"
gradientUnits="userSpaceOnUse"
x1="16"
y1="2.3069999"
x2="16"
y2="29.691999" />
</defs>
</svg>

After

Width:  |  Height:  |  Size: 8.4 KiB

24
plugins/komoot/auth.go Normal file
View File

@@ -0,0 +1,24 @@
package main
import (
"encoding/base64"
"github.com/open-wanderer/wanderer/plugins/sdk"
)
func (c *komootClient) requestHeaders(connector string) map[string]string {
headers := map[string]string{
"Accept": "application/hal+json",
}
if connector == "api" {
headers[sdk.AuthHeaderAuthorization] = basicAuth(c.userID, c.token)
}
if language := acceptLanguage(c.locale); language != "" {
headers["Accept-Language"] = language
}
return headers
}
func basicAuth(username string, password string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
}

9
plugins/komoot/go.mod Normal file
View File

@@ -0,0 +1,9 @@
module github.com/open-wanderer/wanderer/plugins/komoot
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/komoot/go.sum Normal file
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=

301
plugins/komoot/komoot.go Normal file
View File

@@ -0,0 +1,301 @@
//go:build tinygo
package main
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"github.com/open-wanderer/wanderer/plugins/sdk"
)
const komootJSONMaxBytes int64 = 16 * 1024 * 1024
const komootMaxHighlightTipRequests = 20
var komootJSONContentTypes = []string{"application/json", "application/hal+json"}
var errTourKindMismatch = errors.New("tour kind mismatch")
func login(email string, password string) (*komootClient, error) {
response, body, err := sdk.HostRequest(sdk.HostRequestSpec{
Method: "GET",
Target: sdk.RequestTarget{
Type: "connector",
Connector: "api",
Path: "/v006/account/email/" + url.PathEscape(email) + "/",
},
Headers: map[string]string{
sdk.AuthHeaderAuthorization: basicAuth(email, password),
"Accept": "application/hal+json",
},
Expect: sdk.ResponseExpect{
ContentTypes: komootJSONContentTypes,
MaxBytes: komootJSONMaxBytes,
},
})
if err != nil {
return nil, err
}
if response.Status != 200 {
return nil, fmt.Errorf("komoot login failed (%d): %s", response.Status, string(body))
}
var parsed loginResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, err
}
if parsed.Username == "" || parsed.Password == "" {
return nil, fmt.Errorf("komoot login response did not contain credentials")
}
client := &komootClient{userID: parsed.Username, token: parsed.Password, locale: parsed.Locale}
if client.locale == "" {
client.locale = client.profileLocale()
}
return client, nil
}
func loginClient(auth map[string]any) (*komootClient, error) {
email := sdk.StringField(auth, "email")
password := sdk.StringField(auth, "password")
if email == "" || password == "" {
return nil, fmt.Errorf("email and password are required")
}
return login(email, password)
}
func (c *komootClient) get(path string, query []sdk.QueryParam, out any) error {
body, err := c.getRawFromConnector("api", path, query)
if err != nil {
return err
}
return json.Unmarshal(body, out)
}
func (c *komootClient) getFromConnector(connector string, path string, query []sdk.QueryParam, out any) error {
body, err := c.getRawFromConnector(connector, path, query)
if err != nil {
return err
}
return json.Unmarshal(body, out)
}
func (c *komootClient) getRawFromConnector(connector string, path string, query []sdk.QueryParam) ([]byte, error) {
headers := c.requestHeaders(connector)
response, body, err := sdk.HostRequest(sdk.HostRequestSpec{
Method: "GET",
Target: sdk.RequestTarget{
Type: "connector",
Connector: connector,
Path: path,
Query: query,
},
Headers: headers,
Expect: sdk.ResponseExpect{
ContentTypes: komootJSONContentTypes,
MaxBytes: komootJSONMaxBytes,
},
})
if err != nil {
return nil, err
}
if response.Status != 200 {
return body, fmt.Errorf("komoot request failed (%d): %s", response.Status, string(body))
}
return body, nil
}
func (c *komootClient) profileLocale() string {
var data userProfile
if err := c.get("/v007/users/"+url.PathEscape(c.userID), nil, &data); err != nil {
return ""
}
return data.Locale
}
func (c *komootClient) tours(page int, limit int) ([]tour, int, error) {
var data toursResponse
err := c.get("/v007/users/"+url.PathEscape(c.userID)+"/tours/", []sdk.QueryParam{
{Name: "page", Value: strconv.Itoa(page)},
{Name: "sort_field", Value: "date"},
{Name: "sort_direction", Value: "desc"},
{Name: "limit", Value: strconv.Itoa(limit)},
}, &data)
return data.Embedded.Tours, data.Page.TotalPages, err
}
func (c *komootClient) detailedTour(id int64) (*detailedTour, error) {
var data detailedTour
err := c.get(fmt.Sprintf("/v007/tours/%d", id), []sdk.QueryParam{
{Name: "_embedded", Value: "coordinates,way_types,surfaces,directions,participants,timeline,cover_images"},
{Name: "directions", Value: "v2"},
{Name: "fields", Value: "timeline"},
{Name: "format", Value: "coordinate_array"},
{Name: "timeline_highlights_fields", Value: "tips,recommenders"},
{Name: "page", Value: "2"},
}, &data)
if err != nil {
return &data, err
}
if len(data.Embedded.WayPoints.Embedded.Items) == 0 && len(data.Embedded.Timeline.Embedded.Items) == 0 {
if timeline, err := c.webTimeline(id); err == nil {
data.Embedded.WayPoints = timeline
}
}
return &data, nil
}
func (c *komootClient) webTimeline(id int64) (timeline, error) {
var data timeline
token := c.shareToken(id)
var query []sdk.QueryParam
if token != "" {
query = []sdk.QueryParam{{Name: "share_token", Value: token}}
}
err := c.getFromConnector("web", fmt.Sprintf("/webapi/v007/tours/%d/timeline/", id), query, &data)
if err != nil {
return data, err
}
c.addHighlightTips(data.Embedded.Items)
return data, nil
}
func (c *komootClient) shareToken(id int64) string {
token, err := c.shareTokenWithQuery(id, nil)
if err == nil && token != "" {
return token
}
token, _ = c.shareTokenWithQuery(id, []sdk.QueryParam{{Name: "token_name", Value: "invite"}})
return token
}
func (c *komootClient) shareTokenWithQuery(id int64, query []sdk.QueryParam) (string, error) {
body, err := c.getRawFromConnector("api", fmt.Sprintf("/v007/tours/%d/share_token", id), query)
if err != nil {
return "", err
}
var value any
if err := json.Unmarshal(body, &value); err != nil {
return "", err
}
if token, ok := value.(string); ok {
return token, nil
}
return findShareToken(value), nil
}
func findShareToken(value any) string {
switch typed := value.(type) {
case map[string]any:
for _, key := range []string{"token", "share_token", "shareToken"} {
if token, ok := typed[key].(string); ok {
return token
}
}
for _, nested := range typed {
if token := findShareToken(nested); token != "" {
return token
}
}
case []any:
for _, nested := range typed {
if token := findShareToken(nested); token != "" {
return token
}
}
}
return ""
}
func (c *komootClient) addHighlightTips(items []timelineItem) {
requests := 0
for i := range items {
if items[i].Type != "highlight" {
continue
}
ref := &items[i].Embedded.Reference
if ref.ID.String() == "" || len(ref.Embedded.Tips.Embedded.Items) > 0 {
continue
}
if requests >= komootMaxHighlightTipRequests {
return
}
requests++
var data tips
if err := c.get(fmt.Sprintf("/v007/highlights/%s/tips/", url.PathEscape(ref.ID.String())), nil, &data); err == nil {
ref.Embedded.Tips = data
}
}
}
func (c *komootClient) coverImages(id int64) ([]imageItem, error) {
var data coverImages
err := c.get(fmt.Sprintf("/v007/tours/%d/cover_images/", id), nil, &data)
return data.Embedded.Items, err
}
func syncTours(client *komootClient, input listInput, wantKind string) (listOutput, error) {
page := sdk.IntState(input.State, "page", 0)
maxItems := sdk.SyncLimit(input)
rows, totalPages, err := client.tours(page, maxItems)
if err != nil {
return listOutput{}, err
}
items := make([]trailSummary, 0, maxItems)
for _, row := range rows {
if !tourDateAfter(row.Date, sdk.StringOption(input.Options, "after")) {
continue
}
if wantKind == "planned" && row.Type != "tour_planned" {
continue
}
if wantKind == "completed" && row.Type != "tour_recorded" {
continue
}
items = append(items, trailSummary{
Source: trailImportSource{Provider: "komoot", ExternalID: strconv.FormatInt(row.ID, 10)},
Kind: kindFromType(row.Type),
})
if len(items) >= maxItems {
break
}
}
nextPage := page + 1
hasMore := nextPage < totalPages
return listOutput{
Items: items,
State: sdk.NextPageState(nextPage, hasMore),
HasMore: hasMore,
}, nil
}
func tourDetail(client *komootClient, externalID string, wantKind string) (trailImport, error) {
id, err := strconv.ParseInt(externalID, 10, 64)
if err != nil {
return trailImport{}, fmt.Errorf("invalid tour external id")
}
detail, err := client.detailedTour(id)
if err != nil {
return trailImport{}, fmt.Errorf("fetch tour %d details: %w", id, err)
}
if wantKind == "planned" && detail.Type != "tour_planned" {
return trailImport{}, fmt.Errorf("%w: tour %d is not planned", errTourKindMismatch, id)
}
if wantKind == "completed" && detail.Type != "tour_recorded" {
return trailImport{}, fmt.Errorf("%w: tour %d is not completed", errTourKindMismatch, id)
}
var routeImages []imageItem
if len(detail.Embedded.CoverImages.Embedded.Items) > 0 {
routeImages, _ = client.coverImages(detail.ID)
}
item, err := tourImport(detail, routeImages)
if err != nil {
return trailImport{}, fmt.Errorf("map tour %d: %w", id, err)
}
return item, nil
}

View File

@@ -0,0 +1,53 @@
package main
import (
"testing"
"github.com/open-wanderer/wanderer/plugins/sdk"
)
func TestAcceptLanguageFromLocale(t *testing.T) {
tests := []struct {
name string
locale string
want string
}{
{name: "empty", locale: "", want: ""},
{name: "language only", locale: "de", want: "de"},
{name: "underscore region", locale: "de_CH", want: "de-CH,de;q=0.9"},
{name: "hyphen region", locale: "en-US", want: "en-US,en;q=0.9"},
{name: "trim space", locale: " fr_FR ", want: "fr-FR,fr;q=0.9"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := acceptLanguage(test.locale); got != test.want {
t.Fatalf("acceptLanguage(%q) = %q, want %q", test.locale, got, test.want)
}
})
}
}
func TestRequestHeadersOnlySendAuthToAPIConnector(t *testing.T) {
client := &komootClient{
userID: "user",
token: "token",
locale: "de_CH",
}
apiHeaders := client.requestHeaders("api")
if apiHeaders[sdk.AuthHeaderAuthorization] == "" {
t.Fatalf("expected api connector authorization header")
}
if apiHeaders["Accept-Language"] != "de-CH,de;q=0.9" {
t.Fatalf("unexpected api accept language: %#v", apiHeaders)
}
webHeaders := client.requestHeaders("web")
if webHeaders[sdk.AuthHeaderAuthorization] != "" {
t.Fatalf("expected no web connector authorization header, got %#v", webHeaders)
}
if webHeaders["Accept-Language"] != "de-CH,de;q=0.9" {
t.Fatalf("unexpected web accept language: %#v", webHeaders)
}
}

19
plugins/komoot/locale.go Normal file
View File

@@ -0,0 +1,19 @@
package main
import "strings"
func acceptLanguage(locale string) string {
locale = strings.TrimSpace(locale)
if locale == "" {
return ""
}
primary := locale
if index := strings.IndexAny(primary, "_-"); index >= 0 {
primary = primary[:index]
}
locale = strings.ReplaceAll(locale, "_", "-")
if primary == "" || primary == locale {
return locale
}
return locale + "," + primary + ";q=0.9"
}

104
plugins/komoot/main.go Normal file
View File

@@ -0,0 +1,104 @@
//go:build tinygo
package main
import (
"encoding/json"
"errors"
"github.com/extism/go-pdk"
)
func main() {}
//export list_routes_v1
func listRoutesV1() int32 {
return listTours("planned")
}
//export list_activities_v1
func listActivitiesV1() int32 {
return listTours("completed")
}
//export get_route_detail_v1
func getRouteDetailV1() int32 {
return getTourDetail("planned")
}
//export get_activity_detail_v1
func getActivityDetailV1() int32 {
return getTourDetail("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())
}
client, err := loginClient(input.Auth)
if err != nil {
return fail("auth_failed", err.Error())
}
if err := pdk.OutputJSON(refreshSessionOutput{
Token: client.token,
Scheme: "Basic",
}); err != nil {
return fail("internal_error", err.Error())
}
return 0
}
func getTourDetail(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())
}
item, err := tourDetail(client, input.Summary.Source.ExternalID, kind)
if err != nil {
if errors.Is(err, errTourKindMismatch) {
return fail("not_importable", err.Error())
}
return fail("provider_unavailable", err.Error())
}
if err := pdk.OutputJSON(detailOutput{Item: item}); err != nil {
return fail("internal_error", err.Error())
}
return 0
}
func listTours(kind string) int32 {
var input listInput
if err := pdk.InputJSON(&input); err != nil {
return fail("invalid_request", "invalid list input: "+err.Error())
}
client, err := loginClient(input.Auth)
if err != nil {
return fail("auth_failed", err.Error())
}
output, err := syncTours(client, input, kind)
if err != nil {
return fail("provider_unavailable", err.Error())
}
if err := pdk.OutputJSON(output); 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
}

View File

@@ -0,0 +1,5 @@
//go:build !tinygo
package main
func main() {}

226
plugins/komoot/mapper.go Normal file
View File

@@ -0,0 +1,226 @@
package main
import (
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx"
)
func tourImport(tour *detailedTour, routeImages []imageItem) (trailImport, error) {
gpxData, err := tourGPX(tour)
if err != nil {
return trailImport{}, err
}
privacy := privacyFromStatus(tour.Status)
return trailImport{
Source: trailImportSource{
Provider: "komoot",
ExternalID: strconv.FormatInt(tour.ID, 10),
},
Kind: kindFromType(tour.Type),
Name: tour.Name,
Description: tour.Description,
StartedAt: tour.Date,
ActivityType: activityType(tour.Sport),
Privacy: &privacy,
Track: track{
Format: "gpx",
ContentBase64: base64.StdEncoding.EncodeToString(gpxData),
},
Waypoints: waypoints(tour),
Photos: photos(tour, routeImages),
Metadata: map[string]any{
"distance": tour.Distance,
"elevationGain": tour.ElevationUp,
"elevationLoss": tour.ElevationDown,
"duration": tour.Duration,
"providerCategory": tour.Sport,
"sourceSport": tour.Sport,
"difficulty": tour.Difficulty.Grade,
},
}, nil
}
func tourGPX(tour *detailedTour) ([]byte, error) {
items := tour.Embedded.Coordinates.Items
points := make([]sdkgpx.Point, 0, len(items))
startedAt, _ := time.Parse(time.RFC3339, tour.Date)
for _, item := range items {
elevation := item.Alt
point := sdkgpx.Point{
Lat: item.Lat,
Lon: item.Lng,
Elevation: &elevation,
}
if !startedAt.IsZero() {
pointTime := startedAt.Add(time.Duration(item.T) * time.Millisecond).UTC()
point.Time = &pointTime
}
points = append(points, point)
}
return sdkgpx.Track("wanderer Komoot plugin", tour.Name, points)
}
func waypoints(tour *detailedTour) []waypoint {
result := make([]waypoint, 0, len(tour.Embedded.WayPoints.Embedded.Items)+len(tour.Embedded.Timeline.Embedded.Items))
seen := map[string]bool{}
result = appendWaypoints(result, seen, tour.Embedded.WayPoints.Embedded.Items)
result = appendWaypoints(result, seen, tour.Embedded.Timeline.Embedded.Items)
return result
}
func appendWaypoints(result []waypoint, seen map[string]bool, items []timelineItem) []waypoint {
for _, item := range items {
ref := item.Embedded.Reference
point, ok := waypointPoint(ref)
if ref.Name == "" || !ok {
continue
}
key := ref.ID.String()
if key == "" {
key = fmt.Sprintf("%s:%0.7f:%0.7f", ref.Name, point.Lat, point.Lng)
}
if seen[key] {
continue
}
seen[key] = true
description := ""
if len(ref.Embedded.Tips.Embedded.Items) > 0 {
description = ref.Embedded.Tips.Embedded.Items[0].Text
}
ele := point.Alt
result = append(result, waypoint{
ExternalID: ref.ID.String(),
Name: ref.Name,
Description: description,
Lat: point.Lat,
Lon: point.Lng,
Ele: &ele,
Icon: "circle",
Photos: waypointPhotos(item),
})
}
return result
}
func waypointPoint(ref waypointReference) (point, bool) {
if ref.StartPoint.Lat != 0 || ref.StartPoint.Lng != 0 {
return ref.StartPoint, true
}
if ref.Location.Lat != 0 || ref.Location.Lng != 0 {
return ref.Location, true
}
return point{}, false
}
func photos(tour *detailedTour, routeImages []imageItem) []photo {
images := routeImages
if len(images) == 0 {
images = tour.Embedded.CoverImages.Embedded.Items
}
if len(images) == 0 && tour.MapImage.Src != "" {
images = []imageItem{{Src: tour.MapImage.Src, Type: "image/jpeg"}}
}
return photosFromImages(images, "komoot-photo.jpg")
}
func waypointPhotos(item timelineItem) []photo {
ref := item.Embedded.Reference
images := ref.Embedded.Images.Embedded.Items
if ref.Embedded.FrontImage.Src != "" {
images = append([]imageItem{ref.Embedded.FrontImage}, images...)
}
return photosFromImages(images, "komoot-waypoint-photo.jpg")
}
func photosFromImages(images []imageItem, fallbackFilename string) []photo {
result := make([]photo, 0, len(images))
seen := map[string]bool{}
for _, image := range images {
source := expandImageURL(image.Src)
if source == "" || strings.HasSuffix(strings.ToLower(source), ".gif") {
continue
}
key := image.ID.String()
if key == "" {
key = source
}
if seen[key] {
continue
}
seen[key] = true
result = append(result, photo{
ExternalID: image.ID.String(),
Filename: filenameForImage(image.ID, fallbackFilename),
ContentType: contentType(image.Type),
Lat: optionalCoordinate(image.Location.Lat),
Lon: optionalCoordinate(image.Location.Lng),
Source: mediaSource{
Type: "url",
URL: source,
},
})
}
return result
}
func expandImageURL(source string) string {
source = strings.ReplaceAll(source, "{crop}", "false")
source = strings.ReplaceAll(source, "{width}", "")
source = strings.ReplaceAll(source, "{height}", "")
return source
}
func filenameForImage(id flexibleID, fallback string) string {
if id.String() == "" {
return fallback
}
return fmt.Sprintf("komoot-%s.jpg", id.String())
}
func contentType(value string) string {
if strings.HasPrefix(value, "image/") {
return value
}
return "image/jpeg"
}
func optionalCoordinate(value float64) *float64 {
if value == 0 {
return nil
}
return &value
}
func kindFromType(value string) string {
if value == "tour_recorded" {
return "completed"
}
return "planned"
}
func privacyFromStatus(value string) string {
if value == "public" {
return "public"
}
return "private"
}
func activityType(sport string) string {
switch sport {
case "hike", "mountaineering":
return "hiking"
case "jogging":
return "running"
case "touringbicycle", "mtb", "racebike", "mtb_easy", "mtb_advanced":
return "biking"
default:
return sport
}
}

View File

@@ -0,0 +1,134 @@
package main
import "testing"
func TestWaypointsFromEmbeddedWayPoints(t *testing.T) {
tour := &detailedTour{
Embedded: detailedTourEmbedded{
WayPoints: timeline{
Embedded: timelineEmbedded{
Items: []timelineItem{{
Embedded: timelineItemEmbedded{
Reference: waypointReference{
ID: flexibleID("2355158"),
Name: "Ruedertaler Hofglace Rastplatz",
Location: point{
Lat: 47.280262,
Lng: 8.046906,
Alt: 476.7,
},
StartPoint: point{
Lat: 47.280262,
Lng: 8.046906,
Alt: 476.7,
},
},
},
}},
},
},
},
}
points := waypoints(tour)
if len(points) != 1 {
t.Fatalf("expected 1 waypoint, got %d", len(points))
}
if points[0].ExternalID != "2355158" || points[0].Name != "Ruedertaler Hofglace Rastplatz" {
t.Fatalf("unexpected waypoint identity: %#v", points[0])
}
if points[0].Lat != 47.280262 || points[0].Lon != 8.046906 || points[0].Ele == nil || *points[0].Ele != 476.7 {
t.Fatalf("unexpected waypoint coordinates: %#v", points[0])
}
}
func TestWaypointsDeduplicateWayPointsAndTimeline(t *testing.T) {
item := timelineItem{
Embedded: timelineItemEmbedded{
Reference: waypointReference{
ID: flexibleID("8277503"),
Name: "Aarebruecke bei Aarburg",
StartPoint: point{Lat: 47.320204, Lng: 7.897589},
},
},
}
tour := &detailedTour{
Embedded: detailedTourEmbedded{
WayPoints: timeline{Embedded: timelineEmbedded{Items: []timelineItem{item}}},
Timeline: timeline{Embedded: timelineEmbedded{Items: []timelineItem{item}}},
},
}
points := waypoints(tour)
if len(points) != 1 {
t.Fatalf("expected duplicate waypoint to be collapsed, got %d", len(points))
}
}
func TestWaypointsIncludeFrontImage(t *testing.T) {
tour := &detailedTour{
Embedded: detailedTourEmbedded{
WayPoints: timeline{
Embedded: timelineEmbedded{
Items: []timelineItem{{
Embedded: timelineItemEmbedded{
Reference: waypointReference{
ID: flexibleID("4266004"),
Name: "Blick auf die Solothurner Altstadt und die St.-Ursen-Kathedrale",
StartPoint: point{Lat: 47.205925, Lng: 7.535326, Alt: 424.6},
Embedded: waypointSubEmbedded{
FrontImage: imageItem{
ID: flexibleID("48446190"),
Src: "https://example.test/image.jpg",
Type: "image/*",
},
},
},
},
}},
},
},
},
}
points := waypoints(tour)
if len(points) != 1 {
t.Fatalf("expected 1 waypoint, got %d", len(points))
}
if len(points[0].Photos) != 1 {
t.Fatalf("expected 1 waypoint photo, got %d", len(points[0].Photos))
}
if points[0].Photos[0].ExternalID != "48446190" || points[0].Photos[0].Source.URL != "https://example.test/image.jpg" {
t.Fatalf("unexpected waypoint photo: %#v", points[0].Photos[0])
}
}
func TestWaypointPhotosDeduplicateFrontImage(t *testing.T) {
item := timelineItem{
Embedded: timelineItemEmbedded{
Reference: waypointReference{
Embedded: waypointSubEmbedded{
FrontImage: imageItem{
ID: flexibleID("48446190"),
Src: "https://example.test/front.jpg",
Type: "image/*",
},
Images: coverImages{
Embedded: imagesEmbedded{
Items: []imageItem{{
ID: flexibleID("48446190"),
Src: "https://example.test/front.jpg",
Type: "image/*",
}},
},
},
},
},
},
}
photos := waypointPhotos(item)
if len(photos) != 1 {
t.Fatalf("expected duplicate front image to be collapsed, got %d", len(photos))
}
}

27
plugins/komoot/options.go Normal file
View File

@@ -0,0 +1,27 @@
package main
import (
"time"
)
func tourDateAfter(tourDate string, after string) bool {
if after == "" {
return true
}
limit, err := time.Parse("2006-01-02", after)
if err != nil {
return true
}
date, err := parseKomootDate(tourDate)
if err != nil {
return true
}
return !date.Before(limit)
}
func parseKomootDate(value string) (time.Time, error) {
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed, nil
}
return time.Parse("2006-01-02", value)
}

310
plugins/komoot/plugin.json Normal file
View File

@@ -0,0 +1,310 @@
{
"manifestVersion": "1.0",
"id": "komoot",
"type": "trails",
"name": "komoot",
"description": "Imports planned and completed komoot tours, including photos and waypoints, into wanderer.",
"version": "0.1.0",
"runtime": {
"type": "wasm",
"entrypoint": "plugin.wasm"
},
"capabilities": [
{
"name": "list_routes",
"version": "v1",
"export": "list_routes_v1"
},
{
"name": "get_route_detail",
"version": "v1",
"export": "get_route_detail_v1"
},
{
"name": "list_activities",
"version": "v1",
"export": "list_activities_v1"
},
{
"name": "get_activity_detail",
"version": "v1",
"export": "get_activity_detail_v1"
}
],
"auth": {
"contexts": {
"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://api.komoot.de",
"allowedPathPrefixes": [
"/v006",
"/v007"
]
},
{
"name": "web",
"type": "public_api",
"fixedBaseURL": "https://www.komoot.com",
"allowedPathPrefixes": [
"/webapi/v007"
]
}
]
},
"auth": [
"provider_session"
],
"downloads": {
"maxBytes": 16777216,
"contentTypes": [
"application/json",
"application/hal+json"
]
}
},
"configSchema": [
{
"key": "after",
"type": "date",
"label": "Start date",
"labels": {
"de": "Startdatum",
"en": "Start date"
},
"description": "Ignore tours before this date.",
"descriptions": {
"de": "Touren vor diesem Datum ignorieren.",
"en": "Ignore tours before this date."
}
}
],
"hostConfig": {
"categoryMapping": {
"hike": "Hiking",
"mountaineering": "Hiking",
"racebike": "Biking",
"e_racebike": "Biking",
"touringbicycle": "Biking",
"e_touringbicycle": "Biking",
"mtb": "Biking",
"e_mtb": "Biking",
"mtb_easy": "Biking",
"e_mtb_easy": "Biking",
"mtb_advanced": "Biking",
"e_mtb_advanced": "Biking",
"downhillbike": "Biking",
"unicycle": "Biking",
"citybike": "Biking",
"jogging": "Walking",
"nordicwalking": "Walking",
"skaten": "Walking",
"other": "Walking",
"climbing": "Climbing",
"nordic": "Skiing",
"skialpin": "Skiing",
"skitour": "Skiing",
"sled": "Skiing",
"snowboard": "Skiing",
"snowshoe": "Skiing"
}
},
"metadata": {
"descriptions": {
"cs": "Synchronizuje vaše trasy z aplikace Komoot s Wandererem v pravidelných intervalech.",
"de": "Importiert geplante und abgeschlossene komoot-Touren inklusive Fotos und Wegpunkten in wanderer.",
"en": "Imports planned and completed komoot tours, including photos and waypoints, into wanderer.",
"es": "Sincroniza tus recorridos de Komoot con Wanderer en intervalos regulares.",
"eu": "Zure komooteko ibilbideak wandererekin sinkronizatzen ditu aldian behin.",
"fr": "Synchronisez vos Tours Komoot avec wanderer à intervalles réguliers.",
"it": "Syncs your komoot tours with wanderer in regular intervals.",
"hu": "Syncs your komoot tours with wanderer in regular intervals.",
"nl": "Synchroniseert je Komoot-tochten met Wanderer op regelmatige tijdstippen.",
"no": "Synkroniserer dine Komoot-turer med Wanderer med jevne mellomrom.",
"pl": "Synchronizuje trasy kamoot z wanderer w równych odstępach.",
"pt": "Syncs your komoot tours with wanderer in regular intervals.",
"ru": "Синхронизирует ваши данные с Komoot.",
"zh": "定期与komoot同步您的wanderer。"
},
"icons": {
"light": "icon.svg"
},
"providerCategories": {
"hike": {
"labels": {
"de": "Wandern",
"en": "Hiking"
}
},
"mountaineering": {
"labels": {
"de": "Bergsteigen",
"en": "Mountaineering"
}
},
"racebike": {
"labels": {
"de": "Rennrad",
"en": "Road bike"
}
},
"e_racebike": {
"labels": {
"de": "E-Rennrad",
"en": "E-road bike"
}
},
"touringbicycle": {
"labels": {
"de": "Tourenrad",
"en": "Touring bike"
}
},
"e_touringbicycle": {
"labels": {
"de": "E-Tourenrad",
"en": "E-touring bike"
}
},
"mtb": {
"labels": {
"de": "Mountainbike",
"en": "Mountain bike"
}
},
"e_mtb": {
"labels": {
"de": "E-Mountainbike",
"en": "E-mountain bike"
}
},
"mtb_easy": {
"labels": {
"de": "Mountainbike einfach",
"en": "Easy mountain bike"
}
},
"e_mtb_easy": {
"labels": {
"de": "E-Mountainbike einfach",
"en": "Easy e-mountain bike"
}
},
"mtb_advanced": {
"labels": {
"de": "Mountainbike anspruchsvoll",
"en": "Advanced mountain bike"
}
},
"e_mtb_advanced": {
"labels": {
"de": "E-Mountainbike anspruchsvoll",
"en": "Advanced e-mountain bike"
}
},
"downhillbike": {
"labels": {
"de": "Downhill-Bike",
"en": "Downhill bike"
}
},
"unicycle": {
"labels": {
"de": "Einrad",
"en": "Unicycle"
}
},
"citybike": {
"labels": {
"de": "Citybike",
"en": "City bike"
}
},
"jogging": {
"labels": {
"de": "Joggen",
"en": "Jogging"
}
},
"nordicwalking": {
"labels": {
"de": "Nordic Walking",
"en": "Nordic walking"
}
},
"skaten": {
"labels": {
"de": "Skaten",
"en": "Skating"
}
},
"other": {
"labels": {
"de": "Sonstiges",
"en": "Other"
}
},
"climbing": {
"labels": {
"de": "Klettern",
"en": "Climbing"
}
},
"nordic": {
"labels": {
"de": "Langlauf",
"en": "Cross-country skiing"
}
},
"skialpin": {
"labels": {
"de": "Ski alpin",
"en": "Alpine skiing"
}
},
"skitour": {
"labels": {
"de": "Skitour",
"en": "Ski touring"
}
},
"sled": {
"labels": {
"de": "Schlitten",
"en": "Sledding"
}
},
"snowboard": {
"labels": {
"de": "Snowboard",
"en": "Snowboard"
}
},
"snowshoe": {
"labels": {
"de": "Schneeschuhwandern",
"en": "Snowshoeing"
}
}
}
}
}

197
plugins/komoot/types.go Normal file
View File

@@ -0,0 +1,197 @@
package main
import (
"encoding/json"
"github.com/open-wanderer/wanderer/plugins/sdk"
)
type instanceRef = sdk.InstanceRef
type refreshSessionInput = sdk.RefreshSessionInput
type refreshSessionOutput = sdk.RefreshSessionOutput
type listInput = sdk.ListInput
type listOutput = sdk.ListOutput
type detailInput = sdk.DetailInput
type detailOutput = sdk.DetailOutput
type trailSummary = sdk.TrailSummary
type trailImport = sdk.TrailImport
type trailImportSource = sdk.TrailImportSource
type track = sdk.Track
type waypoint = sdk.Waypoint
type photo = sdk.Photo
type mediaSource = sdk.MediaSource
type pluginError = sdk.PluginError
type komootClient struct {
userID string
token string
locale string
}
type flexibleID string
func (id *flexibleID) UnmarshalJSON(data []byte) error {
var stringValue string
if err := json.Unmarshal(data, &stringValue); err == nil {
*id = flexibleID(stringValue)
return nil
}
var numberValue json.Number
if err := json.Unmarshal(data, &numberValue); err != nil {
return err
}
*id = flexibleID(numberValue.String())
return nil
}
func (id flexibleID) String() string {
return string(id)
}
type loginResponse struct {
Password string `json:"password"`
Username string `json:"username"`
Locale string `json:"locale"`
}
type userProfile struct {
Locale string `json:"locale"`
}
type toursResponse struct {
Embedded toursEmbedded `json:"_embedded"`
Page page `json:"page"`
}
type toursEmbedded struct {
Tours []tour `json:"tours"`
}
type page struct {
TotalPages int `json:"totalPages"`
}
type tour struct {
ID int64 `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
Date string `json:"date"`
Sport string `json:"sport"`
ChangedAt string `json:"changed_at"`
}
type detailedTour struct {
ID int64 `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
Status string `json:"status"`
Date string `json:"date"`
Sport string `json:"sport"`
Distance float64 `json:"distance"`
Duration int `json:"duration"`
ElevationUp float64 `json:"elevation_up"`
ElevationDown float64 `json:"elevation_down"`
MapImage mapImage `json:"map_image"`
Difficulty difficulty `json:"difficulty"`
ChangedAt string `json:"changed_at"`
Embedded detailedTourEmbedded `json:"_embedded"`
}
type difficulty struct {
Grade string `json:"grade"`
}
type mapImage struct {
Src string `json:"src"`
}
type detailedTourEmbedded struct {
Coordinates coordinates `json:"coordinates"`
Timeline timeline `json:"timeline"`
WayPoints timeline `json:"way_points"`
CoverImages coverImages `json:"cover_images"`
}
type coordinates struct {
Items []coordinate `json:"items"`
}
type coordinate struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Alt float64 `json:"alt"`
T int `json:"t"`
}
type timeline struct {
Embedded timelineEmbedded `json:"_embedded"`
}
type timelineEmbedded struct {
Items []timelineItem `json:"items"`
}
type timelineItem struct {
Type string `json:"type"`
Embedded timelineItemEmbedded `json:"_embedded"`
}
type timelineItemEmbedded struct {
Reference waypointReference `json:"reference"`
}
type waypointReference struct {
ID flexibleID `json:"id"`
Name string `json:"name"`
StartPoint point `json:"start_point"`
Location point `json:"location"`
Embedded waypointSubEmbedded `json:"_embedded"`
}
type point struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Alt float64 `json:"alt"`
}
type waypointSubEmbedded struct {
Tips tips `json:"tips"`
Images coverImages `json:"images"`
FrontImage imageItem `json:"front_image"`
}
type tips struct {
Embedded tipsEmbedded `json:"_embedded"`
}
type tipsEmbedded struct {
Items []tipItem `json:"items"`
}
type tipItem struct {
Text string `json:"text"`
}
type coverImages struct {
Embedded imagesEmbedded `json:"_embedded"`
}
type imagesEmbedded struct {
Items []imageItem `json:"items"`
}
type imageItem struct {
ID flexibleID `json:"id"`
Src string `json:"src"`
Location location `json:"location"`
Type string `json:"type"`
}
type location struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
}