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:
15
plugins/komoot/Makefile
Normal file
15
plugins/komoot/Makefile
Normal 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
11
plugins/komoot/README.md
Normal 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.
|
||||
197
plugins/komoot/assets/icon.svg
Normal file
197
plugins/komoot/assets/icon.svg
Normal 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
24
plugins/komoot/auth.go
Normal 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
9
plugins/komoot/go.mod
Normal 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
2
plugins/komoot/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=
|
||||
301
plugins/komoot/komoot.go
Normal file
301
plugins/komoot/komoot.go
Normal 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
|
||||
}
|
||||
53
plugins/komoot/komoot_test.go
Normal file
53
plugins/komoot/komoot_test.go
Normal 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
19
plugins/komoot/locale.go
Normal 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
104
plugins/komoot/main.go
Normal 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
|
||||
}
|
||||
5
plugins/komoot/main_stub.go
Normal file
5
plugins/komoot/main_stub.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build !tinygo
|
||||
|
||||
package main
|
||||
|
||||
func main() {}
|
||||
226
plugins/komoot/mapper.go
Normal file
226
plugins/komoot/mapper.go
Normal 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
|
||||
}
|
||||
}
|
||||
134
plugins/komoot/mapper_test.go
Normal file
134
plugins/komoot/mapper_test.go
Normal 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
27
plugins/komoot/options.go
Normal 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
310
plugins/komoot/plugin.json
Normal 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
197
plugins/komoot/types.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user