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:
slothful-vassal
2026-05-09 13:09:26 +02:00
committed by GitHub
parent d2268429c7
commit b5739cb72e
43 changed files with 5232 additions and 216 deletions

View File

@@ -4,8 +4,10 @@
!go.*
!integrations
!main.go
!trail_merge_routes.go
!migrations
!templates
!trailmerge
!waypointcluster
!waypointcluster/**
!util

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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"`

View File

@@ -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) {

View File

@@ -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)

View 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)
})
}

View File

@@ -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
View 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,
})
})
}

View 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

File diff suppressed because it is too large Load Diff

18
db/util/distance.go Normal file
View 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
View 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]
}

View 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
}