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

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
}