merge trails function added (#627)
* merge trails function added * fix conflict resolve issues * avoid incorrect authorship when merging trail comments * require edit access for all selected trails before merge * fix likes checkbox binding in trail merge modal * improve error handling * require editable target and deletable sources for merge * avoid duplicate trail fetch during merge * prevent duplicate likes when merging trails * check photo download responses during trail merge * add merge completion toast notifications * cleanup * small fixes * move merge code to backend, option to merge single trail selection with similar one, automatic merge for integrations add as option, maintenance page to find and merge similar trails added * fix build error * fix * docu, beautifying, refactoring --------- Co-authored-by: Flomp <Flomp@users.noreply.github.com>
This commit is contained in:
@@ -4,8 +4,10 @@
|
||||
!go.*
|
||||
!integrations
|
||||
!main.go
|
||||
!trail_merge_routes.go
|
||||
!migrations
|
||||
!templates
|
||||
!trailmerge
|
||||
!waypointcluster
|
||||
!waypointcluster/**
|
||||
!util
|
||||
|
||||
@@ -16,15 +16,19 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/tkrajina/gpxgo/gpx"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
"pocketbase/util"
|
||||
)
|
||||
|
||||
func SyncHammerhead(app core.App) error {
|
||||
func SyncHammerhead(app core.App, client meilisearch.ServiceManager) error {
|
||||
integrations, err := app.FindAllRecords("integrations", dbx.NewExp("true"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -44,12 +48,11 @@ func SyncHammerhead(app core.App) error {
|
||||
app.Logger().Warn(warning)
|
||||
continue
|
||||
}
|
||||
actorId := actor.Id
|
||||
|
||||
hammerheadString := i.GetString("hammerhead")
|
||||
hammerheadIntegration := HammerheadIntegration{
|
||||
Planned: true,
|
||||
Completed: true,
|
||||
Merge: trailmerge.DefaultIntegrationAutoMergeSettings(),
|
||||
}
|
||||
json.Unmarshal([]byte(hammerheadString), &hammerheadIntegration)
|
||||
|
||||
@@ -108,7 +111,7 @@ func SyncHammerhead(app core.App) error {
|
||||
totalPages = curTotalPages
|
||||
}
|
||||
|
||||
err, stopped = syncTrailWithTours(app, h, actorId, tours, after)
|
||||
err, stopped = syncTrailWithTours(app, client, h, actor, hammerheadIntegration, tours, after)
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err)
|
||||
fmt.Print(warning)
|
||||
@@ -139,7 +142,7 @@ func SyncHammerhead(app core.App) error {
|
||||
totalPages = curTotalPages
|
||||
}
|
||||
|
||||
err, stopped = syncTrailWithActivities(app, h, actorId, tours, after)
|
||||
err, stopped = syncTrailWithActivities(app, client, h, actor, hammerheadIntegration, tours, after)
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err)
|
||||
fmt.Print(warning)
|
||||
@@ -406,15 +409,13 @@ func (h *HammerheadApi) fetchDetailedTour(tour HammerheadTourResponse) (*Hammerh
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func syncTrailWithTours(app core.App, k *HammerheadApi, actor string, tours []HammerheadTourResponse, after int64) (error, bool) {
|
||||
func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, k *HammerheadApi, actor *core.Record, integration HammerheadIntegration, tours []HammerheadTourResponse, after int64) (error, bool) {
|
||||
for _, tour := range tours {
|
||||
|
||||
trails, err := app.FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": tour.ID})
|
||||
existingTrail, err := util.FindTrailByExternalReference(app, "hammerhead", tour.ID)
|
||||
if err != nil {
|
||||
return err, true
|
||||
}
|
||||
|
||||
if len(trails) != 0 {
|
||||
if existingTrail != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -439,25 +440,26 @@ func syncTrailWithTours(app core.App, k *HammerheadApi, actor string, tours []Ha
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = createTrailFromTour(app, detailedTour, gpx, actor)
|
||||
trailID, err := createTrailFromTour(app, detailedTour, gpx, actor.Id)
|
||||
if err != nil {
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
|
||||
continue
|
||||
}
|
||||
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailID, integration.Merge); err != nil {
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Hammerhead tour '%s': %v", tour.Name, err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func syncTrailWithActivities(app core.App, k *HammerheadApi, actor string, tours []HammerheadActivityResponse, after int64) (error, bool) {
|
||||
func syncTrailWithActivities(app core.App, client meilisearch.ServiceManager, k *HammerheadApi, actor *core.Record, integration HammerheadIntegration, tours []HammerheadActivityResponse, after int64) (error, bool) {
|
||||
for _, tour := range tours {
|
||||
|
||||
trails, err := app.FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": tour.ID})
|
||||
existingTrail, err := util.FindTrailByExternalReference(app, "hammerhead", tour.ID)
|
||||
if err != nil {
|
||||
return err, true
|
||||
}
|
||||
|
||||
if len(trails) != 0 {
|
||||
if existingTrail != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -483,11 +485,14 @@ func syncTrailWithActivities(app core.App, k *HammerheadApi, actor string, tours
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = createTrailFromActivity(app, detailedTour, gpx, actor)
|
||||
trailID, err := createTrailFromActivity(app, detailedTour, gpx, actor.Id)
|
||||
if err != nil {
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
|
||||
continue
|
||||
}
|
||||
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailID, integration.Merge); err != nil {
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Hammerhead activity '%s': %v", tour.Name, err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
@@ -565,6 +570,9 @@ func createTrailFromActivity(app core.App, detailedTour *HammerheadActivity, gpx
|
||||
if err := app.Save(record); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := util.EnsureTrailExternalReference(app, trailid, "hammerhead", detailedTour.ActivityData.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
collection, err = app.FindCollectionByNameOrId("summit_logs")
|
||||
if err != nil {
|
||||
@@ -607,20 +615,18 @@ func createTrailFromTour(app core.App, detailedTour *HammerheadTour, gpx *filesy
|
||||
diffculty := "easy" // ToDo: calculate difficulty
|
||||
|
||||
record.Load(map[string]any{
|
||||
"id": trailid,
|
||||
"name": detailedTour.Name,
|
||||
"public": detailedTour.IsPublic,
|
||||
"distance": detailedTour.Distance,
|
||||
"elevation_gain": detailedTour.Elevation.Gain,
|
||||
"elevation_loss": detailedTour.Elevation.Loss,
|
||||
"date": detailedTour.CreatedAt,
|
||||
"external_provider": "hammerhead",
|
||||
"external_id": detailedTour.ID,
|
||||
"lat": detailedTour.StartLocation.Lat,
|
||||
"lon": detailedTour.StartLocation.Lng,
|
||||
"difficulty": diffculty,
|
||||
"category": categoryId,
|
||||
"author": actor,
|
||||
"id": trailid,
|
||||
"name": detailedTour.Name,
|
||||
"public": detailedTour.IsPublic,
|
||||
"distance": detailedTour.Distance,
|
||||
"elevation_gain": detailedTour.Elevation.Gain,
|
||||
"elevation_loss": detailedTour.Elevation.Loss,
|
||||
"date": detailedTour.CreatedAt,
|
||||
"lat": detailedTour.StartLocation.Lat,
|
||||
"lon": detailedTour.StartLocation.Lng,
|
||||
"difficulty": diffculty,
|
||||
"category": categoryId,
|
||||
"author": actor,
|
||||
})
|
||||
|
||||
if gpx != nil {
|
||||
@@ -630,6 +636,9 @@ func createTrailFromTour(app core.App, detailedTour *HammerheadTour, gpx *filesy
|
||||
if err := app.Save(record); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := util.EnsureTrailExternalReference(app, trailid, "hammerhead", detailedTour.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return trailid, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package hammerhead
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
)
|
||||
|
||||
type HammerheadToursResponse struct {
|
||||
@@ -72,12 +74,13 @@ type HammerheadTour struct {
|
||||
}
|
||||
|
||||
type HammerheadIntegration struct {
|
||||
Active bool `json:"active"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Planned bool `json:"planned"`
|
||||
Completed bool `json:"completed"`
|
||||
After string `json:"after,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Planned bool `json:"planned"`
|
||||
Completed bool `json:"completed"`
|
||||
After string `json:"after,omitempty"`
|
||||
Merge trailmerge.IntegrationAutoMergeSettings `json:"merge"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
|
||||
@@ -12,14 +12,18 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/tkrajina/gpxgo/gpx"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
"pocketbase/util"
|
||||
)
|
||||
|
||||
func SyncKomoot(app core.App) error {
|
||||
func SyncKomoot(app core.App, client meilisearch.ServiceManager) error {
|
||||
integrations, err := app.FindAllRecords("integrations", dbx.NewExp("true"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -39,12 +43,11 @@ func SyncKomoot(app core.App) error {
|
||||
app.Logger().Warn(warning)
|
||||
continue
|
||||
}
|
||||
actorId := actor.Id
|
||||
|
||||
komootString := i.GetString("komoot")
|
||||
komootIntegration := KomootIntegration{
|
||||
Planned: true,
|
||||
Completed: true,
|
||||
Merge: trailmerge.DefaultIntegrationAutoMergeSettings(),
|
||||
}
|
||||
json.Unmarshal([]byte(komootString), &komootIntegration)
|
||||
|
||||
@@ -79,7 +82,7 @@ func SyncKomoot(app core.App) error {
|
||||
}
|
||||
totalPages = tp
|
||||
|
||||
allAlreadySynced, err := syncTrailWithTours(app, k, komootIntegration, userId, actorId, tours)
|
||||
allAlreadySynced, err := syncTrailWithTours(app, client, k, komootIntegration, userId, actor, tours)
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("error syncing komoot tours with trails: %v\n", err)
|
||||
fmt.Print(warning)
|
||||
@@ -188,14 +191,14 @@ func (k *KomootApi) fetchDetailedTour(tour KomootTour) (*DetailedKomootTour, err
|
||||
// when every tour on this page was already imported, so the caller can stop paginating
|
||||
// early during incremental syncs. Tours skipped due to type filters do NOT count as
|
||||
// synced - only tours already present in the DB do.
|
||||
func syncTrailWithTours(app core.App, k *KomootApi, i KomootIntegration, user string, actor string, tours []KomootTour) (bool, error) {
|
||||
func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, k *KomootApi, i KomootIntegration, user string, actor *core.Record, tours []KomootTour) (bool, error) {
|
||||
allAlreadySynced := true
|
||||
for _, tour := range tours {
|
||||
trails, err := app.FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": strconv.Itoa(int(tour.ID))})
|
||||
existingTrail, err := util.FindTrailByExternalReference(app, "komoot", strconv.Itoa(int(tour.ID)))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if len(trails) != 0 {
|
||||
if existingTrail != nil {
|
||||
continue
|
||||
}
|
||||
// Tour is not yet in the DB - we must keep paginating regardless of type filter
|
||||
@@ -213,7 +216,7 @@ func syncTrailWithTours(app core.App, k *KomootApi, i KomootIntegration, user st
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to generate GPX for tour '%s': %v", tour.Name, err))
|
||||
continue
|
||||
}
|
||||
trailid, err := createTrailFromTour(app, k, detailedTour, gpx, user, actor, i.Privacy)
|
||||
trailid, err := createTrailFromTour(app, k, detailedTour, gpx, user, actor.Id, i.Privacy)
|
||||
if err != nil {
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
|
||||
continue
|
||||
@@ -223,6 +226,9 @@ func syncTrailWithTours(app core.App, k *KomootApi, i KomootIntegration, user st
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for tour '%s': %v", tour.Name, err))
|
||||
continue
|
||||
}
|
||||
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailid, i.Merge); err != nil {
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported komoot tour '%s': %v", tour.Name, err))
|
||||
}
|
||||
|
||||
}
|
||||
return allAlreadySynced, nil
|
||||
@@ -317,6 +323,9 @@ func createTrailFromTour(app core.App, k *KomootApi, detailedTour *DetailedKomoo
|
||||
if err := app.Save(record); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := util.EnsureTrailExternalReference(app, trailid, "komoot", strconv.Itoa(detailedTour.ID)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if detailedTour.Type == "tour_recorded" {
|
||||
collection, err := app.FindCollectionByNameOrId("summit_logs")
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package komoot
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
)
|
||||
|
||||
type KomootIntegration struct {
|
||||
Active bool `json:"active"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Planned bool `json:"planned"`
|
||||
Completed bool `json:"completed"`
|
||||
Privacy string `json:"privacy"`
|
||||
Active bool `json:"active"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Planned bool `json:"planned"`
|
||||
Completed bool `json:"completed"`
|
||||
Privacy string `json:"privacy"`
|
||||
Merge trailmerge.IntegrationAutoMergeSettings `json:"merge"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package strava
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
)
|
||||
|
||||
type TokenRequest struct {
|
||||
ClientID int32 `json:"client_id"`
|
||||
@@ -21,16 +25,17 @@ type RefreshTokenResponse struct {
|
||||
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"`
|
||||
Privacy string `json:"privacy"`
|
||||
After string `json:"after,omitempty"`
|
||||
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"`
|
||||
Privacy string `json:"privacy"`
|
||||
After string `json:"after,omitempty"`
|
||||
Merge trailmerge.IntegrationAutoMergeSettings `json:"merge"`
|
||||
}
|
||||
type StravaRoute struct {
|
||||
Athlete Athlete `json:"athlete"`
|
||||
|
||||
@@ -11,19 +11,23 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||
"github.com/pocketbase/pocketbase/tools/security"
|
||||
"github.com/tkrajina/gpxgo/gpx"
|
||||
"github.com/twpayne/go-polyline"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
"pocketbase/util"
|
||||
)
|
||||
|
||||
type StravaApi struct {
|
||||
AceessToken string
|
||||
}
|
||||
|
||||
func SyncStrava(app core.App) error {
|
||||
func SyncStrava(app core.App, client meilisearch.ServiceManager) error {
|
||||
integrations, err := app.FindAllRecords("integrations", dbx.NewExp("true"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -43,8 +47,6 @@ func SyncStrava(app core.App) error {
|
||||
app.Logger().Warn(warning)
|
||||
continue
|
||||
}
|
||||
actorId := actor.Id
|
||||
|
||||
stravaString := i.GetString("strava")
|
||||
var stravaIntegration StravaIntegration
|
||||
err = json.Unmarshal([]byte(stravaString), &stravaIntegration)
|
||||
@@ -102,7 +104,7 @@ func SyncStrava(app core.App) error {
|
||||
app.Logger().Warn(warning)
|
||||
break
|
||||
}
|
||||
err = syncTrailsWithRoutes(app, stravaIntegration, r.AccessToken, userId, actorId, routes)
|
||||
err = syncTrailsWithRoutes(app, client, stravaIntegration, r.AccessToken, userId, actor, routes)
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("error syncing strava routes with trails: %v\n", err)
|
||||
fmt.Print(warning)
|
||||
@@ -134,7 +136,7 @@ func SyncStrava(app core.App) error {
|
||||
app.Logger().Warn(warning)
|
||||
break
|
||||
}
|
||||
err = syncTrailsWithActivities(app, stravaIntegration, r.AccessToken, userId, actorId, activities)
|
||||
err = syncTrailsWithActivities(app, client, stravaIntegration, r.AccessToken, userId, actor, activities)
|
||||
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("error syncing strava activities with trails: %v", err)
|
||||
@@ -248,13 +250,13 @@ func fetchStravaActivities(accessToken string, page int, after int64) ([]StravaA
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
func syncTrailsWithRoutes(app core.App, i StravaIntegration, accessToken string, user string, actor string, routes []StravaRoute) error {
|
||||
func syncTrailsWithRoutes(app core.App, client meilisearch.ServiceManager, i StravaIntegration, accessToken string, user string, actor *core.Record, routes []StravaRoute) error {
|
||||
for _, route := range routes {
|
||||
trails, err := app.FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": route.IDStr})
|
||||
existingTrail, err := util.FindTrailByExternalReference(app, "strava", route.IDStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(trails) != 0 {
|
||||
if existingTrail != nil {
|
||||
continue
|
||||
}
|
||||
gpx, err := fetchRouteGPX(route, accessToken)
|
||||
@@ -262,7 +264,7 @@ func syncTrailsWithRoutes(app core.App, i StravaIntegration, accessToken string,
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to fetch GPX for route '%s': %v", route.Name, err))
|
||||
continue
|
||||
}
|
||||
trailid, err := createTrailFromRoute(app, route, gpx, user, actor, i.Privacy)
|
||||
trailid, err := createTrailFromRoute(app, route, gpx, user, actor.Id, i.Privacy)
|
||||
if err != nil {
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to create trail for route '%s': %v", route.Name, err))
|
||||
continue
|
||||
@@ -272,6 +274,9 @@ func syncTrailsWithRoutes(app core.App, i StravaIntegration, accessToken string,
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for route '%s': %v", route.Name, err))
|
||||
continue
|
||||
}
|
||||
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailid, i.Merge); err != nil {
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Strava route '%s': %v", route.Name, err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -365,21 +370,19 @@ func createTrailFromRoute(app core.App, route StravaRoute, gpx *filesystem.File,
|
||||
}
|
||||
|
||||
record.Load(map[string]any{
|
||||
"id": trailid,
|
||||
"name": route.Name,
|
||||
"description": route.Description,
|
||||
"public": public,
|
||||
"distance": route.Distance,
|
||||
"elevation_gain": route.ElevationGain,
|
||||
"duration": route.EstimatedMovingTime,
|
||||
"date": time.Unix(int64(route.Timestamp), 0),
|
||||
"external_provider": "strava",
|
||||
"external_id": route.IDStr,
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"difficulty": "easy",
|
||||
"category": category,
|
||||
"author": actor,
|
||||
"id": trailid,
|
||||
"name": route.Name,
|
||||
"description": route.Description,
|
||||
"public": public,
|
||||
"distance": route.Distance,
|
||||
"elevation_gain": route.ElevationGain,
|
||||
"duration": route.EstimatedMovingTime,
|
||||
"date": time.Unix(int64(route.Timestamp), 0),
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"difficulty": "easy",
|
||||
"category": category,
|
||||
"author": actor,
|
||||
})
|
||||
|
||||
if gpx != nil {
|
||||
@@ -389,6 +392,9 @@ func createTrailFromRoute(app core.App, route StravaRoute, gpx *filesystem.File,
|
||||
if err := app.Save(record); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := util.EnsureTrailExternalReference(app, trailid, "strava", route.IDStr); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return trailid, err
|
||||
}
|
||||
@@ -420,13 +426,13 @@ func createWaypointsFromRoute(app core.App, route StravaRoute, user string, trai
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncTrailsWithActivities(app core.App, i StravaIntegration, accessToken string, user string, actor string, activities []StravaActivity) error {
|
||||
func syncTrailsWithActivities(app core.App, client meilisearch.ServiceManager, i StravaIntegration, accessToken string, user string, actor *core.Record, activities []StravaActivity) error {
|
||||
for _, activity := range activities {
|
||||
trails, err := app.FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": strconv.Itoa(int(activity.ID))})
|
||||
existingTrail, err := util.FindTrailByExternalReference(app, "strava", strconv.Itoa(int(activity.ID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(trails) != 0 {
|
||||
if existingTrail != nil {
|
||||
continue
|
||||
}
|
||||
detailedActivity, err := fetchDetailedActivity(activity, accessToken)
|
||||
@@ -439,11 +445,14 @@ func syncTrailsWithActivities(app core.App, i StravaIntegration, accessToken str
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to fetch GPX for activity '%s': %v", activity.Name, err))
|
||||
continue
|
||||
}
|
||||
err = createTrailFromActivity(app, detailedActivity, gpx, user, actor, i.Privacy)
|
||||
trailID, err := createTrailFromActivity(app, detailedActivity, gpx, user, actor.Id, i.Privacy)
|
||||
if err != nil {
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to create trail from activity '%s': %v", activity.Name, err))
|
||||
continue
|
||||
}
|
||||
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailID, i.Merge); err != nil {
|
||||
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Strava activity '%s': %v", activity.Name, err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -476,21 +485,21 @@ func fetchDetailedActivity(activity StravaActivity, accessToken string) (*Detail
|
||||
return &detailedActivity, nil
|
||||
}
|
||||
|
||||
func createTrailFromActivity(app core.App, activity *DetailedStravaActivity, gpx *filesystem.File, user string, actor string, privacy string) error {
|
||||
func createTrailFromActivity(app core.App, activity *DetailedStravaActivity, gpx *filesystem.File, user string, actor string, privacy string) (string, error) {
|
||||
if len(activity.StartLatlng) < 2 {
|
||||
return nil
|
||||
return "", nil
|
||||
}
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("trails")
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
var photo *filesystem.File
|
||||
if len(activity.Photos.Primary.Urls.Num600) > 0 {
|
||||
photo, err = fetchActivityPhoto(activity)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,27 +561,25 @@ func createTrailFromActivity(app core.App, activity *DetailedStravaActivity, gpx
|
||||
settings, _ := app.FindFirstRecordByData("settings", "user", user)
|
||||
err = settings.UnmarshalJSONField("privacy", &privacySettings)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
public = privacySettings.Trails == "public"
|
||||
}
|
||||
|
||||
record.Load(map[string]any{
|
||||
"name": activity.Name,
|
||||
"description": activity.Description,
|
||||
"public": public,
|
||||
"distance": activity.Distance,
|
||||
"elevation_gain": activity.TotalElevationGain,
|
||||
"duration": activity.ElapsedTime,
|
||||
"date": activity.StartDate,
|
||||
"external_provider": "strava",
|
||||
"external_id": activity.ID,
|
||||
"lat": activity.StartLatlng[0],
|
||||
"lon": activity.StartLatlng[1],
|
||||
"difficulty": "easy",
|
||||
"category": categoryId,
|
||||
"author": actor,
|
||||
"name": activity.Name,
|
||||
"description": activity.Description,
|
||||
"public": public,
|
||||
"distance": activity.Distance,
|
||||
"elevation_gain": activity.TotalElevationGain,
|
||||
"duration": activity.ElapsedTime,
|
||||
"date": activity.StartDate,
|
||||
"lat": activity.StartLatlng[0],
|
||||
"lon": activity.StartLatlng[1],
|
||||
"difficulty": "easy",
|
||||
"category": categoryId,
|
||||
"author": actor,
|
||||
})
|
||||
|
||||
if photo != nil {
|
||||
@@ -584,10 +591,13 @@ func createTrailFromActivity(app core.App, activity *DetailedStravaActivity, gpx
|
||||
}
|
||||
|
||||
if err := app.Save(record); err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
if err := util.EnsureTrailExternalReference(app, record.Id, "strava", strconv.Itoa(int(activity.ID))); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return nil
|
||||
return record.Id, nil
|
||||
}
|
||||
|
||||
func fetchActivityPhoto(activity *DetailedStravaActivity) (*filesystem.File, error) {
|
||||
|
||||
11
db/main.go
11
db/main.go
@@ -1022,7 +1022,7 @@ func createAPITokenHandler() func(e *core.RecordEvent) error {
|
||||
func onBeforeServeHandler(client meilisearch.ServiceManager) func(se *core.ServeEvent) error {
|
||||
return func(se *core.ServeEvent) error {
|
||||
registerRoutes(se, client)
|
||||
registerCronJobs(se.App)
|
||||
registerCronJobs(se.App, client)
|
||||
bootstrapData(se.App, client)
|
||||
|
||||
return se.Next()
|
||||
@@ -1073,6 +1073,7 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
registerTrailMergeRoutes(se, client)
|
||||
se.Router.POST("/waypoint/cluster", waypointcluster.Handler)
|
||||
|
||||
se.Router.POST("/auth/token", func(e *core.RequestEvent) error {
|
||||
@@ -1398,26 +1399,26 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
|
||||
})
|
||||
}
|
||||
|
||||
func registerCronJobs(app core.App) {
|
||||
func registerCronJobs(app core.App, client meilisearch.ServiceManager) {
|
||||
schedule := os.Getenv("POCKETBASE_CRON_SYNC_SCHEDULE")
|
||||
if len(schedule) == 0 {
|
||||
schedule = "0 2 * * *"
|
||||
}
|
||||
|
||||
app.Cron().MustAdd("integrations", schedule, func() {
|
||||
err := strava.SyncStrava(app)
|
||||
err := strava.SyncStrava(app, client)
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("Error syncing with strava: %v", err)
|
||||
fmt.Println(warning)
|
||||
app.Logger().Error(warning)
|
||||
}
|
||||
err = komoot.SyncKomoot(app)
|
||||
err = komoot.SyncKomoot(app, client)
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("Error syncing with komoot: %v", err)
|
||||
fmt.Println(warning)
|
||||
app.Logger().Error(warning)
|
||||
}
|
||||
err = hammerhead.SyncHammerhead(app)
|
||||
err = hammerhead.SyncHammerhead(app, client)
|
||||
if err != nil {
|
||||
warning := fmt.Sprintf("Error syncing with hammerhead: %v", err)
|
||||
fmt.Println(warning)
|
||||
|
||||
178
db/migrations/1772400001_created_trail_external_reference.go
Normal file
178
db/migrations/1772400001_created_trail_external_reference.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
jsonData := `{
|
||||
"createRule": null,
|
||||
"deleteRule": null,
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "e864strfxo14pm4",
|
||||
"hidden": false,
|
||||
"id": "relation420001001",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "trail",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "select420001002",
|
||||
"maxSelect": 1,
|
||||
"name": "provider",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"strava",
|
||||
"komoot",
|
||||
"hammerhead"
|
||||
]
|
||||
},
|
||||
{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "text420001003",
|
||||
"max": 255,
|
||||
"min": 1,
|
||||
"name": "external_id",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate420001004",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate420001005",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"id": "pbc_420001000",
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX ` + "`" + `idx_trail_external_reference_provider_external_id` + "`" + ` ON ` + "`" + `trail_external_reference` + "`" + ` (` + "`" + `provider` + "`" + `, ` + "`" + `external_id` + "`" + `)",
|
||||
"CREATE UNIQUE INDEX ` + "`" + `idx_trail_external_reference_trail_provider_external_id` + "`" + ` ON ` + "`" + `trail_external_reference` + "`" + ` (` + "`" + `trail` + "`" + `, ` + "`" + `provider` + "`" + `, ` + "`" + `external_id` + "`" + `)"
|
||||
],
|
||||
"listRule": null,
|
||||
"name": "trail_external_reference",
|
||||
"system": false,
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
}`
|
||||
|
||||
collection := &core.Collection{}
|
||||
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.Save(collection); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
referenceCollection, err := app.FindCollectionByNameOrId("trail_external_reference")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trails, err := app.FindRecordsByFilter(
|
||||
"trails",
|
||||
"external_provider != '' && external_id != ''",
|
||||
"",
|
||||
-1,
|
||||
0,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, trail := range trails {
|
||||
provider := trail.GetString("external_provider")
|
||||
externalID := trail.GetString("external_id")
|
||||
if provider == "" || externalID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
existing, err := app.FindRecordsByFilter(
|
||||
"trail_external_reference",
|
||||
"provider={:provider} && external_id={:external_id}",
|
||||
"",
|
||||
1,
|
||||
0,
|
||||
dbx.Params{
|
||||
"provider": provider,
|
||||
"external_id": externalID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(existing) > 0 {
|
||||
app.Logger().Warn("Skipping duplicate trail external reference during migration", "provider", provider, "external_id", externalID, "trail", trail.Id)
|
||||
continue
|
||||
}
|
||||
|
||||
record := core.NewRecord(referenceCollection)
|
||||
record.Load(map[string]any{
|
||||
"trail": trail.Id,
|
||||
"provider": provider,
|
||||
"external_id": externalID,
|
||||
})
|
||||
if err := app.Save(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}, func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("pbc_420001000")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Delete(collection)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collection.Fields.RemoveById("sajmiuau")
|
||||
collection.Fields.RemoveById("htr35nha")
|
||||
|
||||
return app.Save(collection)
|
||||
}, func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(17, []byte(`{
|
||||
"autogeneratePattern": "",
|
||||
"hidden": false,
|
||||
"id": "sajmiuau",
|
||||
"max": 0,
|
||||
"min": 0,
|
||||
"name": "external_id",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := collection.Fields.AddMarshaledJSONAt(18, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "htr35nha",
|
||||
"maxSelect": 1,
|
||||
"name": "external_provider",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": [
|
||||
"strava",
|
||||
"komoot",
|
||||
"hammerhead"
|
||||
]
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
})
|
||||
}
|
||||
88
db/trail_merge_routes.go
Normal file
88
db/trail_merge_routes.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"pocketbase/trailmerge"
|
||||
)
|
||||
|
||||
type mergeExecuteRequest struct {
|
||||
SourceTrailID string `json:"sourceTrailId"`
|
||||
TargetTrailID string `json:"targetTrailId"`
|
||||
Settings trailmerge.MergeSettings `json:"settings"`
|
||||
}
|
||||
|
||||
func registerTrailMergeRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
|
||||
se.Router.POST("/trail-merge/suggest", func(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return apis.NewUnauthorizedError("trail_merge_auth_required", nil)
|
||||
}
|
||||
|
||||
actor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
|
||||
}
|
||||
|
||||
var request trailmerge.SuggestRequest
|
||||
if err := e.BindBody(&request); err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_invalid_request", err)
|
||||
}
|
||||
|
||||
if request.Mode == trailmerge.SuggestModeMaintenance {
|
||||
response, err := trailmerge.SuggestGroups(e.App, actor.Id, request)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
response, err := trailmerge.Suggest(e.App, actor.Id, request)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
se.Router.POST("/trail-merge", func(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return apis.NewUnauthorizedError("trail_merge_auth_required", nil)
|
||||
}
|
||||
|
||||
actor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
|
||||
}
|
||||
|
||||
var request mergeExecuteRequest
|
||||
if err := e.BindBody(&request); err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_invalid_request", err)
|
||||
}
|
||||
|
||||
source, err := e.App.FindRecordById("trails", request.SourceTrailID)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_source_not_found", err)
|
||||
}
|
||||
target, err := e.App.FindRecordById("trails", request.TargetTrailID)
|
||||
if err != nil {
|
||||
return apis.NewBadRequestError("trail_merge_target_not_found", err)
|
||||
}
|
||||
|
||||
if !trailmerge.CanMerge(e.App, actor.Id, source, target, request.Settings.Delete) {
|
||||
return apis.NewForbiddenError("trail_merge_not_allowed", nil)
|
||||
}
|
||||
|
||||
if err := trailmerge.Merge(e.App, client, actor, request.SourceTrailID, request.TargetTrailID, request.Settings); err != nil {
|
||||
return apis.NewBadRequestError(err.Error(), err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"acknowledged": true,
|
||||
})
|
||||
})
|
||||
}
|
||||
44
db/trailmerge/integration_merge.go
Normal file
44
db/trailmerge/integration_merge.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package trailmerge
|
||||
|
||||
import (
|
||||
"github.com/meilisearch/meilisearch-go"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func TryAutoMergeImportedTrail(
|
||||
app core.App,
|
||||
client meilisearch.ServiceManager,
|
||||
actor *core.Record,
|
||||
sourceTrailID string,
|
||||
settings IntegrationAutoMergeSettings,
|
||||
) error {
|
||||
if actor == nil || sourceTrailID == "" || !settings.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
response, err := Suggest(app, actor.Id, SuggestRequest{
|
||||
Mode: SuggestModeAutoDiscovery,
|
||||
SourceTrailID: sourceTrailID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
selectableCandidates := make([]SuggestCandidate, 0, len(response.Candidates))
|
||||
for _, candidate := range response.Candidates {
|
||||
if candidate.Selectable {
|
||||
selectableCandidates = append(selectableCandidates, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
if len(selectableCandidates) != 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetTrailID := selectableCandidates[0].TrailID
|
||||
if targetTrailID == "" || targetTrailID == sourceTrailID {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Merge(app, client, actor, sourceTrailID, targetTrailID, DefaultIntegrationAutoMergeMergeSettings())
|
||||
}
|
||||
1609
db/trailmerge/service.go
Normal file
1609
db/trailmerge/service.go
Normal file
File diff suppressed because it is too large
Load Diff
18
db/util/distance.go
Normal file
18
db/util/distance.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package util
|
||||
|
||||
import "math"
|
||||
|
||||
func HaversineDistanceMeters(lat1 float64, lon1 float64, lat2 float64, lon2 float64) float64 {
|
||||
const earthRadius = 6371000.0
|
||||
|
||||
lat1Rad := lat1 * math.Pi / 180
|
||||
lat2Rad := lat2 * math.Pi / 180
|
||||
dLat := (lat2 - lat1) * math.Pi / 180
|
||||
dLon := (lon2 - lon1) * math.Pi / 180
|
||||
|
||||
sinLat := math.Sin(dLat / 2)
|
||||
sinLon := math.Sin(dLon / 2)
|
||||
|
||||
h := sinLat*sinLat + math.Cos(lat1Rad)*math.Cos(lat2Rad)*sinLon*sinLon
|
||||
return 2 * earthRadius * math.Atan2(math.Sqrt(h), math.Sqrt(1-h))
|
||||
}
|
||||
173
db/util/geometry.go
Normal file
173
db/util/geometry.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/tkrajina/gpxgo/gpx"
|
||||
)
|
||||
|
||||
type TrailGeometryMetrics struct {
|
||||
MeanDistanceMeters float64
|
||||
MaxDistanceMeters float64
|
||||
StartDistanceMeters float64
|
||||
EndDistanceMeters float64
|
||||
}
|
||||
|
||||
type trailPoint struct {
|
||||
Lat float64
|
||||
Lon float64
|
||||
}
|
||||
|
||||
func TrailCoordinates(app core.App, r *core.Record) ([][2]float64, error) {
|
||||
gpxPath := r.GetString("gpx")
|
||||
if gpxPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
fsys, err := app.NewFilesystem()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer fsys.Close()
|
||||
|
||||
reader, err := fsys.GetReader(r.BaseFilesPath() + "/" + gpxPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
content := new(bytes.Buffer)
|
||||
if _, err := io.Copy(content, reader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gpxData, err := gpx.Parse(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
points := make([][2]float64, 0)
|
||||
for _, trk := range gpxData.Tracks {
|
||||
for _, seg := range trk.Segments {
|
||||
for _, pt := range seg.Points {
|
||||
points = append(points, [2]float64{pt.Latitude, pt.Longitude})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return points, nil
|
||||
}
|
||||
|
||||
func TrailGeometrySimilarity(app core.App, a *core.Record, b *core.Record) (*TrailGeometryMetrics, error) {
|
||||
aCoords, err := TrailCoordinates(app, a)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load source geometry: %w", err)
|
||||
}
|
||||
bCoords, err := TrailCoordinates(app, b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load target geometry: %w", err)
|
||||
}
|
||||
|
||||
return CompareTrailCoordinates(aCoords, bCoords)
|
||||
}
|
||||
|
||||
func CompareTrailCoordinates(aCoords [][2]float64, bCoords [][2]float64) (*TrailGeometryMetrics, error) {
|
||||
if len(aCoords) < 2 || len(bCoords) < 2 {
|
||||
return nil, fmt.Errorf("missing geometry")
|
||||
}
|
||||
|
||||
a := resampleTrailCoordinates(aCoords, 64)
|
||||
b := resampleTrailCoordinates(bCoords, 64)
|
||||
if len(a) < 2 || len(b) < 2 {
|
||||
return nil, fmt.Errorf("missing geometry")
|
||||
}
|
||||
|
||||
return compareSampledTrails(a, b), nil
|
||||
}
|
||||
|
||||
func compareSampledTrails(a []trailPoint, b []trailPoint) *TrailGeometryMetrics {
|
||||
count := min(len(a), len(b))
|
||||
if count == 0 {
|
||||
return &TrailGeometryMetrics{}
|
||||
}
|
||||
|
||||
sum := 0.0
|
||||
maxDistance := 0.0
|
||||
for i := 0; i < count; i++ {
|
||||
distance := HaversineDistanceMeters(a[i].Lat, a[i].Lon, b[i].Lat, b[i].Lon)
|
||||
sum += distance
|
||||
if distance > maxDistance {
|
||||
maxDistance = distance
|
||||
}
|
||||
}
|
||||
|
||||
return &TrailGeometryMetrics{
|
||||
MeanDistanceMeters: sum / float64(count),
|
||||
MaxDistanceMeters: maxDistance,
|
||||
StartDistanceMeters: HaversineDistanceMeters(a[0].Lat, a[0].Lon, b[0].Lat, b[0].Lon),
|
||||
EndDistanceMeters: HaversineDistanceMeters(a[count-1].Lat, a[count-1].Lon, b[count-1].Lat, b[count-1].Lon),
|
||||
}
|
||||
}
|
||||
|
||||
func resampleTrailCoordinates(coords [][2]float64, targetPoints int) []trailPoint {
|
||||
points := make([]trailPoint, 0, len(coords))
|
||||
for _, coord := range coords {
|
||||
points = append(points, trailPoint{Lat: coord[0], Lon: coord[1]})
|
||||
}
|
||||
|
||||
if len(points) <= 2 || targetPoints <= 2 {
|
||||
return points
|
||||
}
|
||||
|
||||
cumulative := make([]float64, len(points))
|
||||
total := 0.0
|
||||
for i := 1; i < len(points); i++ {
|
||||
total += HaversineDistanceMeters(points[i-1].Lat, points[i-1].Lon, points[i].Lat, points[i].Lon)
|
||||
cumulative[i] = total
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return []trailPoint{points[0], points[len(points)-1]}
|
||||
}
|
||||
|
||||
resampled := make([]trailPoint, 0, targetPoints)
|
||||
for i := 0; i < targetPoints; i++ {
|
||||
targetDistance := (float64(i) / float64(targetPoints-1)) * total
|
||||
resampled = append(resampled, interpolateTrailPoint(points, cumulative, targetDistance))
|
||||
}
|
||||
|
||||
return resampled
|
||||
}
|
||||
|
||||
func interpolateTrailPoint(points []trailPoint, cumulative []float64, targetDistance float64) trailPoint {
|
||||
if targetDistance <= 0 {
|
||||
return points[0]
|
||||
}
|
||||
lastIndex := len(points) - 1
|
||||
if targetDistance >= cumulative[lastIndex] {
|
||||
return points[lastIndex]
|
||||
}
|
||||
|
||||
for i := 1; i < len(points); i++ {
|
||||
if cumulative[i] < targetDistance {
|
||||
continue
|
||||
}
|
||||
|
||||
prevDistance := cumulative[i-1]
|
||||
nextDistance := cumulative[i]
|
||||
if nextDistance == prevDistance {
|
||||
return points[i]
|
||||
}
|
||||
|
||||
ratio := (targetDistance - prevDistance) / (nextDistance - prevDistance)
|
||||
return trailPoint{
|
||||
Lat: points[i-1].Lat + (points[i].Lat-points[i-1].Lat)*ratio,
|
||||
Lon: points[i-1].Lon + (points[i].Lon-points[i-1].Lon)*ratio,
|
||||
}
|
||||
}
|
||||
|
||||
return points[lastIndex]
|
||||
}
|
||||
130
db/util/trail_external_reference.go
Normal file
130
db/util/trail_external_reference.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func FindTrailByExternalReference(app core.App, provider string, externalID string) (*core.Record, error) {
|
||||
if provider == "" || externalID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
refs, err := app.FindRecordsByFilter(
|
||||
"trail_external_reference",
|
||||
"provider={:provider} && external_id={:external_id}",
|
||||
"+created",
|
||||
1,
|
||||
0,
|
||||
dbx.Params{
|
||||
"provider": provider,
|
||||
"external_id": externalID,
|
||||
},
|
||||
)
|
||||
if err != nil || len(refs) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trailID := refs[0].GetString("trail")
|
||||
if trailID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return app.FindRecordById("trails", trailID)
|
||||
}
|
||||
|
||||
func EnsureTrailExternalReference(app core.App, trailID string, provider string, externalID string) error {
|
||||
if trailID == "" || provider == "" || externalID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
refs, err := app.FindRecordsByFilter(
|
||||
"trail_external_reference",
|
||||
"provider={:provider} && external_id={:external_id}",
|
||||
"",
|
||||
1,
|
||||
0,
|
||||
dbx.Params{
|
||||
"provider": provider,
|
||||
"external_id": externalID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(refs) > 0 {
|
||||
if refs[0].GetString("trail") == trailID {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("trail external reference already exists for another trail")
|
||||
}
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId("trail_external_reference")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
record.Load(map[string]any{
|
||||
"trail": trailID,
|
||||
"provider": provider,
|
||||
"external_id": externalID,
|
||||
})
|
||||
|
||||
return app.Save(record)
|
||||
}
|
||||
|
||||
func ReassignTrailExternalReferences(app core.App, sourceTrailID string, targetTrailID string) error {
|
||||
if sourceTrailID == "" || targetTrailID == "" || sourceTrailID == targetTrailID {
|
||||
return nil
|
||||
}
|
||||
|
||||
refs, err := app.FindRecordsByFilter(
|
||||
"trail_external_reference",
|
||||
"trail={:trail}",
|
||||
"",
|
||||
-1,
|
||||
0,
|
||||
dbx.Params{"trail": sourceTrailID},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, ref := range refs {
|
||||
provider := ref.GetString("provider")
|
||||
externalID := ref.GetString("external_id")
|
||||
|
||||
existing, err := app.FindRecordsByFilter(
|
||||
"trail_external_reference",
|
||||
"trail={:trail} && provider={:provider} && external_id={:external_id}",
|
||||
"",
|
||||
1,
|
||||
0,
|
||||
dbx.Params{
|
||||
"trail": targetTrailID,
|
||||
"provider": provider,
|
||||
"external_id": externalID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(existing) > 0 {
|
||||
if err := app.Delete(ref); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
ref.Set("trail", targetTrailID)
|
||||
if err := app.Save(ref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user