Files
wanderer/plugins/strava/mapper.go
slothful-vassal 3b8f00fd26 feat: Refine trail category model (#1059)
* feat: advanced trail categories

* rename remote_category

* fix merge issues

* subcategories for plugins mapping

* fix refresh

* remove vertical trail filter category scrolling

* cleanup

* add confirm modal for disabling a category

* Fix federation issues

* fix review findings

* redesign category settings page

* remove prio badge

* fix subcategory badge layout

* optimize subcategory settings layout

* further settings page layout optimization

* fix subcategory icon position

* fix

* update docs

---------

Co-authored-by: Christian Beutel <>
2026-06-29 03:16:26 +02:00

213 lines
5.3 KiB
Go

//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 {
return activityTypeFromProvider(providerActivityType(activity))
}