adds strava integration
This commit is contained in:
372
db/cron/models.go
Normal file
372
db/cron/models.go
Normal 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
582
db/cron/strava.go
Normal 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
|
||||||
|
}
|
||||||
19
db/go.mod
19
db/go.mod
@@ -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
|
||||||
|
|||||||
39
db/go.sum
39
db/go.sum
@@ -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=
|
||||||
|
|||||||
35
db/main.go
35
db/main.go
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
76
db/migrations/1738498368_created_integrations.go
Normal file
76
db/migrations/1738498368_created_integrations.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
41
db/migrations/1738509501_updated_integrations.go
Normal file
41
db/migrations/1738509501_updated_integrations.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
79
db/migrations/1738517634_updated_trails.go
Normal file
79
db/migrations/1738517634_updated_trails.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { pb } from '$lib/pocketbase';
|
|||||||
import { currentUser } from '$lib/stores/user_store';
|
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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
<input
|
<label class="relative my-2 inline-flex items-center" class:cursor-pointer={!disabled}>
|
||||||
{name}
|
<input
|
||||||
bind:checked={value}
|
{name}
|
||||||
type="checkbox"
|
bind:checked={value}
|
||||||
class="sr-only peer"
|
type="checkbox"
|
||||||
value="1"
|
class="sr-only peer"
|
||||||
onchange={handleToggleChange}
|
value="1"
|
||||||
/>
|
{disabled}
|
||||||
<div
|
onchange={handleToggleChange}
|
||||||
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
|
||||||
<span class="ms-3 text-sm font-medium">{label}</span>
|
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"
|
||||||
</label>
|
></div>
|
||||||
|
{#if label}
|
||||||
|
<span class="ms-3 text-sm font-medium">{label}</span>
|
||||||
|
{/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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -291,4 +295,4 @@
|
|||||||
"welcome_to": "Willkommen bei",
|
"welcome_to": "Willkommen bei",
|
||||||
"wrong-username-or-password": "Falscher Nutzername oder falsches Passwort",
|
"wrong-username-or-password": "Falscher Nutzername oder falsches Passwort",
|
||||||
"you-can": "Du kannst"
|
"you-can": "Du kannst"
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
@@ -291,4 +295,4 @@
|
|||||||
"welcome_to": "Welcome to",
|
"welcome_to": "Welcome to",
|
||||||
"wrong-username-or-password": "Wrong username or password",
|
"wrong-username-or-password": "Wrong username or password",
|
||||||
"you-can": "You can"
|
"you-can": "You can"
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "意大利语",
|
||||||
|
|||||||
25
web/src/lib/models/api/integration_schema.ts
Normal file
25
web/src/lib/models/api/integration_schema.ts
Normal 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 }
|
||||||
25
web/src/lib/models/integration.ts
Normal file
25
web/src/lib/models/integration.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
web/src/lib/stores/integration_store.ts
Normal file
74
web/src/lib/stores/integration_store.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
23
web/src/routes/api/v1/integration/+server.ts
Normal file
23
web/src/routes/api/v1/integration/+server.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
web/src/routes/api/v1/integration/[id]/+server.ts
Normal file
32
web/src/routes/api/v1/integration/[id]/+server.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
|||||||
201
web/src/routes/settings/integrations/+page.svelte
Normal file
201
web/src/routes/settings/integrations/+page.svelte
Normal 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
|
||||||
|
>
|
||||||
7
web/src/routes/settings/integrations/+page.ts
Normal file
7
web/src/routes/settings/integrations/+page.ts
Normal 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) }
|
||||||
|
};
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user