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

518
plugins/README.md Normal file
View File

@@ -0,0 +1,518 @@
# wanderer plugins
This directory contains first-party WASM provider plugins.
Each plugin is a standalone Go/TinyGo module with:
- `plugin.json` as the source manifest
- `plugins/schema/plugin.schema.json` for editor completion and manifest help
- `go run github.com/open-wanderer/wanderer/plugins/sdk/cmd/manifestcheck` for normalized dist manifest output
- ignored `dist/<plugin-id>/plugin.json` and `dist/<plugin-id>/plugin.wasm` build output for runtime discovery
Build the dist bundles before running from a fresh checkout:
```sh
make plugins-build
```
The runtime loads plugins from direct child directories of `data/plugins`, for example `data/plugins/strava/plugin.json`. To build and install the bundled plugins into that gitignored local runtime directory, run:
```sh
make plugins-install-local
```
To rebuild a single plugin, install TinyGo and run:
```sh
cd plugins/strava
make build
```
Repeat for `hammerhead` and `komoot` as needed.
Release builds create plugin bundle archives in CI. The database Docker image does not include plugins; users install release bundles into `data/plugins`.
Plugin authors can reference the manifest schema from a source manifest:
```json
{
"$schema": "../schema/plugin.schema.json",
"manifestVersion": "1.0",
"type": "trails"
}
```
## Runtime flows
This section maps the main runtime flows for debugging and maintenance. The diagrams use readable step names instead of every exact function name, but they point at the backend paths involved when the host invokes plugin capabilities, host requests, OAuth, and trail sending. The code these flows reference lives in the core backend under `db/` (PocketBase handlers, sync manager, host functions), not in this `plugins/` directory.
### Sync overview
```mermaid
flowchart TD
subgraph Host[Host backend]
Manual[Manual sync]
Cron[Scheduled sync]
Discover[Refresh plugin cache]
LoadPlugin[Load plugin]
Instance[Plugin instance]
Actor[Find actor]
Auth[Refresh auth]
Config[Resolve config]
Session[Open WASM session]
Dedupe[Skip known trails]
Import[Import trail]
Records[(trails waypoints photos)]
Merge{Auto-merge?}
AutoMerge[Try auto-merge]
Done[Update sync status]
end
subgraph Plugin[WASM plugin]
ListExport([List provider trails])
DetailExport([Get trail details])
Summaries[/Trail summaries/]
TrailImport[/Trail import payload/]
end
Cron --> Discover
Manual --> Discover
Discover --> LoadPlugin
LoadPlugin --> Instance
Instance --> Actor
Instance --> Auth
Instance --> Config
Actor --> Session
Auth --> Session
Config --> Session
Session --> ListExport
ListExport --> Summaries
Summaries --> Dedupe
Dedupe --> DetailExport
DetailExport --> TrailImport
TrailImport --> Import
Import --> Records
Records --> Merge
Merge -->|yes| AutoMerge
Merge -->|no| Done
AutoMerge --> Done
```
### User vs actor IDs
Plugin sync starts from `plugin_instances.user`, the local wanderer user that owns the plugin instance. The importer keeps that user ID for user-scoped host decisions, but writes imported record ownership through the user's local ActivityPub actor.
| ID | Used for |
| --- | --- |
| `plugin_instances.user` | Deduplicating provider imports for that user and applying user privacy defaults. |
| `activitypub_actors.id` found by `user` | Writing `trails.author`, `waypoints.author`, and `summit_logs.author`. |
### Host request boundary
Plugins cannot open provider connections themselves. They send a request spec to the host; the host resolves the connector, enforces policy, injects allowed auth, executes the HTTP request, and returns a bounded response. Host request failures after request decoding are returned to the plugin as `HostResponse.error` with the `provider_unavailable` code.
Host request bodies may be JSON, `application/x-www-form-urlencoded`, or
multipart, subject to the manifest upload limits and content-type allow-list.
Here "uploads" means plugin-to-provider request bodies, including login forms,
not only media/file uploads.
Redirect following is enabled by default; plugins can set `followRedirects` to
`false` to receive a 3xx response directly and handle provider login flows
step-by-step. `HostResponse.headerValues` preserves all values for headers such
as `Set-Cookie` and is the only response-header representation exposed to
plugins.
Plugins can emit host-visible diagnostics through the `wanderer:log` host
function. The payload is a JSON object with a strict `level` (`debug`, `info`,
`warn`, or `error`) and a non-empty `message`. The Go SDK exposes this as
`sdk.LogDebug`, `sdk.LogInfo`, `sdk.LogWarn`, and `sdk.LogError`.
Log messages are written to the host logs. Keep them short and never include
secrets, credentials, cookies, tokens, authorization codes, or full URLs with
query parameters.
```go
sdk.LogInfo("provider detail fetch took 420ms externalID=abc")
sdk.LogWarn("provider returned an optional photo without a URL")
```
Declare host functions used by a capability in `requiredHostFunctions`, for
example `["http_request", "log"]`.
```mermaid
sequenceDiagram
box WASM plugin
participant Plugin as Plugin code
end
box Plugin worker
participant Worker as http_request host function
end
box Host backend
participant Host as Host HTTP executor
end
box Provider API
participant Provider as Provider API
end
Plugin->>Worker: HostRequestSpec
Worker->>Host: http_request RPC
Host->>Host: Resolve connector
Host->>Host: Validate manifest policy
alt denied
Host-->>Worker: HostResponse.error provider_unavailable
Worker-->>Plugin: HostResponse.error
else allowed
Host->>Host: Inject auth and apply limits
Host->>Provider: Scoped HTTP request
Provider-->>Host: HTTP response
Host->>Host: Validate response
Host-->>Worker: HostResponse
Worker-->>Plugin: HostResponse
end
```
### Plugin discovery
Used when the backend refreshes the list of plugin bundles installed on disk and caches their manifests in PocketBase.
```mermaid
flowchart TD
subgraph Host[Host backend]
Refresh[Refresh plugin cache]
Scan[Scan data/plugins]
Load[Load bundle]
Validate[Validate manifest]
Store[(installed_plugins)]
end
subgraph Disk[Plugin directory]
Bundle[(Plugin bundle)]
end
Refresh --> Scan
Scan --> Bundle
Bundle --> Load
Load --> Validate
Validate --> Store
```
Manifest `configSchema` defines plugin-owned settings that are passed to plugin exports. Host-owned settings are documented by the host and are not passed to plugins. A manifest may only suggest host defaults via `hostConfig`; the current host fields are:
| Field | Purpose |
| --- | --- |
| `planned` | Enables `list_routes.v1` sync. |
| `completed` | Enables `list_activities.v1` sync. |
| `privacy` | Chooses provider visibility or local user privacy settings. |
| `merge.available` | Controls whether the UI offers auto-merge for this plugin. Defaults to `true`. |
| `merge.enabled` | Runs auto-merge after trail import. |
| `createSummitLogForCompleted` | Creates summit logs for completed imports. |
| `categoryMapping` | Maps `metadata.providerCategory` to local category IDs or names. |
| `connectors` | Provides host-owned base URL, TLS, private-network, and storage redirect settings for configured connectors. |
The settings UI lets users edit `categoryMapping` per plugin instance for trail import plugins.
Plugins may describe provider-owned category values for the settings UI with
`metadata.providerCategories`. This is display-only metadata; `categoryMapping`
keys still use the raw provider category values emitted as
`metadata.providerCategory`.
Trail import plugins should keep provider-specific category values in `metadata.providerCategory`. They may also provide provider summary metrics in `metadata.distance`, `metadata.elevationGain`, `metadata.elevationLoss`, and `metadata.duration`; the host uses those positive values instead of GPX-derived summary metrics and falls back to GPX when a value is missing. Plugins may provide an intended start coordinate in `metadata.providerStart` as `{ "lat": 47.123, "lon": 8.456 }`; the host uses it only when it is close enough to the imported GPX track to be plausible.
Photo descriptors may be returned either on the imported trail or on individual waypoints. The host downloads those media files and stores them on the corresponding PocketBase records.
### List plugins
Used by the settings UI to show locally available plugins, their metadata, icons, capabilities, and current availability status.
Plugins may provide optional UI metadata through `manifest.metadata`:
| Field | Purpose |
| --- | --- |
| `displayName` | Human-facing provider name shown in the UI. Falls back to manifest `name`. |
| `displayNames` | Optional localized provider names keyed by locale, e.g. `de` or `de-CH`. Falls back to `displayName` and `name`. |
| `descriptions` | Optional localized plugin descriptions keyed by locale. Falls back to manifest `description`. |
| `providerCategories` | Optional metadata for provider-owned category values. The settings UI uses `providerCategories.*.labels` for localized category mapping labels. |
| `icons.light` | Light-theme icon path inside the plugin bundle. |
| `icons.dark` | Dark-theme icon path inside the plugin bundle. |
Config schema fields may also localize plugin-owned UI text. The simple
`label` and `description` strings remain valid fallbacks; optional `labels`
and `descriptions` maps override them for matching locales. Select options can
use `label` and `labels` in the same way. Fields with `"required": true` are
validated in the settings modal. Fields with `"hidden": true` are not rendered
in the settings modal, but their saved values are preserved and still passed to
plugin exports.
Locale lookup uses the exact locale first, then the language, then `en`, then
the simple fallback string.
```json
{
"description": "Imports public hike suggestions from Schweizer Wanderwege.",
"metadata": {
"displayName": "Schweizer Wanderwege",
"displayNames": {
"de": "Schweizer Wanderwege",
"en": "Swiss Hiking Trails"
},
"descriptions": {
"de": "Importiert öffentliche Wandervorschläge der Schweizer Wanderwege.",
"en": "Imports public hike suggestions from Swiss Hiking Trails."
}
},
"configSchema": [
{
"key": "maxPhotos",
"type": "text",
"label": "Max photos",
"labels": {
"de": "Max. Fotos",
"en": "Max photos"
},
"description": "Maximum photos to import per hike. Use 0 for none or -1 for all.",
"descriptions": {
"de": "Maximale Anzahl Fotos pro Wanderung. 0 importiert keine Fotos, -1 alle.",
"en": "Maximum photos to import per hike. Use 0 for none or -1 for all."
},
"required": true
}
]
}
```
```mermaid
flowchart TD
subgraph UI[Settings UI]
Request[GET /plugins]
Response[/PluginInfo list/]
end
subgraph Host[Host backend]
Handler[PluginSystemPluginsList]
Refresh[Refresh plugin cache]
Load[Load installed plugins]
Icons[Attach icons]
end
Request --> Handler
Handler --> Refresh
Refresh --> Load
Load --> Icons
Icons --> Response
```
### Save plugin instance
Used whenever a user creates or updates their personal plugin configuration. This path is where auth values are encrypted and default status is assigned.
```mermaid
flowchart TD
subgraph UI[Settings UI]
Save[Save plugin instance]
end
subgraph Host[Host backend]
Hook[create/update hook]
Manifest[Load manifest]
Status[Set status]
Secrets[Find secret fields]
Encrypt[Encrypt auth]
Instance[(plugin_instances)]
end
Save --> Hook
Hook --> Manifest
Manifest --> Status
Manifest --> Secrets
Secrets --> Encrypt
Status --> Instance
Encrypt --> Instance
```
### OAuth connection
Used when the UI connects a plugin instance to an OAuth provider. Start and callback are separate HTTP endpoints, but together they form one browser redirect flow. The host exchanges the authorization code and stores tokens encrypted on the plugin instance.
```mermaid
sequenceDiagram
box Settings UI
participant UI as Settings UI
end
box Host backend
participant Start as OAuth start handler
participant DB as plugin_instances
participant Callback as OAuth callback handler
end
box OAuth provider
participant Provider as OAuth provider
end
UI->>Start: Start OAuth
Start->>Start: Load plugin and OAuth context
Start->>Start: Decrypt auth and validate redirect
Start->>DB: Store state and PKCE verifier
Start-->>UI: Authorization URL
UI->>Provider: Browser redirect
Provider-->>Callback: Redirect with code
Callback->>Callback: Load plugin
Callback->>DB: Load encrypted auth and OAuth state
Callback->>Provider: Exchange code at token endpoint
Provider-->>Callback: Access and refresh tokens
Callback->>DB: Store tokens encrypted
Callback->>DB: Clear transient OAuth fields
```
### Cron sync
Used by the scheduled background sync. It refreshes installed plugin metadata and syncs enabled plugin instances.
```mermaid
flowchart TD
subgraph Host[Host backend]
Cron[Scheduled sync]
Refresh[Refresh plugin cache]
Load[Load plugins]
Instances[Enabled instances]
Sync[Sync instance]
Next[Next instance]
end
Cron --> Refresh
Refresh --> Load
Load --> Instances
Instances --> Sync
Sync --> Next
Next --> Instances
```
### Sync retry handling
Used when a previous sync failed with a retry delay. Cron skips the instance until `retry_not_before` is reached. A successful sync clears `retry_not_before`.
```mermaid
flowchart TD
subgraph Host[Host backend]
Instance[Plugin instance]
Retry{Retry delayed?}
Skip[Skip for now]
Sync[Sync instance]
Error{Needs retry?}
Store[Store retry_not_before]
Clear[Clear retry_not_before]
end
Instance --> Retry
Retry -->|yes| Skip
Retry -->|no| Sync
Sync --> Error
Error -->|yes| Store
Error -->|no| Clear
```
### Sync one instance
Used to prepare one user/plugin instance for sync: actor lookup, runtime selection, auth decryption, OAuth refresh, and capability dispatch.
```mermaid
flowchart TD
subgraph Host[Host backend]
Instance[Plugin instance]
Actor[Find actor]
Runtime[Select runtime]
Auth[Decrypt auth]
Refresh[Refresh OAuth]
Session[Open WASM session]
Sync[Sync capabilities]
Close[Close session]
end
subgraph Plugin[WASM plugin]
Worker([Worker session])
end
Instance --> Actor
Instance --> Runtime
Instance --> Auth
Auth --> Refresh
Actor --> Session
Runtime --> Session
Refresh --> Session
Session --> Worker
Worker --> Sync
Sync --> Close
```
### Capabilities
Every plugin capability is declared as a manifest capability. The runtime flow depends on what the capability does: importing trails uses a list/detail pair, while sending a trail asks the plugin for a provider request plan.
#### Capability: Trail import
Used for one import capability pair such as `list_routes.v1` with `get_route_detail.v1`, or `list_activities.v1` with `get_activity_detail.v1`. This is where provider summaries become imported trails.
```mermaid
flowchart TD
subgraph Host[Host backend]
Start[Trail import sync]
ListCall[Ask plugin for trails]
Dedupe[Skip known trails]
Import[Import trail]
end
subgraph Plugin[WASM plugin]
ListExport([List provider trails])
Summaries[/Trail summaries/]
DetailExport([Get trail details])
TrailImport[/Trail import payload/]
end
Start --> ListCall
ListCall --> ListExport
ListExport --> Summaries
Summaries --> Dedupe
Dedupe --> DetailExport
DetailExport --> TrailImport
TrailImport --> Import
```
#### Capability: Send trail
Used when a user sends an existing wanderer trail to an external provider.
```mermaid
flowchart TD
subgraph UI[Trail UI]
Send[Send trail]
end
subgraph Host[Host backend]
Handler[Send trail handler]
Capability[Load send capability]
Access[Check access]
GPX[Read GPX]
Session[Open WASM session]
Validate[Validate send plan]
Auth[Inject auth]
Execute[Execute request]
Close[Close session]
end
subgraph Plugin[WASM plugin]
Prepare([Prepare send])
TrailSendPlan[/TrailSendPlan/]
end
subgraph Provider[Provider API]
ProviderSend[Send trail]
end
Send --> Handler
Handler --> Capability
Capability --> Access
Access --> GPX
GPX --> Session
Session --> Prepare
Prepare --> TrailSendPlan
TrailSendPlan --> Validate
Validate --> Auth
Auth --> Execute
Execute --> ProviderSend
ProviderSend --> Close
```

