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:
518
plugins/README.md
Normal file
518
plugins/README.md
Normal 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
|
||||
```
|
||||
16
plugins/hammerhead/Makefile
Normal file
16
plugins/hammerhead/Makefile
Normal 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
|
||||
29
plugins/hammerhead/README.md
Normal file
29
plugins/hammerhead/README.md
Normal 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
|
||||
```
|
||||
15
plugins/hammerhead/assets/icon.svg
Normal file
15
plugins/hammerhead/assets/icon.svg
Normal 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 |
15
plugins/hammerhead/assets/icon_dark.svg
Normal file
15
plugins/hammerhead/assets/icon_dark.svg
Normal 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 |
9
plugins/hammerhead/go.mod
Normal file
9
plugins/hammerhead/go.mod
Normal 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
|
||||
2
plugins/hammerhead/go.sum
Normal file
2
plugins/hammerhead/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
||||
66
plugins/hammerhead/gpx.go
Normal file
66
plugins/hammerhead/gpx.go
Normal 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)
|
||||
}
|
||||
163
plugins/hammerhead/hammerhead.go
Normal file
163
plugins/hammerhead/hammerhead.go
Normal 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)
|
||||
}
|
||||
97
plugins/hammerhead/hammerhead_test.go
Normal file
97
plugins/hammerhead/hammerhead_test.go
Normal 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 & 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
28
plugins/hammerhead/jwt.go
Normal 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
350
plugins/hammerhead/main.go
Normal 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
|
||||
}
|
||||
131
plugins/hammerhead/plugin.json
Normal file
131
plugins/hammerhead/plugin.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
plugins/hammerhead/send.go
Normal file
24
plugins/hammerhead/send.go
Normal 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
106
plugins/hammerhead/types.go
Normal 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
15
plugins/komoot/Makefile
Normal file
@@ -0,0 +1,15 @@
|
||||
PLUGIN_ID := komoot
|
||||
DIST_DIR := dist/$(PLUGIN_ID)
|
||||
|
||||
.PHONY: build manifest clean
|
||||
|
||||
build: manifest
|
||||
tinygo build -target=wasi -scheduler=none -no-debug -o $(DIST_DIR)/plugin.wasm .
|
||||
|
||||
manifest:
|
||||
mkdir -p $(DIST_DIR)
|
||||
go run github.com/open-wanderer/wanderer/plugins/sdk/cmd/manifestcheck > $(DIST_DIR)/plugin.json
|
||||
cp assets/icon.svg $(DIST_DIR)/icon.svg
|
||||
|
||||
clean:
|
||||
rm -rf dist
|
||||
11
plugins/komoot/README.md
Normal file
11
plugins/komoot/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# wanderer Komoot WASM Plugin
|
||||
|
||||
Komoot provider for the wanderer WASM plugin system.
|
||||
|
||||
```sh
|
||||
make build
|
||||
```
|
||||
|
||||
The build output is written to `dist/komoot`. Copy it below `data/plugins` or
|
||||
run `make plugins-install-local` from the repository root to install all bundled
|
||||
plugins locally.
|
||||
197
plugins/komoot/assets/icon.svg
Normal file
197
plugins/komoot/assets/icon.svg
Normal file
@@ -0,0 +1,197 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="295.82901mm"
|
||||
height="78.07637mm"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
viewBox="0 0 1118.0939 295.09179"
|
||||
id="svg882"
|
||||
sodipodi:docname="komoot-logo-type.svg"
|
||||
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview884"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
showgrid="false"
|
||||
units="mm"
|
||||
fit-margin-top="0"
|
||||
fit-margin-left="0"
|
||||
fit-margin-right="0"
|
||||
fit-margin-bottom="0"
|
||||
inkscape:zoom="0.98784722"
|
||||
inkscape:cx="456.04218"
|
||||
inkscape:cy="390.74868"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="996"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg882"
|
||||
inkscape:document-units="mm" />
|
||||
<path
|
||||
d="m 442.20776,107.91206 h 37.51116 l -44.92654,49.82164 49.33677,55.2986 h -36.83341 c -0.8717,0 -39.20823,-44.92654 -39.20823,-44.92654 v 44.87805 H 378.47554 V 77.282918 h 29.61197 v 72.794032 l 34.12025,-42.16382 z"
|
||||
fill="url(#paint0_linear)"
|
||||
style="font-variation-settings:normal;fill:url(#linearGradient966);stroke-width:10.775;-inkscape-stroke:none"
|
||||
id="path855" />
|
||||
<g
|
||||
clip-rule="evenodd"
|
||||
fill="url(#paint0_linear)"
|
||||
fill-rule="evenodd"
|
||||
id="g863"
|
||||
style="fill:url(#linearGradient974)"
|
||||
transform="matrix(10.775043,0,0,10.775043,-24.847249,-24.836474)">
|
||||
<path
|
||||
d="m 47.003,17.173 c 0,-2.8382 2.2984,-5.1411 5.1321,-5.1411 2.8337,0 5.1321,2.3029 5.1321,5.1411 0,2.8382 -2.2984,5.1411 -5.1321,5.1411 -2.8337,0 -5.1321,-2.2984 -5.1321,-5.1411 z m 2.6987,0.0045 c 0,1.4168 1.0885,2.5638 2.4334,2.5638 1.3449,0 2.4333,-1.147 2.4333,-2.5638 0,-1.4168 -1.0884,-2.5638 -2.4333,-2.5638 -1.3449,0 -2.4334,1.147 -2.4334,2.5638 z"
|
||||
style="font-variation-settings:normal;fill:url(#linearGradient968);-inkscape-stroke:none"
|
||||
id="path857" />
|
||||
<path
|
||||
d="m 75.965,17.173 c 0,-2.8382 2.2984,-5.1411 5.1321,-5.1411 2.8337,0 5.1321,2.3029 5.1321,5.1411 0,2.8382 -2.2984,5.1411 -5.1321,5.1411 -2.8337,0 -5.1321,-2.2984 -5.1321,-5.1411 z m 2.6987,0.0045 c 0,1.4258 1.0885,2.5818 2.4334,2.5818 1.3449,0 2.4334,-1.156 2.4334,-2.5818 0,-1.4258 -1.0885,-2.5818 -2.4334,-2.5818 -1.3449,0 -2.4334,1.156 -2.4334,2.5818 z"
|
||||
style="font-variation-settings:normal;fill:url(#linearGradient970);-inkscape-stroke:none"
|
||||
id="path859" />
|
||||
<path
|
||||
d="m 92.819,12.032 c -2.8337,0 -5.1321,2.3029 -5.1321,5.1411 0,2.8427 2.2984,5.1411 5.1321,5.1411 2.8337,0 5.1321,-2.3029 5.1321,-5.1411 0,-2.8382 -2.2984,-5.1411 -5.1321,-5.1411 z m 0,7.7274 c -1.3448,0 -2.4333,-1.156 -2.4333,-2.5818 0,-1.4258 1.0885,-2.5818 2.4333,-2.5818 1.3449,0 2.4334,1.156 2.4334,2.5818 0,1.4258 -1.0885,2.5818 -2.4334,2.5818 z"
|
||||
style="font-variation-settings:normal;fill:url(#linearGradient972);-inkscape-stroke:none"
|
||||
id="path861" />
|
||||
</g>
|
||||
<g
|
||||
fill="url(#paint0_linear)"
|
||||
id="g873"
|
||||
style="fill:url(#linearGradient984)"
|
||||
transform="matrix(10.775043,0,0,10.775043,-24.847249,-24.836474)">
|
||||
<path
|
||||
d="m 105.52,19.89 c -1.03,0.1755 -1.615,-0.2384 -1.858,-0.4902 -0.252,-0.2609 -0.436,-0.6837 -0.436,-0.9581 v -3.8052 h 2.564 v -2.2984 h -2.564 V 9.6843 h -2.717 v 2.6538 h -1.8575 v 2.2984 h 1.8575 v 3.8052 c 0,2.1365 1.736,3.8727 3.873,3.8727 0,0 0.994,0.036 1.691,-0.2519 z"
|
||||
style="font-variation-settings:normal;fill:url(#linearGradient976);-inkscape-stroke:none"
|
||||
id="path865" />
|
||||
<path
|
||||
d="m 61.788,17.016 c -0.009,1.2279 0,5.0421 0,5.0421 v 0.0045 h -2.7348 v -9.72 h 2.2805 l 0.3328,1.1965 c 0.4768,-0.7422 1.0795,-1.201 1.9746,-1.3629 0.9985,-0.1799 1.8621,-0.0674 2.6358,0.3374 0.4948,0.2608 0.8861,0.6702 1.1649,1.2189 0.0045,-0.009 0.0135,-0.0225 0.0225,-0.0315 0.1124,-0.1528 0.225,-0.3059 0.3464,-0.4498 0.4812,-0.5667 1.1109,-0.922 1.8711,-1.066 0.7556,-0.1394 1.4798,-0.1124 2.159,0.09 0.7781,0.2294 1.3763,0.7151 1.7722,1.4393 0.2968,0.5398 0.4677,1.156 0.5262,1.8756 0.027,0.3509 0.0405,0.6657 0.0405,0.9716 v 5.5054 h -2.7212 c 0,0 0.009,-4.246 0,-5.6269 -0.0045,-0.4228 -0.1035,-0.8006 -0.2834,-1.1244 -0.1889,-0.3329 -0.4543,-0.5353 -0.8186,-0.6252 -0.4498,-0.1125 -0.8276,-0.0945 -1.2235,0.0584 -0.5082,0.1934 -0.8501,0.5848 -1.048,1.192 -0.1034,0.3148 -0.1529,0.6702 -0.1529,1.075 v 5.0421 h -2.6583 v -5.5414 c 0,-0.3328 -0.0179,-0.7242 -0.1709,-1.084 -0.2114,-0.5083 -0.5937,-0.7736 -1.1739,-0.8096 -0.4363,-0.027 -0.8051,0.036 -1.129,0.1889 -0.5173,0.2474 -0.7601,0.6747 -0.9041,1.2819 -0.0674,0.2924 -0.1079,0.6027 -0.1079,0.9221 z"
|
||||
style="font-variation-settings:normal;fill:url(#linearGradient978);-inkscape-stroke:none"
|
||||
id="path867" />
|
||||
<path
|
||||
d="m 2.3064,15.998 c 0,-7.5486 6.1446,-13.693 13.693,-13.693 7.5532,0 13.693,6.1446 13.693,13.693 0,3.0384 -0.9752,5.9053 -2.8172,8.3116 l -6.8669,-6.8669 c 0.1715,-0.4695 0.2573,-0.9571 0.2573,-1.4492 0,-2.3522 -1.9142,-4.2664 -4.2664,-4.2664 -2.3522,0 -4.2664,1.9142 -4.2664,4.2664 0,0.4921 0.0858,0.9797 0.2573,1.4492 L 5.1232,24.3096 C 3.2812,21.9078 2.306,19.0364 2.306,15.998 Z"
|
||||
id="path869"
|
||||
style="fill:url(#linearGradient980)" />
|
||||
<path
|
||||
d="m 13.489,19.231 2.5102,-3.9143 2.5102,3.9097 6.8037,6.8038 c -2.5418,2.3612 -5.8421,3.6614 -9.3139,3.6614 -3.4718,0 -6.7721,-1.3002 -9.3139,-3.6614 z"
|
||||
id="path871"
|
||||
style="fill:url(#linearGradient982)" />
|
||||
</g>
|
||||
<defs
|
||||
id="defs880">
|
||||
<linearGradient
|
||||
id="paint0_linear"
|
||||
x1="16"
|
||||
x2="16"
|
||||
y1="2.3069999"
|
||||
y2="29.691999"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop
|
||||
stop-color="#8FCE3C"
|
||||
offset="0"
|
||||
id="stop875" />
|
||||
<stop
|
||||
stop-color="#64A322"
|
||||
offset="1"
|
||||
id="stop877" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#paint0_linear"
|
||||
id="linearGradient966"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="16"
|
||||
y1="2.3069999"
|
||||
x2="16"
|
||||
y2="29.691999"
|
||||
gradientTransform="matrix(10.775043,0,0,10.775043,-24.847249,-24.836474)" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#paint0_linear"
|
||||
id="linearGradient968"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="16"
|
||||
y1="2.3069999"
|
||||
x2="16"
|
||||
y2="29.691999" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#paint0_linear"
|
||||
id="linearGradient970"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="16"
|
||||
y1="2.3069999"
|
||||
x2="16"
|
||||
y2="29.691999" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#paint0_linear"
|
||||
id="linearGradient972"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="16"
|
||||
y1="2.3069999"
|
||||
x2="16"
|
||||
y2="29.691999" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#paint0_linear"
|
||||
id="linearGradient974"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="16"
|
||||
y1="2.3069999"
|
||||
x2="16"
|
||||
y2="29.691999" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#paint0_linear"
|
||||
id="linearGradient976"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="16"
|
||||
y1="2.3069999"
|
||||
x2="16"
|
||||
y2="29.691999" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#paint0_linear"
|
||||
id="linearGradient978"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="16"
|
||||
y1="2.3069999"
|
||||
x2="16"
|
||||
y2="29.691999" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#paint0_linear"
|
||||
id="linearGradient980"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="16"
|
||||
y1="2.3069999"
|
||||
x2="16"
|
||||
y2="29.691999" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#paint0_linear"
|
||||
id="linearGradient982"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="16"
|
||||
y1="2.3069999"
|
||||
x2="16"
|
||||
y2="29.691999" />
|
||||
<linearGradient
|
||||
inkscape:collect="always"
|
||||
xlink:href="#paint0_linear"
|
||||
id="linearGradient984"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="16"
|
||||
y1="2.3069999"
|
||||
x2="16"
|
||||
y2="29.691999" />
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.4 KiB |
24
plugins/komoot/auth.go
Normal file
24
plugins/komoot/auth.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/open-wanderer/wanderer/plugins/sdk"
|
||||
)
|
||||
|
||||
func (c *komootClient) requestHeaders(connector string) map[string]string {
|
||||
headers := map[string]string{
|
||||
"Accept": "application/hal+json",
|
||||
}
|
||||
if connector == "api" {
|
||||
headers[sdk.AuthHeaderAuthorization] = basicAuth(c.userID, c.token)
|
||||
}
|
||||
if language := acceptLanguage(c.locale); language != "" {
|
||||
headers["Accept-Language"] = language
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
func basicAuth(username string, password string) string {
|
||||
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
|
||||
}
|
||||
9
plugins/komoot/go.mod
Normal file
9
plugins/komoot/go.mod
Normal file
@@ -0,0 +1,9 @@
|
||||
module github.com/open-wanderer/wanderer/plugins/komoot
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/extism/go-pdk v1.1.3
|
||||
|
||||
require github.com/open-wanderer/wanderer/plugins/sdk v0.0.0
|
||||
|
||||
replace github.com/open-wanderer/wanderer/plugins/sdk => ../sdk
|
||||
2
plugins/komoot/go.sum
Normal file
2
plugins/komoot/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
||||
301
plugins/komoot/komoot.go
Normal file
301
plugins/komoot/komoot.go
Normal file
@@ -0,0 +1,301 @@
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/open-wanderer/wanderer/plugins/sdk"
|
||||
)
|
||||
|
||||
const komootJSONMaxBytes int64 = 16 * 1024 * 1024
|
||||
const komootMaxHighlightTipRequests = 20
|
||||
|
||||
var komootJSONContentTypes = []string{"application/json", "application/hal+json"}
|
||||
|
||||
var errTourKindMismatch = errors.New("tour kind mismatch")
|
||||
|
||||
func login(email string, password string) (*komootClient, error) {
|
||||
response, body, err := sdk.HostRequest(sdk.HostRequestSpec{
|
||||
Method: "GET",
|
||||
Target: sdk.RequestTarget{
|
||||
Type: "connector",
|
||||
Connector: "api",
|
||||
Path: "/v006/account/email/" + url.PathEscape(email) + "/",
|
||||
},
|
||||
Headers: map[string]string{
|
||||
sdk.AuthHeaderAuthorization: basicAuth(email, password),
|
||||
"Accept": "application/hal+json",
|
||||
},
|
||||
Expect: sdk.ResponseExpect{
|
||||
ContentTypes: komootJSONContentTypes,
|
||||
MaxBytes: komootJSONMaxBytes,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.Status != 200 {
|
||||
return nil, fmt.Errorf("komoot login failed (%d): %s", response.Status, string(body))
|
||||
}
|
||||
|
||||
var parsed loginResponse
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parsed.Username == "" || parsed.Password == "" {
|
||||
return nil, fmt.Errorf("komoot login response did not contain credentials")
|
||||
}
|
||||
client := &komootClient{userID: parsed.Username, token: parsed.Password, locale: parsed.Locale}
|
||||
if client.locale == "" {
|
||||
client.locale = client.profileLocale()
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func loginClient(auth map[string]any) (*komootClient, error) {
|
||||
email := sdk.StringField(auth, "email")
|
||||
password := sdk.StringField(auth, "password")
|
||||
if email == "" || password == "" {
|
||||
return nil, fmt.Errorf("email and password are required")
|
||||
}
|
||||
return login(email, password)
|
||||
}
|
||||
|
||||
func (c *komootClient) get(path string, query []sdk.QueryParam, out any) error {
|
||||
body, err := c.getRawFromConnector("api", path, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(body, out)
|
||||
}
|
||||
|
||||
func (c *komootClient) getFromConnector(connector string, path string, query []sdk.QueryParam, out any) error {
|
||||
body, err := c.getRawFromConnector(connector, path, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(body, out)
|
||||
}
|
||||
|
||||
func (c *komootClient) getRawFromConnector(connector string, path string, query []sdk.QueryParam) ([]byte, error) {
|
||||
headers := c.requestHeaders(connector)
|
||||
response, body, err := sdk.HostRequest(sdk.HostRequestSpec{
|
||||
Method: "GET",
|
||||
Target: sdk.RequestTarget{
|
||||
Type: "connector",
|
||||
Connector: connector,
|
||||
Path: path,
|
||||
Query: query,
|
||||
},
|
||||
Headers: headers,
|
||||
Expect: sdk.ResponseExpect{
|
||||
ContentTypes: komootJSONContentTypes,
|
||||
MaxBytes: komootJSONMaxBytes,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if response.Status != 200 {
|
||||
return body, fmt.Errorf("komoot request failed (%d): %s", response.Status, string(body))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (c *komootClient) profileLocale() string {
|
||||
var data userProfile
|
||||
if err := c.get("/v007/users/"+url.PathEscape(c.userID), nil, &data); err != nil {
|
||||
return ""
|
||||
}
|
||||
return data.Locale
|
||||
}
|
||||
|
||||
func (c *komootClient) tours(page int, limit int) ([]tour, int, error) {
|
||||
var data toursResponse
|
||||
err := c.get("/v007/users/"+url.PathEscape(c.userID)+"/tours/", []sdk.QueryParam{
|
||||
{Name: "page", Value: strconv.Itoa(page)},
|
||||
{Name: "sort_field", Value: "date"},
|
||||
{Name: "sort_direction", Value: "desc"},
|
||||
{Name: "limit", Value: strconv.Itoa(limit)},
|
||||
}, &data)
|
||||
return data.Embedded.Tours, data.Page.TotalPages, err
|
||||
}
|
||||
|
||||
func (c *komootClient) detailedTour(id int64) (*detailedTour, error) {
|
||||
var data detailedTour
|
||||
err := c.get(fmt.Sprintf("/v007/tours/%d", id), []sdk.QueryParam{
|
||||
{Name: "_embedded", Value: "coordinates,way_types,surfaces,directions,participants,timeline,cover_images"},
|
||||
{Name: "directions", Value: "v2"},
|
||||
{Name: "fields", Value: "timeline"},
|
||||
{Name: "format", Value: "coordinate_array"},
|
||||
{Name: "timeline_highlights_fields", Value: "tips,recommenders"},
|
||||
{Name: "page", Value: "2"},
|
||||
}, &data)
|
||||
if err != nil {
|
||||
return &data, err
|
||||
}
|
||||
if len(data.Embedded.WayPoints.Embedded.Items) == 0 && len(data.Embedded.Timeline.Embedded.Items) == 0 {
|
||||
if timeline, err := c.webTimeline(id); err == nil {
|
||||
data.Embedded.WayPoints = timeline
|
||||
}
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func (c *komootClient) webTimeline(id int64) (timeline, error) {
|
||||
var data timeline
|
||||
token := c.shareToken(id)
|
||||
var query []sdk.QueryParam
|
||||
if token != "" {
|
||||
query = []sdk.QueryParam{{Name: "share_token", Value: token}}
|
||||
}
|
||||
err := c.getFromConnector("web", fmt.Sprintf("/webapi/v007/tours/%d/timeline/", id), query, &data)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
c.addHighlightTips(data.Embedded.Items)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c *komootClient) shareToken(id int64) string {
|
||||
token, err := c.shareTokenWithQuery(id, nil)
|
||||
if err == nil && token != "" {
|
||||
return token
|
||||
}
|
||||
token, _ = c.shareTokenWithQuery(id, []sdk.QueryParam{{Name: "token_name", Value: "invite"}})
|
||||
return token
|
||||
}
|
||||
|
||||
func (c *komootClient) shareTokenWithQuery(id int64, query []sdk.QueryParam) (string, error) {
|
||||
body, err := c.getRawFromConnector("api", fmt.Sprintf("/v007/tours/%d/share_token", id), query)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(body, &value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token, ok := value.(string); ok {
|
||||
return token, nil
|
||||
}
|
||||
return findShareToken(value), nil
|
||||
}
|
||||
|
||||
func findShareToken(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range []string{"token", "share_token", "shareToken"} {
|
||||
if token, ok := typed[key].(string); ok {
|
||||
return token
|
||||
}
|
||||
}
|
||||
for _, nested := range typed {
|
||||
if token := findShareToken(nested); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, nested := range typed {
|
||||
if token := findShareToken(nested); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *komootClient) addHighlightTips(items []timelineItem) {
|
||||
requests := 0
|
||||
for i := range items {
|
||||
if items[i].Type != "highlight" {
|
||||
continue
|
||||
}
|
||||
ref := &items[i].Embedded.Reference
|
||||
if ref.ID.String() == "" || len(ref.Embedded.Tips.Embedded.Items) > 0 {
|
||||
continue
|
||||
}
|
||||
if requests >= komootMaxHighlightTipRequests {
|
||||
return
|
||||
}
|
||||
requests++
|
||||
var data tips
|
||||
if err := c.get(fmt.Sprintf("/v007/highlights/%s/tips/", url.PathEscape(ref.ID.String())), nil, &data); err == nil {
|
||||
ref.Embedded.Tips = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *komootClient) coverImages(id int64) ([]imageItem, error) {
|
||||
var data coverImages
|
||||
err := c.get(fmt.Sprintf("/v007/tours/%d/cover_images/", id), nil, &data)
|
||||
return data.Embedded.Items, err
|
||||
}
|
||||
|
||||
func syncTours(client *komootClient, input listInput, wantKind string) (listOutput, error) {
|
||||
page := sdk.IntState(input.State, "page", 0)
|
||||
maxItems := sdk.SyncLimit(input)
|
||||
rows, totalPages, err := client.tours(page, maxItems)
|
||||
if err != nil {
|
||||
return listOutput{}, err
|
||||
}
|
||||
|
||||
items := make([]trailSummary, 0, maxItems)
|
||||
for _, row := range rows {
|
||||
if !tourDateAfter(row.Date, sdk.StringOption(input.Options, "after")) {
|
||||
continue
|
||||
}
|
||||
if wantKind == "planned" && row.Type != "tour_planned" {
|
||||
continue
|
||||
}
|
||||
if wantKind == "completed" && row.Type != "tour_recorded" {
|
||||
continue
|
||||
}
|
||||
|
||||
items = append(items, trailSummary{
|
||||
Source: trailImportSource{Provider: "komoot", ExternalID: strconv.FormatInt(row.ID, 10)},
|
||||
Kind: kindFromType(row.Type),
|
||||
})
|
||||
if len(items) >= maxItems {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
nextPage := page + 1
|
||||
hasMore := nextPage < totalPages
|
||||
return listOutput{
|
||||
Items: items,
|
||||
State: sdk.NextPageState(nextPage, hasMore),
|
||||
HasMore: hasMore,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func tourDetail(client *komootClient, externalID string, wantKind string) (trailImport, error) {
|
||||
id, err := strconv.ParseInt(externalID, 10, 64)
|
||||
if err != nil {
|
||||
return trailImport{}, fmt.Errorf("invalid tour external id")
|
||||
}
|
||||
detail, err := client.detailedTour(id)
|
||||
if err != nil {
|
||||
return trailImport{}, fmt.Errorf("fetch tour %d details: %w", id, err)
|
||||
}
|
||||
if wantKind == "planned" && detail.Type != "tour_planned" {
|
||||
return trailImport{}, fmt.Errorf("%w: tour %d is not planned", errTourKindMismatch, id)
|
||||
}
|
||||
if wantKind == "completed" && detail.Type != "tour_recorded" {
|
||||
return trailImport{}, fmt.Errorf("%w: tour %d is not completed", errTourKindMismatch, id)
|
||||
}
|
||||
var routeImages []imageItem
|
||||
if len(detail.Embedded.CoverImages.Embedded.Items) > 0 {
|
||||
routeImages, _ = client.coverImages(detail.ID)
|
||||
}
|
||||
item, err := tourImport(detail, routeImages)
|
||||
if err != nil {
|
||||
return trailImport{}, fmt.Errorf("map tour %d: %w", id, err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
53
plugins/komoot/komoot_test.go
Normal file
53
plugins/komoot/komoot_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/open-wanderer/wanderer/plugins/sdk"
|
||||
)
|
||||
|
||||
func TestAcceptLanguageFromLocale(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
locale string
|
||||
want string
|
||||
}{
|
||||
{name: "empty", locale: "", want: ""},
|
||||
{name: "language only", locale: "de", want: "de"},
|
||||
{name: "underscore region", locale: "de_CH", want: "de-CH,de;q=0.9"},
|
||||
{name: "hyphen region", locale: "en-US", want: "en-US,en;q=0.9"},
|
||||
{name: "trim space", locale: " fr_FR ", want: "fr-FR,fr;q=0.9"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := acceptLanguage(test.locale); got != test.want {
|
||||
t.Fatalf("acceptLanguage(%q) = %q, want %q", test.locale, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestHeadersOnlySendAuthToAPIConnector(t *testing.T) {
|
||||
client := &komootClient{
|
||||
userID: "user",
|
||||
token: "token",
|
||||
locale: "de_CH",
|
||||
}
|
||||
|
||||
apiHeaders := client.requestHeaders("api")
|
||||
if apiHeaders[sdk.AuthHeaderAuthorization] == "" {
|
||||
t.Fatalf("expected api connector authorization header")
|
||||
}
|
||||
if apiHeaders["Accept-Language"] != "de-CH,de;q=0.9" {
|
||||
t.Fatalf("unexpected api accept language: %#v", apiHeaders)
|
||||
}
|
||||
|
||||
webHeaders := client.requestHeaders("web")
|
||||
if webHeaders[sdk.AuthHeaderAuthorization] != "" {
|
||||
t.Fatalf("expected no web connector authorization header, got %#v", webHeaders)
|
||||
}
|
||||
if webHeaders["Accept-Language"] != "de-CH,de;q=0.9" {
|
||||
t.Fatalf("unexpected web accept language: %#v", webHeaders)
|
||||
}
|
||||
}
|
||||
19
plugins/komoot/locale.go
Normal file
19
plugins/komoot/locale.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
func acceptLanguage(locale string) string {
|
||||
locale = strings.TrimSpace(locale)
|
||||
if locale == "" {
|
||||
return ""
|
||||
}
|
||||
primary := locale
|
||||
if index := strings.IndexAny(primary, "_-"); index >= 0 {
|
||||
primary = primary[:index]
|
||||
}
|
||||
locale = strings.ReplaceAll(locale, "_", "-")
|
||||
if primary == "" || primary == locale {
|
||||
return locale
|
||||
}
|
||||
return locale + "," + primary + ";q=0.9"
|
||||
}
|
||||
104
plugins/komoot/main.go
Normal file
104
plugins/komoot/main.go
Normal file
@@ -0,0 +1,104 @@
|
||||
//go:build tinygo
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
func main() {}
|
||||
|
||||
//export list_routes_v1
|
||||
func listRoutesV1() int32 {
|
||||
return listTours("planned")
|
||||
}
|
||||
|
||||
//export list_activities_v1
|
||||
func listActivitiesV1() int32 {
|
||||
return listTours("completed")
|
||||
}
|
||||
|
||||
//export get_route_detail_v1
|
||||
func getRouteDetailV1() int32 {
|
||||
return getTourDetail("planned")
|
||||
}
|
||||
|
||||
//export get_activity_detail_v1
|
||||
func getActivityDetailV1() int32 {
|
||||
return getTourDetail("completed")
|
||||
}
|
||||
|
||||
//export refresh_session_v1
|
||||
func refreshSessionV1() int32 {
|
||||
var input refreshSessionInput
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
return fail("invalid_request", "invalid refresh_session input: "+err.Error())
|
||||
}
|
||||
|
||||
client, err := loginClient(input.Auth)
|
||||
if err != nil {
|
||||
return fail("auth_failed", err.Error())
|
||||
}
|
||||
|
||||
if err := pdk.OutputJSON(refreshSessionOutput{
|
||||
Token: client.token,
|
||||
Scheme: "Basic",
|
||||
}); err != nil {
|
||||
return fail("internal_error", err.Error())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getTourDetail(kind string) int32 {
|
||||
var input detailInput
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
return fail("invalid_request", "invalid detail input: "+err.Error())
|
||||
}
|
||||
client, err := loginClient(input.Auth)
|
||||
if err != nil {
|
||||
return fail("auth_failed", err.Error())
|
||||
}
|
||||
item, err := tourDetail(client, input.Summary.Source.ExternalID, kind)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTourKindMismatch) {
|
||||
return fail("not_importable", err.Error())
|
||||
}
|
||||
return fail("provider_unavailable", err.Error())
|
||||
}
|
||||
if err := pdk.OutputJSON(detailOutput{Item: item}); err != nil {
|
||||
return fail("internal_error", err.Error())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func listTours(kind string) int32 {
|
||||
var input listInput
|
||||
if err := pdk.InputJSON(&input); err != nil {
|
||||
return fail("invalid_request", "invalid list input: "+err.Error())
|
||||
}
|
||||
client, err := loginClient(input.Auth)
|
||||
if err != nil {
|
||||
return fail("auth_failed", err.Error())
|
||||
}
|
||||
output, err := syncTours(client, input, kind)
|
||||
if err != nil {
|
||||
return fail("provider_unavailable", err.Error())
|
||||
}
|
||||
if err := pdk.OutputJSON(output); err != nil {
|
||||
return fail("internal_error", err.Error())
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func fail(code string, message string) int32 {
|
||||
data, err := json.Marshal(pluginError{Code: code, Message: message})
|
||||
if err != nil {
|
||||
pdk.SetErrorString(message)
|
||||
return 1
|
||||
}
|
||||
pdk.SetErrorString(string(data))
|
||||
return 1
|
||||
}
|
||||
5
plugins/komoot/main_stub.go
Normal file
5
plugins/komoot/main_stub.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build !tinygo
|
||||
|
||||
package main
|
||||
|
||||
func main() {}
|
||||
226
plugins/komoot/mapper.go
Normal file
226
plugins/komoot/mapper.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx"
|
||||
)
|
||||
|
||||
func tourImport(tour *detailedTour, routeImages []imageItem) (trailImport, error) {
|
||||
gpxData, err := tourGPX(tour)
|
||||
if err != nil {
|
||||
return trailImport{}, err
|
||||
}
|
||||
|
||||
privacy := privacyFromStatus(tour.Status)
|
||||
return trailImport{
|
||||
Source: trailImportSource{
|
||||
Provider: "komoot",
|
||||
ExternalID: strconv.FormatInt(tour.ID, 10),
|
||||
},
|
||||
Kind: kindFromType(tour.Type),
|
||||
Name: tour.Name,
|
||||
Description: tour.Description,
|
||||
StartedAt: tour.Date,
|
||||
ActivityType: activityType(tour.Sport),
|
||||
Privacy: &privacy,
|
||||
Track: track{
|
||||
Format: "gpx",
|
||||
ContentBase64: base64.StdEncoding.EncodeToString(gpxData),
|
||||
},
|
||||
Waypoints: waypoints(tour),
|
||||
Photos: photos(tour, routeImages),
|
||||
Metadata: map[string]any{
|
||||
"distance": tour.Distance,
|
||||
"elevationGain": tour.ElevationUp,
|
||||
"elevationLoss": tour.ElevationDown,
|
||||
"duration": tour.Duration,
|
||||
"providerCategory": tour.Sport,
|
||||
"sourceSport": tour.Sport,
|
||||
"difficulty": tour.Difficulty.Grade,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func tourGPX(tour *detailedTour) ([]byte, error) {
|
||||
items := tour.Embedded.Coordinates.Items
|
||||
points := make([]sdkgpx.Point, 0, len(items))
|
||||
startedAt, _ := time.Parse(time.RFC3339, tour.Date)
|
||||
for _, item := range items {
|
||||
elevation := item.Alt
|
||||
point := sdkgpx.Point{
|
||||
Lat: item.Lat,
|
||||
Lon: item.Lng,
|
||||
Elevation: &elevation,
|
||||
}
|
||||
if !startedAt.IsZero() {
|
||||
pointTime := startedAt.Add(time.Duration(item.T) * time.Millisecond).UTC()
|
||||
point.Time = &pointTime
|
||||
}
|
||||
points = append(points, point)
|
||||
}
|
||||
return sdkgpx.Track("wanderer Komoot plugin", tour.Name, points)
|
||||
}
|
||||
|
||||
func waypoints(tour *detailedTour) []waypoint {
|
||||
result := make([]waypoint, 0, len(tour.Embedded.WayPoints.Embedded.Items)+len(tour.Embedded.Timeline.Embedded.Items))
|
||||
seen := map[string]bool{}
|
||||
result = appendWaypoints(result, seen, tour.Embedded.WayPoints.Embedded.Items)
|
||||
result = appendWaypoints(result, seen, tour.Embedded.Timeline.Embedded.Items)
|
||||
return result
|
||||
}
|
||||
|
||||
func appendWaypoints(result []waypoint, seen map[string]bool, items []timelineItem) []waypoint {
|
||||
for _, item := range items {
|
||||
ref := item.Embedded.Reference
|
||||
point, ok := waypointPoint(ref)
|
||||
if ref.Name == "" || !ok {
|
||||
continue
|
||||
}
|
||||
key := ref.ID.String()
|
||||
if key == "" {
|
||||
key = fmt.Sprintf("%s:%0.7f:%0.7f", ref.Name, point.Lat, point.Lng)
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
|
||||
description := ""
|
||||
if len(ref.Embedded.Tips.Embedded.Items) > 0 {
|
||||
description = ref.Embedded.Tips.Embedded.Items[0].Text
|
||||
}
|
||||
ele := point.Alt
|
||||
result = append(result, waypoint{
|
||||
ExternalID: ref.ID.String(),
|
||||
Name: ref.Name,
|
||||
Description: description,
|
||||
Lat: point.Lat,
|
||||
Lon: point.Lng,
|
||||
Ele: &ele,
|
||||
Icon: "circle",
|
||||
Photos: waypointPhotos(item),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func waypointPoint(ref waypointReference) (point, bool) {
|
||||
if ref.StartPoint.Lat != 0 || ref.StartPoint.Lng != 0 {
|
||||
return ref.StartPoint, true
|
||||
}
|
||||
if ref.Location.Lat != 0 || ref.Location.Lng != 0 {
|
||||
return ref.Location, true
|
||||
}
|
||||
return point{}, false
|
||||
}
|
||||
|
||||
func photos(tour *detailedTour, routeImages []imageItem) []photo {
|
||||
images := routeImages
|
||||
if len(images) == 0 {
|
||||
images = tour.Embedded.CoverImages.Embedded.Items
|
||||
}
|
||||
if len(images) == 0 && tour.MapImage.Src != "" {
|
||||
images = []imageItem{{Src: tour.MapImage.Src, Type: "image/jpeg"}}
|
||||
}
|
||||
return photosFromImages(images, "komoot-photo.jpg")
|
||||
}
|
||||
|
||||
func waypointPhotos(item timelineItem) []photo {
|
||||
ref := item.Embedded.Reference
|
||||
images := ref.Embedded.Images.Embedded.Items
|
||||
if ref.Embedded.FrontImage.Src != "" {
|
||||
images = append([]imageItem{ref.Embedded.FrontImage}, images...)
|
||||
}
|
||||
return photosFromImages(images, "komoot-waypoint-photo.jpg")
|
||||
}
|
||||
|
||||
func photosFromImages(images []imageItem, fallbackFilename string) []photo {
|
||||
result := make([]photo, 0, len(images))
|
||||
seen := map[string]bool{}
|
||||
for _, image := range images {
|
||||
source := expandImageURL(image.Src)
|
||||
if source == "" || strings.HasSuffix(strings.ToLower(source), ".gif") {
|
||||
continue
|
||||
}
|
||||
key := image.ID.String()
|
||||
if key == "" {
|
||||
key = source
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
result = append(result, photo{
|
||||
ExternalID: image.ID.String(),
|
||||
Filename: filenameForImage(image.ID, fallbackFilename),
|
||||
ContentType: contentType(image.Type),
|
||||
Lat: optionalCoordinate(image.Location.Lat),
|
||||
Lon: optionalCoordinate(image.Location.Lng),
|
||||
Source: mediaSource{
|
||||
Type: "url",
|
||||
URL: source,
|
||||
},
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func expandImageURL(source string) string {
|
||||
source = strings.ReplaceAll(source, "{crop}", "false")
|
||||
source = strings.ReplaceAll(source, "{width}", "")
|
||||
source = strings.ReplaceAll(source, "{height}", "")
|
||||
return source
|
||||
}
|
||||
|
||||
func filenameForImage(id flexibleID, fallback string) string {
|
||||
if id.String() == "" {
|
||||
return fallback
|
||||
}
|
||||
return fmt.Sprintf("komoot-%s.jpg", id.String())
|
||||
}
|
||||
|
||||
func contentType(value string) string {
|
||||
if strings.HasPrefix(value, "image/") {
|
||||
return value
|
||||
}
|
||||
return "image/jpeg"
|
||||
}
|
||||
|
||||
func optionalCoordinate(value float64) *float64 {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func kindFromType(value string) string {
|
||||
if value == "tour_recorded" {
|
||||
return "completed"
|
||||
}
|
||||
return "planned"
|
||||
}
|
||||
|
||||
func privacyFromStatus(value string) string {
|
||||
if value == "public" {
|
||||
return "public"
|
||||
}
|
||||
return "private"
|
||||
}
|
||||
|
||||
func activityType(sport string) string {
|
||||
switch sport {
|
||||
case "hike", "mountaineering":
|
||||
return "hiking"
|
||||
case "jogging":
|
||||
return "running"
|
||||
case "touringbicycle", "mtb", "racebike", "mtb_easy", "mtb_advanced":
|
||||
return "biking"
|
||||
default:
|
||||
return sport
|
||||
}
|
||||
}
|
||||
134
plugins/komoot/mapper_test.go
Normal file
134
plugins/komoot/mapper_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWaypointsFromEmbeddedWayPoints(t *testing.T) {
|
||||
tour := &detailedTour{
|
||||
Embedded: detailedTourEmbedded{
|
||||
WayPoints: timeline{
|
||||
Embedded: timelineEmbedded{
|
||||
Items: []timelineItem{{
|
||||
Embedded: timelineItemEmbedded{
|
||||
Reference: waypointReference{
|
||||
ID: flexibleID("2355158"),
|
||||
Name: "Ruedertaler Hofglace Rastplatz",
|
||||
Location: point{
|
||||
Lat: 47.280262,
|
||||
Lng: 8.046906,
|
||||
Alt: 476.7,
|
||||
},
|
||||
StartPoint: point{
|
||||
Lat: 47.280262,
|
||||
Lng: 8.046906,
|
||||
Alt: 476.7,
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
points := waypoints(tour)
|
||||
if len(points) != 1 {
|
||||
t.Fatalf("expected 1 waypoint, got %d", len(points))
|
||||
}
|
||||
if points[0].ExternalID != "2355158" || points[0].Name != "Ruedertaler Hofglace Rastplatz" {
|
||||
t.Fatalf("unexpected waypoint identity: %#v", points[0])
|
||||
}
|
||||
if points[0].Lat != 47.280262 || points[0].Lon != 8.046906 || points[0].Ele == nil || *points[0].Ele != 476.7 {
|
||||
t.Fatalf("unexpected waypoint coordinates: %#v", points[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaypointsDeduplicateWayPointsAndTimeline(t *testing.T) {
|
||||
item := timelineItem{
|
||||
Embedded: timelineItemEmbedded{
|
||||
Reference: waypointReference{
|
||||
ID: flexibleID("8277503"),
|
||||
Name: "Aarebruecke bei Aarburg",
|
||||
StartPoint: point{Lat: 47.320204, Lng: 7.897589},
|
||||
},
|
||||
},
|
||||
}
|
||||
tour := &detailedTour{
|
||||
Embedded: detailedTourEmbedded{
|
||||
WayPoints: timeline{Embedded: timelineEmbedded{Items: []timelineItem{item}}},
|
||||
Timeline: timeline{Embedded: timelineEmbedded{Items: []timelineItem{item}}},
|
||||
},
|
||||
}
|
||||
|
||||
points := waypoints(tour)
|
||||
if len(points) != 1 {
|
||||
t.Fatalf("expected duplicate waypoint to be collapsed, got %d", len(points))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaypointsIncludeFrontImage(t *testing.T) {
|
||||
tour := &detailedTour{
|
||||
Embedded: detailedTourEmbedded{
|
||||
WayPoints: timeline{
|
||||
Embedded: timelineEmbedded{
|
||||
Items: []timelineItem{{
|
||||
Embedded: timelineItemEmbedded{
|
||||
Reference: waypointReference{
|
||||
ID: flexibleID("4266004"),
|
||||
Name: "Blick auf die Solothurner Altstadt und die St.-Ursen-Kathedrale",
|
||||
StartPoint: point{Lat: 47.205925, Lng: 7.535326, Alt: 424.6},
|
||||
Embedded: waypointSubEmbedded{
|
||||
FrontImage: imageItem{
|
||||
ID: flexibleID("48446190"),
|
||||
Src: "https://example.test/image.jpg",
|
||||
Type: "image/*",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
points := waypoints(tour)
|
||||
if len(points) != 1 {
|
||||
t.Fatalf("expected 1 waypoint, got %d", len(points))
|
||||
}
|
||||
if len(points[0].Photos) != 1 {
|
||||
t.Fatalf("expected 1 waypoint photo, got %d", len(points[0].Photos))
|
||||
}
|
||||
if points[0].Photos[0].ExternalID != "48446190" || points[0].Photos[0].Source.URL != "https://example.test/image.jpg" {
|
||||
t.Fatalf("unexpected waypoint photo: %#v", points[0].Photos[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaypointPhotosDeduplicateFrontImage(t *testing.T) {
|
||||
item := timelineItem{
|
||||
Embedded: timelineItemEmbedded{
|
||||
Reference: waypointReference{
|
||||
Embedded: waypointSubEmbedded{
|
||||
FrontImage: imageItem{
|
||||
ID: flexibleID("48446190"),
|
||||
Src: "https://example.test/front.jpg",
|
||||
Type: "image/*",
|
||||
},
|
||||
Images: coverImages{
|
||||
Embedded: imagesEmbedded{
|
||||
Items: []imageItem{{
|
||||
ID: flexibleID("48446190"),
|
||||
Src: "https://example.test/front.jpg",
|
||||
Type: "image/*",
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
photos := waypointPhotos(item)
|
||||
if len(photos) != 1 {
|
||||
t.Fatalf("expected duplicate front image to be collapsed, got %d", len(photos))
|
||||
}
|
||||
}
|
||||
27
plugins/komoot/options.go
Normal file
27
plugins/komoot/options.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func tourDateAfter(tourDate string, after string) bool {
|
||||
if after == "" {
|
||||
return true
|
||||
}
|
||||
limit, err := time.Parse("2006-01-02", after)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
date, err := parseKomootDate(tourDate)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return !date.Before(limit)
|
||||
}
|
||||
|
||||
func parseKomootDate(value string) (time.Time, error) {
|
||||
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
return time.Parse("2006-01-02", value)
|
||||
}
|
||||
310
plugins/komoot/plugin.json
Normal file
310
plugins/komoot/plugin.json
Normal file
@@ -0,0 +1,310 @@
|
||||
{
|
||||
"manifestVersion": "1.0",
|
||||
"id": "komoot",
|
||||
"type": "trails",
|
||||
"name": "komoot",
|
||||
"description": "Imports planned and completed komoot tours, including photos and waypoints, into wanderer.",
|
||||
"version": "0.1.0",
|
||||
"runtime": {
|
||||
"type": "wasm",
|
||||
"entrypoint": "plugin.wasm"
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"name": "list_routes",
|
||||
"version": "v1",
|
||||
"export": "list_routes_v1"
|
||||
},
|
||||
{
|
||||
"name": "get_route_detail",
|
||||
"version": "v1",
|
||||
"export": "get_route_detail_v1"
|
||||
},
|
||||
{
|
||||
"name": "list_activities",
|
||||
"version": "v1",
|
||||
"export": "list_activities_v1"
|
||||
},
|
||||
{
|
||||
"name": "get_activity_detail",
|
||||
"version": "v1",
|
||||
"export": "get_activity_detail_v1"
|
||||
}
|
||||
],
|
||||
"auth": {
|
||||
"contexts": {
|
||||
"provider_session": {
|
||||
"type": "session",
|
||||
"fields": [
|
||||
"email",
|
||||
"password"
|
||||
],
|
||||
"secretFields": [
|
||||
"password"
|
||||
],
|
||||
"refresh": {
|
||||
"mode": "plugin",
|
||||
"function": "refresh_session_v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"network": {
|
||||
"connectors": [
|
||||
{
|
||||
"name": "api",
|
||||
"type": "public_api",
|
||||
"fixedBaseURL": "https://api.komoot.de",
|
||||
"allowedPathPrefixes": [
|
||||
"/v006",
|
||||
"/v007"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"type": "public_api",
|
||||
"fixedBaseURL": "https://www.komoot.com",
|
||||
"allowedPathPrefixes": [
|
||||
"/webapi/v007"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"auth": [
|
||||
"provider_session"
|
||||
],
|
||||
"downloads": {
|
||||
"maxBytes": 16777216,
|
||||
"contentTypes": [
|
||||
"application/json",
|
||||
"application/hal+json"
|
||||
]
|
||||
}
|
||||
},
|
||||
"configSchema": [
|
||||
{
|
||||
"key": "after",
|
||||
"type": "date",
|
||||
"label": "Start date",
|
||||
"labels": {
|
||||
"de": "Startdatum",
|
||||
"en": "Start date"
|
||||
},
|
||||
"description": "Ignore tours before this date.",
|
||||
"descriptions": {
|
||||
"de": "Touren vor diesem Datum ignorieren.",
|
||||
"en": "Ignore tours before this date."
|
||||
}
|
||||
}
|
||||
],
|
||||
"hostConfig": {
|
||||
"categoryMapping": {
|
||||
"hike": "Hiking",
|
||||
"mountaineering": "Hiking",
|
||||
"racebike": "Biking",
|
||||
"e_racebike": "Biking",
|
||||
"touringbicycle": "Biking",
|
||||
"e_touringbicycle": "Biking",
|
||||
"mtb": "Biking",
|
||||
"e_mtb": "Biking",
|
||||
"mtb_easy": "Biking",
|
||||
"e_mtb_easy": "Biking",
|
||||
"mtb_advanced": "Biking",
|
||||
"e_mtb_advanced": "Biking",
|
||||
"downhillbike": "Biking",
|
||||
"unicycle": "Biking",
|
||||
"citybike": "Biking",
|
||||
"jogging": "Walking",
|
||||
"nordicwalking": "Walking",
|
||||
"skaten": "Walking",
|
||||
"other": "Walking",
|
||||
"climbing": "Climbing",
|
||||
"nordic": "Skiing",
|
||||
"skialpin": "Skiing",
|
||||
"skitour": "Skiing",
|
||||
"sled": "Skiing",
|
||||
"snowboard": "Skiing",
|
||||
"snowshoe": "Skiing"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"descriptions": {
|
||||
"cs": "Synchronizuje vaše trasy z aplikace Komoot s Wandererem v pravidelných intervalech.",
|
||||
"de": "Importiert geplante und abgeschlossene komoot-Touren inklusive Fotos und Wegpunkten in wanderer.",
|
||||
"en": "Imports planned and completed komoot tours, including photos and waypoints, into wanderer.",
|
||||
"es": "Sincroniza tus recorridos de Komoot con Wanderer en intervalos regulares.",
|
||||
"eu": "Zure komooteko ibilbideak wandererekin sinkronizatzen ditu aldian behin.",
|
||||
"fr": "Synchronisez vos Tours Komoot avec wanderer à intervalles réguliers.",
|
||||
"it": "Syncs your komoot tours with wanderer in regular intervals.",
|
||||
"hu": "Syncs your komoot tours with wanderer in regular intervals.",
|
||||
"nl": "Synchroniseert je Komoot-tochten met Wanderer op regelmatige tijdstippen.",
|
||||
"no": "Synkroniserer dine Komoot-turer med Wanderer med jevne mellomrom.",
|
||||
"pl": "Synchronizuje trasy kamoot z wanderer w równych odstępach.",
|
||||
"pt": "Syncs your komoot tours with wanderer in regular intervals.",
|
||||
"ru": "Синхронизирует ваши данные с Komoot.",
|
||||
"zh": "定期与komoot同步您的wanderer。"
|
||||
},
|
||||
"icons": {
|
||||
"light": "icon.svg"
|
||||
},
|
||||
"providerCategories": {
|
||||
"hike": {
|
||||
"labels": {
|
||||
"de": "Wandern",
|
||||
"en": "Hiking"
|
||||
}
|
||||
},
|
||||
"mountaineering": {
|
||||
"labels": {
|
||||
"de": "Bergsteigen",
|
||||
"en": "Mountaineering"
|
||||
}
|
||||
},
|
||||
"racebike": {
|
||||
"labels": {
|
||||
"de": "Rennrad",
|
||||
"en": "Road bike"
|
||||
}
|
||||
},
|
||||
"e_racebike": {
|
||||
"labels": {
|
||||
"de": "E-Rennrad",
|
||||
"en": "E-road bike"
|
||||
}
|
||||
},
|
||||
"touringbicycle": {
|
||||
"labels": {
|
||||
"de": "Tourenrad",
|
||||
"en": "Touring bike"
|
||||
}
|
||||
},
|
||||
"e_touringbicycle": {
|
||||
"labels": {
|
||||
"de": "E-Tourenrad",
|
||||
"en": "E-touring bike"
|
||||
}
|
||||
},
|
||||
"mtb": {
|
||||
"labels": {
|
||||
"de": "Mountainbike",
|
||||
"en": "Mountain bike"
|
||||
}
|
||||
},
|
||||
"e_mtb": {
|
||||
"labels": {
|
||||
"de": "E-Mountainbike",
|
||||
"en": "E-mountain bike"
|
||||
}
|
||||
},
|
||||
"mtb_easy": {
|
||||
"labels": {
|
||||
"de": "Mountainbike einfach",
|
||||
"en": "Easy mountain bike"
|
||||
}
|
||||
},
|
||||
"e_mtb_easy": {
|
||||
"labels": {
|
||||
"de": "E-Mountainbike einfach",
|
||||
"en": "Easy e-mountain bike"
|
||||
}
|
||||
},
|
||||
"mtb_advanced": {
|
||||
"labels": {
|
||||
"de": "Mountainbike anspruchsvoll",
|
||||
"en": "Advanced mountain bike"
|
||||
}
|
||||
},
|
||||
"e_mtb_advanced": {
|
||||
"labels": {
|
||||
"de": "E-Mountainbike anspruchsvoll",
|
||||
"en": "Advanced e-mountain bike"
|
||||
}
|
||||
},
|
||||
"downhillbike": {
|
||||
"labels": {
|
||||
"de": "Downhill-Bike",
|
||||
"en": "Downhill bike"
|
||||
}
|
||||
},
|
||||
"unicycle": {
|
||||
"labels": {
|
||||
"de": "Einrad",
|
||||
"en": "Unicycle"
|
||||
}
|
||||
},
|
||||
"citybike": {
|
||||
"labels": {
|
||||
"de": "Citybike",
|
||||
"en": "City bike"
|
||||
}
|
||||
},
|
||||
"jogging": {
|
||||
"labels": {
|
||||
"de": "Joggen",
|
||||
"en": "Jogging"
|
||||
}
|
||||
},
|
||||
"nordicwalking": {
|
||||
"labels": {
|
||||
"de": "Nordic Walking",
|
||||
"en": "Nordic walking"
|
||||
}
|
||||
},
|
||||
"skaten": {
|
||||
"labels": {
|
||||
"de": "Skaten",
|
||||
"en": "Skating"
|
||||
}
|
||||
},
|
||||
"other": {
|
||||
"labels": {
|
||||
"de": "Sonstiges",
|
||||
"en": "Other"
|
||||
}
|
||||
},
|
||||
"climbing": {
|
||||
"labels": {
|
||||
"de": "Klettern",
|
||||
"en": "Climbing"
|
||||
}
|
||||
},
|
||||
"nordic": {
|
||||
"labels": {
|
||||
"de": "Langlauf",
|
||||
"en": "Cross-country skiing"
|
||||
}
|
||||
},
|
||||
"skialpin": {
|
||||
"labels": {
|
||||
"de": "Ski alpin",
|
||||
"en": "Alpine skiing"
|
||||
}
|
||||
},
|
||||
"skitour": {
|
||||
"labels": {
|
||||
"de": "Skitour",
|
||||
"en": "Ski touring"
|
||||
}
|
||||
},
|
||||
"sled": {
|
||||
"labels": {
|
||||
"de": "Schlitten",
|
||||
"en": "Sledding"
|
||||
}
|
||||
},
|
||||
"snowboard": {
|
||||
"labels": {
|
||||
"de": "Snowboard",
|
||||
"en": "Snowboard"
|
||||
}
|
||||
},
|
||||
"snowshoe": {
|
||||
"labels": {
|
||||
"de": "Schneeschuhwandern",
|
||||
"en": "Snowshoeing"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
197
plugins/komoot/types.go
Normal file
197
plugins/komoot/types.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/open-wanderer/wanderer/plugins/sdk"
|
||||
)
|
||||
|
||||
type instanceRef = sdk.InstanceRef
|
||||
type refreshSessionInput = sdk.RefreshSessionInput
|
||||
type refreshSessionOutput = sdk.RefreshSessionOutput
|
||||
type listInput = sdk.ListInput
|
||||
type listOutput = sdk.ListOutput
|
||||
type detailInput = sdk.DetailInput
|
||||
type detailOutput = sdk.DetailOutput
|
||||
type trailSummary = sdk.TrailSummary
|
||||
type trailImport = sdk.TrailImport
|
||||
type trailImportSource = sdk.TrailImportSource
|
||||
type track = sdk.Track
|
||||
type waypoint = sdk.Waypoint
|
||||
type photo = sdk.Photo
|
||||
type mediaSource = sdk.MediaSource
|
||||
|
||||
type pluginError = sdk.PluginError
|
||||
|
||||
type komootClient struct {
|
||||
userID string
|
||||
token string
|
||||
locale string
|
||||
}
|
||||
|
||||
type flexibleID string
|
||||
|
||||
func (id *flexibleID) UnmarshalJSON(data []byte) error {
|
||||
var stringValue string
|
||||
if err := json.Unmarshal(data, &stringValue); err == nil {
|
||||
*id = flexibleID(stringValue)
|
||||
return nil
|
||||
}
|
||||
var numberValue json.Number
|
||||
if err := json.Unmarshal(data, &numberValue); err != nil {
|
||||
return err
|
||||
}
|
||||
*id = flexibleID(numberValue.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (id flexibleID) String() string {
|
||||
return string(id)
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
type userProfile struct {
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
type toursResponse struct {
|
||||
Embedded toursEmbedded `json:"_embedded"`
|
||||
Page page `json:"page"`
|
||||
}
|
||||
|
||||
type toursEmbedded struct {
|
||||
Tours []tour `json:"tours"`
|
||||
}
|
||||
|
||||
type page struct {
|
||||
TotalPages int `json:"totalPages"`
|
||||
}
|
||||
|
||||
type tour struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Date string `json:"date"`
|
||||
Sport string `json:"sport"`
|
||||
ChangedAt string `json:"changed_at"`
|
||||
}
|
||||
|
||||
type detailedTour struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Date string `json:"date"`
|
||||
Sport string `json:"sport"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration int `json:"duration"`
|
||||
ElevationUp float64 `json:"elevation_up"`
|
||||
ElevationDown float64 `json:"elevation_down"`
|
||||
MapImage mapImage `json:"map_image"`
|
||||
Difficulty difficulty `json:"difficulty"`
|
||||
ChangedAt string `json:"changed_at"`
|
||||
Embedded detailedTourEmbedded `json:"_embedded"`
|
||||
}
|
||||
|
||||
type difficulty struct {
|
||||
Grade string `json:"grade"`
|
||||
}
|
||||
|
||||
type mapImage struct {
|
||||
Src string `json:"src"`
|
||||
}
|
||||
|
||||
type detailedTourEmbedded struct {
|
||||
Coordinates coordinates `json:"coordinates"`
|
||||
Timeline timeline `json:"timeline"`
|
||||
WayPoints timeline `json:"way_points"`
|
||||
CoverImages coverImages `json:"cover_images"`
|
||||
}
|
||||
|
||||
type coordinates struct {
|
||||
Items []coordinate `json:"items"`
|
||||
}
|
||||
|
||||
type coordinate struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Alt float64 `json:"alt"`
|
||||
T int `json:"t"`
|
||||
}
|
||||
|
||||
type timeline struct {
|
||||
Embedded timelineEmbedded `json:"_embedded"`
|
||||
}
|
||||
|
||||
type timelineEmbedded struct {
|
||||
Items []timelineItem `json:"items"`
|
||||
}
|
||||
|
||||
type timelineItem struct {
|
||||
Type string `json:"type"`
|
||||
Embedded timelineItemEmbedded `json:"_embedded"`
|
||||
}
|
||||
|
||||
type timelineItemEmbedded struct {
|
||||
Reference waypointReference `json:"reference"`
|
||||
}
|
||||
|
||||
type waypointReference struct {
|
||||
ID flexibleID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
StartPoint point `json:"start_point"`
|
||||
Location point `json:"location"`
|
||||
Embedded waypointSubEmbedded `json:"_embedded"`
|
||||
}
|
||||
|
||||
type point struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Alt float64 `json:"alt"`
|
||||
}
|
||||
|
||||
type waypointSubEmbedded struct {
|
||||
Tips tips `json:"tips"`
|
||||
Images coverImages `json:"images"`
|
||||
FrontImage imageItem `json:"front_image"`
|
||||
}
|
||||
|
||||
type tips struct {
|
||||
Embedded tipsEmbedded `json:"_embedded"`
|
||||
}
|
||||
|
||||
type tipsEmbedded struct {
|
||||
Items []tipItem `json:"items"`
|
||||
}
|
||||
|
||||
type tipItem struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type coverImages struct {
|
||||
Embedded imagesEmbedded `json:"_embedded"`
|
||||
}
|
||||
|
||||
type imagesEmbedded struct {
|
||||
Items []imageItem `json:"items"`
|
||||
}
|
||||
|
||||
type imageItem struct {
|
||||
ID flexibleID `json:"id"`
|
||||
Src string `json:"src"`
|
||||
Location location `json:"location"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type location struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
}
|
||||
481
plugins/schema/plugin.schema.json
Normal file
481
plugins/schema/plugin.schema.json
Normal 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
63
plugins/sdk/README.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Wanderer Plugin SDK for Go
|
||||
|
||||
TinyGo-compatible helpers for Wanderer WASM plugins.
|
||||
|
||||
```go
|
||||
import "github.com/open-wanderer/wanderer/plugins/sdk"
|
||||
```
|
||||
|
||||
The SDK contains only plugin-side protocol types and host-function helpers. It
|
||||
does not depend on Wanderer core or PocketBase.
|
||||
|
||||
Common protocol types:
|
||||
|
||||
- `ListInput`, `ListOutput`, `TrailImport`, `Track`, `Waypoint`, `Photo`
|
||||
- `RefreshSessionInput`, `RefreshSessionOutput`
|
||||
- `TrailSendInput`, `TrailSendPlan`
|
||||
- `HostRequestSpec`, `HostResponse`, `PluginError`
|
||||
|
||||
Provider HTTP requests use connector targets. Plugins provide a connector name,
|
||||
a relative path, and ordered query parameters; the host owns the final base URL,
|
||||
path scope, redirects, TLS, and private-network policy. Public external media
|
||||
URLs remain available only through `MediaSource{Type: "url"}`.
|
||||
|
||||
Host HTTP request bodies support JSON, `application/x-www-form-urlencoded`, and
|
||||
multipart. Use `PostJSON` for JSON and `PostForm` for ordered form fields. Any
|
||||
request body, including a login form POST, is governed by manifest
|
||||
`permissions.uploads.maxBytes` and `permissions.uploads.contentTypes`; in this
|
||||
contract "uploads" means plugin-to-provider request bodies, not only media/file
|
||||
uploads.
|
||||
|
||||
Set `HostRequestSpec.FollowRedirects` to `sdk.Bool(false)` when a plugin needs
|
||||
to inspect a redirect response itself, for example to collect `Location` and
|
||||
`Set-Cookie` during a provider login flow. `HostResponse.HeaderValues` is the
|
||||
only response-header representation and preserves all values. Prefer
|
||||
`FirstHeader` for scalar headers and `HeaderValuesFor` for headers that can
|
||||
appear more than once.
|
||||
|
||||
Plugins can emit host-visible logs with `LogDebug`, `LogInfo`, `LogWarn`, and
|
||||
`LogError`. Log levels are strict (`debug`, `info`, `warn`, `error`) and
|
||||
messages must be non-empty. Use logs for short diagnostics and timing markers;
|
||||
they are best-effort and should not be part of plugin control flow.
|
||||
|
||||
Small sync helpers are included for the repeated mechanics that every provider
|
||||
needs:
|
||||
|
||||
- `StringField` / `StringOption`
|
||||
- `IntState`
|
||||
- `IntOption`
|
||||
- `KnownIDs`
|
||||
- `SyncLimit`
|
||||
- `NextPageState`
|
||||
|
||||
Additional TinyGo-compatible helper packages:
|
||||
|
||||
```go
|
||||
import sdkgpx "github.com/open-wanderer/wanderer/plugins/sdk/gpx"
|
||||
import "github.com/open-wanderer/wanderer/plugins/sdk/polyline"
|
||||
```
|
||||
|
||||
- `gpx` writes simple GPX 1.1 track documents from provider track points.
|
||||
- `polyline` decodes Google-style encoded polylines and provides small helpers
|
||||
for coordinate scale normalization, coordinate swap detection, and mapping
|
||||
shorter elevation arrays onto track points.
|
||||
19
plugins/sdk/cmd/manifestcheck/main.go
Normal file
19
plugins/sdk/cmd/manifestcheck/main.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/open-wanderer/wanderer/plugins/sdk/manifestcheck"
|
||||
)
|
||||
|
||||
func main() {
|
||||
path := "plugin.json"
|
||||
if len(os.Args) > 1 {
|
||||
path = os.Args[1]
|
||||
}
|
||||
if err := manifestcheck.PrintFile(os.Stdout, path); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "manifestcheck: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
5
plugins/sdk/go.mod
Normal file
5
plugins/sdk/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module github.com/open-wanderer/wanderer/plugins/sdk
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/extism/go-pdk v1.1.3
|
||||
2
plugins/sdk/go.sum
Normal file
2
plugins/sdk/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
||||
58
plugins/sdk/gpx/gpx.go
Normal file
58
plugins/sdk/gpx/gpx.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package gpx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Point struct {
|
||||
Lat float64
|
||||
Lon float64
|
||||
Elevation *float64
|
||||
Time *time.Time
|
||||
}
|
||||
|
||||
func Track(creator string, name string, points []Point) ([]byte, error) {
|
||||
if len(points) == 0 {
|
||||
return nil, fmt.Errorf("track has no points")
|
||||
}
|
||||
if creator == "" {
|
||||
creator = "wanderer plugin"
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(xml.Header)
|
||||
buf.WriteString(`<gpx version="1.1" creator="`)
|
||||
_ = xml.EscapeText(&buf, []byte(creator))
|
||||
buf.WriteString(`" xmlns="http://www.topografix.com/GPX/1/1">`)
|
||||
buf.WriteString("<trk>")
|
||||
buf.WriteString("<name>")
|
||||
_ = xml.EscapeText(&buf, []byte(name))
|
||||
buf.WriteString("</name>")
|
||||
buf.WriteString("<trkseg>")
|
||||
for _, point := range points {
|
||||
buf.WriteString(`<trkpt lat="`)
|
||||
buf.WriteString(strconv.FormatFloat(point.Lat, 'f', 8, 64))
|
||||
buf.WriteString(`" lon="`)
|
||||
buf.WriteString(strconv.FormatFloat(point.Lon, 'f', 8, 64))
|
||||
buf.WriteString(`">`)
|
||||
if point.Elevation != nil {
|
||||
buf.WriteString("<ele>")
|
||||
buf.WriteString(strconv.FormatFloat(*point.Elevation, 'f', 2, 64))
|
||||
buf.WriteString("</ele>")
|
||||
}
|
||||
if point.Time != nil {
|
||||
buf.WriteString("<time>")
|
||||
buf.WriteString(point.Time.UTC().Format(time.RFC3339))
|
||||
buf.WriteString("</time>")
|
||||
}
|
||||
buf.WriteString("</trkpt>")
|
||||
}
|
||||
buf.WriteString("</trkseg>")
|
||||
buf.WriteString("</trk>")
|
||||
buf.WriteString("</gpx>")
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
39
plugins/sdk/gpx/gpx_test.go
Normal file
39
plugins/sdk/gpx/gpx_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package gpx
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTrackEscapesFields(t *testing.T) {
|
||||
elevation := 123.456
|
||||
timestamp := time.Date(2026, 6, 1, 10, 30, 0, 0, time.UTC)
|
||||
data, err := Track("creator & test", "A & B", []Point{{
|
||||
Lat: 46.1,
|
||||
Lon: 8.2,
|
||||
Elevation: &elevation,
|
||||
Time: ×tamp,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
gpx := string(data)
|
||||
for _, want := range []string{
|
||||
`creator="creator & test"`,
|
||||
"<name>A & B</name>",
|
||||
`lat="46.10000000" lon="8.20000000"`,
|
||||
"<ele>123.46</ele>",
|
||||
"<time>2026-06-01T10:30:00Z</time>",
|
||||
} {
|
||||
if !strings.Contains(gpx, want) {
|
||||
t.Fatalf("expected %q in %s", want, gpx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackRejectsEmptyPoints(t *testing.T) {
|
||||
if _, err := Track("", "empty", nil); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
154
plugins/sdk/host_http.go
Normal file
154
plugins/sdk/host_http.go
Normal file
@@ -0,0 +1,154 @@
|
||||
//go:build tinygo
|
||||
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/extism/go-pdk"
|
||||
)
|
||||
|
||||
//go:wasmimport wanderer http_request
|
||||
func wandererHTTPRequest(uint64) uint64
|
||||
|
||||
//go:wasmimport wanderer log
|
||||
func wandererLog(uint64)
|
||||
|
||||
func Log(level LogLevel, message string) {
|
||||
entry := HostLogEntry{
|
||||
Level: level,
|
||||
Message: message,
|
||||
}
|
||||
memory, err := pdk.AllocateJSON(entry)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer memory.Free()
|
||||
wandererLog(memory.Offset())
|
||||
}
|
||||
|
||||
func LogDebug(message string) {
|
||||
Log(LogLevelDebug, message)
|
||||
}
|
||||
|
||||
func LogInfo(message string) {
|
||||
Log(LogLevelInfo, message)
|
||||
}
|
||||
|
||||
func LogWarn(message string) {
|
||||
Log(LogLevelWarn, message)
|
||||
}
|
||||
|
||||
func LogError(message string) {
|
||||
Log(LogLevelError, message)
|
||||
}
|
||||
|
||||
func HostRequest(spec HostRequestSpec) (HostResponse, []byte, error) {
|
||||
requestMemory, err := pdk.AllocateJSON(spec)
|
||||
if err != nil {
|
||||
return HostResponse{}, nil, err
|
||||
}
|
||||
defer requestMemory.Free()
|
||||
|
||||
responsePointer := wandererHTTPRequest(requestMemory.Offset())
|
||||
if responsePointer == 0 {
|
||||
return HostResponse{}, nil, fmt.Errorf("host http request returned no response")
|
||||
}
|
||||
responseMemory := pdk.FindMemory(responsePointer)
|
||||
var response HostResponse
|
||||
if err := json.Unmarshal(responseMemory.ReadBytes(), &response); err != nil {
|
||||
return HostResponse{}, nil, err
|
||||
}
|
||||
if response.Error != nil {
|
||||
return response, nil, fmt.Errorf("%s: %s", response.Error.Code, response.Error.Message)
|
||||
}
|
||||
body, err := base64.StdEncoding.DecodeString(response.BodyBase64)
|
||||
if err != nil {
|
||||
return response, nil, err
|
||||
}
|
||||
return response, body, nil
|
||||
}
|
||||
|
||||
func ConnectorRequest(method string, connector string, path string, query []QueryParam, headers map[string]string, expect ResponseExpect) (HostResponse, []byte, error) {
|
||||
return HostRequest(HostRequestSpec{
|
||||
Method: method,
|
||||
Target: RequestTarget{
|
||||
Type: "connector",
|
||||
Connector: connector,
|
||||
Path: path,
|
||||
Query: query,
|
||||
},
|
||||
Headers: headers,
|
||||
Expect: expect,
|
||||
})
|
||||
}
|
||||
|
||||
func Get(connector string, path string, query []QueryParam, headers map[string]string, expect ResponseExpect) (HostResponse, []byte, error) {
|
||||
return ConnectorRequest("GET", connector, path, query, headers, expect)
|
||||
}
|
||||
|
||||
func PostJSON(connector string, path string, query []QueryParam, headers map[string]string, body any, expect ResponseExpect) (HostResponse, []byte, error) {
|
||||
return HostRequest(HostRequestSpec{
|
||||
Method: "POST",
|
||||
Target: RequestTarget{
|
||||
Type: "connector",
|
||||
Connector: connector,
|
||||
Path: path,
|
||||
Query: query,
|
||||
},
|
||||
Headers: headers,
|
||||
Body: &HostRequestBody{
|
||||
Type: HostRequestBodyTypeJSON,
|
||||
JSON: body,
|
||||
},
|
||||
Expect: expect,
|
||||
})
|
||||
}
|
||||
|
||||
func PostForm(connector string, path string, query []QueryParam, headers map[string]string, form []FormField, expect ResponseExpect) (HostResponse, []byte, error) {
|
||||
return HostRequest(HostRequestSpec{
|
||||
Method: "POST",
|
||||
Target: RequestTarget{
|
||||
Type: "connector",
|
||||
Connector: connector,
|
||||
Path: path,
|
||||
Query: query,
|
||||
},
|
||||
Headers: headers,
|
||||
Body: &HostRequestBody{
|
||||
Type: HostRequestBodyTypeForm,
|
||||
Form: form,
|
||||
},
|
||||
Expect: expect,
|
||||
})
|
||||
}
|
||||
|
||||
func (r HostResponse) FirstHeader(name string) string {
|
||||
values := r.HeaderValuesFor(name)
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
func (r HostResponse) HeaderValuesFor(name string) []string {
|
||||
if r.HeaderValues == nil {
|
||||
return nil
|
||||
}
|
||||
if values, ok := r.HeaderValues[name]; ok {
|
||||
return values
|
||||
}
|
||||
for key, values := range r.HeaderValues {
|
||||
if strings.EqualFold(key, name) {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Bool(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
26
plugins/sdk/manifestcheck/manifestcheck.go
Normal file
26
plugins/sdk/manifestcheck/manifestcheck.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package manifestcheck
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func PrintFile(w io.Writer, path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var manifest map[string]any
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(w, string(encoded))
|
||||
return err
|
||||
}
|
||||
125
plugins/sdk/polyline/polyline.go
Normal file
125
plugins/sdk/polyline/polyline.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package polyline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
func Decode(encoded string, precision float64) ([][2]float64, error) {
|
||||
if precision == 0 {
|
||||
return nil, fmt.Errorf("precision must not be zero")
|
||||
}
|
||||
var coords [][2]float64
|
||||
index := 0
|
||||
lat := 0
|
||||
lon := 0
|
||||
for index < len(encoded) {
|
||||
dlat, next, err := decodeValue(encoded, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index = next
|
||||
dlon, next, err := decodeValue(encoded, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index = next
|
||||
lat += dlat
|
||||
lon += dlon
|
||||
coords = append(coords, [2]float64{float64(lat) / precision, float64(lon) / precision})
|
||||
}
|
||||
return coords, nil
|
||||
}
|
||||
|
||||
func DecodeValues(encoded string, precision float64) ([]float64, error) {
|
||||
if precision == 0 {
|
||||
return nil, fmt.Errorf("precision must not be zero")
|
||||
}
|
||||
var values []float64
|
||||
index := 0
|
||||
value := 0
|
||||
for index < len(encoded) {
|
||||
delta, next, err := decodeValue(encoded, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
index = next
|
||||
value += delta
|
||||
values = append(values, float64(value)/precision)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func NormalizeCoordinateScale(coords [][2]float64) {
|
||||
if len(coords) == 0 {
|
||||
return
|
||||
}
|
||||
maxLat := 0.0
|
||||
maxLon := 0.0
|
||||
for _, coord := range coords {
|
||||
if abs := math.Abs(coord[0]); abs > maxLat {
|
||||
maxLat = abs
|
||||
}
|
||||
if abs := math.Abs(coord[1]); abs > maxLon {
|
||||
maxLon = abs
|
||||
}
|
||||
}
|
||||
for (maxLat > 90 || maxLon > 180) && maxLat > 0 && maxLon > 0 {
|
||||
for i := range coords {
|
||||
coords[i][0] /= 10
|
||||
coords[i][1] /= 10
|
||||
}
|
||||
maxLat /= 10
|
||||
maxLon /= 10
|
||||
}
|
||||
}
|
||||
|
||||
func ShouldSwapCoordinates(coords [][2]float64) bool {
|
||||
validAsLat := 0
|
||||
validAsLon := 0
|
||||
for _, coord := range coords {
|
||||
if validLatLon(coord[0], coord[1]) {
|
||||
validAsLat++
|
||||
}
|
||||
if validLatLon(coord[1], coord[0]) {
|
||||
validAsLon++
|
||||
}
|
||||
}
|
||||
return validAsLon > validAsLat
|
||||
}
|
||||
|
||||
func ProportionalIndex(i int, sourceLen int, targetLen int) int {
|
||||
if targetLen <= 1 || sourceLen <= 1 {
|
||||
return 0
|
||||
}
|
||||
j := int(math.Round(float64(i) * float64(targetLen-1) / float64(sourceLen-1)))
|
||||
if j < 0 {
|
||||
return 0
|
||||
}
|
||||
if j >= targetLen {
|
||||
return targetLen - 1
|
||||
}
|
||||
return j
|
||||
}
|
||||
|
||||
func validLatLon(lat float64, lon float64) bool {
|
||||
return lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180
|
||||
}
|
||||
|
||||
func decodeValue(encoded string, index int) (int, int, error) {
|
||||
result := 0
|
||||
shift := uint(0)
|
||||
for {
|
||||
if index >= len(encoded) {
|
||||
return 0, index, fmt.Errorf("invalid polyline encoding")
|
||||
}
|
||||
b := int(encoded[index]) - 63
|
||||
index++
|
||||
result |= (b & 0x1F) << shift
|
||||
shift += 5
|
||||
if b < 0x20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return (result >> 1) ^ (-(result & 1)), index, nil
|
||||
}
|
||||
40
plugins/sdk/polyline/polyline_test.go
Normal file
40
plugins/sdk/polyline/polyline_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package polyline
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDecode(t *testing.T) {
|
||||
points, err := Decode("_p~iF~ps|U_ulLnnqC_mqNvxq`@", 1e5)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(points) != 3 {
|
||||
t.Fatalf("expected 3 points, got %d", len(points))
|
||||
}
|
||||
if points[0][0] != 38.5 || points[0][1] != -120.2 {
|
||||
t.Fatalf("unexpected first point: %#v", points[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCoordinateScale(t *testing.T) {
|
||||
points := [][2]float64{{385, -1202}}
|
||||
NormalizeCoordinateScale(points)
|
||||
if points[0][0] != 38.5 || points[0][1] != -120.2 {
|
||||
t.Fatalf("expected normalized point, got %#v", points[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSwapCoordinates(t *testing.T) {
|
||||
coords := [][2]float64{{120.2, 38.5}, {121.0, 39.0}}
|
||||
if !ShouldSwapCoordinates(coords) {
|
||||
t.Fatal("expected coordinates to be detected as swapped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProportionalIndex(t *testing.T) {
|
||||
if got := ProportionalIndex(2, 5, 3); got != 1 {
|
||||
t.Fatalf("got %d, want 1", got)
|
||||
}
|
||||
if got := ProportionalIndex(4, 5, 3); got != 2 {
|
||||
t.Fatalf("got %d, want 2", got)
|
||||
}
|
||||
}
|
||||
83
plugins/sdk/sync.go
Normal file
83
plugins/sdk/sync.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package sdk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func StringField(values map[string]any, key string) string {
|
||||
value, _ := values[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func StringOption(options map[string]any, key string) string {
|
||||
return StringField(options, key)
|
||||
}
|
||||
|
||||
func IntOption(options map[string]any, key string, fallback int) int {
|
||||
return intValue(options, key, fallback)
|
||||
}
|
||||
|
||||
func BoolOption(options map[string]any, key string, fallback bool) bool {
|
||||
return boolValue(options, key, fallback)
|
||||
}
|
||||
|
||||
func IntState(state map[string]any, key string, fallback int) int {
|
||||
return intValue(state, key, fallback)
|
||||
}
|
||||
|
||||
func intValue(values map[string]any, key string, fallback int) int {
|
||||
switch value := values[key].(type) {
|
||||
case float64:
|
||||
return int(value)
|
||||
case int:
|
||||
return value
|
||||
case json.Number:
|
||||
parsed, err := value.Int64()
|
||||
if err == nil {
|
||||
return int(parsed)
|
||||
}
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func boolValue(values map[string]any, key string, fallback bool) bool {
|
||||
switch value := values[key].(type) {
|
||||
case bool:
|
||||
return value
|
||||
case string:
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
if err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func KnownIDs(ids []string) map[string]bool {
|
||||
known := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
known[id] = true
|
||||
}
|
||||
return known
|
||||
}
|
||||
|
||||
func SyncLimit(input ListInput) int {
|
||||
if input.Limits.MaxItems > 0 {
|
||||
return input.Limits.MaxItems
|
||||
}
|
||||
return 10
|
||||
}
|
||||
|
||||
func NextPageState(nextPage int, hasMore bool) map[string]any {
|
||||
if !hasMore {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"page": nextPage}
|
||||
}
|
||||
209
plugins/sdk/types.go
Normal file
209
plugins/sdk/types.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package sdk
|
||||
|
||||
const (
|
||||
HostRequestBodyTypeJSON = "json"
|
||||
HostRequestBodyTypeForm = "form"
|
||||
HostRequestBodyTypeMultipart = "multipart"
|
||||
MultipartSourceTrail = "trail"
|
||||
MultipartSourceTrailGPX = "trail.gpx"
|
||||
|
||||
AuthHeaderAuthorization = "Authorization"
|
||||
AuthSchemeBearer = "Bearer"
|
||||
)
|
||||
|
||||
type HostRequestSpec struct {
|
||||
Method string `json:"method"`
|
||||
Target RequestTarget `json:"target"`
|
||||
Auth string `json:"auth,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Body *HostRequestBody `json:"body,omitempty"`
|
||||
Expect ResponseExpect `json:"expect,omitempty"`
|
||||
FollowRedirects *bool `json:"followRedirects,omitempty"`
|
||||
}
|
||||
|
||||
type RequestTarget struct {
|
||||
Type string `json:"type"`
|
||||
Connector string `json:"connector,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Query []QueryParam `json:"query,omitempty"`
|
||||
}
|
||||
|
||||
type QueryParam struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type HostRequestBody struct {
|
||||
Type string `json:"type"`
|
||||
JSON any `json:"json,omitempty"`
|
||||
Form []FormField `json:"form,omitempty"`
|
||||
Parts []MultipartPart `json:"parts,omitempty"`
|
||||
}
|
||||
|
||||
type FormField struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type MultipartPart struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
JSON any `json:"json,omitempty"`
|
||||
}
|
||||
|
||||
type ResponseExpect struct {
|
||||
ContentTypes []string `json:"contentTypes,omitempty"`
|
||||
MaxBytes int64 `json:"maxBytes,omitempty"`
|
||||
}
|
||||
|
||||
type HostResponse struct {
|
||||
Status int `json:"status"`
|
||||
HeaderValues map[string][]string `json:"headerValues,omitempty"`
|
||||
BodyBase64 string `json:"bodyBase64,omitempty"`
|
||||
Error *PluginError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type PluginError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type LogLevel string
|
||||
|
||||
const (
|
||||
LogLevelDebug LogLevel = "debug"
|
||||
LogLevelInfo LogLevel = "info"
|
||||
LogLevelWarn LogLevel = "warn"
|
||||
LogLevelError LogLevel = "error"
|
||||
)
|
||||
|
||||
type HostLogEntry struct {
|
||||
Level LogLevel `json:"level"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type InstanceRef struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"pluginId"`
|
||||
}
|
||||
|
||||
type RefreshSessionInput struct {
|
||||
Instance InstanceRef `json:"instance"`
|
||||
Auth map[string]any `json:"auth,omitempty"`
|
||||
Config map[string]any `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
type RefreshSessionOutput struct {
|
||||
Token string `json:"token"`
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
ExpiresAt string `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
type SyncLimits struct {
|
||||
MaxItems int `json:"maxItems,omitempty"`
|
||||
}
|
||||
|
||||
type ListInput struct {
|
||||
Instance InstanceRef `json:"instance"`
|
||||
Auth map[string]any `json:"auth,omitempty"`
|
||||
State map[string]any `json:"state,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
Limits SyncLimits `json:"limits,omitempty"`
|
||||
}
|
||||
|
||||
type ListOutput struct {
|
||||
Items []TrailSummary `json:"items"`
|
||||
State map[string]any `json:"state,omitempty"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
Error *PluginError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type DetailInput struct {
|
||||
Instance InstanceRef `json:"instance"`
|
||||
Auth map[string]any `json:"auth,omitempty"`
|
||||
Options map[string]any `json:"options,omitempty"`
|
||||
Summary TrailSummary `json:"summary"`
|
||||
}
|
||||
|
||||
type DetailOutput struct {
|
||||
Item TrailImport `json:"item"`
|
||||
Error *PluginError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type TrailSummary struct {
|
||||
Source TrailImportSource `json:"source"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
|
||||
type TrailImport struct {
|
||||
Source TrailImportSource `json:"source"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
StartedAt string `json:"startedAt,omitempty"`
|
||||
ActivityType string `json:"activityType,omitempty"`
|
||||
Privacy *string `json:"privacy,omitempty"`
|
||||
Track Track `json:"track"`
|
||||
Waypoints []Waypoint `json:"waypoints,omitempty"`
|
||||
Photos []Photo `json:"photos,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type TrailImportSource struct {
|
||||
Provider string `json:"provider"`
|
||||
ExternalID string `json:"externalId"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
Format string `json:"format"`
|
||||
ContentBase64 string `json:"contentBase64"`
|
||||
}
|
||||
|
||||
type Waypoint struct {
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Ele *float64 `json:"ele,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Photos []Photo `json:"photos,omitempty"`
|
||||
}
|
||||
|
||||
type Photo struct {
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Lon *float64 `json:"lon,omitempty"`
|
||||
Source MediaSource `json:"source"`
|
||||
}
|
||||
|
||||
type MediaSource struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url,omitempty"`
|
||||
MediaRef *MediaRef `json:"mediaRef,omitempty"`
|
||||
}
|
||||
|
||||
type MediaRef struct {
|
||||
Connector string `json:"connector"`
|
||||
Auth string `json:"auth,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Query []QueryParam `json:"query,omitempty"`
|
||||
AssetID string `json:"assetId,omitempty"`
|
||||
}
|
||||
|
||||
type TrailSendInput struct {
|
||||
Instance InstanceRef `json:"instance"`
|
||||
Auth map[string]any `json:"auth,omitempty"`
|
||||
Config map[string]any `json:"config,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Trail Track `json:"trail"`
|
||||
}
|
||||
|
||||
type TrailSendPlan struct {
|
||||
Request HostRequestSpec `json:"request"`
|
||||
}
|
||||
15
plugins/strava/Makefile
Normal file
15
plugins/strava/Makefile
Normal 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
11
plugins/strava/README.md
Normal 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.
|
||||
3
plugins/strava/assets/icon.svg
Normal file
3
plugins/strava/assets/icon.svg
Normal 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
9
plugins/strava/go.mod
Normal 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
2
plugins/strava/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
|
||||
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
|
||||
126
plugins/strava/main.go
Normal file
126
plugins/strava/main.go
Normal 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
228
plugins/strava/mapper.go
Normal 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
40
plugins/strava/options.go
Normal 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
541
plugins/strava/plugin.json
Normal 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
199
plugins/strava/strava.go
Normal 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
95
plugins/strava/types.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user