adds komoot integration
This commit is contained in:
371
db/integrations/komoot/komoot.go
Normal file
371
db/integrations/komoot/komoot.go
Normal file
@@ -0,0 +1,371 @@
|
||||
package komoot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
func SyncKomoot(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")
|
||||
komootString := i.GetString("komoot")
|
||||
var komootIntegration KomootIntegration
|
||||
json.Unmarshal([]byte(komootString), &komootIntegration)
|
||||
|
||||
if !komootIntegration.Active || komootIntegration.Email == "" || komootIntegration.Password == "" {
|
||||
continue
|
||||
}
|
||||
k := &KomootApi{}
|
||||
err = k.login(komootIntegration.Email, komootIntegration.Password)
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("komoot login failed: %v\n", err)
|
||||
fmt.Print(warning)
|
||||
app.Logger().Warn(warning)
|
||||
continue
|
||||
}
|
||||
hasNewTours := true
|
||||
page := 0
|
||||
for hasNewTours {
|
||||
tours, err := k.fetchTours(page)
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("error fetching tours from komoot: %v\n", err)
|
||||
fmt.Print(warning)
|
||||
app.Logger().Warn(warning)
|
||||
break
|
||||
}
|
||||
|
||||
hasNewTours, err = syncTrailWithTours(app, k, userId, tours)
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("error syncing komoot tours with trails: %v\n", err)
|
||||
fmt.Print(warning)
|
||||
app.Logger().Warn(warning)
|
||||
break
|
||||
}
|
||||
page += 1
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type BasicAuthToken struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
func (b BasicAuthToken) Apply(req *http.Request) {
|
||||
authStr := "Basic " + base64.StdEncoding.EncodeToString([]byte(b.Key+":"+b.Value))
|
||||
req.Header.Set("Authorization", authStr)
|
||||
}
|
||||
|
||||
type KomootApi struct {
|
||||
UserID string
|
||||
Token string
|
||||
}
|
||||
|
||||
func (k *KomootApi) buildHeader() *BasicAuthToken {
|
||||
if k.UserID != "" && k.Token != "" {
|
||||
return &BasicAuthToken{k.UserID, k.Token}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendRequest(url string, auth *BasicAuthToken) ([]byte, error) {
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if auth != nil {
|
||||
auth.Apply(req)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("error sending request to komoot (%d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func (k *KomootApi) login(email, password string) error {
|
||||
url := fmt.Sprintf("https://api.komoot.de/v006/account/email/%s/", email)
|
||||
|
||||
body, err := sendRequest(url, &BasicAuthToken{email, password})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var data LoginResponse
|
||||
json.Unmarshal(body, &data)
|
||||
|
||||
k.UserID = data.Username
|
||||
k.Token = data.Password
|
||||
|
||||
return nil
|
||||
}
|
||||
func (k *KomootApi) fetchTours(page int) ([]KomootTour, error) {
|
||||
currentUri := fmt.Sprintf("https://api.komoot.de/v007/users/%s/tours/?page=%d&sort_field=date&sort_direction=desc&limit=30", k.UserID, page)
|
||||
|
||||
body, err := sendRequest(currentUri, k.buildHeader())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data KomootToursResponse
|
||||
json.Unmarshal(body, &data)
|
||||
|
||||
tours := data.Embedded.Tours
|
||||
|
||||
return tours, nil
|
||||
}
|
||||
|
||||
func (k *KomootApi) fetchDetailedTour(tour KomootTour) (*DetailedKomootTour, error) {
|
||||
url := fmt.Sprintf("https://api.komoot.de/v007/tours/%d?_embedded=coordinates,way_types,surfaces,directions,participants,timeline&directions=v2&fields=timeline&format=coordinate_array&timeline_highlights_fields=tips,recommenders", tour.ID)
|
||||
body, err := sendRequest(url, k.buildHeader())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data *DetailedKomootTour
|
||||
json.Unmarshal(body, &data)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func syncTrailWithTours(app *pocketbase.PocketBase, k *KomootApi, user string, tours []KomootTour) (bool, error) {
|
||||
hasNewTours := false
|
||||
for _, tour := range tours {
|
||||
trails, err := app.Dao().FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": strconv.Itoa(int(tour.ID))})
|
||||
if err != nil {
|
||||
return hasNewTours, err
|
||||
}
|
||||
if len(trails) != 0 {
|
||||
continue
|
||||
}
|
||||
hasNewTours = true
|
||||
detailedTour, err := k.fetchDetailedTour(tour)
|
||||
if err != nil {
|
||||
return hasNewTours, err
|
||||
}
|
||||
gpx, err := generateTourGPX(detailedTour)
|
||||
if err != nil {
|
||||
return hasNewTours, err
|
||||
}
|
||||
wpIds, err := createWaypointsFromTour(app, detailedTour, user)
|
||||
if err != nil {
|
||||
return hasNewTours, err
|
||||
}
|
||||
err = createTrailFromTour(app, detailedTour, gpx, user, wpIds)
|
||||
if err != nil {
|
||||
return hasNewTours, err
|
||||
}
|
||||
|
||||
}
|
||||
return hasNewTours, nil
|
||||
}
|
||||
|
||||
func createTrailFromTour(app *pocketbase.PocketBase, detailedTour *DetailedKomootTour, 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)
|
||||
|
||||
categoryMap := map[string]string{
|
||||
"hike": "Hiking",
|
||||
"touringbicycle": "Biking",
|
||||
"mtb": "Biking",
|
||||
"racebike": "Biking",
|
||||
"jogging": "Walking",
|
||||
"mtb_easy": "Workout",
|
||||
"mtb_advanced": "Walking",
|
||||
"mountaineering": "Hiking",
|
||||
}
|
||||
|
||||
category, _ := app.Dao().FindFirstRecordByData("categories", "name", categoryMap[detailedTour.Sport])
|
||||
categoryId := ""
|
||||
if category != nil {
|
||||
categoryId = category.Id
|
||||
}
|
||||
|
||||
photo, err := fetchPhoto(detailedTour.MapImage.Src, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
form.LoadData(map[string]any{
|
||||
"name": detailedTour.Name,
|
||||
"public": detailedTour.Status == "public",
|
||||
"distance": detailedTour.Distance,
|
||||
"elevation_gain": detailedTour.ElevationUp,
|
||||
"elevation_loss": detailedTour.ElevationDown,
|
||||
"duration": detailedTour.Duration / 60,
|
||||
"date": detailedTour.Date,
|
||||
"external_provider": "komoot",
|
||||
"external_id": strconv.Itoa(detailedTour.ID),
|
||||
"lat": detailedTour.StartPoint.Lat,
|
||||
"lon": detailedTour.StartPoint.Lng,
|
||||
"difficulty": detailedTour.Difficulty.Grade,
|
||||
"category": categoryId,
|
||||
"waypoints": wpIds,
|
||||
"author": user,
|
||||
})
|
||||
|
||||
form.AddFiles("photos", photo)
|
||||
form.AddFiles("gpx", gpx)
|
||||
|
||||
if err := form.Submit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createWaypointsFromTour(app *pocketbase.PocketBase, tour *DetailedKomootTour, user string) ([]string, error) {
|
||||
collection, err := app.Dao().FindCollectionByNameOrId("waypoints")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wpIds := make([]string, len(tour.Embedded.Timeline.Embedded.Items))
|
||||
|
||||
for i, wp := range tour.Embedded.Timeline.Embedded.Items {
|
||||
photos, err := fetchWaypointPhotos(wp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record := models.NewRecord(collection)
|
||||
form := forms.NewRecordUpsert(app, record)
|
||||
|
||||
wpDescription := ""
|
||||
if len(wp.Embedded.Reference.Embedded.Tips.Embedded.Items) > 0 {
|
||||
wpDescription = wp.Embedded.Reference.Embedded.Tips.Embedded.Items[0].Text
|
||||
}
|
||||
|
||||
wpLat := wp.Embedded.Reference.StartPoint.Lat
|
||||
if wpLat == 0 {
|
||||
wpLat = tour.StartPoint.Lat
|
||||
}
|
||||
|
||||
wpLon := wp.Embedded.Reference.StartPoint.Lng
|
||||
if wpLon == 0 {
|
||||
wpLon = tour.StartPoint.Lng
|
||||
}
|
||||
|
||||
form.LoadData(map[string]any{
|
||||
"name": wp.Embedded.Reference.Name,
|
||||
"description": wpDescription,
|
||||
"lat": wpLat,
|
||||
"lon": wpLon,
|
||||
"icon": "circle",
|
||||
"author": user,
|
||||
"distance_from_start": 0,
|
||||
})
|
||||
|
||||
for _, photo := range photos {
|
||||
form.AddFiles("photos", photo)
|
||||
}
|
||||
|
||||
if err := form.Submit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
wpIds[i] = record.Id
|
||||
}
|
||||
|
||||
return wpIds, nil
|
||||
}
|
||||
|
||||
func fetchWaypointPhotos(wp Item) ([]*filesystem.File, error) {
|
||||
|
||||
photos := make([]*filesystem.File, len(wp.Embedded.Reference.Embedded.Images.Embedded.Items))
|
||||
|
||||
for i, img := range wp.Embedded.Reference.Embedded.Images.Embedded.Items {
|
||||
photo, err := fetchPhoto(img.Src, "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
photos[i] = photo
|
||||
}
|
||||
|
||||
return photos, nil
|
||||
}
|
||||
|
||||
func fetchPhoto(url string, width string, height string) (*filesystem.File, error) {
|
||||
url = strings.Replace(url, "{crop}", "false", 1)
|
||||
url = strings.Replace(url, "{width}", width, 1)
|
||||
url = strings.Replace(url, "{height}", height, 1)
|
||||
|
||||
bytes, err := sendRequest(url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return filesystem.NewFileFromBytes(bytes, "photo")
|
||||
}
|
||||
|
||||
func generateTourGPX(detailedTour *DetailedKomootTour) (*filesystem.File, error) {
|
||||
var points []*gpx.WptType
|
||||
|
||||
for _, item := range detailedTour.Embedded.Coordinates.Items {
|
||||
t := detailedTour.Date.Unix() + int64(item.T)
|
||||
|
||||
points = append(points, &gpx.WptType{Lat: item.Lat, Lon: item.Lng, Ele: item.Alt, Time: time.Unix(t, 0)})
|
||||
}
|
||||
|
||||
gpx := &gpx.GPX{
|
||||
Version: "1.1",
|
||||
Creator: "Strava GPX Exporter",
|
||||
Trk: []*gpx.TrkType{
|
||||
{
|
||||
Name: detailedTour.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(), detailedTour.Name+".gpx")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gpxFile, nil
|
||||
}
|
||||
379
db/integrations/komoot/models.go
Normal file
379
db/integrations/komoot/models.go
Normal file
@@ -0,0 +1,379 @@
|
||||
package komoot
|
||||
|
||||
import "time"
|
||||
|
||||
type KomootIntegration struct {
|
||||
Active bool `json:"active"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
User User `json:"user"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
HasImage bool `json:"hasImage"`
|
||||
}
|
||||
|
||||
type Fitness struct {
|
||||
Personalised bool `json:"personalised"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Content Content `json:"content"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Displayname string `json:"displayname"`
|
||||
Fitness Fitness `json:"fitness"`
|
||||
ImageURL string `json:"imageUrl"`
|
||||
Locale string `json:"locale"`
|
||||
Metric bool `json:"metric"`
|
||||
Newsletter bool `json:"newsletter"`
|
||||
State string `json:"state"`
|
||||
Username string `json:"username"`
|
||||
WelcomeMails bool `json:"welcomeMails"`
|
||||
}
|
||||
|
||||
type KomootToursResponse struct {
|
||||
Embedded Embedded `json:"_embedded"`
|
||||
Links ResponseLinks `json:"_links"`
|
||||
Page Page `json:"page"`
|
||||
}
|
||||
type StartPoint struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Alt float64 `json:"alt"`
|
||||
}
|
||||
type Surfaces struct {
|
||||
Type string `json:"type"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
type WayTypes struct {
|
||||
Type string `json:"type"`
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
type Summary struct {
|
||||
Surfaces []Surfaces `json:"surfaces"`
|
||||
WayTypes []WayTypes `json:"way_types"`
|
||||
}
|
||||
type Difficulty struct {
|
||||
Grade string `json:"grade"`
|
||||
ExplanationTechnical string `json:"explanation_technical"`
|
||||
ExplanationFitness string `json:"explanation_fitness"`
|
||||
}
|
||||
type Location struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
}
|
||||
type Path struct {
|
||||
Location Location `json:"location"`
|
||||
Index int `json:"index"`
|
||||
Reference string `json:"reference,omitempty"`
|
||||
EndIndex int `json:"end_index,omitempty"`
|
||||
SegmentType string `json:"segment_type,omitempty"`
|
||||
}
|
||||
type Segments struct {
|
||||
Type string `json:"type"`
|
||||
From int `json:"from"`
|
||||
To int `json:"to"`
|
||||
}
|
||||
type MapImage struct {
|
||||
Src string `json:"src"`
|
||||
Templated bool `json:"templated"`
|
||||
Type string `json:"type"`
|
||||
Attribution string `json:"attribution"`
|
||||
}
|
||||
type MapImagePreview struct {
|
||||
Src string `json:"src"`
|
||||
Templated bool `json:"templated"`
|
||||
Type string `json:"type"`
|
||||
Attribution string `json:"attribution"`
|
||||
}
|
||||
type VectorMapImage struct {
|
||||
Src string `json:"src"`
|
||||
Templated bool `json:"templated"`
|
||||
Type string `json:"type"`
|
||||
Attribution string `json:"attribution"`
|
||||
}
|
||||
type VectorMapImagePreview struct {
|
||||
Src string `json:"src"`
|
||||
Templated bool `json:"templated"`
|
||||
Type string `json:"type"`
|
||||
Attribution string `json:"attribution"`
|
||||
}
|
||||
|
||||
type Relation struct {
|
||||
Href string `json:"href"`
|
||||
Templated bool `json:"templated"`
|
||||
}
|
||||
type CreatorLinks struct {
|
||||
Relation Relation `json:"relation"`
|
||||
}
|
||||
|
||||
type LinksEmbedded struct {
|
||||
Creator Creator `json:"creator"`
|
||||
}
|
||||
type LinksCreator struct {
|
||||
Href string `json:"href"`
|
||||
}
|
||||
type LinksCoordinates struct {
|
||||
Href string `json:"href"`
|
||||
}
|
||||
type LinksTourLine struct {
|
||||
Href string `json:"href"`
|
||||
}
|
||||
type LinksParticipants struct {
|
||||
Href string `json:"href"`
|
||||
}
|
||||
type LinksWayTypes struct {
|
||||
Href string `json:"href"`
|
||||
}
|
||||
type LinksSurfaces struct {
|
||||
Href string `json:"href"`
|
||||
}
|
||||
type LinksDirections struct {
|
||||
Href string `json:"href"`
|
||||
}
|
||||
type LinksTimeline struct {
|
||||
Href string `json:"href"`
|
||||
}
|
||||
type LinksTranslations struct {
|
||||
Href string `json:"href"`
|
||||
}
|
||||
type LinksCoverImages struct {
|
||||
Href string `json:"href"`
|
||||
}
|
||||
type LinksTourRating struct {
|
||||
Href string `json:"href"`
|
||||
}
|
||||
type TourLinks struct {
|
||||
Creator LinksCreator `json:"creator"`
|
||||
Coordinates LinksCoordinates `json:"coordinates"`
|
||||
TourLine LinksTourLine `json:"tour_line"`
|
||||
Participants LinksParticipants `json:"participants"`
|
||||
WayTypes LinksWayTypes `json:"way_types"`
|
||||
Surfaces LinksSurfaces `json:"surfaces"`
|
||||
Directions LinksDirections `json:"directions"`
|
||||
Timeline LinksTimeline `json:"timeline"`
|
||||
Translations LinksTranslations `json:"translations"`
|
||||
CoverImages LinksCoverImages `json:"cover_images"`
|
||||
TourRating LinksTourRating `json:"tour_rating"`
|
||||
}
|
||||
type KomootTour struct {
|
||||
ID int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
RoutingVersion string `json:"routing_version"`
|
||||
Status string `json:"status"`
|
||||
Date time.Time `json:"date"`
|
||||
KcalActive int `json:"kcal_active"`
|
||||
KcalResting int `json:"kcal_resting"`
|
||||
StartPoint StartPoint `json:"start_point"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration int `json:"duration"`
|
||||
ElevationUp float64 `json:"elevation_up"`
|
||||
ElevationDown float64 `json:"elevation_down"`
|
||||
Sport string `json:"sport"`
|
||||
Query string `json:"query"`
|
||||
Constitution int `json:"constitution"`
|
||||
Summary Summary `json:"summary"`
|
||||
Difficulty Difficulty `json:"difficulty"`
|
||||
TourInformation []any `json:"tour_information"`
|
||||
Path []Path `json:"path"`
|
||||
Segments []Segments `json:"segments"`
|
||||
ChangedAt time.Time `json:"changed_at"`
|
||||
MapImage MapImage `json:"map_image"`
|
||||
MapImagePreview MapImagePreview `json:"map_image_preview"`
|
||||
VectorMapImage VectorMapImage `json:"vector_map_image"`
|
||||
VectorMapImagePreview VectorMapImagePreview `json:"vector_map_image_preview"`
|
||||
PotentialRouteUpdate bool `json:"potential_route_update"`
|
||||
Embedded Embedded `json:"_embedded"`
|
||||
Links TourLinks `json:"_links"`
|
||||
}
|
||||
type Embedded struct {
|
||||
Tours []KomootTour `json:"tours"`
|
||||
}
|
||||
type Next struct {
|
||||
Href string `json:"href"`
|
||||
}
|
||||
type ResponseLinks struct {
|
||||
Next Next `json:"next"`
|
||||
}
|
||||
type Page struct {
|
||||
Size int `json:"size"`
|
||||
TotalElements int `json:"totalElements"`
|
||||
TotalPages int `json:"totalPages"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
type DetailedKomootTour struct {
|
||||
ID int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Date time.Time `json:"date"`
|
||||
KcalActive float64 `json:"kcal_active"`
|
||||
KcalResting float64 `json:"kcal_resting"`
|
||||
StartPoint StartPoint `json:"start_point"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration int `json:"duration"`
|
||||
ElevationUp float64 `json:"elevation_up"`
|
||||
ElevationDown float64 `json:"elevation_down"`
|
||||
Sport string `json:"sport"`
|
||||
MapImage MapImage `json:"map_image"`
|
||||
Difficulty Difficulty `json:"difficulty"`
|
||||
ChangedAt time.Time `json:"changed_at"`
|
||||
Embedded DetailedTourEmbedded `json:"_embedded"`
|
||||
}
|
||||
|
||||
type Items struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Alt float64 `json:"alt"`
|
||||
T int `json:"t"`
|
||||
}
|
||||
|
||||
type Coordinates struct {
|
||||
Items []Items `json:"items"`
|
||||
}
|
||||
|
||||
type DetailedTourEmbedded struct {
|
||||
Coordinates Coordinates `json:"coordinates"`
|
||||
Timeline Timeline `json:"timeline"`
|
||||
}
|
||||
|
||||
type Timeline struct {
|
||||
Embedded TimelineEmbedded `json:"_embedded"`
|
||||
Links Links `json:"_links"`
|
||||
Page Page `json:"page"`
|
||||
}
|
||||
|
||||
type TimelineEmbedded struct {
|
||||
Items []Item `json:"items"`
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
Index int `json:"index"`
|
||||
Cover int `json:"cover"`
|
||||
Type string `json:"type"`
|
||||
Embedded TimelineItemEmbedded `json:"_embedded"`
|
||||
}
|
||||
|
||||
type TimelineItemEmbedded struct {
|
||||
Reference Reference `json:"reference"`
|
||||
}
|
||||
|
||||
type Reference struct {
|
||||
ID int `json:"id"`
|
||||
Type string `json:"type"`
|
||||
BaseName string `json:"base_name"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ChangedAt time.Time `json:"changed_at"`
|
||||
Sport string `json:"sport"`
|
||||
Routable bool `json:"routable"`
|
||||
StartPoint Point `json:"start_point"`
|
||||
MidPoint Point `json:"mid_point"`
|
||||
EndPoint Point `json:"end_point"`
|
||||
Distance float64 `json:"distance"`
|
||||
ElevationUp float64 `json:"elevation_up"`
|
||||
ElevationDown float64 `json:"elevation_down"`
|
||||
Score float64 `json:"score"`
|
||||
WikiPOIID string `json:"wiki_poi_id"`
|
||||
PoorQuality bool `json:"poor_quality"`
|
||||
Categories []string `json:"categories"`
|
||||
Flagged bool `json:"flagged"`
|
||||
Links Links `json:"_links"`
|
||||
Embedded SubEmbedded `json:"_embedded"`
|
||||
}
|
||||
|
||||
type Point struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Alt float64 `json:"alt"`
|
||||
}
|
||||
|
||||
type Links struct {
|
||||
Self Link `json:"self"`
|
||||
}
|
||||
|
||||
type Link struct {
|
||||
Href string `json:"href"`
|
||||
Templated bool `json:"templated,omitempty"`
|
||||
}
|
||||
|
||||
type SubEmbedded struct {
|
||||
Creator Creator `json:"creator"`
|
||||
Images Images `json:"images"`
|
||||
Tips Tips `json:"tips"`
|
||||
}
|
||||
|
||||
type Creator struct {
|
||||
Username string `json:"username"`
|
||||
Avatar Avatar `json:"avatar"`
|
||||
Status string `json:"status"`
|
||||
Links Links `json:"_links"`
|
||||
DisplayName string `json:"display_name"`
|
||||
IsPremium bool `json:"is_premium"`
|
||||
}
|
||||
|
||||
type Avatar struct {
|
||||
Src string `json:"src"`
|
||||
Templated bool `json:"templated"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type Images struct {
|
||||
Embedded ImagesEmbedded `json:"_embedded"`
|
||||
Links Links `json:"_links"`
|
||||
Page Page `json:"page"`
|
||||
}
|
||||
|
||||
type ImagesEmbedded struct {
|
||||
Items []ImageItem `json:"items"`
|
||||
}
|
||||
|
||||
type ImageItem struct {
|
||||
ID int `json:"id"`
|
||||
Src string `json:"src"`
|
||||
Rating Rating `json:"rating"`
|
||||
Templated bool `json:"templated"`
|
||||
HighlightID int `json:"highlight_id"`
|
||||
ClientHash string `json:"client_hash,omitempty"`
|
||||
Type string `json:"type"`
|
||||
Links Links `json:"_links"`
|
||||
Embedded SubEmbedded `json:"_embedded"`
|
||||
}
|
||||
|
||||
type Rating struct {
|
||||
Up int `json:"up"`
|
||||
Down int `json:"down"`
|
||||
}
|
||||
|
||||
type Tips struct {
|
||||
Embedded TipsEmbedded `json:"_embedded"`
|
||||
Links Links `json:"_links"`
|
||||
Page Page `json:"page"`
|
||||
}
|
||||
|
||||
type TipsEmbedded struct {
|
||||
Items []TipItem `json:"items"`
|
||||
}
|
||||
|
||||
type TipItem struct {
|
||||
ID int `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Rating Rating `json:"rating"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
TextLanguage string `json:"text_language"`
|
||||
TranslatedText string `json:"translated_text"`
|
||||
TranslatedTextLanguage string `json:"translated_text_language"`
|
||||
Attribution string `json:"attribution"`
|
||||
HighlightID int `json:"highlight_id"`
|
||||
Links Links `json:"_links"`
|
||||
Embedded SubEmbedded `json:"_embedded"`
|
||||
}
|
||||
372
db/integrations/strava/models.go
Normal file
372
db/integrations/strava/models.go
Normal 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"`
|
||||
}
|
||||
607
db/integrations/strava/strava.go
Normal file
607
db/integrations/strava/strava.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user