View File

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

View File

@@ -0,0 +1,29 @@
# wanderer Hammerhead WASM plugin
WASM/Extism version of the Hammerhead provider for wanderer.
This plugin exports the wanderer plugin-system ABI:
- `list_routes_v1`
- `list_activities_v1`
- `refresh_session_v1`
- `prepare_trail_send_v1`
## Build
Install TinyGo, then run:
```sh
make build
```
The plugin bundle is written to `dist/hammerhead/`. Copy it below
`data/plugins` or run `make plugins-install-local` from the repository root to
install all bundled plugins locally.
## Development
```sh
GOCACHE=/tmp/wanderer-go-cache go test ./...
make manifest
```

View File

@@ -0,0 +1,15 @@
<svg id="hammerhead" data-name="hammerhead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 150">
<defs>
<style>
.cls-1 {
fill: none;
}
</style>
</defs>
<g id="Layer_3" data-name="Layer 3">
<path id="Layer_3-2" data-name="Layer 3" class="cls-1" d="M0,.2H150v150H0Z" transform="translate(0 -0.2)" />
</g>
<path
d="M145.64,74.71a8.05,8.05,0,0,1-2.55,5.87q-6.36,6.33-12.71,12.69l-49,49a8.16,8.16,0,0,1-11.74,0Q38.91,111.4,8,80.68a8.27,8.27,0,0,1-2.63-5.74,8,8,0,0,1,2.48-6.06L22.08,54.69Q45.72,31.07,69.35,7.43a8.51,8.51,0,0,1,5.31-2.76,7.92,7.92,0,0,1,6.67,2.42L94.72,20.47q24.11,24.09,48.21,48.18A8.36,8.36,0,0,1,145.64,74.71ZM88.88,39.61c0,7.58.05,15.13,0,22.7A2,2,0,0,1,87,64.12c-7.94,0-15.89,0-23.83,0a2,2,0,0,1-2-1.9c0-7.54,0-15.07,0-22.62h-18a2.39,2.39,0,0,0-2.7,2.7v64.5a2.35,2.35,0,0,0,2.65,2.65c6-.06,12,.13,18-.08,0-7.41,0-14.81,0-22.23A2,2,0,0,1,63.35,85q11.79,0,23.57,0a2,2,0,0,1,2,2c0,7.49,0,15,0,22.49,6.06.14,12.12,0,18.18.06a2.46,2.46,0,0,0,2.55-2.67q0-32.25,0-64.51a2.53,2.53,0,0,0-2.71-2.7C100.88,39.64,94.92,39.61,88.88,39.61ZM66.81,90.77c0,7.55,0,15.09,0,22.63A1.9,1.9,0,0,1,65,115.22c-4.74,0-9.48,0-14.22,0l-.09.15C58.53,123,66.13,130.91,74,138.56a2.51,2.51,0,0,0,3.29-.13c7.73-7.66,15.35-15.43,23.13-23l-.09-.17c-5.07,0-10.14,0-15.2,0a2,2,0,0,1-1.83-1.8c0-7.54,0-15.09,0-22.64ZM50.87,33.87c4.66,0,9.24,0,13.89,0a2,2,0,0,1,2,2q0,11.21,0,22.4H83.24q0-11,0-22a2.45,2.45,0,0,1,.45-1.66,2.19,2.19,0,0,1,1.87-.74c4.84,0,9.68.11,14.5-.07-7.59-7.62-15-15.07-22.6-22.64a2.6,2.6,0,0,0-2.27-.88,2.92,2.92,0,0,0-1.7,1C65.9,18.86,58.48,26.28,50.87,33.87Zm64.53,66.32c8-7.76,15.87-15.85,23.83-23.72a2.36,2.36,0,0,0,0-3.52C131.3,65,123.43,57,115.4,49.15Zm-80.76-50c-7.72,7.42-15.18,15.17-22.8,22.71a2.36,2.36,0,0,0,0,3.64c7.62,7.51,15,15.26,22.76,22.63Z"
transform="translate(0 -0.2)" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,15 @@
<svg id="hammerhead" data-name="hammerhead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 150">
<defs>
<style>
.cls-1 {
fill: none;
}
</style>
</defs>
<g id="Layer_3" data-name="Layer 3">
<path id="Layer_3-2" data-name="Layer 3" class="cls-1" d="M0,.2H150v150H0Z" transform="translate(0 -0.2)" />
</g>
<path fill="white"
d="M145.64,74.71a8.05,8.05,0,0,1-2.55,5.87q-6.36,6.33-12.71,12.69l-49,49a8.16,8.16,0,0,1-11.74,0Q38.91,111.4,8,80.68a8.27,8.27,0,0,1-2.63-5.74,8,8,0,0,1,2.48-6.06L22.08,54.69Q45.72,31.07,69.35,7.43a8.51,8.51,0,0,1,5.31-2.76,7.92,7.92,0,0,1,6.67,2.42L94.72,20.47q24.11,24.09,48.21,48.18A8.36,8.36,0,0,1,145.64,74.71ZM88.88,39.61c0,7.58.05,15.13,0,22.7A2,2,0,0,1,87,64.12c-7.94,0-15.89,0-23.83,0a2,2,0,0,1-2-1.9c0-7.54,0-15.07,0-22.62h-18a2.39,2.39,0,0,0-2.7,2.7v64.5a2.35,2.35,0,0,0,2.65,2.65c6-.06,12,.13,18-.08,0-7.41,0-14.81,0-22.23A2,2,0,0,1,63.35,85q11.79,0,23.57,0a2,2,0,0,1,2,2c0,7.49,0,15,0,22.49,6.06.14,12.12,0,18.18.06a2.46,2.46,0,0,0,2.55-2.67q0-32.25,0-64.51a2.53,2.53,0,0,0-2.71-2.7C100.88,39.64,94.92,39.61,88.88,39.61ZM66.81,90.77c0,7.55,0,15.09,0,22.63A1.9,1.9,0,0,1,65,115.22c-4.74,0-9.48,0-14.22,0l-.09.15C58.53,123,66.13,130.91,74,138.56a2.51,2.51,0,0,0,3.29-.13c7.73-7.66,15.35-15.43,23.13-23l-.09-.17c-5.07,0-10.14,0-15.2,0a2,2,0,0,1-1.83-1.8c0-7.54,0-15.09,0-22.64ZM50.87,33.87c4.66,0,9.24,0,13.89,0a2,2,0,0,1,2,2q0,11.21,0,22.4H83.24q0-11,0-22a2.45,2.45,0,0,1,.45-1.66,2.19,2.19,0,0,1,1.87-.74c4.84,0,9.68.11,14.5-.07-7.59-7.62-15-15.07-22.6-22.64a2.6,2.6,0,0,0-2.27-.88,2.92,2.92,0,0,0-1.7,1C65.9,18.86,58.48,26.28,50.87,33.87Zm64.53,66.32c8-7.76,15.87-15.85,23.83-23.72a2.36,2.36,0,0,0,0-3.52C131.3,65,123.43,57,115.4,49.15Zm-80.76-50c-7.72,7.42-15.18,15.17-22.8,22.71a2.36,2.36,0,0,0,0,3.64c7.62,7.51,15,15.26,22.76,22.63Z"
transform="translate(0 -0.2)" />
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,9 @@
module github.com/open-wanderer/wanderer/plugins/hammerhead
go 1.25.0
require github.com/extism/go-pdk v1.1.3
require github.com/open-wanderer/wanderer/plugins/sdk v0.0.0
replace github.com/open-wanderer/wanderer/plugins/sdk => ../sdk

View File

@@ -0,0 +1,2 @@
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=

66
plugins/hammerhead/gpx.go Normal file
View File

@@ -0,0 +1,66 @@
package main
import (
"math"
"time"
sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx"
"github.com/open-wanderer/wanderer/plugins/sdk/polyline"
)
func activityGPX(activity *activity) ([]byte, error) {
points := make([]sdkgpx.Point, 0, len(activity.RecordData.Timestamp))
const zeroEps = 1e-4
for i, timestamp := range activity.RecordData.Timestamp {
if i >= len(activity.RecordData.Lat) || i >= len(activity.RecordData.Lng) {
continue
}
lat := activity.RecordData.Lat[i]
lng := activity.RecordData.Lng[i]
if math.Abs(lat) < zeroEps && math.Abs(lng) < zeroEps {
continue
}
elevation := 0.0
if i < len(activity.RecordData.Elevation) {
elevation = activity.RecordData.Elevation[i] / 1000.0
}
pointTime := time.Unix(int64(timestamp), 0).UTC()
points = append(points, sdkgpx.Point{
Lat: lat,
Lon: lng,
Elevation: &elevation,
Time: &pointTime,
})
}
return sdkgpx.Track("wanderer Hammerhead plugin", activity.ActivityData.Name, points)
}
func tourGPX(tour *tour) ([]byte, error) {
coords, err := polyline.Decode(tour.RoutePolyline, 1e5)
if err != nil {
return nil, err
}
polyline.NormalizeCoordinateScale(coords)
elevations, _ := polyline.DecodeValues(tour.Elevation.Polyline, 100000)
points := make([]sdkgpx.Point, 0, len(coords))
swap := polyline.ShouldSwapCoordinates(coords)
for i, coord := range coords {
lat := coord[0]
lon := coord[1]
if swap {
lat, lon = coord[1], coord[0]
}
var elevation *float64
if len(elevations) == len(coords) {
elevation = &elevations[i]
} else if len(elevations) > 0 {
elevation = &elevations[polyline.ProportionalIndex(i, len(coords), len(elevations))]
}
points = append(points, sdkgpx.Point{
Lat: lat,
Lon: lon,
Elevation: elevation,
})
}
return sdkgpx.Track("wanderer Hammerhead plugin", tour.Name, points)
}

View File

@@ -0,0 +1,163 @@
//go:build tinygo
package main
import (
"encoding/json"
"fmt"
"strconv"
"github.com/extism/go-pdk"
"github.com/open-wanderer/wanderer/plugins/sdk"
)
type hammerheadClient struct {
userID string
token string
}
func login(email string, password string) (string, error) {
spec := sdk.HostRequestSpec{
Method: "POST",
Target: sdk.RequestTarget{
Type: "connector",
Connector: "api",
Path: "/v1/auth/token",
},
Headers: map[string]string{
"Accept": "application/json",
},
Body: &sdk.HostRequestBody{
Type: sdk.HostRequestBodyTypeJSON,
JSON: map[string]string{
"grant_type": "password",
"username": email,
"password": password,
},
},
Expect: sdk.ResponseExpect{
ContentTypes: []string{"application/json"},
MaxBytes: 1048576,
},
}
response, body, err := sdk.HostRequest(spec)
if err != nil {
return "", err
}
if response.Status != 200 {
return "", fmt.Errorf("hammerhead login failed (%d): %s", response.Status, string(body))
}
var parsed loginResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return "", err
}
if parsed.Token == "" {
return "", fmt.Errorf("hammerhead login returned no access token")
}
pdk.SetVar("hammerhead_access_token", []byte(parsed.Token))
return parsed.Token, nil
}
func loginClient(auth map[string]any) (hammerheadClient, error) {
email := sdk.StringField(auth, "email")
password := sdk.StringField(auth, "password")
if email == "" || password == "" {
return hammerheadClient{}, fmt.Errorf("email and password are required")
}
token, err := login(email, password)
if err != nil {
return hammerheadClient{}, err
}
userID, err := userIDFromJWT(token)
if err != nil {
return hammerheadClient{}, err
}
return hammerheadClient{userID: userID, token: token}, nil
}
func (c hammerheadClient) get(path string, query []sdk.QueryParam, out any) error {
response, body, err := sdk.HostRequest(sdk.HostRequestSpec{
Method: "GET",
Target: sdk.RequestTarget{
Type: "connector",
Connector: "api",
Path: "/v1/users/" + c.userID + path,
Query: query,
},
Headers: map[string]string{
sdk.AuthHeaderAuthorization: sdk.AuthSchemeBearer + " " + c.token,
"Accept": "application/json",
},
Expect: sdk.ResponseExpect{
ContentTypes: []string{"application/json"},
MaxBytes: 1048576,
},
})
if err != nil {
return err
}
if response.Status != 200 {
return fmt.Errorf("hammerhead request failed (%d): %s", response.Status, string(body))
}
return json.Unmarshal(body, out)
}
func (c hammerheadClient) activities(page int, perPage int) ([]activityResponse, int, error) {
var data activitiesResponse
err := c.get("/activities", hammerheadListQuery(page, perPage), &data)
return data.Data, data.TotalPages, err
}
func (c hammerheadClient) tours(page int, perPage int) ([]tourResponse, int, error) {
var data toursResponse
err := c.get("/routes", hammerheadListQuery(page, perPage), &data)
return data.Data, data.TotalPages, err
}
func (c hammerheadClient) activity(id string) (*activity, error) {
var data activity
err := c.get("/activities/"+id+"/details", nil, &data)
return &data, err
}
func (c hammerheadClient) tour(id string) (*tour, error) {
var data tour
err := c.get("/routes/"+id, nil, &data)
return &data, err
}
func hammerheadListQuery(page int, perPage int) []sdk.QueryParam {
return []sdk.QueryParam{
{Name: "page", Value: strconv.Itoa(page)},
{Name: "perPage", Value: strconv.Itoa(perPage)},
{Name: "orderBy", Value: "NEWEST"},
{Name: "ascending", Value: "true"},
}
}
func userIDForUpload(auth map[string]any) (string, error) {
token := string(pdk.GetVar("hammerhead_access_token"))
if token == "" {
email := sdk.StringField(auth, "email")
password := sdk.StringField(auth, "password")
if email == "" || password == "" {
return "", fmt.Errorf("email and password are required")
}
var err error
token, err = login(email, password)
if err != nil {
return "", err
}
}
return userIDFromJWT(token)
}
func userIDFromSession() (string, error) {
token := string(pdk.GetVar("hammerhead_access_token"))
if token == "" {
return "", fmt.Errorf("session token is not available")
}
return userIDFromJWT(token)
}

