adds komoot integration

This commit is contained in:
Christian Beutel
2025-02-04 21:23:54 +01:00
parent a0b6480854
commit 4d1d07922a
32 changed files with 1373 additions and 189 deletions

View File

@@ -0,0 +1,372 @@
package strava
import "time"
type RefreshTokenRequest struct {
ClientID int32 `json:"client_id"`
ClientSecret string `json:"client_secret"`
RefreshToken string `json:"refresh_token"`
GrantType string `json:"grant_type"`
}
type RefreshTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt int64 `json:"expires_at"`
}
type StravaIntegration struct {
Active bool `json:"active"`
Routes bool `json:"routes"`
Activities bool `json:"activities"`
ClientID int32 `json:"clientId"`
ClientSecret string `json:"clientSecret"`
AccessToken string `json:"accessToken,omitempty"`
RefreshToken string `json:"refreshToken,omitempty"`
ExpiresAt int64 `json:"expiresAt,omitempty"`
}
type StravaRoute struct {
Athlete Athlete `json:"athlete"`
Description string `json:"description"`
Distance float32 `json:"distance"`
ElevationGain float32 `json:"elevation_gain"`
ID int `json:"id"`
IDStr string `json:"id_str"`
Map Map `json:"map"`
Name string `json:"name"`
Private bool `json:"private"`
Starred bool `json:"starred"`
Timestamp int `json:"timestamp"`
Type int `json:"type"`
SubType int `json:"sub_type"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
EstimatedMovingTime int `json:"estimated_moving_time"`
Segments []Segments `json:"segments"`
Waypoints []Waypoints `json:"waypoints"`
}
type Athlete struct {
ID int `json:"id"`
ResourceState int `json:"resource_state"`
Firstname string `json:"firstname"`
Lastname string `json:"lastname"`
ProfileMedium string `json:"profile_medium"`
Profile string `json:"profile"`
City string `json:"city"`
State string `json:"state"`
Country string `json:"country"`
Sex string `json:"sex"`
Premium bool `json:"premium"`
Summit bool `json:"summit"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Map struct {
ID string `json:"id"`
Polyline string `json:"polyline"`
SummaryPolyline string `json:"summary_polyline"`
}
type AthletePrEffort struct {
PrActivityID int `json:"pr_activity_id"`
PrElapsedTime int `json:"pr_elapsed_time"`
PrDate time.Time `json:"pr_date"`
EffortCount int `json:"effort_count"`
}
type AthleteSegmentStats struct {
ID int `json:"id"`
ActivityID int `json:"activity_id"`
ElapsedTime int `json:"elapsed_time"`
StartDate time.Time `json:"start_date"`
StartDateLocal time.Time `json:"start_date_local"`
Distance float32 `json:"distance"`
IsKom bool `json:"is_kom"`
}
type Segments struct {
ID int `json:"id"`
Name string `json:"name"`
ActivityType string `json:"activity_type"`
Distance float32 `json:"distance"`
AverageGrade float32 `json:"average_grade"`
MaximumGrade float32 `json:"maximum_grade"`
ElevationHigh float32 `json:"elevation_high"`
ElevationLow float32 `json:"elevation_low"`
StartLatlng []float32 `json:"start_latlng"`
EndLatlng []float32 `json:"end_latlng"`
ClimbCategory int `json:"climb_category"`
City string `json:"city"`
State string `json:"state"`
Country string `json:"country"`
Private bool `json:"private"`
AthletePrEffort AthletePrEffort `json:"athlete_pr_effort"`
AthleteSegmentStats AthleteSegmentStats `json:"athlete_segment_stats"`
}
type Waypoints struct {
Latlng []float32 `json:"latlng"`
TargetLatlng []float32 `json:"target_latlng"`
Categories []string `json:"categories"`
Title string `json:"title"`
Description string `json:"description"`
DistanceIntoRoute int `json:"distance_into_route"`
}
type StravaActivity struct {
ResourceState int `json:"resource_state"`
Athlete Athlete `json:"athlete"`
Name string `json:"name"`
Distance float64 `json:"distance"`
MovingTime int `json:"moving_time"`
ElapsedTime int `json:"elapsed_time"`
TotalElevationGain float64 `json:"total_elevation_gain"`
Type string `json:"type"`
SportType string `json:"sport_type"`
WorkoutType any `json:"workout_type"`
ID int64 `json:"id"`
ExternalID string `json:"external_id"`
UploadID int64 `json:"upload_id"`
StartDate time.Time `json:"start_date"`
StartDateLocal time.Time `json:"start_date_local"`
Timezone string `json:"timezone"`
StartLatlng any `json:"start_latlng"`
EndLatlng any `json:"end_latlng"`
LocationCity any `json:"location_city"`
LocationState any `json:"location_state"`
LocationCountry string `json:"location_country"`
AchievementCount int `json:"achievement_count"`
KudosCount int `json:"kudos_count"`
CommentCount int `json:"comment_count"`
AthleteCount int `json:"athlete_count"`
PhotoCount int `json:"photo_count"`
Map Map `json:"map"`
Trainer bool `json:"trainer"`
Commute bool `json:"commute"`
Manual bool `json:"manual"`
Private bool `json:"private"`
Flagged bool `json:"flagged"`
GearID string `json:"gear_id"`
FromAcceptedTag bool `json:"from_accepted_tag"`
AverageSpeed float64 `json:"average_speed"`
MaxSpeed float64 `json:"max_speed"`
AverageCadence float64 `json:"average_cadence"`
AverageWatts float64 `json:"average_watts"`
WeightedAverageWatts int `json:"weighted_average_watts"`
Kilojoules float64 `json:"kilojoules"`
DeviceWatts bool `json:"device_watts"`
HasHeartrate bool `json:"has_heartrate"`
AverageHeartrate float64 `json:"average_heartrate"`
MaxHeartrate int `json:"max_heartrate"`
MaxWatts int `json:"max_watts"`
PrCount int `json:"pr_count"`
TotalPhotoCount int `json:"total_photo_count"`
HasKudoed bool `json:"has_kudoed"`
SufferScore int `json:"suffer_score"`
}
type DetailedStravaActivity struct {
ID int64 `json:"id"`
ResourceState int `json:"resource_state"`
ExternalID string `json:"external_id"`
UploadID int64 `json:"upload_id"`
Athlete Athlete `json:"athlete"`
Name string `json:"name"`
Distance float64 `json:"distance"`
MovingTime int `json:"moving_time"`
ElapsedTime int `json:"elapsed_time"`
TotalElevationGain float64 `json:"total_elevation_gain"`
Type string `json:"type"`
SportType string `json:"sport_type"`
StartDate time.Time `json:"start_date"`
StartDateLocal time.Time `json:"start_date_local"`
Timezone string `json:"timezone"`
StartLatlng []float64 `json:"start_latlng"`
EndLatlng []float64 `json:"end_latlng"`
AchievementCount int `json:"achievement_count"`
KudosCount int `json:"kudos_count"`
CommentCount int `json:"comment_count"`
AthleteCount int `json:"athlete_count"`
PhotoCount int `json:"photo_count"`
Map Map `json:"map"`
Trainer bool `json:"trainer"`
Commute bool `json:"commute"`
Manual bool `json:"manual"`
Private bool `json:"private"`
Flagged bool `json:"flagged"`
GearID string `json:"gear_id"`
FromAcceptedTag bool `json:"from_accepted_tag"`
AverageSpeed float64 `json:"average_speed"`
MaxSpeed float64 `json:"max_speed"`
AverageCadence float64 `json:"average_cadence"`
AverageTemp int `json:"average_temp"`
AverageWatts float64 `json:"average_watts"`
WeightedAverageWatts int `json:"weighted_average_watts"`
Kilojoules float64 `json:"kilojoules"`
DeviceWatts bool `json:"device_watts"`
HasHeartrate bool `json:"has_heartrate"`
MaxWatts int `json:"max_watts"`
ElevHigh float64 `json:"elev_high"`
ElevLow float64 `json:"elev_low"`
PrCount int `json:"pr_count"`
TotalPhotoCount int `json:"total_photo_count"`
HasKudoed bool `json:"has_kudoed"`
WorkoutType int `json:"workout_type"`
SufferScore any `json:"suffer_score"`
Description string `json:"description"`
Calories float64 `json:"calories"`
SegmentEfforts []SegmentEfforts `json:"segment_efforts"`
SplitsMetric []SplitsMetric `json:"splits_metric"`
Laps []Laps `json:"laps"`
Gear Gear `json:"gear"`
PartnerBrandTag any `json:"partner_brand_tag"`
Photos Photos `json:"photos"`
HighlightedKudosers []HighlightedKudosers `json:"highlighted_kudosers"`
HideFromHome bool `json:"hide_from_home"`
DeviceName string `json:"device_name"`
EmbedToken string `json:"embed_token"`
SegmentLeaderboardOptOut bool `json:"segment_leaderboard_opt_out"`
LeaderboardOptOut bool `json:"leaderboard_opt_out"`
}
type SegmentActivity struct {
ID int64 `json:"id"`
ResourceState int `json:"resource_state"`
}
type Segment struct {
ID int `json:"id"`
ResourceState int `json:"resource_state"`
Name string `json:"name"`
ActivityType string `json:"activity_type"`
Distance float64 `json:"distance"`
AverageGrade float64 `json:"average_grade"`
MaximumGrade float64 `json:"maximum_grade"`
ElevationHigh float64 `json:"elevation_high"`
ElevationLow float64 `json:"elevation_low"`
StartLatlng []float64 `json:"start_latlng"`
EndLatlng []float64 `json:"end_latlng"`
ClimbCategory int `json:"climb_category"`
City string `json:"city"`
State string `json:"state"`
Country string `json:"country"`
Private bool `json:"private"`
Hazardous bool `json:"hazardous"`
Starred bool `json:"starred"`
}
type SegmentEfforts struct {
ID int64 `json:"id"`
ResourceState int `json:"resource_state"`
Name string `json:"name"`
Activity SegmentActivity `json:"activity"`
Athlete Athlete `json:"athlete"`
ElapsedTime int `json:"elapsed_time"`
MovingTime int `json:"moving_time"`
StartDate time.Time `json:"start_date"`
StartDateLocal time.Time `json:"start_date_local"`
Distance float64 `json:"distance"`
StartIndex int `json:"start_index"`
EndIndex int `json:"end_index"`
AverageCadence float64 `json:"average_cadence"`
DeviceWatts bool `json:"device_watts"`
AverageWatts float64 `json:"average_watts"`
Segment Segment `json:"segment"`
KomRank any `json:"kom_rank"`
PrRank any `json:"pr_rank"`
Achievements []any `json:"achievements"`
Hidden bool `json:"hidden"`
}
type SplitsMetric struct {
Distance float64 `json:"distance"`
ElapsedTime int `json:"elapsed_time"`
ElevationDifference float64 `json:"elevation_difference"`
MovingTime int `json:"moving_time"`
Split int `json:"split"`
AverageSpeed float64 `json:"average_speed"`
PaceZone int `json:"pace_zone"`
}
type Laps struct {
ID int64 `json:"id"`
ResourceState int `json:"resource_state"`
Name string `json:"name"`
Activity SegmentActivity `json:"activity"`
Athlete Athlete `json:"athlete"`
ElapsedTime int `json:"elapsed_time"`
MovingTime int `json:"moving_time"`
StartDate time.Time `json:"start_date"`
StartDateLocal time.Time `json:"start_date_local"`
Distance float64 `json:"distance"`
StartIndex int `json:"start_index"`
EndIndex int `json:"end_index"`
TotalElevationGain float64 `json:"total_elevation_gain"`
AverageSpeed float64 `json:"average_speed"`
MaxSpeed float64 `json:"max_speed"`
AverageCadence float64 `json:"average_cadence"`
DeviceWatts bool `json:"device_watts"`
AverageWatts float64 `json:"average_watts"`
LapIndex int `json:"lap_index"`
Split int `json:"split"`
}
type Gear struct {
ID string `json:"id"`
Primary bool `json:"primary"`
Name string `json:"name"`
ResourceState int `json:"resource_state"`
Distance int `json:"distance"`
}
type Urls struct {
Num100 string `json:"100"`
Num600 string `json:"600"`
}
type Primary struct {
ID any `json:"id"`
UniqueID string `json:"unique_id"`
Urls Urls `json:"urls"`
Source int `json:"source"`
}
type Photos struct {
Primary Primary `json:"primary"`
UsePrimaryPhoto bool `json:"use_primary_photo"`
Count int `json:"count"`
}
type HighlightedKudosers struct {
DestinationURL string `json:"destination_url"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
ShowName bool `json:"show_name"`
}
type ActivityStreamResponse struct {
LatLng LatLngStream `json:"latlng"`
Altitude AltitudeStream `json:"altitude"`
Time TimeStream `json:"time"`
}
type ActivityStream struct {
OriginalSize int `json:"original_size"`
Resolution string `json:"resolution"`
SeriesType string `json:"series_type"`
}
type TimeStream struct {
ActivityStream
Data []int `json:"data"`
}
type LatLngStream struct {
ActivityStream
Data [][]float64 `json:"data"`
}
type AltitudeStream struct {
ActivityStream
Data []float64 `json:"data"`
}

View File

@@ -0,0 +1,607 @@
package strava
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/twpayne/go-gpx"
"github.com/twpayne/go-polyline"
)
type StravaApi struct {
AceessToken string
}
func SyncStrava(app *pocketbase.PocketBase) error {
integrations, err := app.Dao().FindRecordsByExpr("integrations", dbx.NewExp("true"))
if err != nil {
return err
}
for _, i := range integrations {
userId := i.GetString("user")
stravaString := i.GetString("strava")
var stravaIntegration StravaIntegration
json.Unmarshal([]byte(stravaString), &stravaIntegration)
if !stravaIntegration.Active || stravaIntegration.RefreshToken == "" {
continue
}
r, err := refreshStravaToken(stravaIntegration.ClientID, stravaIntegration.ClientSecret, stravaIntegration.RefreshToken)
if err != nil {
warning := fmt.Sprintf("error refreshing strava access token: %v\n", err)
fmt.Print(warning)
app.Logger().Warn(warning)
continue
}
if r.AccessToken != "" {
stravaIntegration.AccessToken = r.AccessToken
}
if r.RefreshToken != "" {
stravaIntegration.RefreshToken = r.RefreshToken
}
if r.AccessToken != "" {
stravaIntegration.ExpiresAt = r.ExpiresAt
}
b, err := json.Marshal(stravaIntegration)
if err != nil {
return err
}
i.Set("strava", string(b))
app.Dao().SaveRecord(i)
if stravaIntegration.Routes {
page := 1
hasNewRoutes := true
for hasNewRoutes {
routes, err := fetchStravaRoutes(r.AccessToken, page)
if err != nil {
warning := fmt.Sprintf("error fetching routes from strava: %v\n", err)
fmt.Print(warning)
app.Logger().Warn(warning)
break
}
hasNewRoutes, err = syncTrailsWithRoutes(app, r.AccessToken, userId, routes)
if err != nil {
warning := fmt.Sprintf("error syncing strava routes with trails: %v\n", err)
fmt.Print(warning)
app.Logger().Warn(warning)
break
}
page += 1
}
}
if stravaIntegration.Activities {
page := 1
hasNewActivities := true
for hasNewActivities {
activities, err := fetchStravaActivities(r.AccessToken, page)
if err != nil {
warning := fmt.Sprintf("error fetching activities from strava: %v", err)
fmt.Print(warning)
app.Logger().Warn(warning)
break
}
hasNewActivities, err = syncTrailsWithActivities(app, r.AccessToken, userId, activities)
if err != nil {
warning := fmt.Sprintf("error syncing strava activities with trails: %v", err)
fmt.Print(warning)
app.Logger().Warn(warning)
break
}
page += 1
}
}
}
return nil
}
func refreshStravaToken(clientID int32, clientSecret, refreshToken string) (*RefreshTokenResponse, error) {
const stravaTokenURL = "https://www.strava.com/oauth/token"
requestBody, err := json.Marshal(RefreshTokenRequest{
ClientID: clientID,
ClientSecret: clientSecret,
RefreshToken: refreshToken,
GrantType: "refresh_token",
})
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", stravaTokenURL, bytes.NewBuffer(requestBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to refresh token: received status %d", resp.StatusCode)
}
var tokenResponse RefreshTokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
return nil, err
}
return &tokenResponse, nil
}
func fetchStravaRoutes(accessToken string, page int) ([]StravaRoute, error) {
stravaRoutesURL := fmt.Sprintf("https://www.strava.com/api/v3/athlete/routes?page=%d", page)
req, err := http.NewRequest("GET", stravaRoutesURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch routes: received status %d", resp.StatusCode)
}
var routes []StravaRoute
if err := json.NewDecoder(resp.Body).Decode(&routes); err != nil {
return nil, err
}
return routes, nil
}
func fetchStravaActivities(accessToken string, page int) ([]StravaActivity, error) {
stravaRoutesURL := fmt.Sprintf("https://www.strava.com/api/v3/athlete/activities?page=%d", page)
req, err := http.NewRequest("GET", stravaRoutesURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch activities: received status %d", resp.StatusCode)
}
var activities []StravaActivity
if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil {
return nil, err
}
return activities, nil
}
func syncTrailsWithRoutes(app *pocketbase.PocketBase, accessToken string, user string, routes []StravaRoute) (bool, error) {
hasNewRoutes := false
for _, route := range routes {
trails, err := app.Dao().FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": route.IDStr})
if err != nil {
return hasNewRoutes, err
}
if len(trails) != 0 {
continue
}
hasNewRoutes = true
gpx, err := fetchRouteGPX(route, accessToken)
if err != nil {
return hasNewRoutes, err
}
wpIds, err := createWaypointsFromRoute(app, route, user)
if err != nil {
return hasNewRoutes, err
}
err = createTrailFromRoute(app, route, gpx, user, wpIds)
if err != nil {
return hasNewRoutes, err
}
}
return hasNewRoutes, nil
}
func fetchRouteGPX(route StravaRoute, accessToken string) (*filesystem.File, error) {
url := fmt.Sprintf("https://www.strava.com/api/v3/routes/%s/export_gpx", route.IDStr)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() {
if resp.Body != nil {
resp.Body.Close()
}
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch GPX: received status %d", resp.StatusCode)
}
var buf bytes.Buffer
_, err = io.Copy(&buf, resp.Body)
if err != nil {
return nil, err
}
gpxFile, err := filesystem.NewFileFromBytes(buf.Bytes(), route.Name+".gpx")
if err != nil {
return nil, err
}
return gpxFile, nil
}
func createTrailFromRoute(app *pocketbase.PocketBase, route StravaRoute, gpx *filesystem.File, user string, wpIds []string) error {
collection, err := app.Dao().FindCollectionByNameOrId("trails")
if err != nil {
return err
}
record := models.NewRecord(collection)
form := forms.NewRecordUpsert(app, record)
buf := []byte(route.Map.SummaryPolyline)
coords, _, _ := polyline.DecodeCoords(buf)
var lat, lon float64
if len(coords) > 0 && len(coords[0]) >= 2 {
lat = coords[0][0]
lon = coords[0][1]
} else {
app.Logger().Warn("Warning: No coordinates available, setting lat/lon to 0")
lat, lon = 0, 0
}
bikeCategory, _ := app.Dao().FindFirstRecordByData("categories", "name", "Biking")
hikeCategory, _ := app.Dao().FindFirstRecordByData("categories", "name", "Walking")
category := ""
if route.Type == 1 && bikeCategory != nil {
category = bikeCategory.Id
} else if route.Type == 2 && hikeCategory != nil {
category = hikeCategory.Id
}
form.LoadData(map[string]any{
"name": route.Name,
"description": route.Description,
"public": !route.Private,
"distance": route.Distance,
"elevation_gain": route.ElevationGain,
"duration": route.EstimatedMovingTime / 60,
"date": time.Unix(int64(route.Timestamp), 0),
"external_provider": "strava",
"external_id": route.IDStr,
"lat": lat,
"lon": lon,
"waypoints": wpIds,
"difficulty": "easy",
"category": category,
"author": user,
})
form.AddFiles("gpx", gpx)
if err := form.Submit(); err != nil {
return err
}
return nil
}
func createWaypointsFromRoute(app *pocketbase.PocketBase, route StravaRoute, user string) ([]string, error) {
collection, err := app.Dao().FindCollectionByNameOrId("waypoints")
if err != nil {
return nil, err
}
wpIds := make([]string, len(route.Waypoints))
for i, wp := range route.Waypoints {
record := models.NewRecord(collection)
record.Set("name", strconv.Itoa(i))
record.Set("description", wp.Description)
record.Set("lat", wp.Latlng[0])
record.Set("lon", wp.Latlng[1])
record.Set("icon", "circle")
record.Set("author", user)
record.Set("distance_from_start", wp.DistanceIntoRoute)
app.Dao().SaveRecord(record)
wpIds[i] = record.Id
}
return wpIds, nil
}
func syncTrailsWithActivities(app *pocketbase.PocketBase, accessToken string, user string, activities []StravaActivity) (bool, error) {
hasNewActivites := false
for _, activity := range activities {
trails, err := app.Dao().FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": strconv.Itoa(int(activity.ID))})
if err != nil {
return hasNewActivites, err
}
if len(trails) != 0 {
continue
}
hasNewActivites = true
detailedActivity, err := fetchDetailedActivity(activity, accessToken)
if err != nil {
return hasNewActivites, err
}
gpx, err := generateActivityGPX(detailedActivity, accessToken)
if err != nil {
return hasNewActivites, err
}
err = createTrailFromActivity(app, detailedActivity, gpx, user)
if err != nil {
return hasNewActivites, err
}
}
return hasNewActivites, nil
}
func fetchDetailedActivity(activity StravaActivity, accessToken string) (*DetailedStravaActivity, error) {
url := fmt.Sprintf("https://www.strava.com/api/v3/activities/%d", activity.ID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch activity: received status %d", resp.StatusCode)
}
var detailedActivity DetailedStravaActivity
if err := json.NewDecoder(resp.Body).Decode(&detailedActivity); err != nil {
return nil, err
}
return &detailedActivity, nil
}
func createTrailFromActivity(app *pocketbase.PocketBase, activity *DetailedStravaActivity, gpx *filesystem.File, user string) error {
collection, err := app.Dao().FindCollectionByNameOrId("trails")
if err != nil {
return err
}
var photo *filesystem.File
if len(activity.Photos.Primary.Urls.Num600) > 0 {
photo, err = fetchActivityPhoto(activity)
if err != nil {
return err
}
}
record := models.NewRecord(collection)
form := forms.NewRecordUpsert(app, record)
activityMap := map[string]string{
"AlpineSki": "Skiing",
"BackcountrySki": "Skiing",
"Canoeing": "Canoeing",
"Crossfit": "Workout",
"EBikeRide": "Biking",
"Elliptical": "Workout",
"Golf": "Walking",
"Handcycle": "Biking",
"Hike": "Hiking",
"IceSkate": "Skiing",
"InlineSkate": "Biking",
"Kayaking": "Canoeing",
"Kitesurf": "Canoeing",
"NordicSki": "Skiing",
"Ride": "Biking",
"RockClimbing": "Climbing",
"RollerSki": "Skiing",
"Rowing": "Canoeing",
"Run": "Walking",
"Sail": "Canoeing",
"Skateboard": "Walking",
"Snowboard": "Skiing",
"Snowshoe": "Hiking",
"Soccer": "Workout",
"StairStepper": "Workout",
"StandUpPaddling": "Canoeing",
"Surfing": "Canoeing",
"Swim": "Workout",
"Velomobile": "Biking",
"VirtualRide": "Biking",
"VirtualRun": "Walking",
"Walk": "Walking",
"WeightTraining": "Workout",
"Wheelchair": "Walking",
"Windsurf": "Canoeing",
"Workout": "Workout",
"Yoga": "Workout",
}
category, _ := app.Dao().FindFirstRecordByData("categories", "name", activityMap[activity.Type])
categoryId := ""
if category != nil {
categoryId = category.Id
}
form.LoadData(map[string]any{
"name": activity.Name,
"description": activity.Description,
"public": !activity.Private,
"distance": activity.Distance,
"elevation_gain": activity.TotalElevationGain,
"duration": activity.ElapsedTime / 60,
"date": activity.StartDate,
"external_provider": "strava",
"external_id": activity.ID,
"lat": activity.StartLatlng[0],
"lon": activity.StartLatlng[1],
"difficulty": "easy",
"category": categoryId,
"author": user,
})
if photo != nil {
form.AddFiles("photos", photo)
}
form.AddFiles("gpx", gpx)
if err := form.Submit(); err != nil {
return err
}
return nil
}
func fetchActivityPhoto(activity *DetailedStravaActivity) (*filesystem.File, error) {
req, err := http.NewRequest("GET", activity.Photos.Primary.Urls.Num600, nil)
if err != nil {
return nil, err
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch photo: received status %d", resp.StatusCode)
}
var buf bytes.Buffer
_, err = io.Copy(&buf, resp.Body)
if err != nil {
return nil, err
}
photo, err := filesystem.NewFileFromBytes(buf.Bytes(), "photo")
if err != nil {
return nil, err
}
return photo, nil
}
func generateActivityGPX(activity *DetailedStravaActivity, accessToken string) (*filesystem.File, error) {
url := fmt.Sprintf("https://www.strava.com/api/v3/activities/%d/streams?keys=latlng,time,altitude&key_by_type=true", activity.ID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch activity: %s", resp.Status)
}
var streamResponse ActivityStreamResponse
if err := json.NewDecoder(resp.Body).Decode(&streamResponse); err != nil {
return nil, err
}
latLngStream := streamResponse.LatLng
timeStream := streamResponse.Time
altitudeStream := streamResponse.Altitude
var points []*gpx.WptType
for i, latlng := range latLngStream.Data {
lat := latlng[0]
lon := latlng[1]
alt := altitudeStream.Data[i]
t := activity.StartDate.Unix() + int64(timeStream.Data[i])
points = append(points, &gpx.WptType{Lat: lat, Lon: lon, Ele: alt, Time: time.Unix(t, 0)})
}
gpx := &gpx.GPX{
Version: "1.1",
Creator: "Strava GPX Exporter",
Trk: []*gpx.TrkType{
{
Name: activity.Name,
TrkSeg: []*gpx.TrkSegType{
{
TrkPt: points,
},
},
},
},
}
var buf bytes.Buffer
err = gpx.Write(&buf)
if err != nil {
return nil, err
}
gpxFile, err := filesystem.NewFileFromBytes(buf.Bytes(), activity.Name+".gpx")
if err != nil {
return nil, err
}
return gpxFile, nil
}