adds strava integration

This commit is contained in:
Christian Beutel
2025-02-04 11:43:48 +01:00
parent 3ab591177a
commit a0b6480854
32 changed files with 1737 additions and 57 deletions

372
db/cron/models.go Normal file
View File

@@ -0,0 +1,372 @@
package cron
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"`
}

582
db/cron/strava.go Normal file
View File

@@ -0,0 +1,582 @@
package cron
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"
)
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 == nil {
continue
}
r, err := refreshStravaToken(stravaIntegration.ClientID, stravaIntegration.ClientSecret, *stravaIntegration.RefreshToken)
if err != nil {
return err
}
stravaIntegration.AccessToken = &r.AccessToken
stravaIntegration.RefreshToken = &r.RefreshToken
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 {
return err
}
hasNewRoutes, err = syncTrailsWithRoutes(app, r.AccessToken, userId, routes)
if err != nil {
return err
}
page += 1
}
}
if stravaIntegration.Activities {
page := 1
hasNewActivities := true
for hasNewActivities {
activities, err := fetchStravaActivities(r.AccessToken, page)
if err != nil {
return err
}
hasNewActivities, err = syncTrailsWithActivities(app, r.AccessToken, userId, activities)
if err != nil {
return err
}
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 {
fmt.Println("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", string(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
}

View File

@@ -9,6 +9,11 @@ require (
github.com/pocketbase/pocketbase v0.22.26 github.com/pocketbase/pocketbase v0.22.26
) )
require (
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/twpayne/go-geom v1.5.7 // indirect
)
require ( require (
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect
@@ -58,18 +63,20 @@ require (
github.com/spf13/cast v1.7.0 // indirect github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/cobra v1.8.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/twpayne/go-gpx v1.4.1
github.com/twpayne/go-polyline v1.1.1
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
gocloud.dev v0.39.0 // indirect gocloud.dev v0.39.0 // indirect
golang.org/x/crypto v0.28.0 // indirect golang.org/x/crypto v0.31.0 // indirect
golang.org/x/image v0.19.0 // indirect golang.org/x/image v0.19.0 // indirect
golang.org/x/net v0.30.0 // indirect golang.org/x/net v0.33.0 // indirect
golang.org/x/oauth2 v0.22.0 // indirect golang.org/x/oauth2 v0.22.0 // indirect
golang.org/x/sync v0.8.0 // indirect golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.26.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/term v0.25.0 // indirect golang.org/x/term v0.27.0 // indirect
golang.org/x/text v0.19.0 // indirect golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.6.0 // indirect golang.org/x/time v0.6.0 // indirect
golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect
google.golang.org/api v0.194.0 // indirect google.golang.org/api v0.194.0 // indirect

View File

@@ -78,6 +78,7 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -188,8 +189,8 @@ github.com/pocketbase/pocketbase v0.22.26/go.mod h1:h2ojT2pqBWH9LLl1aiawkwXiICKt
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
@@ -198,15 +199,24 @@ github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3k
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/twpayne/go-geom v1.5.7 h1:7fdceDUr03/MP7rAKOaTV6x9njMiQdxB/D0PDzMTCDc=
github.com/twpayne/go-geom v1.5.7/go.mod h1:y4fTAQtLedXW8eG2Yo4tYrIGN1yIwwKkmA+K3iSHKBA=
github.com/twpayne/go-gpx v1.4.1 h1:Y41EDC/r49OH6pTAQUk4Qpcp9z96fOvVLchRq/P4iys=
github.com/twpayne/go-gpx v1.4.1/go.mod h1:6bVeKyVqzHRZ25UdFOWxv0f6SMW0P9lO7GO1aNNznEU=
github.com/twpayne/go-polyline v1.1.1 h1:/tSF1BR7rN4HWj4XKqvRUNrCiYVMCvywxTFVofvDV0w=
github.com/twpayne/go-polyline v1.1.1/go.mod h1:ybd9IWWivW/rlXPXuuckeKUyF3yrIim+iqA7kSl4NFY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
@@ -229,8 +239,8 @@ gocloud.dev v0.39.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ= golang.org/x/image v0.19.0 h1:D9FX4QWkLfkeqaC62SonffIIuYdOk/UE2XKUBgRIBIQ=
@@ -251,8 +261,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
@@ -260,8 +270,8 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -273,19 +283,19 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -336,6 +346,7 @@ google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWn
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View File

@@ -18,9 +18,11 @@ import (
"github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/plugins/migratecmd" "github.com/pocketbase/pocketbase/plugins/migratecmd"
pbCron "github.com/pocketbase/pocketbase/tools/cron"
"github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/hook" "github.com/pocketbase/pocketbase/tools/hook"
"pocketbase/cron"
_ "pocketbase/migrations" _ "pocketbase/migrations"
"pocketbase/util" "pocketbase/util"
) )
@@ -54,7 +56,9 @@ func registerMigrations(app *pocketbase.PocketBase) {
func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceManager) { func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceManager) {
app.OnModelAfterCreate("users").Add(createUserHandler(app, client)) app.OnModelAfterCreate("users").Add(createUserHandler(app, client))
app.OnRecordAfterCreateRequest("trails").Add(createTrailHandler(app, client)) app.OnModelAfterCreate("trails").Add(createTrailIndexHandler(client))
app.OnRecordAfterCreateRequest("trails").Add(createTrailHandler(app))
app.OnRecordAfterUpdateRequest("trails").Add(updateTrailHandler(client)) app.OnRecordAfterUpdateRequest("trails").Add(updateTrailHandler(client))
app.OnRecordAfterDeleteRequest("trails").Add(deleteTrailHandler(client)) app.OnRecordAfterDeleteRequest("trails").Add(deleteTrailHandler(client))
@@ -115,11 +119,18 @@ func createDefaultUserSettings(app *pocketbase.PocketBase, userId string) error
return app.Dao().SaveRecord(settings) return app.Dao().SaveRecord(settings)
} }
func createTrailHandler(app *pocketbase.PocketBase, client meilisearch.ServiceManager) func(e *core.RecordCreateEvent) error { func createTrailIndexHandler(client meilisearch.ServiceManager) func(e *core.ModelEvent) error {
return func(e *core.RecordCreateEvent) error { return func(e *core.ModelEvent) error {
if err := util.IndexTrail(e.Record, client); err != nil { record := e.Model.(*models.Record)
if err := util.IndexTrail(record, client); err != nil {
return err return err
} }
return nil
}
}
func createTrailHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateEvent) error {
return func(e *core.RecordCreateEvent) error {
if e.Record.GetBool("public") { if e.Record.GetBool("public") {
notification := util.Notification{ notification := util.Notification{
Type: util.TrailCreate, Type: util.TrailCreate,
@@ -335,6 +346,7 @@ func changeUserEmailHandler(app *pocketbase.PocketBase) func(e *core.RecordReque
func onBeforeServeHandler(app *pocketbase.PocketBase, client meilisearch.ServiceManager) func(e *core.ServeEvent) error { func onBeforeServeHandler(app *pocketbase.PocketBase, client meilisearch.ServiceManager) func(e *core.ServeEvent) error {
return func(e *core.ServeEvent) error { return func(e *core.ServeEvent) error {
registerRoutes(e, app, client) registerRoutes(e, app, client)
registerCronJobs(app)
return bootstrapData(app, client) return bootstrapData(app, client)
} }
@@ -391,6 +403,19 @@ func registerRoutes(e *core.ServeEvent, app *pocketbase.PocketBase, client meili
}) })
} }
func registerCronJobs(app *pocketbase.PocketBase) {
scheduler := pbCron.New()
scheduler.MustAdd("strava", "*/15 * * * *", func() {
err := cron.SyncStrava(app)
if err != nil {
app.Logger().Warn(fmt.Sprintf("Error syncing with strava: %v", err))
}
})
scheduler.Start()
}
func bootstrapData(app *pocketbase.PocketBase, client meilisearch.ServiceManager) error { func bootstrapData(app *pocketbase.PocketBase, client meilisearch.ServiceManager) error {
bootstrapCategories(app) bootstrapCategories(app)
bootstrapMeilisearchTrails(app, client) bootstrapMeilisearchTrails(app, client)
@@ -431,8 +456,6 @@ func bootstrapMeilisearchTrails(app *pocketbase.PocketBase, client meilisearch.S
} }
for _, trail := range trails { for _, trail := range trails {
log.Println(trail)
if err := util.UpdateTrail(trail, client); err != nil { if err := util.UpdateTrail(trail, client); err != nil {
return err return err
} }

View File

@@ -0,0 +1,76 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
)
func init() {
m.Register(func(db dbx.Builder) error {
jsonData := `{
"id": "iz4sezoehde64wp",
"created": "2025-02-02 12:12:48.256Z",
"updated": "2025-02-02 12:12:48.256Z",
"name": "integrations",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "yhqqdtrf",
"name": "strava",
"type": "json",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSize": 2000000
}
},
{
"system": false,
"id": "bmktyzqz",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}
],
"indexes": [],
"listRule": "@request.auth.id = user.id",
"viewRule": "@request.auth.id = user.id",
"createRule": "@request.auth.id = user.id",
"updateRule": "@request.auth.id = user.id",
"deleteRule": "@request.auth.id = user.id",
"options": {}
}`
collection := &models.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
return daos.New(db).SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("iz4sezoehde64wp")
if err != nil {
return err
}
return dao.DeleteCollection(collection)
})
}

View File

@@ -0,0 +1,41 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("iz4sezoehde64wp")
if err != nil {
return err
}
if err := json.Unmarshal([]byte(`[
"CREATE UNIQUE INDEX ` + "`" + `idx_qMhw0Em` + "`" + ` ON ` + "`" + `integrations` + "`" + ` (` + "`" + `user` + "`" + `)"
]`), &collection.Indexes); err != nil {
return err
}
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("iz4sezoehde64wp")
if err != nil {
return err
}
if err := json.Unmarshal([]byte(`[]`), &collection.Indexes); err != nil {
return err
}
return dao.SaveCollection(collection)
})
}

View File

@@ -0,0 +1,79 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("e864strfxo14pm4")
if err != nil {
return err
}
// add
new_external_id := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "sajmiuau",
"name": "external_id",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}`), new_external_id); err != nil {
return err
}
collection.Schema.AddField(new_external_id)
// add
new_external_provider := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "htr35nha",
"name": "external_provider",
"type": "select",
"required": false,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"strava"
]
}
}`), new_external_provider); err != nil {
return err
}
collection.Schema.AddField(new_external_provider)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("e864strfxo14pm4")
if err != nil {
return err
}
// remove
collection.Schema.RemoveField("sajmiuau")
// remove
collection.Schema.RemoveField("htr35nha")
return dao.SaveCollection(collection)
})
}

View File

@@ -5,5 +5,5 @@ import { currentUser } from '$lib/stores/user_store';
pb.authStore.loadFromCookie(document.cookie) pb.authStore.loadFromCookie(document.cookie)
pb.authStore.onChange(() => { pb.authStore.onChange(() => {
currentUser.set(pb.authStore.model as User) currentUser.set(pb.authStore.model as User)
document.cookie = pb.authStore.exportToCookie({ httpOnly: false, secure: location.protocol === "https:" }) document.cookie = pb.authStore.exportToCookie({ httpOnly: false, secure: location.protocol === "https:" || location.hostname === "localhost", sameSite: 'none' })
}, true) }, true)

View File

@@ -67,7 +67,7 @@ const auth: Handle = async ({ event, resolve }) => {
try { try {
// get an up-to-date auth store state by verifying and refreshing the loaded auth model (if any) // get an up-to-date auth store state by verifying and refreshing the loaded auth model (if any)
if (pb.authStore.isValid) { if (pb.authStore.isValid) {
await pb.collection('users').authRefresh({requestKey: null}) await pb.collection('users').authRefresh({ requestKey: null })
} }
} catch (_) { } catch (_) {
// clear the auth store on failed refresh // clear the auth store on failed refresh
@@ -78,7 +78,7 @@ const auth: Handle = async ({ event, resolve }) => {
let settings: Settings | undefined; let settings: Settings | undefined;
if (pb.authStore.model) { if (pb.authStore.model) {
meiliApiKey = pb.authStore.model.token meiliApiKey = pb.authStore.model.token
settings = await pb.collection('settings').getFirstListItem<Settings>(`user="${pb.authStore.model.id}"`, {requestKey: null}) settings = await pb.collection('settings').getFirstListItem<Settings>(`user="${pb.authStore.model.id}"`, { requestKey: null })
} else { } else {
const r = await event.fetch(pb.buildUrl("/public/search/token")); const r = await event.fetch(pb.buildUrl("/public/search/token"));
const response = await r.json(); const response = await r.json();
@@ -105,7 +105,7 @@ const auth: Handle = async ({ event, resolve }) => {
// send back the default 'pb_auth' cookie to the client with the latest store state // send back the default 'pb_auth' cookie to the client with the latest store state
response.headers.set( response.headers.set(
'set-cookie', 'set-cookie',
pb.authStore.exportToCookie({ httpOnly: false, secure: event.url.protocol === "https:" }) pb.authStore.exportToCookie({ httpOnly: false, secure: event.url.protocol === "https:" || event.url.hostname === "localhost", sameSite: "none" })
) )
return response return response

View File

@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
name?: string; name?: string;
value?: boolean; value?: boolean;
label?: string; label?: string;
error?: string; error?: string;
onchange?: (value: boolean) => void disabled?: boolean;
onchange?: (value: boolean) => void;
} }
let { let {
@@ -13,30 +13,35 @@
value = $bindable(false), value = $bindable(false),
label = "", label = "",
error = "", error = "",
onchange disabled = false,
onchange,
}: Props = $props(); }: Props = $props();
function handleToggleChange() { function handleToggleChange() {
onchange?.(value); onchange?.(value);
} }
</script> </script>
<label class="relative my-2 inline-flex items-center cursor-pointer"> <div>
<label class="relative my-2 inline-flex items-center" class:cursor-pointer={!disabled}>
<input <input
{name} {name}
bind:checked={value} bind:checked={value}
type="checkbox" type="checkbox"
class="sr-only peer" class="sr-only peer"
value="1" value="1"
{disabled}
onchange={handleToggleChange} onchange={handleToggleChange}
/> />
<div <div
class="w-11 h-6 bg-input-background border border-input-border peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-input-ring rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary" class="w-11 h-6 bg-input-background border border-input-border peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-input-ring rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"
></div> ></div>
{#if label}
<span class="ms-3 text-sm font-medium">{label}</span> <span class="ms-3 text-sm font-medium">{label}</span>
</label> {/if}
</label>
<span class="toggle-error text-xs text-red-400"> <p class="toggle-error text-xs text-red-400">
{error} {error}
</span> </p>
</div>

View File

@@ -362,7 +362,7 @@
{:else} {:else}
<EmptyStateDescription></EmptyStateDescription> <EmptyStateDescription></EmptyStateDescription>
{/if} {/if}
<h4 class="text-2xl font-semibold mb-6 mt-12">{$_("route")}</h4> <h4 class="text-2xl font-semibold mb-6 mt-12">{$_("route", { values: { n: 2 } })}</h4>
{#if mode === "overview"} {#if mode === "overview"}
<div <div
class="relative border border-input-border rounded-xl p-2 mb-6 text-xs" class="relative border border-input-border rounded-xl p-2 mb-6 text-xs"

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Löschen bestätigen", "confirm-deletion": "Löschen bestätigen",
"confirm-publish": "Veröffentlichen bestätigen", "confirm-publish": "Veröffentlichen bestätigen",
"confirm-share": "Teilen bestätigen", "confirm-share": "Teilen bestätigen",
"connect": "",
"contribute": "Mitwirken", "contribute": "Mitwirken",
"copy-link": "Link kopieren", "copy-link": "Link kopieren",
"create-new-list": "Neue Liste erstellen", "create-new-list": "Neue Liste erstellen",
@@ -95,6 +96,7 @@
"english": "Englisch", "english": "Englisch",
"entry": "Eintrag", "entry": "Eintrag",
"error-creating-user": "Fehler beim Erstellen des Nutzers", "error-creating-user": "Fehler beim Erstellen des Nutzers",
"error-disabling-strava-integration": "",
"error-during-login": "Fehler beim Login", "error-during-login": "Fehler beim Login",
"error-during-password-reset": "Email zum Zurücksetzen des Passworts konnte nicht versandt werden", "error-during-password-reset": "Email zum Zurücksetzen des Passworts konnte nicht versandt werden",
"error-exporting-trail": "Fehler beim Exportieren des Trails", "error-exporting-trail": "Fehler beim Exportieren des Trails",
@@ -103,6 +105,7 @@
"error-reading-file": "Fehler beim Lesen der Datei", "error-reading-file": "Fehler beim Lesen der Datei",
"error-saving-list": "Fehler beim Speichern der Liste", "error-saving-list": "Fehler beim Speichern der Liste",
"error-saving-trail": "Fehler beim Speichern der Route", "error-saving-trail": "Fehler beim Speichern der Route",
"error-setting-up-strava-integration": "",
"error-updating-password": "Fehler beim Aktualisieren des Passworts", "error-updating-password": "Fehler beim Aktualisieren des Passworts",
"est-duration": "Gesch. Dauer", "est-duration": "Gesch. Dauer",
"explore": "Erkunden", "explore": "Erkunden",
@@ -140,6 +143,7 @@
"import": "Importieren", "import": "Importieren",
"import-hint": "GPX, FIT, KML oder TCX Dateien auswählen oder hierher ziehen...", "import-hint": "GPX, FIT, KML oder TCX Dateien auswählen oder hierher ziehen...",
"include-description": "Beschreibung übernehmen", "include-description": "Beschreibung übernehmen",
"integrations": "",
"invalid-date": "Ungültiges Datum", "invalid-date": "Ungültiges Datum",
"invalid-username": "Ungültiger Nutzername", "invalid-username": "Ungültiger Nutzername",
"italian": "Italienisch", "italian": "Italienisch",
@@ -229,7 +233,7 @@
"removed-trail-from": "Route entfernt aus", "removed-trail-from": "Route entfernt aus",
"required": "Pflichtfeld", "required": "Pflichtfeld",
"reset-password": "Passwort zurücksetzen", "reset-password": "Passwort zurücksetzen",
"route": "", "route": "{n, plural, =1 {Route} other {Routen}}",
"route-point": "Routenpunkt", "route-point": "Routenpunkt",
"save": "Speichern", "save": "Speichern",
"save-list": "Liste speichern", "save-list": "Liste speichern",

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Confirm Deletion", "confirm-deletion": "Confirm Deletion",
"confirm-publish": "Confirm publishing", "confirm-publish": "Confirm publishing",
"confirm-share": "Confirm share", "confirm-share": "Confirm share",
"connect": "Connect",
"contribute": "Contribute", "contribute": "Contribute",
"copy-link": "Copy Link", "copy-link": "Copy Link",
"create-new-list": "Create new list", "create-new-list": "Create new list",
@@ -95,6 +96,7 @@
"english": "English", "english": "English",
"entry": "Entry", "entry": "Entry",
"error-creating-user": "Error creating user", "error-creating-user": "Error creating user",
"error-disabling-strava-integration": "Error disabling strava integration",
"error-during-login": "Error during login", "error-during-login": "Error during login",
"error-during-password-reset": "Unable to send password reset email", "error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Error exporting trail", "error-exporting-trail": "Error exporting trail",
@@ -103,6 +105,7 @@
"error-reading-file": "Error reading file", "error-reading-file": "Error reading file",
"error-saving-list": "Error saving list", "error-saving-list": "Error saving list",
"error-saving-trail": "Error saving trail", "error-saving-trail": "Error saving trail",
"error-setting-up-strava-integration": "Error setting up strava integration",
"error-updating-password": "Error updating password", "error-updating-password": "Error updating password",
"est-duration": "Est. duration", "est-duration": "Est. duration",
"explore": "Explore", "explore": "Explore",
@@ -140,6 +143,7 @@
"import": "Import", "import": "Import",
"import-hint": "Select or drag GPX, FIT, KML or TCX files here...", "import-hint": "Select or drag GPX, FIT, KML or TCX files here...",
"include-description": "Include description", "include-description": "Include description",
"integrations": "Integrations",
"invalid-date": "Invalid Date", "invalid-date": "Invalid Date",
"invalid-username": "Invalid username", "invalid-username": "Invalid username",
"italian": "Italian", "italian": "Italian",
@@ -229,7 +233,7 @@
"removed-trail-from": "Removed trail from", "removed-trail-from": "Removed trail from",
"required": "Required", "required": "Required",
"reset-password": "Reset Password", "reset-password": "Reset Password",
"route": "Route", "route": "{n, plural, =1 {Route} other {Routes}}",
"route-point": "Route Point", "route-point": "Route Point",
"save": "Save", "save": "Save",
"save-list": "Save List", "save-list": "Save List",

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Confirmar Borrado", "confirm-deletion": "Confirmar Borrado",
"confirm-publish": "Confirmar publicación", "confirm-publish": "Confirmar publicación",
"confirm-share": "Confirmar compartir", "confirm-share": "Confirmar compartir",
"connect": "",
"contribute": "Contribuir", "contribute": "Contribuir",
"copy-link": "Copiar Enlace", "copy-link": "Copiar Enlace",
"create-new-list": "Crear una nueva lista", "create-new-list": "Crear una nueva lista",
@@ -95,6 +96,7 @@
"english": "Inglés", "english": "Inglés",
"entry": "Entrada", "entry": "Entrada",
"error-creating-user": "Error creando el usuario", "error-creating-user": "Error creando el usuario",
"error-disabling-strava-integration": "",
"error-during-login": "Error durante el acceso", "error-during-login": "Error durante el acceso",
"error-during-password-reset": "Imposible enviar la contraseña de restablecimiento al correo electrónico", "error-during-password-reset": "Imposible enviar la contraseña de restablecimiento al correo electrónico",
"error-exporting-trail": "Error exportando la ruta", "error-exporting-trail": "Error exportando la ruta",
@@ -103,6 +105,7 @@
"error-reading-file": "Error leyendo el archivo", "error-reading-file": "Error leyendo el archivo",
"error-saving-list": "Error guardando la lista", "error-saving-list": "Error guardando la lista",
"error-saving-trail": "Error guardando la ruta", "error-saving-trail": "Error guardando la ruta",
"error-setting-up-strava-integration": "",
"error-updating-password": "Error actualizando la contraseña", "error-updating-password": "Error actualizando la contraseña",
"est-duration": "Duración estimada", "est-duration": "Duración estimada",
"explore": "Explora", "explore": "Explora",
@@ -140,6 +143,7 @@
"import": "Importar", "import": "Importar",
"import-hint": "Selecciona o arrastra aquí archivos GPX, FIT, KML o TCX...", "import-hint": "Selecciona o arrastra aquí archivos GPX, FIT, KML o TCX...",
"include-description": "Incluir descripción", "include-description": "Incluir descripción",
"integrations": "",
"invalid-date": "Fecha no válida", "invalid-date": "Fecha no válida",
"invalid-username": "Usuario no válido", "invalid-username": "Usuario no válido",
"italian": "Italiano", "italian": "Italiano",

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Confirmer la suppression", "confirm-deletion": "Confirmer la suppression",
"confirm-publish": "Confirmer la publication", "confirm-publish": "Confirmer la publication",
"confirm-share": "Confirmer le partage", "confirm-share": "Confirmer le partage",
"connect": "",
"contribute": "Contribuer", "contribute": "Contribuer",
"copy-link": "Copier le lien", "copy-link": "Copier le lien",
"create-new-list": "Créer une nouvelle liste", "create-new-list": "Créer une nouvelle liste",
@@ -95,6 +96,7 @@
"english": "Anglais", "english": "Anglais",
"entry": "Entrée", "entry": "Entrée",
"error-creating-user": "Erreur durant la création de l'utilisateur", "error-creating-user": "Erreur durant la création de l'utilisateur",
"error-disabling-strava-integration": "",
"error-during-login": "Erreur durant la connexion", "error-during-login": "Erreur durant la connexion",
"error-during-password-reset": "Unable to send password reset email", "error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Erreur lors de l'export de l'itinéraire", "error-exporting-trail": "Erreur lors de l'export de l'itinéraire",
@@ -103,6 +105,7 @@
"error-reading-file": "Erreur de lecture du fichier", "error-reading-file": "Erreur de lecture du fichier",
"error-saving-list": "Error saving list", "error-saving-list": "Error saving list",
"error-saving-trail": "Erreur lors de l'enregistrement de l'itinéraire", "error-saving-trail": "Erreur lors de l'enregistrement de l'itinéraire",
"error-setting-up-strava-integration": "",
"error-updating-password": "Erreur lors de la mise à jour du mot de passe", "error-updating-password": "Erreur lors de la mise à jour du mot de passe",
"est-duration": "Temps estimé", "est-duration": "Temps estimé",
"explore": "Explorer", "explore": "Explorer",
@@ -140,6 +143,7 @@
"import": "Importer", "import": "Importer",
"import-hint": "Sélectionnez ou glissez des fichiers GPX, FIT, KML ou TCX ici...", "import-hint": "Sélectionnez ou glissez des fichiers GPX, FIT, KML ou TCX ici...",
"include-description": "Inclure la description", "include-description": "Inclure la description",
"integrations": "",
"invalid-date": "Date invalide", "invalid-date": "Date invalide",
"invalid-username": "Nom d'utilisateur invalide", "invalid-username": "Nom d'utilisateur invalide",
"italian": "Italien", "italian": "Italien",

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Confirm Deletion", "confirm-deletion": "Confirm Deletion",
"confirm-publish": "Confirm publishing", "confirm-publish": "Confirm publishing",
"confirm-share": "Confirm share", "confirm-share": "Confirm share",
"connect": "",
"contribute": "Hozzájárulás", "contribute": "Hozzájárulás",
"copy-link": "Copy Link", "copy-link": "Copy Link",
"create-new-list": "Új lista létrehozása", "create-new-list": "Új lista létrehozása",
@@ -95,6 +96,7 @@
"english": "Angol", "english": "Angol",
"entry": "Bejegyzés", "entry": "Bejegyzés",
"error-creating-user": "Hiba felhasználó hozzáadása közben", "error-creating-user": "Hiba felhasználó hozzáadása közben",
"error-disabling-strava-integration": "",
"error-during-login": "Hiba bejelentkezés közben", "error-during-login": "Hiba bejelentkezés közben",
"error-during-password-reset": "Unable to send password reset email", "error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Error exporting trail", "error-exporting-trail": "Error exporting trail",
@@ -103,6 +105,7 @@
"error-reading-file": "Hiba a fájl olvasása közben", "error-reading-file": "Hiba a fájl olvasása közben",
"error-saving-list": "Error saving list", "error-saving-list": "Error saving list",
"error-saving-trail": "Error saving trail", "error-saving-trail": "Error saving trail",
"error-setting-up-strava-integration": "",
"error-updating-password": "Error updating password", "error-updating-password": "Error updating password",
"est-duration": "Becsült időtartam", "est-duration": "Becsült időtartam",
"explore": "Felfedezés", "explore": "Felfedezés",
@@ -140,6 +143,7 @@
"import": "Import", "import": "Import",
"import-hint": "Select or drag GPX, FIT, KML or TCX files here...", "import-hint": "Select or drag GPX, FIT, KML or TCX files here...",
"include-description": "Include description", "include-description": "Include description",
"integrations": "",
"invalid-date": "Érvénytelen dátum", "invalid-date": "Érvénytelen dátum",
"invalid-username": "Érvénytelen felhasználó", "invalid-username": "Érvénytelen felhasználó",
"italian": "Olasz", "italian": "Olasz",

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Conferma eliminazione", "confirm-deletion": "Conferma eliminazione",
"confirm-publish": "Conferma pubblicazione", "confirm-publish": "Conferma pubblicazione",
"confirm-share": "Conferma condivisione", "confirm-share": "Conferma condivisione",
"connect": "",
"contribute": "Contribuisci", "contribute": "Contribuisci",
"copy-link": "Copia link", "copy-link": "Copia link",
"create-new-list": "Crea nuova lista", "create-new-list": "Crea nuova lista",
@@ -95,6 +96,7 @@
"english": "Inglese", "english": "Inglese",
"entry": "Voce", "entry": "Voce",
"error-creating-user": "Errore nella creazione dell'utente", "error-creating-user": "Errore nella creazione dell'utente",
"error-disabling-strava-integration": "",
"error-during-login": "Errore durante il login", "error-during-login": "Errore durante il login",
"error-during-password-reset": "Impossibile inviare email per ripristinare la password", "error-during-password-reset": "Impossibile inviare email per ripristinare la password",
"error-exporting-trail": "Errore durante l'esportazione del percorso", "error-exporting-trail": "Errore durante l'esportazione del percorso",
@@ -103,6 +105,7 @@
"error-reading-file": "Errore durante la lettura del file", "error-reading-file": "Errore durante la lettura del file",
"error-saving-list": "Errore salvando la lista", "error-saving-list": "Errore salvando la lista",
"error-saving-trail": "Errore nel salvataggio del percorso", "error-saving-trail": "Errore nel salvataggio del percorso",
"error-setting-up-strava-integration": "",
"error-updating-password": "Errore nell'aggiornamento della password", "error-updating-password": "Errore nell'aggiornamento della password",
"est-duration": "Durata stimata", "est-duration": "Durata stimata",
"explore": "Esplora", "explore": "Esplora",
@@ -140,6 +143,7 @@
"import": "Importa", "import": "Importa",
"import-hint": "Seleziona o trascina qui i file GPX, FIT, KML o TCX...", "import-hint": "Seleziona o trascina qui i file GPX, FIT, KML o TCX...",
"include-description": "Adotta descrizione", "include-description": "Adotta descrizione",
"integrations": "",
"invalid-date": "Data non valida", "invalid-date": "Data non valida",
"invalid-username": "Nome utente non valido", "invalid-username": "Nome utente non valido",
"italian": "Italiano", "italian": "Italiano",

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Confirm Deletion", "confirm-deletion": "Confirm Deletion",
"confirm-publish": "Confirm publishing", "confirm-publish": "Confirm publishing",
"confirm-share": "Confirm share", "confirm-share": "Confirm share",
"connect": "",
"contribute": "Bijdragen", "contribute": "Bijdragen",
"copy-link": "Copy Link", "copy-link": "Copy Link",
"create-new-list": "Nieuwe lijst", "create-new-list": "Nieuwe lijst",
@@ -95,6 +96,7 @@
"english": "Engels", "english": "Engels",
"entry": "Item", "entry": "Item",
"error-creating-user": "Het account kan niet worden aangemaakt", "error-creating-user": "Het account kan niet worden aangemaakt",
"error-disabling-strava-integration": "",
"error-during-login": "Het inloggen is mislukt", "error-during-login": "Het inloggen is mislukt",
"error-during-password-reset": "Unable to send password reset email", "error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Error exporting trail", "error-exporting-trail": "Error exporting trail",
@@ -103,6 +105,7 @@
"error-reading-file": "Het bestand kan niet worden ingelezen", "error-reading-file": "Het bestand kan niet worden ingelezen",
"error-saving-list": "Error saving list", "error-saving-list": "Error saving list",
"error-saving-trail": "Error saving trail", "error-saving-trail": "Error saving trail",
"error-setting-up-strava-integration": "",
"error-updating-password": "Error updating password", "error-updating-password": "Error updating password",
"est-duration": "Geschatte duur", "est-duration": "Geschatte duur",
"explore": "Verkennen", "explore": "Verkennen",
@@ -140,6 +143,7 @@
"import": "Import", "import": "Import",
"import-hint": "Select or drag GPX, FIT, KML or TCX files here...", "import-hint": "Select or drag GPX, FIT, KML or TCX files here...",
"include-description": "Inclusief beschrijving", "include-description": "Inclusief beschrijving",
"integrations": "",
"invalid-date": "Ongeldige datum", "invalid-date": "Ongeldige datum",
"invalid-username": "Ongeldige gebruikersnaam", "invalid-username": "Ongeldige gebruikersnaam",
"italian": "Italiaans", "italian": "Italiaans",

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Potwierdź usunięcie", "confirm-deletion": "Potwierdź usunięcie",
"confirm-publish": "Potwierdź publikację", "confirm-publish": "Potwierdź publikację",
"confirm-share": "Potwierdź udostępnienie", "confirm-share": "Potwierdź udostępnienie",
"connect": "",
"contribute": "Kontrybuuj", "contribute": "Kontrybuuj",
"copy-link": "Kopiuj link", "copy-link": "Kopiuj link",
"create-new-list": "Stwórz nową listę", "create-new-list": "Stwórz nową listę",
@@ -95,6 +96,7 @@
"english": "Angielski", "english": "Angielski",
"entry": "Pozycja", "entry": "Pozycja",
"error-creating-user": "Błąd tworzenia użytkownika", "error-creating-user": "Błąd tworzenia użytkownika",
"error-disabling-strava-integration": "",
"error-during-login": "Błąd podczas logowania", "error-during-login": "Błąd podczas logowania",
"error-during-password-reset": "Nie udało się wysłać e-maila z resetowaniem hasła", "error-during-password-reset": "Nie udało się wysłać e-maila z resetowaniem hasła",
"error-exporting-trail": "Błąd podczas eksportowania szlaku", "error-exporting-trail": "Błąd podczas eksportowania szlaku",
@@ -103,6 +105,7 @@
"error-reading-file": "Błąd wczytywania pliku", "error-reading-file": "Błąd wczytywania pliku",
"error-saving-list": "Błąd przy zapisywaniu listy", "error-saving-list": "Błąd przy zapisywaniu listy",
"error-saving-trail": "Błąd podczas zapisywania szlaku", "error-saving-trail": "Błąd podczas zapisywania szlaku",
"error-setting-up-strava-integration": "",
"error-updating-password": "Błąd podczas aktualizacji hasła", "error-updating-password": "Błąd podczas aktualizacji hasła",
"est-duration": "Szacowany czas", "est-duration": "Szacowany czas",
"explore": "Eksploruj", "explore": "Eksploruj",
@@ -140,6 +143,7 @@
"import": "Importuj", "import": "Importuj",
"import-hint": "Wybierz lub przeciągnij tutaj plik GPX, FIT, KML lub TCX...", "import-hint": "Wybierz lub przeciągnij tutaj plik GPX, FIT, KML lub TCX...",
"include-description": "Dołącz opis", "include-description": "Dołącz opis",
"integrations": "",
"invalid-date": "Nieprawidłowa data", "invalid-date": "Nieprawidłowa data",
"invalid-username": "Błędna nazwa użytkownika", "invalid-username": "Błędna nazwa użytkownika",
"italian": "Włoski", "italian": "Włoski",

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "Confirmar eliminação", "confirm-deletion": "Confirmar eliminação",
"confirm-publish": "Confirm publishing", "confirm-publish": "Confirm publishing",
"confirm-share": "Confirmar partilha", "confirm-share": "Confirmar partilha",
"connect": "",
"contribute": "Contribuir", "contribute": "Contribuir",
"copy-link": "Copiar ligação", "copy-link": "Copiar ligação",
"create-new-list": "Criar nova lista", "create-new-list": "Criar nova lista",
@@ -95,6 +96,7 @@
"english": "Inglês", "english": "Inglês",
"entry": "Entrada", "entry": "Entrada",
"error-creating-user": "Erro ao criar utilizador", "error-creating-user": "Erro ao criar utilizador",
"error-disabling-strava-integration": "",
"error-during-login": "Erro durante o login", "error-during-login": "Erro durante o login",
"error-during-password-reset": "Unable to send password reset email", "error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Erro na exportação do percurso", "error-exporting-trail": "Erro na exportação do percurso",
@@ -103,6 +105,7 @@
"error-reading-file": "Erro ao ler o arquivo", "error-reading-file": "Erro ao ler o arquivo",
"error-saving-list": "Erro ao gravar lista", "error-saving-list": "Erro ao gravar lista",
"error-saving-trail": "Erro ao gravar percurso", "error-saving-trail": "Erro ao gravar percurso",
"error-setting-up-strava-integration": "",
"error-updating-password": "Erro ao atualizar password", "error-updating-password": "Erro ao atualizar password",
"est-duration": "Duração prevista", "est-duration": "Duração prevista",
"explore": "Explorar", "explore": "Explorar",
@@ -140,6 +143,7 @@
"import": "Importar", "import": "Importar",
"import-hint": "Selecionar ou arrastar ficheiros GPX, FIT, KML ou TCX para aqui...", "import-hint": "Selecionar ou arrastar ficheiros GPX, FIT, KML ou TCX para aqui...",
"include-description": "Incluir descrição", "include-description": "Incluir descrição",
"integrations": "",
"invalid-date": "Data inválida", "invalid-date": "Data inválida",
"invalid-username": "Nome de usuário inválido", "invalid-username": "Nome de usuário inválido",
"italian": "Italiano", "italian": "Italiano",

View File

@@ -48,6 +48,7 @@
"confirm-deletion": "确认删除", "confirm-deletion": "确认删除",
"confirm-publish": "Confirm publishing", "confirm-publish": "Confirm publishing",
"confirm-share": "确认分享", "confirm-share": "确认分享",
"connect": "",
"contribute": "贡献", "contribute": "贡献",
"copy-link": "复制链接", "copy-link": "复制链接",
"create-new-list": "创建新列表", "create-new-list": "创建新列表",
@@ -95,6 +96,7 @@
"english": "英语", "english": "英语",
"entry": "日程", "entry": "日程",
"error-creating-user": "创建用户错误", "error-creating-user": "创建用户错误",
"error-disabling-strava-integration": "",
"error-during-login": "登录错误", "error-during-login": "登录错误",
"error-during-password-reset": "Unable to send password reset email", "error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "导出路线失败", "error-exporting-trail": "导出路线失败",
@@ -103,6 +105,7 @@
"error-reading-file": "读取文件错误", "error-reading-file": "读取文件错误",
"error-saving-list": "保存列表失败", "error-saving-list": "保存列表失败",
"error-saving-trail": "保存路线失败", "error-saving-trail": "保存路线失败",
"error-setting-up-strava-integration": "",
"error-updating-password": "更新密码失败", "error-updating-password": "更新密码失败",
"est-duration": "预计时长", "est-duration": "预计时长",
"explore": "探索", "explore": "探索",
@@ -140,6 +143,7 @@
"import": "导入", "import": "导入",
"import-hint": "在此选择或拖拽GPX、FIT、KML或TCX文件...", "import-hint": "在此选择或拖拽GPX、FIT、KML或TCX文件...",
"include-description": "包含描述", "include-description": "包含描述",
"integrations": "",
"invalid-date": "无效日期", "invalid-date": "无效日期",
"invalid-username": "无效用户名", "invalid-username": "无效用户名",
"italian": "意大利语", "italian": "意大利语",

View File

@@ -0,0 +1,25 @@
import { z, ZodType } from "zod";
import type { Integration } from "../integration";
const StravaSchema = z.object({
clientId: z.number({ coerce: true }).int().positive(),
clientSecret: z.string().length(40),
routes: z.boolean(),
activities: z.boolean(),
accessToken: z.string().length(40).optional(),
refreshToken: z.string().length(40).optional(),
expiresAt: z.number().int().positive().optional(),
active: z.boolean()
})
const IntegrationCreateSchema = z.object({
user: z.string().length(15),
strava: StravaSchema,
}) satisfies ZodType<Integration>
const IntegrationUpdateSchema = z.object({
strava: StravaSchema.optional(),
}) satisfies ZodType<Partial<Integration>>
export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema }

View File

@@ -0,0 +1,25 @@
interface BaseIntegration {
active: boolean
}
interface StravaIntegration extends BaseIntegration {
clientId: string | number;
clientSecret: string;
routes: boolean;
activities: boolean;
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
}
export class Integration {
id?: string;
user: string;
strava?: StravaIntegration;
constructor(user: string, strava?: StravaIntegration) {
this.user = user;
this.strava = strava;
}
}

View File

@@ -0,0 +1,74 @@
import { Integration } from "$lib/models/integration";
import { pb } from "$lib/pocketbase";
import { APIError } from "$lib/util/api_util";
import { type ListResult } from "pocketbase";
import { writable, type Writable } from "svelte/store";
export const integrations: Writable<Integration[]> = writable([])
export async function integrations_index(f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let r = await f('/api/v1/integration' + new URLSearchParams({
}), {
method: 'GET',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const fetchedIntegrations: ListResult<Integration> = await r.json();
integrations.set(fetchedIntegrations.items);
return fetchedIntegrations.items;
}
export async function integrations_create(integration: Integration) {
if (!pb.authStore.model) {
throw new Error("Unauthenticated");
}
integration.user = pb.authStore.model!.id;
let r = await fetch('/api/v1/integration', {
method: 'PUT',
body: JSON.stringify(integration),
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const model: Integration = await r.json();
return model;
}
export async function integrations_update(integration: Integration, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let r = await f('/api/v1/integration/' + integration.id, {
method: 'POST',
body: JSON.stringify(integration),
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const model: Integration = await r.json();
return model;
}
export async function integrations_delete(integration: Integration) {
const r = await fetch('/api/v1/integration/' + integration.id, {
method: 'DELETE',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
}

View File

@@ -23,6 +23,7 @@ export enum Collection {
categories = "categories", categories = "categories",
comments = "comments", comments = "comments",
follows = "follows", follows = "follows",
integrations = "integrations",
list_share = "list_share", list_share = "list_share",
lists = "lists", lists = "lists",
notifications = "notifications", notifications = "notifications",

View File

@@ -0,0 +1,23 @@
import { IntegrationCreateSchema } from "$lib/models/api/integration_schema";
import type { Integration } from "$lib/models/integration";
import { Collection, create, handleError, list } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
export async function GET(event: RequestEvent) {
try {
const r = await list<Integration>(event, Collection.integrations);
return json(r)
} catch (e) {
throw handleError(e)
}
}
export async function PUT(event: RequestEvent) {
try {
const r = await create<Comment>(event, IntegrationCreateSchema, Collection.integrations)
return json(r);
} catch (e) {
throw handleError(e)
}
}

View File

@@ -0,0 +1,32 @@
import { IntegrationUpdateSchema } from "$lib/models/api/integration_schema";
import type { Integration } from "$lib/models/integration";
import { pb } from "$lib/pocketbase";
import { Collection, handleError, remove, show, update } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
export async function GET(event: RequestEvent) {
try {
const r = await show<Integration>(event, Collection.integrations)
return json(r)
} catch (e: any) {
throw handleError(e)
}
}
export async function POST(event: RequestEvent) {
try {
const r = await update<Integration>(event, IntegrationUpdateSchema, Collection.integrations)
return json(r);
} catch (e: any) {
throw handleError(e)
}
}
export async function DELETE(event: RequestEvent) {
try {
const r = await remove(event, Collection.integrations)
return json(r);
} catch (e: any) {
throw handleError(e)
}
}

View File

@@ -21,6 +21,7 @@
}, },
{ text: $_("notifications"), value: "/settings/notifications" }, { text: $_("notifications"), value: "/settings/notifications" },
{ text: $_("map"), value: "/settings/map" }, { text: $_("map"), value: "/settings/map" },
{ text: $_("integrations"), value: "/settings/integrations" },
{ text: `${$_("import")}/${$_("export")}`, value: "/settings/export" }, { text: `${$_("import")}/${$_("export")}`, value: "/settings/export" },
{ {
text: $_("help"), text: $_("help"),

View File

@@ -0,0 +1,201 @@
<script lang="ts">
import { page } from "$app/state";
import Button from "$lib/components/base/button.svelte";
import Modal from "$lib/components/base/modal.svelte";
import TextField from "$lib/components/base/text_field.svelte";
import Toggle from "$lib/components/base/toggle.svelte";
import { StravaSchema } from "$lib/models/api/integration_schema.js";
import { Integration } from "$lib/models/integration.js";
import {
integrations_create,
integrations_update,
} from "$lib/stores/integration_store.js";
import { show_toast } from "$lib/stores/toast_store.js";
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { _ } from "svelte-i18n";
let { data } = $props();
let integration = $state(data.integration);
const scope = "read_all,activity:read_all";
const redirectUri = page.url.href + "/callback/strava";
let stravaSettingsModal: Modal;
let stravaToggleValue: boolean = $derived(
integration?.strava?.active || false,
);
const { form, errors, data: d } = createForm({
initialValues: {
clientId: data.integration?.strava?.clientId ?? "",
clientSecret: data.integration?.strava?.clientSecret ?? "",
routes: data.integration?.strava?.routes ?? true,
activities: data.integration?.strava?.activities ?? true,
active: data.integration?.strava?.active ?? false,
},
extend: validator({
schema: StravaSchema,
}),
onSubmit: async (form) => {
stravaSettingsModal.closeModal();
try {
if (integration) {
integration.strava = {
clientId: form.clientId,
clientSecret: form.clientSecret,
routes: form.routes,
activities: form.activities,
active: integration.strava?.active ?? false,
};
integration = await integrations_update(integration);
} else {
const newIntegration = new Integration("", {
routes: form.routes,
activities: form.activities,
clientId: form.clientId,
clientSecret: form.clientSecret,
active: false,
});
integration = await integrations_create(newIntegration);
}
} catch (e) {
show_toast({
text: $_("error-setting-up-strava-integration"),
icon: "close",
type: "error",
});
}
},
});
async function onStravaToggle(value: boolean) {
if (!integration?.strava) {
return;
}
if (value) {
const authUrl = `https://www.strava.com/oauth/authorize?client_id=${integration.strava.clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}&approval_prompt=auto`;
window.location.href = authUrl;
} else {
const deauthUrl = `https://www.strava.com/oauth/deauthorize`;
const r = await fetch(deauthUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${integration.strava.accessToken}`,
},
});
if (!r.ok) {
show_toast({
text: $_("error-disabling-strava-integration"),
icon: "close",
type: "error",
});
return;
}
integration.strava = {
clientId: integration.strava.clientId,
clientSecret: integration.strava.clientSecret,
routes: integration.strava.routes,
activities: integration.strava.activities,
accessToken: undefined,
refreshToken: undefined,
expiresAt: undefined,
active: false,
};
try {
integration = await integrations_update(integration);
} catch (e) {
show_toast({
text: $_("error-disabling-strava-integration"),
icon: "close",
type: "error",
});
}
}
}
</script>
<svelte:head>
<title>{$_("settings")} | wanderer</title>
</svelte:head>
<h3 class="text-2xl font-semibold">{$_("integrations")}</h3>
<hr class="mt-4 mb-6 border-input-border" />
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
<div class="border border-input-border rounded-lg p-4 space-y-4">
<img
src="https://upload.wikimedia.org/wikipedia/commons/c/cb/Strava_Logo.svg"
alt="strava logo"
/>
<div>
<h5 class="text-xl font-semibold">strava</h5>
<p class="text-sm text-gray-500">
Syncs your strava routes with wanderer in regular intervals.
</p>
</div>
<div class="flex items-center justify-between">
<button
class="btn-secondary"
onclick={() => stravaSettingsModal.openModal()}
><i class="fa fa-cogs mr-2"></i>{$_("settings")}</button
>
<Toggle
value={stravaToggleValue}
onchange={onStravaToggle}
disabled={!integration?.strava}
></Toggle>
</div>
</div>
</div>
<Modal
id="strava-settings-modal"
size="max-w-lg"
title={"strava " + $_("settings")}
bind:this={stravaSettingsModal}
>
{#snippet content()}
<form id="strava-settings-form" class="space-y-2" use:form>
<TextField
label="Client-ID"
placeholder="000000"
name="clientId"
error={$errors.clientId}
></TextField>
<TextField
label="Client Secret"
placeholder="de8b3789bd7116d..."
name="clientSecret"
type="password"
error={$errors.clientSecret}
></TextField>
<div class="flex gap-x-4">
<Toggle name="routes" label={$_("route", { values: { n: 2 } })}
></Toggle>
<Toggle
name="activities"
label={$_("activity", { values: { n: 2 } })}
></Toggle>
</div>
</form>
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button
class="btn-secondary"
onclick={() => stravaSettingsModal.closeModal()}
>{$_("cancel")}</button
>
<button
class="btn-primary"
form="strava-settings-form"
type="submit"
name="save">{$_("save")}</button
>
</div>
{/snippet}</Modal
>

View File

@@ -0,0 +1,7 @@
import { integrations_index } from "$lib/stores/integration_store";
import { type Load } from "@sveltejs/kit";
export const load: Load = async ({ params, fetch }) => {
const integrations = await integrations_index(fetch)
return { integration: integrations.at(0) }
};

View File

@@ -0,0 +1,55 @@
import { integrations_index, integrations_update } from "$lib/stores/integration_store";
import { error, redirect, type RequestEvent, type ServerLoad } from "@sveltejs/kit";
export const load: ServerLoad = async ({ url, fetch }) => {
const oauthError = url.searchParams.get('error');
if (oauthError) {
return error(400, {
message: oauthError
});
}
const code = url.searchParams.get('code');
if (!code) {
return error(400, {
message: "No code provided"
});
}
const integrations = await integrations_index(fetch);
if (!integrations.length || !integrations[0].strava) {
return error(400, {
message: "Missing integration record"
});
}
const integration = integrations[0]
const tokenResponse = await fetch('https://www.strava.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: integration.strava?.clientId,
client_secret: integration.strava?.clientSecret,
code,
grant_type: 'authorization_code'
})
});
if (!tokenResponse.ok) {
const r = await tokenResponse.json()
console.error(r)
return error(500, 'Failed to get access token');
}
const { access_token, refresh_token, expires_at } = await tokenResponse.json();
integration.strava!.accessToken = access_token
integration.strava!.refreshToken = refresh_token
integration.strava!.expiresAt = expires_at
integration.strava!.active = true
await integrations_update(integration, fetch);
return redirect(302, '/settings/integrations')
}