View File

@@ -0,0 +1,97 @@
package main
import (
"strings"
"testing"
sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx"
"github.com/open-wanderer/wanderer/plugins/sdk/polyline"
)
func TestUserIDFromJWT(t *testing.T) {
token := "header.eyJzdWIiOiJ1c2VyLTEyMyJ9.signature"
got, err := userIDFromJWT(token)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != "user-123" {
t.Fatalf("got %q", got)
}
}
func TestUserIDFromJWTRejectsInvalidToken(t *testing.T) {
if _, err := userIDFromJWT("not-a-jwt"); err == nil {
t.Fatal("expected error")
}
}
func TestDecodePolyline(t *testing.T) {
points, err := polyline.Decode("_p~iF~ps|U_ulLnnqC_mqNvxq`@", 1e5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(points) != 3 {
t.Fatalf("expected 3 points, got %d", len(points))
}
if points[0][0] != 38.5 || points[0][1] != -120.2 {
t.Fatalf("unexpected first point: %#v", points[0])
}
}
func TestDecodePolylineNormalizesOutOfRangeScale(t *testing.T) {
points, err := polyline.Decode("_p~iF~ps|U", 1e5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
points[0][0] *= 10
points[0][1] *= 10
polyline.NormalizeCoordinateScale(points)
if points[0][0] != 38.5 || points[0][1] != -120.2 {
t.Fatalf("expected normalized point, got %#v", points[0])
}
}
func TestShouldSwapCoordinates(t *testing.T) {
coords := [][2]float64{{120.2, 38.5}, {121.0, 39.0}}
if !polyline.ShouldSwapCoordinates(coords) {
t.Fatal("expected coordinates to be detected as swapped")
}
}
func TestProportionalIndex(t *testing.T) {
if got := polyline.ProportionalIndex(2, 5, 3); got != 1 {
t.Fatalf("got %d, want 1", got)
}
if got := polyline.ProportionalIndex(4, 5, 3); got != 2 {
t.Fatalf("got %d, want 2", got)
}
}
func TestGPXBytesEscapesTrackName(t *testing.T) {
data, err := sdkgpx.Track("wanderer Hammerhead plugin", "A & B", []sdkgpx.Point{{Lat: 46.1, Lon: 8.2}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
gpx := string(data)
if !strings.Contains(gpx, "<name>A &amp; B</name>") {
t.Fatalf("expected escaped name, got %s", gpx)
}
if !strings.Contains(gpx, `lat="46.10000000" lon="8.20000000"`) {
t.Fatalf("expected track point, got %s", gpx)
}
}
func TestTrailGPXFilename(t *testing.T) {
tests := map[string]string{
"": "trail.gpx",
"My Route": "My Route.gpx",
"My Route.gpx": "My Route.gpx",
"../Bad/Route\\Name ": "Bad-Route-Name.gpx",
}
for input, want := range tests {
if got := trailGPXFilename(input); got != want {
t.Fatalf("trailGPXFilename(%q) = %q, want %q", input, got, want)
}
}
}

28
plugins/hammerhead/jwt.go Normal file
View File

@@ -0,0 +1,28 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
)
func userIDFromJWT(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return "", fmt.Errorf("token is not a JWT")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", err
}
var claims map[string]any
if err := json.Unmarshal(payload, &claims); err != nil {
return "", err
}
sub, _ := claims["sub"].(string)
if sub == "" {
return "", fmt.Errorf("token has no sub claim")
}
return sub, nil
}

350
plugins/hammerhead/main.go Normal file
View File

@@ -0,0 +1,350 @@
//go:build tinygo
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/extism/go-pdk"
"github.com/open-wanderer/wanderer/plugins/sdk"
)
func main() {}
//export list_routes_v1
func listRoutesV1() int32 {
var input listInput
if err := pdk.InputJSON(&input); err != nil {
return fail("invalid_request", "invalid list_routes input: "+err.Error())
}
client, err := loginClient(input.Auth)
if err != nil {
return fail("auth_failed", err.Error())
}
output, err := listRoutes(client, input)
if err != nil {
return fail("provider_unavailable", err.Error())
}
if err := pdk.OutputJSON(output); err != nil {
return fail("internal_error", err.Error())
}
return 0
}
//export list_activities_v1
func listActivitiesV1() int32 {
var input listInput
if err := pdk.InputJSON(&input); err != nil {
return fail("invalid_request", "invalid list_activities input: "+err.Error())
}
client, err := loginClient(input.Auth)
if err != nil {
return fail("auth_failed", err.Error())
}
output, err := listActivities(client, input)
if err != nil {
return fail("provider_unavailable", err.Error())
}
if err := pdk.OutputJSON(output); err != nil {
return fail("internal_error", err.Error())
}
return 0
}
//export get_route_detail_v1
func getRouteDetailV1() int32 {
return getTrailDetail("planned")
}
//export get_activity_detail_v1
func getActivityDetailV1() int32 {
return getTrailDetail("completed")
}
//export refresh_session_v1
func refreshSessionV1() int32 {
var input refreshSessionInput
if err := pdk.InputJSON(&input); err != nil {
return fail("invalid_request", "invalid refresh_session input: "+err.Error())
}
email := sdk.StringField(input.Auth, "email")
password := sdk.StringField(input.Auth, "password")
if email == "" || password == "" {
return fail("auth_failed", "email and password are required")
}
token, err := login(email, password)
if err != nil {
return fail("auth_failed", err.Error())
}
if err := pdk.OutputJSON(refreshSessionOutput{
Token: token,
Scheme: sdk.AuthSchemeBearer,
}); err != nil {
return fail("internal_error", err.Error())
}
return 0
}
func getTrailDetail(kind string) int32 {
var input detailInput
if err := pdk.InputJSON(&input); err != nil {
return fail("invalid_request", "invalid detail input: "+err.Error())
}
client, err := loginClient(input.Auth)
if err != nil {
return fail("auth_failed", err.Error())
}
var item trailImport
switch kind {
case "planned":
detail, err := client.tour(input.Summary.Source.ExternalID)
if err != nil {
return fail("provider_unavailable", err.Error())
}
item, err = tourImport(detail)
if err != nil {
return fail("provider_unavailable", err.Error())
}
case "completed":
detail, err := client.activity(input.Summary.Source.ExternalID)
if err != nil {
return fail("provider_unavailable", err.Error())
}
item, err = activityImport(detail)
if err != nil {
return fail("provider_unavailable", err.Error())
}
default:
return fail("invalid_request", "unsupported detail kind")
}
if err := pdk.OutputJSON(detailOutput{Item: item}); err != nil {
return fail("internal_error", err.Error())
}
return 0
}
//export prepare_trail_send_v1
func prepareTrailSendV1() int32 {
var input trailSendInput
if err := pdk.InputJSON(&input); err != nil {
return fail("invalid_request", "invalid prepare_trail_send input: "+err.Error())
}
if input.Trail.Format != "gpx" || input.Trail.ContentBase64 == "" {
return fail("invalid_request", "a GPX trail is required")
}
userID, err := userIDForUpload(input.Auth)
if err != nil {
return fail("auth_failed", err.Error())
}
plan := trailSendPlan{
Request: sdk.HostRequestSpec{
Method: "POST",
Target: sdk.RequestTarget{
Type: "connector",
Connector: "api",
Path: fmt.Sprintf("/v1/users/%s/routes/import/file", userID),
},
Auth: "provider_session",
Body: &sdk.HostRequestBody{
Type: sdk.HostRequestBodyTypeMultipart,
Parts: []sdk.MultipartPart{
{
Name: "file",
Source: sdk.MultipartSourceTrail,
Filename: trailGPXFilename(input.Name),
ContentType: "application/gpx+xml",
},
},
},
Expect: sdk.ResponseExpect{
ContentTypes: []string{"application/json"},
MaxBytes: 1048576,
},
},
}
if err := pdk.OutputJSON(plan); err != nil {
return fail("internal_error", err.Error())
}
return 0
}
func fail(code string, message string) int32 {
data, err := json.Marshal(pluginError{Code: code, Message: message})
if err != nil {
pdk.SetErrorString(message)
return 1
}
pdk.SetErrorString(string(data))
return 1
}
func listRoutes(client hammerheadClient, input listInput) (listOutput, error) {
page := sdk.IntState(input.State, "page", 1)
if page <= 0 {
page = 1
}
limit := sdk.SyncLimit(input)
rows, totalPages, err := client.tours(page, limit)
if err != nil {
return listOutput{}, err
}
after := sdk.StringField(input.Options, "after")
items := make([]trailSummary, 0, min(limit, len(rows)))
for _, row := range rows {
if after != "" && row.CreatedAt < after {
return listOutput{Items: items}, nil
}
items = append(items, trailSummary{
Source: trailImportSource{Provider: "hammerhead", ExternalID: row.ID},
Kind: "planned",
})
if len(items) >= limit {
break
}
}
nextPage := page + 1
hasMore := nextPage <= totalPages
return listOutput{
Items: items,
State: sdk.NextPageState(nextPage, hasMore),
HasMore: hasMore,
}, nil
}
func listActivities(client hammerheadClient, input listInput) (listOutput, error) {
page := sdk.IntState(input.State, "page", 1)
if page <= 0 {
page = 1
}
limit := sdk.SyncLimit(input)
rows, totalPages, err := client.activities(page, limit)
if err != nil {
return listOutput{}, err
}
after := sdk.StringField(input.Options, "after")
items := make([]trailSummary, 0, min(limit, len(rows)))
for _, row := range rows {
if after != "" && row.CreatedAt < after {
return listOutput{Items: items}, nil
}
items = append(items, trailSummary{
Source: trailImportSource{Provider: "hammerhead", ExternalID: row.ID},
Kind: "completed",
})
if len(items) >= limit {
break
}
}
nextPage := page + 1
hasMore := nextPage <= totalPages
return listOutput{
Items: items,
State: sdk.NextPageState(nextPage, hasMore),
HasMore: hasMore,
}, nil
}
func tourImport(tour *tour) (trailImport, error) {
gpxData, err := tourGPX(tour)
if err != nil {
return trailImport{}, err
}
privacy := privacyFromPublic(tour.IsPublic)
return trailImport{
Source: trailImportSource{
Provider: "hammerhead",
ExternalID: tour.ID,
},
Kind: "planned",
Name: tour.Name,
StartedAt: tour.CreatedAt,
ActivityType: "biking",
Privacy: &privacy,
Track: track{
Format: "gpx",
ContentBase64: base64.StdEncoding.EncodeToString(gpxData),
},
Metadata: map[string]any{
"distance": tour.Distance,
"elevationGain": tour.Elevation.Gain,
"elevationLoss": tour.Elevation.Loss,
"providerCategory": "biking",
},
}, nil
}
func activityImport(activity *activity) (trailImport, error) {
gpxData, err := activityGPX(activity)
if err != nil {
return trailImport{}, err
}
privacy := "private"
return trailImport{
Source: trailImportSource{
Provider: "hammerhead",
ExternalID: activity.ActivityData.ID,
},
Kind: "completed",
Name: activity.ActivityData.Name,
StartedAt: activity.ActivityData.CreatedAt,
ActivityType: "biking",
Privacy: &privacy,
Track: track{
Format: "gpx",
ContentBase64: base64.StdEncoding.EncodeToString(gpxData),
},
Metadata: map[string]any{
"distance": infoValueOrZero(activity, "TYPE_DISTANCE_ID"),
"elevationGain": infoValueOrZero(activity, "TYPE_ELEVATION_GAIN_ID"),
"elevationLoss": infoValueOrZero(activity, "TYPE_ELEVATION_LOSS_ID"),
"duration": activityDurationSeconds(activity),
"providerCategory": "biking",
},
}, nil
}
func privacyFromPublic(public bool) string {
if public {
return "public"
}
return "private"
}
func activityInfoValue(activity *activity, key string) (float64, bool) {
for _, info := range activity.ActivityData.ActivityInfo {
if info.Key == key {
return info.Value.Value, true
}
}
return 0, false
}
func infoValueOrZero(activity *activity, key string) float64 {
value, _ := activityInfoValue(activity, key)
return value
}
func activityDurationSeconds(activity *activity) float64 {
var total int
for _, lap := range activity.ActivityData.Laps {
total += lap.ActiveTime
}
if total > 0 {
return float64(total) / 1000
}
if activity.ActivityData.Duration.ElapsedTime > 0 {
return float64(activity.ActivityData.Duration.ElapsedTime) / 1000
}
return 0
}

View File

@@ -0,0 +1,131 @@
{
"manifestVersion": "1.0",
"id": "hammerhead",
"type": "trails",
"name": "Hammerhead",
"description": "Imports Hammerhead routes and activities, and can send wanderer routes to Hammerhead.",
"version": "0.1.0",
"runtime": {
"type": "wasm",
"entrypoint": "plugin.wasm"
},
"capabilities": [
{
"name": "list_routes",
"version": "v1",
"export": "list_routes_v1"
},
{
"name": "get_route_detail",
"version": "v1",
"export": "get_route_detail_v1"
},
{
"name": "list_activities",
"version": "v1",
"export": "list_activities_v1"
},
{
"name": "get_activity_detail",
"version": "v1",
"export": "get_activity_detail_v1"
},
{
"name": "prepare_trail_send",
"version": "v1",
"export": "prepare_trail_send_v1"
}
],
"auth": {
"contexts": {
"provider_session": {
"type": "session",
"fields": [
"email",
"password"
],
"secretFields": [
"password"
],
"refresh": {
"mode": "plugin",
"function": "refresh_session_v1"
}
}
}
},
"permissions": {
"network": {
"connectors": [
{
"name": "api",
"type": "public_api",
"fixedBaseURL": "https://dashboard.hammerhead.io",
"allowedPathPrefixes": [
"/v1"
],
"auth": [
"provider_session"
]
}
]
},
"auth": [
"provider_session"
],
"uploads": {
"maxBytes": 25000000,
"contentTypes": [
"application/json",
"multipart/form-data"
]
},
"downloads": {
"maxBytes": 1048576,
"contentTypes": [
"application/json"
]
}
},
"configSchema": [
{
"key": "after",
"type": "date",
"label": "Start date",
"labels": {
"de": "Startdatum",
"en": "Start date"
},
"description": "Ignore routes and activities before this date.",
"descriptions": {
"de": "Wenn Ihr Hammerhead Konto bereits mit anderen Trail-Datenbanken wie komoot oder Strava synchronisiert ist, kann die zusätzliche Synchronisierung Ihrer Hammerhead-Daten zu Duplikaten führen. Um dies zu vermeiden, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.",
"en": "If your hammerhead account is already synced with other trail databases, such as komoot or Strava, start syncing your Hammerhead data may result in duplicates. To avoid this, you can set an start date below, meaning only activities recorded after this date will be synced.",
"no": "Hvis Hammerhead-kontoen din allerede er synkronisert med andre stidatabaser, som Komoot eller Strava, kan synkronisering av Hammerhead-data føre til duplikater. For å unngå dette kan du angi en startdato nedenfor, slik at bare aktiviteter registrert etter denne datoen vil bli synkronisert."
}
}
],
"hostConfig": {
"categoryMapping": {
"biking": "Biking"
}
},
"metadata": {
"descriptions": {
"de": "Importiert Hammerhead-Routen und Aktivitäten und kann wanderer-Routen an Hammerhead senden.",
"en": "Imports Hammerhead routes and activities, and can send wanderer routes to Hammerhead.",
"no": "Synkroniserer Hammerhead-turene dine med Wanderer med jevne mellomrom."
},
"icons": {
"light": "icon.svg",
"dark": "icon_dark.svg"
},
"providerCategories": {
"biking": {
"labels": {
"de": "Radfahren",
"en": "Biking"
}
}
}
}
}

View File

@@ -0,0 +1,24 @@
package main
import "strings"
func trailGPXFilename(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "trail.gpx"
}
name = strings.Map(func(r rune) rune {
if r < 32 || r == '/' || r == '\\' {
return '-'
}
return r
}, name)
name = strings.Trim(name, ". -")
if name == "" {
return "trail.gpx"
}
if strings.HasSuffix(strings.ToLower(name), ".gpx") {
return name
}
return name + ".gpx"
}

106
plugins/hammerhead/types.go Normal file
View File

@@ -0,0 +1,106 @@
package main
import "github.com/open-wanderer/wanderer/plugins/sdk"
type instanceRef = sdk.InstanceRef
type refreshSessionInput = sdk.RefreshSessionInput
type refreshSessionOutput = sdk.RefreshSessionOutput
type trailSendInput = sdk.TrailSendInput
type listInput = sdk.ListInput
type listOutput = sdk.ListOutput
type detailInput = sdk.DetailInput
type detailOutput = sdk.DetailOutput
type trailSummary = sdk.TrailSummary
type trailImport = sdk.TrailImport
type trailImportSource = sdk.TrailImportSource
type track = sdk.Track
type trailSendPlan = sdk.TrailSendPlan
type loginResponse struct {
Token string `json:"access_token"`
}
type toursResponse struct {
TotalPages int `json:"totalPages"`
Data []tourResponse `json:"data"`
}
type tourResponse struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
}
type activitiesResponse struct {
TotalPages int `json:"totalPages"`
Data []activityResponse `json:"data"`
}
type activityResponse struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
}
type tour struct {
ID string `json:"id"`
CreatedAt string `json:"createdAt"`
Name string `json:"name"`
Distance float64 `json:"distance"`
Elevation elevation `json:"elevation"`
StartLocation location `json:"startLocation"`
RoutePolyline string `json:"routePolyline"`
IsPublic bool `json:"isPublic"`
}
type elevation struct {
Gain float64 `json:"gain"`
Loss float64 `json:"loss"`
Polyline string `json:"polyline"`
}
type location struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
}
type activity struct {
ActivityData activityData `json:"activityData"`
RecordData recordData `json:"recordData"`
}
type activityData struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
Duration duration `json:"duration"`
ActivityInfo []info `json:"activityInfo"`
Laps []lapDetail `json:"laps"`
ActivityType string `json:"activityType"`
}
type duration struct {
ElapsedTime int `json:"elapsedTime"`
}
type info struct {
Key string `json:"key"`
Value infoValue `json:"value"`
}
type infoValue struct {
Value float64 `json:"value"`
}
type lapDetail struct {
ActiveTime int `json:"activeTime"`
}
type recordData struct {
Timestamp []int `json:"timestamp"`
Elevation []float64 `json:"elevation"`
Lat []float64 `json:"lat"`
Lng []float64 `json:"lng"`
}
type pluginError = sdk.PluginError

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

View File

@@ -0,0 +1,481 @@
{
"$schema": "https://json-schema.org/draft-07/schema#",
"$id": "https://open-wanderer.github.io/wanderer/schemas/plugin.schema.json",
"title": "wanderer plugin manifest",
"description": "Schema for wanderer plugin.json manifests.",
"type": "object",
"additionalProperties": false,
"required": [
"manifestVersion",
"id",
"type",
"name",
"version",
"runtime",
"capabilities"
],
"properties": {
"$schema": {
"type": "string",
"description": "Optional editor schema reference."
},
"manifestVersion": {
"type": "string",
"const": "1.0"
},
"id": {
"type": "string",
"pattern": "^[a-z0-9][a-z0-9_-]*$"
},
"type": {
"type": "string",
"enum": ["trails"]
},
"name": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string"
},
"version": {
"type": "string",
"minLength": 1
},
"runtime": {
"$ref": "#/definitions/runtime"
},
"capabilities": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/definitions/capability"
}
},
"auth": {
"$ref": "#/definitions/auth"
},
"permissions": {
"$ref": "#/definitions/permissions"
},
"configSchema": {
"type": "array",
"items": {
"$ref": "#/definitions/configField"
}
},
"hostConfig": {
"$ref": "#/definitions/hostConfig"
},
"metadata": {
"$ref": "#/definitions/metadata"
}
},
"definitions": {
"runtime": {
"type": "object",
"additionalProperties": false,
"required": ["type", "entrypoint"],
"properties": {
"type": {
"type": "string",
"const": "wasm"
},
"entrypoint": {
"type": "string",
"minLength": 1
}
}
},
"capability": {
"type": "object",
"additionalProperties": false,
"required": ["name", "version", "export"],
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"version": {
"type": "string",
"minLength": 1
},
"export": {
"type": "string",
"minLength": 1
},
"requiredHostFunctions": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"uniqueItems": true
},
"job": {
"type": "string",
"minLength": 1
}
}
},
"auth": {
"type": "object",
"additionalProperties": false,
"properties": {
"contexts": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/authContext"
}
}
}
},
"authContext": {
"type": "object",
"additionalProperties": false,
"required": ["type"],
"properties": {
"type": {
"type": "string",
"enum": ["oauth2", "api_key", "bearer", "session"]
},
"fields": {
"$ref": "#/definitions/stringList"
},
"authorizationUrl": {
"type": "string",
"format": "uri"
},
"tokenUrl": {
"type": "string",
"format": "uri"
},
"scopes": {
"$ref": "#/definitions/stringList"
},
"scopeSeparator": {
"type": "string"
},
"pkce": {
"type": "boolean"
},
"tokenRequestFormat": {
"type": "string",
"enum": ["json", "form"]
},
"tokenAuth": {
"type": "string",
"enum": ["client_secret_post", "client_secret_basic"]
},
"authorizationParams": {
"$ref": "#/definitions/stringMap"
},
"refresh": {
"$ref": "#/definitions/authRefresh"
},
"placement": {
"type": "string",
"enum": ["query"]
},
"name": {
"type": "string",
"minLength": 1
},
"secretField": {
"type": "string",
"minLength": 1
},
"secretFields": {
"$ref": "#/definitions/stringList"
}
}
},
"authRefresh": {
"type": "object",
"additionalProperties": false,
"required": ["mode"],
"properties": {
"mode": {
"type": "string",
"enum": ["host", "plugin"]
},
"grantType": {
"type": "string",
"minLength": 1
},
"function": {
"type": "string",
"minLength": 1
}
}
},
"permissions": {
"type": "object",
"additionalProperties": false,
"properties": {
"network": {
"$ref": "#/definitions/networkPermissions"
},
"auth": {
"$ref": "#/definitions/stringList"
},
"downloads": {
"$ref": "#/definitions/transferPermissions"
},
"uploads": {
"$ref": "#/definitions/transferPermissions"
}
}
},
"networkPermissions": {
"type": "object",
"additionalProperties": false,
"properties": {
"connectors": {
"type": "array",
"items": {
"$ref": "#/definitions/connector"
}
},
"redirects": {
"$ref": "#/definitions/redirects"
}
}
},
"connector": {
"type": "object",
"additionalProperties": false,
"required": ["name", "type"],
"properties": {
"name": {
"type": "string",
"minLength": 1
},
"type": {
"type": "string",
"enum": ["public_api", "configured"]
},
"fixedBaseURL": {
"type": "string",
"format": "uri"
},
"configKey": {
"type": "string",
"minLength": 1
},
"allowedPathPrefixes": {
"$ref": "#/definitions/stringList"
},
"auth": {
"$ref": "#/definitions/stringList"
},
"supportsMediaAuth": {
"type": "boolean"
},
"supportsStorageRedirects": {
"type": "boolean"
},
"supportsCustomTLS": {
"type": "boolean"
}
},
"allOf": [
{
"if": {
"properties": { "type": { "const": "public_api" } }
},
"then": {
"required": ["fixedBaseURL"],
"not": { "required": ["configKey"] }
}
},
{
"if": {
"properties": { "type": { "const": "configured" } }
},
"then": {
"required": ["configKey"],
"not": { "required": ["fixedBaseURL"] }
}
}
]
},
"redirects": {
"type": "object",
"additionalProperties": false,
"properties": {
"mode": {
"type": "string",
"enum": ["declared_hosts_only"]
},
"hosts": {
"$ref": "#/definitions/stringList"
}
}
},
"transferPermissions": {
"type": "object",
"additionalProperties": false,
"properties": {
"maxBytes": {
"type": "integer",
"minimum": 0
},
"contentTypes": {
"$ref": "#/definitions/stringList"
}
}
},
"configField": {
"type": "object",
"additionalProperties": false,
"required": ["key", "type"],
"properties": {
"key": {
"type": "string",
"minLength": 1
},
"type": {
"type": "string",
"enum": ["boolean", "date", "select", "text", "url"]
},
"label": {
"type": "string"
},
"labels": {
"$ref": "#/definitions/stringMap"
},
"description": {
"type": "string"
},
"descriptions": {
"$ref": "#/definitions/stringMap"
},
"options": {
"type": "array",
"items": {
"$ref": "#/definitions/configFieldOption"
}
},
"default": {},
"required": {
"type": "boolean"
},
"hidden": {
"type": "boolean"
}
}
},
"configFieldOption": {
"type": "object",
"additionalProperties": false,
"required": ["value"],
"properties": {
"value": {
"type": "string"
},
"label": {
"type": "string"
},
"labels": {
"$ref": "#/definitions/stringMap"
}
}
},
"hostConfig": {
"type": "object",
"additionalProperties": true,
"properties": {
"planned": {
"type": "boolean"
},
"completed": {
"type": "boolean"
},
"privacy": {
"type": "string",
"enum": ["original", "settings"]
},
"merge": {
"type": "object",
"additionalProperties": true,
"properties": {
"available": {
"type": "boolean"
},
"enabled": {
"type": "boolean"
}
}
},
"createSummitLogForCompleted": {
"type": "boolean"
},
"categoryMapping": {
"$ref": "#/definitions/stringMap"
},
"connectors": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
},
"metadata": {
"type": "object",
"additionalProperties": true,
"properties": {
"displayName": {
"type": "string"
},
"displayNames": {
"$ref": "#/definitions/stringMap"
},
"descriptions": {
"$ref": "#/definitions/stringMap"
},
"providerCategories": {
"type": "object",
"additionalProperties": {
"$ref": "#/definitions/providerCategoryMetadata"
}
},
"icons": {
"type": "object",
"additionalProperties": true,
"properties": {
"light": {
"type": "string"
},
"dark": {
"type": "string"
}
}
}
}
},
"stringList": {
"type": "array",
"items": {
"type": "string"
},
"uniqueItems": true
},
"stringMap": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"providerCategoryMetadata": {
"type": "object",
"additionalProperties": true,
"properties": {
"labels": {
"$ref": "#/definitions/stringMap"
}
}
}
}
}

63
plugins/sdk/README.md Normal file
View File

@@ -0,0 +1,63 @@
# Wanderer Plugin SDK for Go
TinyGo-compatible helpers for Wanderer WASM plugins.
```go
import "github.com/open-wanderer/wanderer/plugins/sdk"
```
The SDK contains only plugin-side protocol types and host-function helpers. It
does not depend on Wanderer core or PocketBase.
Common protocol types:
- `ListInput`, `ListOutput`, `TrailImport`, `Track`, `Waypoint`, `Photo`
- `RefreshSessionInput`, `RefreshSessionOutput`
- `TrailSendInput`, `TrailSendPlan`
- `HostRequestSpec`, `HostResponse`, `PluginError`
Provider HTTP requests use connector targets. Plugins provide a connector name,
a relative path, and ordered query parameters; the host owns the final base URL,
path scope, redirects, TLS, and private-network policy. Public external media
URLs remain available only through `MediaSource{Type: "url"}`.
Host HTTP request bodies support JSON, `application/x-www-form-urlencoded`, and
multipart. Use `PostJSON` for JSON and `PostForm` for ordered form fields. Any
request body, including a login form POST, is governed by manifest
`permissions.uploads.maxBytes` and `permissions.uploads.contentTypes`; in this
contract "uploads" means plugin-to-provider request bodies, not only media/file
uploads.
Set `HostRequestSpec.FollowRedirects` to `sdk.Bool(false)` when a plugin needs
to inspect a redirect response itself, for example to collect `Location` and
`Set-Cookie` during a provider login flow. `HostResponse.HeaderValues` is the
only response-header representation and preserves all values. Prefer
`FirstHeader` for scalar headers and `HeaderValuesFor` for headers that can
appear more than once.
Plugins can emit host-visible logs with `LogDebug`, `LogInfo`, `LogWarn`, and
`LogError`. Log levels are strict (`debug`, `info`, `warn`, `error`) and
messages must be non-empty. Use logs for short diagnostics and timing markers;
they are best-effort and should not be part of plugin control flow.
Small sync helpers are included for the repeated mechanics that every provider
needs:
- `StringField` / `StringOption`
- `IntState`
- `IntOption`
- `KnownIDs`
- `SyncLimit`
- `NextPageState`
Additional TinyGo-compatible helper packages:
```go
import sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx"
import "github.com/open-wanderer/wanderer/plugins/sdk/polyline"
```
- `gpx` writes simple GPX 1.1 track documents from provider track points.
- `polyline` decodes Google-style encoded polylines and provides small helpers
for coordinate scale normalization, coordinate swap detection, and mapping
shorter elevation arrays onto track points.

View File

@@ -0,0 +1,19 @@
package main
import (
"fmt"
"os"
"github.com/open-wanderer/wanderer/plugins/sdk/manifestcheck"
)
func main() {
path := "plugin.json"
if len(os.Args) > 1 {
path = os.Args[1]
}
if err := manifestcheck.PrintFile(os.Stdout, path); err != nil {
fmt.Fprintf(os.Stderr, "manifestcheck: %v\n", err)
os.Exit(1)
}
}

5
plugins/sdk/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module github.com/open-wanderer/wanderer/plugins/sdk
go 1.25.0
require github.com/extism/go-pdk v1.1.3

2
plugins/sdk/go.sum Normal file
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=

58
plugins/sdk/gpx/gpx.go Normal file
View File

@@ -0,0 +1,58 @@
package gpx
import (
"bytes"
"encoding/xml"
"fmt"
"strconv"
"time"
)
type Point struct {
Lat float64
Lon float64
Elevation *float64
Time *time.Time
}
func Track(creator string, name string, points []Point) ([]byte, error) {
if len(points) == 0 {
return nil, fmt.Errorf("track has no points")
}
if creator == "" {
creator = "wanderer plugin"
}
var buf bytes.Buffer
buf.WriteString(xml.Header)
buf.WriteString(`<gpx version="1.1" creator="`)
_ = xml.EscapeText(&buf, []byte(creator))
buf.WriteString(`" xmlns="http://www.topografix.com/GPX/1/1">`)
buf.WriteString("<trk>")
buf.WriteString("<name>")
_ = xml.EscapeText(&buf, []byte(name))
buf.WriteString("</name>")
buf.WriteString("<trkseg>")
for _, point := range points {
buf.WriteString(`<trkpt lat="`)
buf.WriteString(strconv.FormatFloat(point.Lat, 'f', 8, 64))
buf.WriteString(`" lon="`)
buf.WriteString(strconv.FormatFloat(point.Lon, 'f', 8, 64))
buf.WriteString(`">`)
if point.Elevation != nil {
buf.WriteString("<ele>")
buf.WriteString(strconv.FormatFloat(*point.Elevation, 'f', 2, 64))
buf.WriteString("</ele>")
}
if point.Time != nil {
buf.WriteString("<time>")
buf.WriteString(point.Time.UTC().Format(time.RFC3339))
buf.WriteString("</time>")
}
buf.WriteString("</trkpt>")
}
buf.WriteString("</trkseg>")
buf.WriteString("</trk>")
buf.WriteString("</gpx>")
return buf.Bytes(), nil
}

View File

@@ -0,0 +1,39 @@
package gpx
import (
"strings"
"testing"
"time"
)
func TestTrackEscapesFields(t *testing.T) {
elevation := 123.456
timestamp := time.Date(2026, 6, 1, 10, 30, 0, 0, time.UTC)
data, err := Track("creator & test", "A & B", []Point{{
Lat: 46.1,
Lon: 8.2,
Elevation: &elevation,
Time: &timestamp,
}})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
gpx := string(data)
for _, want := range []string{
`creator="creator &amp; test"`,
"<name>A &amp; B</name>",
`lat="46.10000000" lon="8.20000000"`,
"<ele>123.46</ele>",
"<time>2026-06-01T10:30:00Z</time>",
} {
if !strings.Contains(gpx, want) {
t.Fatalf("expected %q in %s", want, gpx)
}
}
}
func TestTrackRejectsEmptyPoints(t *testing.T) {
if _, err := Track("", "empty", nil); err == nil {
t.Fatal("expected error")
}
}

154
plugins/sdk/host_http.go Normal file
View File

@@ -0,0 +1,154 @@
//go:build tinygo
package sdk
import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"github.com/extism/go-pdk"
)
//go:wasmimport wanderer http_request
func wandererHTTPRequest(uint64) uint64
//go:wasmimport wanderer log
func wandererLog(uint64)
func Log(level LogLevel, message string) {
entry := HostLogEntry{
Level: level,
Message: message,
}
memory, err := pdk.AllocateJSON(entry)
if err != nil {
return
}
defer memory.Free()
wandererLog(memory.Offset())
}
func LogDebug(message string) {
Log(LogLevelDebug, message)
}
func LogInfo(message string) {
Log(LogLevelInfo, message)
}
func LogWarn(message string) {
Log(LogLevelWarn, message)
}
func LogError(message string) {
Log(LogLevelError, message)
}
func HostRequest(spec HostRequestSpec) (HostResponse, []byte, error) {
requestMemory, err := pdk.AllocateJSON(spec)
if err != nil {
return HostResponse{}, nil, err
}
defer requestMemory.Free()
responsePointer := wandererHTTPRequest(requestMemory.Offset())
if responsePointer == 0 {
return HostResponse{}, nil, fmt.Errorf("host http request returned no response")
}
responseMemory := pdk.FindMemory(responsePointer)
var response HostResponse
if err := json.Unmarshal(responseMemory.ReadBytes(), &response); err != nil {
return HostResponse{}, nil, err
}
if response.Error != nil {
return response, nil, fmt.Errorf("%s: %s", response.Error.Code, response.Error.Message)
}
body, err := base64.StdEncoding.DecodeString(response.BodyBase64)
if err != nil {
return response, nil, err
}
return response, body, nil
}
func ConnectorRequest(method string, connector string, path string, query []QueryParam, headers map[string]string, expect ResponseExpect) (HostResponse, []byte, error) {
return HostRequest(HostRequestSpec{
Method: method,
Target: RequestTarget{
Type: "connector",
Connector: connector,
Path: path,
Query: query,
},
Headers: headers,
Expect: expect,
})
}
func Get(connector string, path string, query []QueryParam, headers map[string]string, expect ResponseExpect) (HostResponse, []byte, error) {
return ConnectorRequest("GET", connector, path, query, headers, expect)
}
func PostJSON(connector string, path string, query []QueryParam, headers map[string]string, body any, expect ResponseExpect) (HostResponse, []byte, error) {
return HostRequest(HostRequestSpec{
Method: "POST",
Target: RequestTarget{
Type: "connector",
Connector: connector,
Path: path,
Query: query,
},
Headers: headers,
Body: &HostRequestBody{
Type: HostRequestBodyTypeJSON,
JSON: body,
},
Expect: expect,
})
}
func PostForm(connector string, path string, query []QueryParam, headers map[string]string, form []FormField, expect ResponseExpect) (HostResponse, []byte, error) {
return HostRequest(HostRequestSpec{
Method: "POST",
Target: RequestTarget{
Type: "connector",
Connector: connector,
Path: path,
Query: query,
},
Headers: headers,
Body: &HostRequestBody{
Type: HostRequestBodyTypeForm,
Form: form,
},
Expect: expect,
})
}
func (r HostResponse) FirstHeader(name string) string {
values := r.HeaderValuesFor(name)
if len(values) == 0 {
return ""
}
return values[0]
}
func (r HostResponse) HeaderValuesFor(name string) []string {
if r.HeaderValues == nil {
return nil
}
if values, ok := r.HeaderValues[name]; ok {
return values
}
for key, values := range r.HeaderValues {
if strings.EqualFold(key, name) {
return values
}
}
return nil
}
func Bool(value bool) *bool {
return &value
}

View File

@@ -0,0 +1,26 @@
package manifestcheck
import (
"encoding/json"
"fmt"
"io"
"os"
)
func PrintFile(w io.Writer, path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
var manifest map[string]any
if err := json.Unmarshal(data, &manifest); err != nil {
return err
}
encoded, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
_, err = fmt.Fprintln(w, string(encoded))
return err
}

View File

@@ -0,0 +1,125 @@
package polyline
import (
"fmt"
"math"
)
func Decode(encoded string, precision float64) ([][2]float64, error) {
if precision == 0 {
return nil, fmt.Errorf("precision must not be zero")
}
var coords [][2]float64
index := 0
lat := 0
lon := 0
for index < len(encoded) {
dlat, next, err := decodeValue(encoded, index)
if err != nil {
return nil, err
}
index = next
dlon, next, err := decodeValue(encoded, index)
if err != nil {
return nil, err
}
index = next
lat += dlat
lon += dlon
coords = append(coords, [2]float64{float64(lat) / precision, float64(lon) / precision})
}
return coords, nil
}
func DecodeValues(encoded string, precision float64) ([]float64, error) {
if precision == 0 {
return nil, fmt.Errorf("precision must not be zero")
}
var values []float64
index := 0
value := 0
for index < len(encoded) {
delta, next, err := decodeValue(encoded, index)
if err != nil {
return nil, err
}
index = next
value += delta
values = append(values, float64(value)/precision)
}
return values, nil
}
func NormalizeCoordinateScale(coords [][2]float64) {
if len(coords) == 0 {
return
}
maxLat := 0.0
maxLon := 0.0
for _, coord := range coords {
if abs := math.Abs(coord[0]); abs > maxLat {
maxLat = abs
}
if abs := math.Abs(coord[1]); abs > maxLon {
maxLon = abs
}
}
for (maxLat > 90 || maxLon > 180) && maxLat > 0 && maxLon > 0 {
for i := range coords {
coords[i][0] /= 10
coords[i][1] /= 10
}
maxLat /= 10
maxLon /= 10
}
}
func ShouldSwapCoordinates(coords [][2]float64) bool {
validAsLat := 0
validAsLon := 0
for _, coord := range coords {
if validLatLon(coord[0], coord[1]) {
validAsLat++
}
if validLatLon(coord[1], coord[0]) {
validAsLon++
}
}
return validAsLon > validAsLat
}
func ProportionalIndex(i int, sourceLen int, targetLen int) int {
if targetLen <= 1 || sourceLen <= 1 {
return 0
}
j := int(math.Round(float64(i) * float64(targetLen-1) / float64(sourceLen-1)))
if j < 0 {
return 0
}
if j >= targetLen {
return targetLen - 1
}
return j
}
func validLatLon(lat float64, lon float64) bool {
return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180
}
func decodeValue(encoded string, index int) (int, int, error) {
result := 0
shift := uint(0)
for {
if index >= len(encoded) {
return 0, index, fmt.Errorf("invalid polyline encoding")
}
b := int(encoded[index]) - 63
index++
result |= (b & 0x1F) << shift
shift += 5
if b < 0x20 {
break
}
}
return (result >> 1) ^ (-(result & 1)), index, nil
}

View File

@@ -0,0 +1,40 @@
package polyline
import "testing"
func TestDecode(t *testing.T) {
points, err := Decode("_p~iF~ps|U_ulLnnqC_mqNvxq`@", 1e5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(points) != 3 {
t.Fatalf("expected 3 points, got %d", len(points))
}
if points[0][0] != 38.5 || points[0][1] != -120.2 {
t.Fatalf("unexpected first point: %#v", points[0])
}
}
func TestNormalizeCoordinateScale(t *testing.T) {
points := [][2]float64{{385, -1202}}
NormalizeCoordinateScale(points)
if points[0][0] != 38.5 || points[0][1] != -120.2 {
t.Fatalf("expected normalized point, got %#v", points[0])
}
}
func TestShouldSwapCoordinates(t *testing.T) {
coords := [][2]float64{{120.2, 38.5}, {121.0, 39.0}}
if !ShouldSwapCoordinates(coords) {
t.Fatal("expected coordinates to be detected as swapped")
}
}
func TestProportionalIndex(t *testing.T) {
if got := ProportionalIndex(2, 5, 3); got != 1 {
t.Fatalf("got %d, want 1", got)
}
if got := ProportionalIndex(4, 5, 3); got != 2 {
t.Fatalf("got %d, want 2", got)
}
}

83
plugins/sdk/sync.go Normal file
View File

@@ -0,0 +1,83 @@
package sdk
import (
"encoding/json"
"strconv"
"strings"
)
func StringField(values map[string]any, key string) string {
value, _ := values[key].(string)
return strings.TrimSpace(value)
}
func StringOption(options map[string]any, key string) string {
return StringField(options, key)
}
func IntOption(options map[string]any, key string, fallback int) int {
return intValue(options, key, fallback)
}
func BoolOption(options map[string]any, key string, fallback bool) bool {
return boolValue(options, key, fallback)
}
func IntState(state map[string]any, key string, fallback int) int {
return intValue(state, key, fallback)
}
func intValue(values map[string]any, key string, fallback int) int {
switch value := values[key].(type) {
case float64:
return int(value)
case int:
return value
case json.Number:
parsed, err := value.Int64()
if err == nil {
return int(parsed)
}
case string:
parsed, err := strconv.Atoi(value)
if err == nil {
return parsed
}
}
return fallback
}
func boolValue(values map[string]any, key string, fallback bool) bool {
switch value := values[key].(type) {
case bool:
return value
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
if err == nil {
return parsed
}
}
return fallback
}
func KnownIDs(ids []string) map[string]bool {
known := make(map[string]bool, len(ids))
for _, id := range ids {
known[id] = true
}
return known
}
func SyncLimit(input ListInput) int {
if input.Limits.MaxItems > 0 {
return input.Limits.MaxItems
}
return 10
}
func NextPageState(nextPage int, hasMore bool) map[string]any {
if !hasMore {
return nil
}
return map[string]any{"page": nextPage}
}

209
plugins/sdk/types.go Normal file
View File

@@ -0,0 +1,209 @@
package sdk
const (
HostRequestBodyTypeJSON = "json"
HostRequestBodyTypeForm = "form"
HostRequestBodyTypeMultipart = "multipart"
MultipartSourceTrail = "trail"
MultipartSourceTrailGPX = "trail.gpx"
AuthHeaderAuthorization = "Authorization"
AuthSchemeBearer = "Bearer"
)
type HostRequestSpec struct {
Method string `json:"method"`
Target RequestTarget `json:"target"`
Auth string `json:"auth,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Body *HostRequestBody `json:"body,omitempty"`
Expect ResponseExpect `json:"expect,omitempty"`
FollowRedirects *bool `json:"followRedirects,omitempty"`
}
type RequestTarget struct {
Type string `json:"type"`
Connector string `json:"connector,omitempty"`
Path string `json:"path,omitempty"`
Query []QueryParam `json:"query,omitempty"`
}
type QueryParam struct {
Name string `json:"name"`
Value string `json:"value"`
}
type HostRequestBody struct {
Type string `json:"type"`
JSON any `json:"json,omitempty"`
Form []FormField `json:"form,omitempty"`
Parts []MultipartPart `json:"parts,omitempty"`
}
type FormField struct {
Name string `json:"name"`
Value string `json:"value"`
}
type MultipartPart struct {
Name string `json:"name"`
Source string `json:"source,omitempty"`
Filename string `json:"filename,omitempty"`
ContentType string `json:"contentType,omitempty"`
JSON any `json:"json,omitempty"`
}
type ResponseExpect struct {
ContentTypes []string `json:"contentTypes,omitempty"`
MaxBytes int64 `json:"maxBytes,omitempty"`
}
type HostResponse struct {
Status int `json:"status"`
HeaderValues map[string][]string `json:"headerValues,omitempty"`
BodyBase64 string `json:"bodyBase64,omitempty"`
Error *PluginError `json:"error,omitempty"`
}
type PluginError struct {
Code string `json:"code"`
Message string `json:"message,omitempty"`
}
type LogLevel string
const (
LogLevelDebug LogLevel = "debug"
LogLevelInfo LogLevel = "info"
LogLevelWarn LogLevel = "warn"
LogLevelError LogLevel = "error"
)
type HostLogEntry struct {
Level LogLevel `json:"level"`
Message string `json:"message"`
}
type InstanceRef struct {
ID string `json:"id"`
PluginID string `json:"pluginId"`
}
type RefreshSessionInput struct {
Instance InstanceRef `json:"instance"`
Auth map[string]any `json:"auth,omitempty"`
Config map[string]any `json:"config,omitempty"`
}
type RefreshSessionOutput struct {
Token string `json:"token"`
Scheme string `json:"scheme,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
}
type SyncLimits struct {
MaxItems int `json:"maxItems,omitempty"`
}
type ListInput struct {
Instance InstanceRef `json:"instance"`
Auth map[string]any `json:"auth,omitempty"`
State map[string]any `json:"state,omitempty"`
Options map[string]any `json:"options,omitempty"`
Limits SyncLimits `json:"limits,omitempty"`
}
type ListOutput struct {
Items []TrailSummary `json:"items"`
State map[string]any `json:"state,omitempty"`
HasMore bool `json:"hasMore"`
Error *PluginError `json:"error,omitempty"`
}
type DetailInput struct {
Instance InstanceRef `json:"instance"`
Auth map[string]any `json:"auth,omitempty"`
Options map[string]any `json:"options,omitempty"`
Summary TrailSummary `json:"summary"`
}
type DetailOutput struct {
Item TrailImport `json:"item"`
Error *PluginError `json:"error,omitempty"`
}
type TrailSummary struct {
Source TrailImportSource `json:"source"`
Kind string `json:"kind,omitempty"`
}
type TrailImport struct {
Source TrailImportSource `json:"source"`
Kind string `json:"kind,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
StartedAt string `json:"startedAt,omitempty"`
ActivityType string `json:"activityType,omitempty"`
Privacy *string `json:"privacy,omitempty"`
Track Track `json:"track"`
Waypoints []Waypoint `json:"waypoints,omitempty"`
Photos []Photo `json:"photos,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type TrailImportSource struct {
Provider string `json:"provider"`
ExternalID string `json:"externalId"`
URL string `json:"url,omitempty"`
}
type Track struct {
Format string `json:"format"`
ContentBase64 string `json:"contentBase64"`
}
type Waypoint struct {
ExternalID string `json:"externalId,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Ele *float64 `json:"ele,omitempty"`
Icon string `json:"icon,omitempty"`
Photos []Photo `json:"photos,omitempty"`
}
type Photo struct {
ExternalID string `json:"externalId,omitempty"`
Filename string `json:"filename,omitempty"`
ContentType string `json:"contentType,omitempty"`
Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"lon,omitempty"`
Source MediaSource `json:"source"`
}
type MediaSource struct {
Type string `json:"type"`
URL string `json:"url,omitempty"`
MediaRef *MediaRef `json:"mediaRef,omitempty"`
}
type MediaRef struct {
Connector string `json:"connector"`
Auth string `json:"auth,omitempty"`
Path string `json:"path,omitempty"`
Query []QueryParam `json:"query,omitempty"`
AssetID string `json:"assetId,omitempty"`
}
type TrailSendInput struct {
Instance InstanceRef `json:"instance"`
Auth map[string]any `json:"auth,omitempty"`
Config map[string]any `json:"config,omitempty"`
Name string `json:"name,omitempty"`
Trail Track `json:"trail"`
}
type TrailSendPlan struct {
Request HostRequestSpec `json:"request"`
}

15
plugins/strava/Makefile Normal file
View File

@@ -0,0 +1,15 @@
PLUGIN_ID := strava
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/strava/README.md Normal file
View File

@@ -0,0 +1,11 @@
# wanderer Strava WASM Plugin
Strava provider for the wanderer WASM plugin system.
```sh
make build
```
The build output is written to `dist/strava`. 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,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 432 91"><style type="text/css">
.st0{fill:#FC4C02;}
</style><path class="st0" d="M74.5 49.5c1.6 2.8 2.5 6.3 2.5 10.4v0.2c0 4.2-0.8 8-2.5 11.4 -1.7 3.4-4.1 6.2-7.1 8.6 -3.1 2.3-6.8 4.1-11.2 5.4 -4.4 1.3-9.3 1.9-14.7 1.9 -8.2 0-15.9-1.1-23-3.4 -7.1-2.3-13.2-5.7-18.3-10.2l14.4-17.1c4.4 3.4 9 5.8 13.8 7.2 4.8 1.5 9.6 2.2 14.4 2.2 2.5 0 4.2-0.3 5.3-0.9 1.1-0.6 1.6-1.5 1.6-2.5v-0.2c0-1.2-0.8-2.1-2.4-2.9 -1.6-0.8-4.5-1.6-8.8-2.4 -4.5-0.9-8.8-2-12.9-3.2 -4.1-1.2-7.7-2.8-10.8-4.7 -3.1-1.9-5.6-4.3-7.4-7.2C5.4 39 4.5 35.4 4.5 31.2V31c0-3.8 0.7-7.4 2.2-10.7 1.5-3.3 3.7-6.2 6.6-8.6 2.9-2.5 6.5-4.4 10.7-5.8 4.2-1.4 9.1-2.1 14.7-2.1 7.8 0 14.7 0.9 20.5 2.8 5.9 1.8 11.1 4.6 15.8 8.3L61.9 33c-3.8-2.8-7.9-4.8-12.1-6.1 -4.3-1.3-8.3-1.9-12-1.9 -2 0-3.5 0.3-4.4 0.9 -1 0.6-1.4 1.4-1.4 2.4v0.2c0 1.1 0.7 2 2.2 2.8 1.5 0.8 4.3 1.6 8.5 2.4 5.1 0.9 9.8 2 14 3.3 4.2 1.3 7.8 3 10.9 5C70.5 44.2 72.9 46.6 74.5 49.5zM75.5 28.1h23.7v57.8h26.9V28.1h23.7V5.3H75.5V28.1zM387.9 0.3l-43.3 85.6h25.8l17.5-34.6 17.6 34.6h25.8L387.9 0.3zM267.3 0.3l43.4 85.6h-25.8l-17.5-34.6 -17.5 34.6h-17.5 -8.3 -22.4l-15.2-23h-0.2 -5.5v23h-26.9V5.3H193c7.2 0 13.1 0.8 17.8 2.5 4.6 1.6 8.4 3.9 11.2 6.7 2.5 2.4 4.3 5.2 5.5 8.3 1.2 3.1 1.8 6.7 1.8 10.8v0.2c0 5.9-1.4 10.9-4.3 14.9 -2.8 4.1-6.7 7.3-11.6 9.7l14 20.4L267.3 0.3zM202.5 35.6c0-2.6-0.9-4.5-2.8-5.8 -1.8-1.3-4.3-1.9-7.5-1.9h-11.7v15.8h11.6c3.2 0 5.8-0.7 7.6-2.1 1.8-1.4 2.8-3.3 2.8-5.8V35.6zM345.2 5.3L327.6 40 310 5.3h-25.8l43.4 85.6 43.3-85.6H345.2z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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

@@ -0,0 +1,9 @@
module github.com/open-wanderer/wanderer/plugins/strava
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/strava/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=

126
plugins/strava/main.go Normal file
View File

@@ -0,0 +1,126 @@
//go:build tinygo
package main
import (
"encoding/json"
"strconv"
"github.com/extism/go-pdk"
)
func main() {}
//export list_routes_v1
func listRoutesV1() int32 {
var input listInput
if err := pdk.InputJSON(&input); err != nil {
return fail("invalid_request", "invalid list_routes input: "+err.Error())
}
client, err := newClient(input.Auth)
if err != nil {
return fail("auth_failed", err.Error())
}
output, err := syncRoutes(client, input)
if err != nil {
return fail("provider_unavailable", err.Error())
}
if err := pdk.OutputJSON(output); err != nil {
return fail("internal_error", err.Error())
}
return 0
}
//export list_activities_v1
func listActivitiesV1() int32 {
var input listInput
if err := pdk.InputJSON(&input); err != nil {
return fail("invalid_request", "invalid list_activities input: "+err.Error())
}
client, err := newClient(input.Auth)
if err != nil {
return fail("auth_failed", err.Error())
}
output, err := syncActivities(client, input)
if err != nil {
return fail("provider_unavailable", err.Error())
}
if err := pdk.OutputJSON(output); err != nil {
return fail("internal_error", err.Error())
}
return 0
}
//export get_route_detail_v1
func getRouteDetailV1() int32 {
return getTrailDetail("planned")
}
//export get_activity_detail_v1
func getActivityDetailV1() int32 {
return getTrailDetail("completed")
}
func getTrailDetail(kind string) int32 {
var input detailInput
if err := pdk.InputJSON(&input); err != nil {
return fail("invalid_request", "invalid detail input: "+err.Error())
}
client, err := newClient(input.Auth)
if err != nil {
return fail("auth_failed", err.Error())
}
var item trailImport
switch kind {
case "planned":
route, err := client.route(input.Summary.Source.ExternalID)
if err != nil {
return fail("provider_unavailable", err.Error())
}
gpxData, err := client.routeGPX(input.Summary.Source.ExternalID)
if err != nil {
return fail("provider_unavailable", err.Error())
}
item, err = routeImport(*route, gpxData)
if err != nil {
return fail("provider_unavailable", err.Error())
}
case "completed":
id, err := strconv.ParseInt(input.Summary.Source.ExternalID, 10, 64)
if err != nil {
return fail("invalid_request", "invalid activity external id")
}
detail, err := client.activity(id)
if err != nil {
return fail("provider_unavailable", err.Error())
}
var photos []activityPhoto
if detail.Photos.Count > 0 {
photos, _ = client.activityPhotos(id)
}
streams, err := client.activityStreams(id)
if err != nil {
return fail("provider_unavailable", err.Error())
}
item, err = activityImport(detail, streams, photos)
if err != nil {
return fail("provider_unavailable", err.Error())
}
default:
return fail("invalid_request", "unsupported detail kind")
}
if err := pdk.OutputJSON(detailOutput{Item: item}); err != nil {
return fail("internal_error", err.Error())
}
return 0
}
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
}

228
plugins/strava/mapper.go Normal file
View File

@@ -0,0 +1,228 @@
//go:build tinygo
package main
import (
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx"
)
func routeImport(route route, gpxData []byte) (trailImport, error) {
if len(gpxData) == 0 {
return trailImport{}, fmt.Errorf("route GPX is empty")
}
privacy := privacyFromPrivate(route.Private)
startedAt := time.Unix(route.Timestamp, 0).UTC().Format(time.RFC3339)
return trailImport{
Source: trailImportSource{
Provider: "strava",
ExternalID: route.IDStr,
},
Kind: "planned",
Name: route.Name,
Description: route.Description,
StartedAt: startedAt,
ActivityType: activityTypeForRoute(route.Type),
Privacy: &privacy,
Track: track{
Format: "gpx",
ContentBase64: base64.StdEncoding.EncodeToString(gpxData),
},
Waypoints: routeWaypoints(route),
Metadata: map[string]any{
"distance": route.Distance,
"elevationGain": route.ElevationGain,
"duration": route.EstimatedMovingTime,
"providerCategory": routeCategory(route.Type),
"estimatedMovingTime": route.EstimatedMovingTime,
},
}, nil
}
func activityImport(activity *detailedActivity, streams *activityStreamResponse, photos []activityPhoto) (trailImport, error) {
if len(activity.StartLatlng) < 2 {
return trailImport{}, fmt.Errorf("activity has no start coordinate")
}
gpxData, err := activityGPX(activity, streams)
if err != nil {
return trailImport{}, err
}
privacy := privacyFromPrivate(activity.Private)
return trailImport{
Source: trailImportSource{
Provider: "strava",
ExternalID: strconv.FormatInt(activity.ID, 10),
},
Kind: "completed",
Name: activity.Name,
Description: activity.Description,
StartedAt: activity.StartDate,
ActivityType: activityType(activity),
Privacy: &privacy,
Track: track{
Format: "gpx",
ContentBase64: base64.StdEncoding.EncodeToString(gpxData),
},
Photos: activityPhotos(activity, photos),
Metadata: map[string]any{
"distance": activity.Distance,
"elevationGain": activity.TotalElevationGain,
"duration": activity.ElapsedTime,
"providerCategory": providerActivityType(activity),
},
}, nil
}
func routeWaypoints(route route) []waypoint {
points := make([]waypoint, 0, len(route.Waypoints))
for i, wp := range route.Waypoints {
if len(wp.Latlng) < 2 {
continue
}
name := wp.Title
if name == "" {
name = strconv.Itoa(i)
}
points = append(points, waypoint{
Name: name,
Description: wp.Description,
Lat: wp.Latlng[0],
Lon: wp.Latlng[1],
Icon: "circle",
})
}
return points
}
func activityPhotos(activity *detailedActivity, apiPhotos []activityPhoto) []photo {
photos := make([]photo, 0, len(apiPhotos))
seen := make(map[string]bool, len(apiPhotos))
for _, apiPhoto := range apiPhotos {
url := apiPhoto.Urls.Num600
if url == "" {
url = apiPhoto.Urls.Num100
}
if url == "" {
continue
}
externalID := apiPhoto.UniqueID
if externalID == "" {
externalID = url
}
if seen[externalID] {
continue
}
seen[externalID] = true
photos = append(photos, photo{
ExternalID: externalID,
Filename: fmt.Sprintf("strava-%s.jpg", safePhotoID(externalID)),
Source: mediaSource{
Type: "url",
URL: url,
},
})
}
if len(photos) > 0 {
return photos
}
if activity.Photos.Primary.Urls.Num600 == "" {
return nil
}
externalID := strconv.FormatInt(activity.Photos.Primary.ID, 10)
return []photo{{
ExternalID: externalID,
Filename: fmt.Sprintf("strava-%s.jpg", externalID),
Source: mediaSource{
Type: "url",
URL: activity.Photos.Primary.Urls.Num600,
},
}}
}
func safePhotoID(value string) string {
replacer := strings.NewReplacer("/", "-", "\\", "-", ":", "-", "?", "-", "&", "-", "=", "-")
return replacer.Replace(value)
}
func activityGPX(activity *detailedActivity, streams *activityStreamResponse) ([]byte, error) {
if streams == nil || len(streams.LatLng.Data) == 0 {
return nil, fmt.Errorf("activity has no latlng stream")
}
startedAt, _ := time.Parse(time.RFC3339, activity.StartDate)
points := make([]sdkgpx.Point, 0, len(streams.LatLng.Data))
for i, latlng := range streams.LatLng.Data {
if len(latlng) < 2 || i >= len(streams.Time.Data) {
continue
}
elevation := 0.0
if i < len(streams.Altitude.Data) {
elevation = streams.Altitude.Data[i]
}
point := sdkgpx.Point{
Lat: latlng[0],
Lon: latlng[1],
Elevation: &elevation,
}
if !startedAt.IsZero() {
pointTime := startedAt.Add(time.Duration(streams.Time.Data[i]) * time.Second).UTC()
point.Time = &pointTime
}
points = append(points, point)
}
return sdkgpx.Track("wanderer Strava plugin", activity.Name, points)
}
func privacyFromPrivate(private bool) string {
if private {
return "private"
}
return "public"
}
func activityTypeForRoute(routeType int) string {
switch routeType {
case 1:
return "biking"
case 2:
return "walking"
default:
return ""
}
}
func routeCategory(routeType int) string {
return fmt.Sprintf("route:%d", routeType)
}
func providerActivityType(activity *detailedActivity) string {
if activity.SportType != "" {
return activity.SportType
}
return activity.Type
}
func activityType(activity *detailedActivity) string {
value := providerActivityType(activity)
switch value {
case "AlpineSki", "BackcountrySki", "IceSkate", "NordicSki", "RollerSki", "Snowboard":
return "skiing"
case "Canoeing", "Kayaking", "Kitesurf", "Rowing", "Sail", "StandUpPaddling", "Surfing", "Windsurf":
return "canoeing"
case "Hike", "Snowshoe":
return "hiking"
case "Run", "VirtualRun", "Walk", "Golf", "Skateboard", "Wheelchair":
return "walking"
case "Ride", "EBikeRide", "Handcycle", "InlineSkate", "Velomobile", "VirtualRide":
return "biking"
case "RockClimbing":
return "climbing"
default:
return value
}
}

40
plugins/strava/options.go Normal file
View File

@@ -0,0 +1,40 @@
//go:build tinygo
package main
import (
"strings"
"time"
)
func dateOption(options map[string]any, key string) string {
value, _ := options[key].(string)
return strings.TrimSpace(value)
}
func unixAfter(options map[string]any) int64 {
after := dateOption(options, "after")
if after == "" {
return 0
}
parsed, err := time.Parse("2006-01-02", after)
if err != nil {
return 0
}
return parsed.UTC().Unix()
}
func timeAfterDate(value string, after string) bool {
if after == "" {
return true
}
limit, err := time.Parse("2006-01-02", after)
if err != nil {
return true
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return true
}
return !parsed.Before(limit)
}

541
plugins/strava/plugin.json Normal file
View File

@@ -0,0 +1,541 @@
{
"manifestVersion": "1.0",
"id": "strava",
"type": "trails",
"name": "Strava",
"description": "Imports Strava routes, activities and photos 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": {
"oauth_access_token": {
"type": "oauth2",
"fields": [
"clientId",
"clientSecret"
],
"secretFields": [
"clientSecret",
"accessToken",
"refreshToken"
],
"authorizationUrl": "https://www.strava.com/oauth/authorize",
"tokenUrl": "https://www.strava.com/oauth/token",
"scopes": [
"read_all",
"activity:read_all"
],
"scopeSeparator": ",",
"tokenRequestFormat": "json",
"tokenAuth": "client_secret_post",
"authorizationParams": {
"approval_prompt": "auto"
},
"refresh": {
"mode": "host",
"grantType": "refresh_token"
}
}
}
},
"permissions": {
"network": {
"connectors": [
{
"name": "api",
"type": "public_api",
"fixedBaseURL": "https://www.strava.com/api/v3",
"allowedPathPrefixes": [
"/"
],
"auth": [
"oauth_access_token"
]
},
{
"name": "api_next",
"type": "public_api",
"fixedBaseURL": "https://www.api-v3.strava.com",
"allowedPathPrefixes": [
"/"
],
"auth": [
"oauth_access_token"
]
},
{
"name": "oauth",
"type": "public_api",
"fixedBaseURL": "https://www.strava.com/oauth",
"allowedPathPrefixes": [
"/"
]
}
]
},
"auth": [
"oauth_access_token"
],
"downloads": {
"maxBytes": 1048576,
"contentTypes": [
"application/json",
"application/gpx+xml",
"application/xml",
"text/xml",
"application/octet-stream"
]
}
},
"configSchema": [
{
"key": "after",
"type": "date",
"label": "Start date",
"labels": {
"de": "Startdatum",
"en": "Start date"
},
"description": "Ignore routes and activities before this date.",
"descriptions": {
"cs": "Pokud váš účet obsahuje velké množství aktivit, můžete narazit na limit API služby Strava, což znemožní synchronizaci všech aktivit najednou. Tomuto problému předejdete nastavením data \"Od\" a dále - synchronizují se tak pouze aktivity zaznamenané po tomto datu.",
"de": "Wenn Ihr Konto eine große Anzahl von Aktivitäten enthält, kann es vorkommen, dass Sie aufgrund der API-Restriktionen von Strava nicht alle Aktivitäten auf einmal synchronisieren können. Um dieses Problem zu umgehen, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.",
"en": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an start date below so that only activities that were recorded after this date are synced.",
"es": "Si tu cuenta tiene una gran cantidad de actividades, es posible que alcances el límite de peticiones de la API de Strava, lo que impedirá la sincronización de todas las actividades a la vez. Para mitigar este problema, puedes establecer una fecha \"Posterior a\" a continuación, de modo que solo se sincronicen las actividades que se registraron después de esa fecha.",
"eu": "Zure kontuak ekintza esko baditu Stravaren APIaren mugekin topo egin dezakezu eta agian ezingo dituzu zure ekintza guztiak aldi berean inportatu. Horretarako data jakin batetik aurrerako ekintzak sinkronizatzeko aukera duzu.",
"fr": "Si votre compte a une grande quantité d'activités, vous pouvez rencontrer la limite d'utilisation de l'API de Strava vous empêchant de synchroniser toutes les activités en même temps. Pour atténuer ce problème, vous pouvez définir une date \"Après-\" ci-dessous afin que seules les activités qui ont été enregistrées après cette date soient synchronisées.",
"it": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
"hu": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
"nl": "Als uw account een grote hoeveelheid activiteiten heeft, kunt u op de API-limiet van Strava botsen voorkomend dat u alle activiteiten tegelijk synchroniseert. Om dit probleem te omzeilen kunt u een \"Later\" datum hieronder instellen, zodat alleen activiteiten die na deze datum werden opgenomen worden gesynchroniseerd.",
"no": "Hvis kontoen din har en stor mengde aktiviteter kan du støte på Stravas API-hastighetsgrense som hindrer deg i å synkronisere alle aktiviteter samtidig. For å redusere dette problemet kan du sette en \"Etter\" dato nedenfor slik at bare aktiviteter som ble registrert etter denne datoen blir synkronisert.",
"pl": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
"pt": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
"ru": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
"zh": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced."
}
}
],
"hostConfig": {
"categoryMapping": {
"route:1": "Biking",
"route:2": "Walking",
"AlpineSki": "Skiing",
"BackcountrySki": "Skiing",
"Badminton": "Other",
"Canoeing": "Canoeing",
"Crossfit": "Workout",
"EBikeRide": "Biking",
"EMountainBikeRide": "Biking",
"Elliptical": "Workout",
"Golf": "Other",
"GravelRide": "Biking",
"Handcycle": "Biking",
"HighIntensityIntervalTraining": "Other",
"Hike": "Hiking",
"IceSkate": "Skiing",
"InlineSkate": "Walking",
"Kayaking": "Canoeing",
"Kitesurf": "Canoeing",
"MountainBikeRide": "Biking",
"NordicSki": "Skiing",
"Pickleball": "Other",
"Pilates": "Other",
"Racquetball": "Other",
"Ride": "Biking",
"RockClimbing": "Climbing",
"RollerSki": "Skiing",
"Rowing": "Canoeing",
"Run": "Walking",
"Sail": "Canoeing",
"Skateboard": "Walking",
"Snowboard": "Skiing",
"Snowshoe": "Hiking",
"Soccer": "Other",
"Squash": "Other",
"StairStepper": "Workout",
"StandUpPaddling": "Canoeing",
"Surfing": "Canoeing",
"Swim": "Other",
"TableTennis": "Other",
"Tennis": "Other",
"TrailRun": "Other",
"Training": "Other",
"Velomobile": "Biking",
"VirtualRide": "Biking",
"VirtualRow": "Other",
"VirtualRun": "Walking",
"Walk": "Walking",
"WeightTraining": "Workout",
"Wheelchair": "Walking",
"Windsurf": "Canoeing",
"Workout": "Workout",
"Yoga": "Workout"
}
},
"metadata": {
"descriptions": {
"cs": "Synchronizuje vaše trasy a aktivity z aplikace Strava s Wandererem v pravidelných intervalech.",
"de": "Importiert Strava-Routen, Aktivitäten und Fotos in wanderer.",
"en": "Imports Strava routes, activities and photos into wanderer.",
"es": "Sincroniza tus recorridos y actividades de Strava con Wanderer en intervalos regulares.",
"eu": "Zure stravako ibilbideak wandererekin sinkronizatzen ditu aldian behin.",
"fr": "Synchronisez vos itinéraires et vos activités Strava avec wanderer à intervalles réguliers.",
"it": "Syncs your strava routes & activities with wanderer in regular intervals.",
"hu": "Syncs your strava routes & activities with wanderer in regular intervals.",
"nl": "Synchroniseert je Strava-routes en -activiteiten met Wanderer op regelmatige tijdstippen.",
"no": "Synkroniserer dine Strava-ruter og aktiviteter med Wanderer med jevne mellomrom.",
"pl": "Synchronizuje trasy i aktywność z wanderer w równych odstępach.",
"pt": "Syncs your strava routes & activities with wanderer in regular intervals.",
"ru": "Синхронизирует ваши данные со Strava.",
"zh": "定期与strava同步您的wanderer路线和活动。"
},
"icons": {
"light": "icon.svg"
},
"providerCategories": {
"route:1": {
"labels": {
"de": "Strava-Route: Radfahren",
"en": "Strava route: cycling"
}
},
"route:2": {
"labels": {
"de": "Strava-Route: Laufen",
"en": "Strava route: running"
}
},
"AlpineSki": {
"labels": {
"de": "Ski alpin",
"en": "Alpine ski"
}
},
"BackcountrySki": {
"labels": {
"de": "Skitour",
"en": "Backcountry ski"
}
},
"Badminton": {
"labels": {
"de": "Badminton",
"en": "Badminton"
}
},
"Canoeing": {
"labels": {
"de": "Kanufahren",
"en": "Canoeing"
}
},
"Crossfit": {
"labels": {
"de": "Crossfit",
"en": "Crossfit"
}
},
"EBikeRide": {
"labels": {
"de": "E-Bike-Fahrt",
"en": "E-bike ride"
}
},
"EMountainBikeRide": {
"labels": {
"de": "E-Mountainbike-Fahrt",
"en": "E-mountain bike ride"
}
},
"Elliptical": {
"labels": {
"de": "Crosstrainer",
"en": "Elliptical"
}
},
"Golf": {
"labels": {
"de": "Golf",
"en": "Golf"
}
},
"GravelRide": {
"labels": {
"de": "Gravel-Fahrt",
"en": "Gravel ride"
}
},
"Handcycle": {
"labels": {
"de": "Handbike",
"en": "Handcycle"
}
},
"HighIntensityIntervalTraining": {
"labels": {
"de": "HIIT",
"en": "High-intensity interval training"
}
},
"Hike": {
"labels": {
"de": "Wandern",
"en": "Hike"
}
},
"IceSkate": {
"labels": {
"de": "Schlittschuhlaufen",
"en": "Ice skate"
}
},
"InlineSkate": {
"labels": {
"de": "Inlineskaten",
"en": "Inline skate"
}
},
"Kayaking": {
"labels": {
"de": "Kajakfahren",
"en": "Kayaking"
}
},
"Kitesurf": {
"labels": {
"de": "Kitesurfen",
"en": "Kitesurf"
}
},
"MountainBikeRide": {
"labels": {
"de": "Mountainbike-Fahrt",
"en": "Mountain bike ride"
}
},
"NordicSki": {
"labels": {
"de": "Langlauf",
"en": "Nordic ski"
}
},
"Pickleball": {
"labels": {
"de": "Pickleball",
"en": "Pickleball"
}
},
"Pilates": {
"labels": {
"de": "Pilates",
"en": "Pilates"
}
},
"Racquetball": {
"labels": {
"de": "Racquetball",
"en": "Racquetball"
}
},
"Ride": {
"labels": {
"de": "Radfahren",
"en": "Ride"
}
},
"RockClimbing": {
"labels": {
"de": "Felsklettern",
"en": "Rock climbing"
}
},
"RollerSki": {
"labels": {
"de": "Rollski",
"en": "Roller ski"
}
},
"Rowing": {
"labels": {
"de": "Rudern",
"en": "Rowing"
}
},
"Run": {
"labels": {
"de": "Laufen",
"en": "Run"
}
},
"Sail": {
"labels": {
"de": "Segeln",
"en": "Sail"
}
},
"Skateboard": {
"labels": {
"de": "Skateboard",
"en": "Skateboard"
}
},
"Snowboard": {
"labels": {
"de": "Snowboard",
"en": "Snowboard"
}
},
"Snowshoe": {
"labels": {
"de": "Schneeschuhwandern",
"en": "Snowshoe"
}
},
"Soccer": {
"labels": {
"de": "Fussball",
"en": "Soccer"
}
},
"Squash": {
"labels": {
"de": "Squash",
"en": "Squash"
}
},
"StairStepper": {
"labels": {
"de": "Stepper",
"en": "Stair stepper"
}
},
"StandUpPaddling": {
"labels": {
"de": "Stand-up-Paddling",
"en": "Stand-up paddling"
}
},
"Surfing": {
"labels": {
"de": "Surfen",
"en": "Surfing"
}
},
"Swim": {
"labels": {
"de": "Schwimmen",
"en": "Swim"
}
},
"TableTennis": {
"labels": {
"de": "Tischtennis",
"en": "Table tennis"
}
},
"Tennis": {
"labels": {
"de": "Tennis",
"en": "Tennis"
}
},
"TrailRun": {
"labels": {
"de": "Trailrun",
"en": "Trail run"
}
},
"Training": {
"labels": {
"de": "Training",
"en": "Training"
}
},
"Velomobile": {
"labels": {
"de": "Velomobil",
"en": "Velomobile"
}
},
"VirtualRide": {
"labels": {
"de": "Virtuelle Radfahrt",
"en": "Virtual ride"
}
},
"VirtualRow": {
"labels": {
"de": "Virtuelles Rudern",
"en": "Virtual row"
}
},
"VirtualRun": {
"labels": {
"de": "Virtueller Lauf",
"en": "Virtual run"
}
},
"Walk": {
"labels": {
"de": "Gehen",
"en": "Walk"
}
},
"WeightTraining": {
"labels": {
"de": "Krafttraining",
"en": "Weight training"
}
},
"Wheelchair": {
"labels": {
"de": "Rollstuhl",
"en": "Wheelchair"
}
},
"Windsurf": {
"labels": {
"de": "Windsurfen",
"en": "Windsurf"
}
},
"Workout": {
"labels": {
"de": "Training",
"en": "Workout"
}
},
"Yoga": {
"labels": {
"de": "Yoga",
"en": "Yoga"
}
}
}
}
}

199
plugins/strava/strava.go Normal file
View File

@@ -0,0 +1,199 @@
//go:build tinygo
package main
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"time"
"github.com/open-wanderer/wanderer/plugins/sdk"
)
// Strava is migrating its API host: the new host "https://www.api-v3.strava.com"
// is available from 2027-01-04 and the old one is retired on 2027-06-01 (June
// 2026 Developer Program update). We cut over on 2027-03-01 — after the new host
// has had time to stabilize, well before the old one disappears — so no manual
// change or release is needed at the deadline.
func stravaConnector() string {
return pickStravaConnector(time.Now())
}
func pickStravaConnector(now time.Time) string {
cutover := time.Date(2027, 3, 1, 0, 0, 0, 0, time.UTC)
if now.Before(cutover) {
return "api"
}
return "api_next"
}
type stravaClient struct {
accessToken string
}
func newClient(auth map[string]any) (*stravaClient, error) {
token := sdk.StringField(auth, "accessToken")
if token == "" {
return nil, fmt.Errorf("accessToken is required")
}
return &stravaClient{accessToken: token}, nil
}
func (c *stravaClient) routes(page int, perPage int) ([]route, error) {
var routes []route
err := c.getJSON("/athlete/routes", []sdk.QueryParam{
{Name: "page", Value: strconv.Itoa(page)},
{Name: "per_page", Value: strconv.Itoa(perPage)},
}, &routes)
return routes, err
}
func (c *stravaClient) route(id string) (*route, error) {
var route route
err := c.getJSON("/routes/"+url.PathEscape(id), nil, &route)
return &route, err
}
func (c *stravaClient) routeGPX(id string) ([]byte, error) {
return c.getBytes("/routes/" + url.PathEscape(id) + "/export_gpx")
}
func (c *stravaClient) activities(page int, perPage int, after int64) ([]activity, error) {
var activities []activity
err := c.getJSON("/athlete/activities", []sdk.QueryParam{
{Name: "page", Value: strconv.Itoa(page)},
{Name: "per_page", Value: strconv.Itoa(perPage)},
{Name: "after", Value: strconv.FormatInt(after, 10)},
}, &activities)
return activities, err
}
func (c *stravaClient) activity(id int64) (*detailedActivity, error) {
var activity detailedActivity
err := c.getJSON(fmt.Sprintf("/activities/%d", id), nil, &activity)
return &activity, err
}
func (c *stravaClient) activityStreams(id int64) (*activityStreamResponse, error) {
var streams activityStreamResponse
err := c.getJSON(fmt.Sprintf("/activities/%d/streams", id), []sdk.QueryParam{
{Name: "keys", Value: "latlng,time,altitude"},
{Name: "key_by_type", Value: "true"},
}, &streams)
return &streams, err
}
func (c *stravaClient) activityPhotos(id int64) ([]activityPhoto, error) {
var photos []activityPhoto
err := c.getJSON(fmt.Sprintf("/activities/%d/photos", id), []sdk.QueryParam{{Name: "size", Value: "600"}}, &photos)
return photos, err
}
func (c *stravaClient) getJSON(path string, query []sdk.QueryParam, out any) error {
response, body, err := c.request(path, query, []string{"application/json"})
if err != nil {
return err
}
if response.Status < 200 || response.Status >= 300 {
return fmt.Errorf("strava request failed (%d): %s", response.Status, string(body))
}
return json.Unmarshal(body, out)
}
func (c *stravaClient) getBytes(path string) ([]byte, error) {
response, body, err := c.request(path, nil, []string{"application/gpx+xml", "application/octet-stream", "text/xml", "application/xml"})
if err != nil {
return nil, err
}
if response.Status < 200 || response.Status >= 300 {
return nil, fmt.Errorf("strava request failed (%d): %s", response.Status, string(body))
}
return body, nil
}
func (c *stravaClient) request(path string, query []sdk.QueryParam, contentTypes []string) (sdk.HostResponse, []byte, error) {
accept := "application/json"
if len(contentTypes) > 0 {
accept = contentTypes[0]
}
return sdk.HostRequest(sdk.HostRequestSpec{
Method: "GET",
Target: sdk.RequestTarget{
Type: "connector",
Connector: stravaConnector(),
Path: path,
Query: query,
},
Headers: map[string]string{
sdk.AuthHeaderAuthorization: sdk.AuthSchemeBearer + " " + c.accessToken,
"Accept": accept,
},
Expect: sdk.ResponseExpect{
ContentTypes: contentTypes,
MaxBytes: 1048576,
},
})
}
func syncRoutes(client *stravaClient, input listInput) (listOutput, error) {
page := sdk.IntState(input.State, "page", 1)
if page <= 0 {
page = 1
}
rows, err := client.routes(page, sdk.SyncLimit(input))
if err != nil {
return listOutput{}, err
}
after := dateOption(input.Options, "after")
items := make([]trailSummary, 0, sdk.SyncLimit(input))
for _, row := range rows {
if !timeAfterDate(row.CreatedAt, after) {
continue
}
items = append(items, trailSummary{
Source: trailImportSource{Provider: "strava", ExternalID: row.IDStr},
Kind: "planned",
})
if len(items) >= sdk.SyncLimit(input) {
break
}
}
nextPage := page + 1
hasMore := len(rows) >= sdk.SyncLimit(input)
return listOutput{
Items: items,
State: sdk.NextPageState(nextPage, hasMore),
HasMore: hasMore,
}, nil
}
func syncActivities(client *stravaClient, input listInput) (listOutput, error) {
page := sdk.IntState(input.State, "page", 1)
if page <= 0 {
page = 1
}
rows, err := client.activities(page, sdk.SyncLimit(input), unixAfter(input.Options))
if err != nil {
return listOutput{}, err
}
items := make([]trailSummary, 0, sdk.SyncLimit(input))
for _, row := range rows {
externalID := strconv.FormatInt(row.ID, 10)
items = append(items, trailSummary{
Source: trailImportSource{Provider: "strava", ExternalID: externalID},
Kind: "completed",
})
if len(items) >= sdk.SyncLimit(input) {
break
}
}
nextPage := page + 1
hasMore := len(rows) >= sdk.SyncLimit(input)
return listOutput{
Items: items,
State: sdk.NextPageState(nextPage, hasMore),
HasMore: hasMore,
}, nil
}

95
plugins/strava/types.go Normal file
View File

@@ -0,0 +1,95 @@
package main
import "github.com/open-wanderer/wanderer/plugins/sdk"
type instanceRef = sdk.InstanceRef
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 route struct {
Description string `json:"description"`
Distance float64 `json:"distance"`
ElevationGain float64 `json:"elevation_gain"`
IDStr string `json:"id_str"`
Name string `json:"name"`
Private bool `json:"private"`
Timestamp int64 `json:"timestamp"`
Type int `json:"type"`
CreatedAt string `json:"created_at"`
EstimatedMovingTime int `json:"estimated_moving_time"`
Waypoints []routeWaypoint `json:"waypoints"`
}
type routeWaypoint struct {
Latlng []float64 `json:"latlng"`
Title string `json:"title"`
Description string `json:"description"`
}
type activity struct {
ID int64 `json:"id"`
}
type detailedActivity struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Distance float64 `json:"distance"`
ElapsedTime int `json:"elapsed_time"`
TotalElevationGain float64 `json:"total_elevation_gain"`
Private bool `json:"private"`
StartDate string `json:"start_date"`
StartLatlng []float64 `json:"start_latlng"`
Type string `json:"type"`
SportType string `json:"sport_type"`
Photos photos `json:"photos"`
}
type photos struct {
Count int `json:"count"`
Primary primaryPhoto `json:"primary"`
}
type primaryPhoto struct {
ID int64 `json:"id"`
Urls photoURLs `json:"urls"`
}
type photoURLs struct {
Num100 string `json:"100"`
Num600 string `json:"600"`
}
type activityPhoto struct {
UniqueID string `json:"unique_id"`
Urls photoURLs `json:"urls"`
}
type activityStreamResponse struct {
LatLng streamLatLng `json:"latlng"`
Time streamInt `json:"time"`
Altitude streamFloat64 `json:"altitude"`
}
type streamLatLng struct {
Data [][]float64 `json:"data"`
}
type streamInt struct {
Data []int `json:"data"`
}
type streamFloat64 struct {
Data []float64 `json:"data"`
}