diff --git a/db/.dockerignore b/db/.dockerignore index 27d890ca..6fc22d74 100644 --- a/db/.dockerignore +++ b/db/.dockerignore @@ -4,8 +4,10 @@ !go.* !integrations !main.go +!trail_merge_routes.go !migrations !templates +!trailmerge !waypointcluster !waypointcluster/** !util diff --git a/db/integrations/hammerhead/hammerhead.go b/db/integrations/hammerhead/hammerhead.go index 34b8a9d3..c42c7f1e 100644 --- a/db/integrations/hammerhead/hammerhead.go +++ b/db/integrations/hammerhead/hammerhead.go @@ -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 } diff --git a/db/integrations/hammerhead/models.go b/db/integrations/hammerhead/models.go index 77ac83c5..6271c29a 100644 --- a/db/integrations/hammerhead/models.go +++ b/db/integrations/hammerhead/models.go @@ -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 { diff --git a/db/integrations/komoot/komoot.go b/db/integrations/komoot/komoot.go index db86f63e..6fbda2c0 100644 --- a/db/integrations/komoot/komoot.go +++ b/db/integrations/komoot/komoot.go @@ -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") diff --git a/db/integrations/komoot/models.go b/db/integrations/komoot/models.go index fd378130..456d0827 100644 --- a/db/integrations/komoot/models.go +++ b/db/integrations/komoot/models.go @@ -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 { diff --git a/db/integrations/strava/models.go b/db/integrations/strava/models.go index d381f18a..8710dee0 100644 --- a/db/integrations/strava/models.go +++ b/db/integrations/strava/models.go @@ -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"` diff --git a/db/integrations/strava/strava.go b/db/integrations/strava/strava.go index d9250b02..81f5181d 100644 --- a/db/integrations/strava/strava.go +++ b/db/integrations/strava/strava.go @@ -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) { diff --git a/db/main.go b/db/main.go index e9da1e91..02b1d6e3 100644 --- a/db/main.go +++ b/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) diff --git a/db/migrations/1772400001_created_trail_external_reference.go b/db/migrations/1772400001_created_trail_external_reference.go new file mode 100644 index 00000000..210bde31 --- /dev/null +++ b/db/migrations/1772400001_created_trail_external_reference.go @@ -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) + }) +} diff --git a/db/migrations/1772400002_updated_trails_remove_external_fields.go b/db/migrations/1772400002_updated_trails_remove_external_fields.go new file mode 100644 index 00000000..a5e7f4c6 --- /dev/null +++ b/db/migrations/1772400002_updated_trails_remove_external_fields.go @@ -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) + }) +} diff --git a/db/trail_merge_routes.go b/db/trail_merge_routes.go new file mode 100644 index 00000000..c231b44d --- /dev/null +++ b/db/trail_merge_routes.go @@ -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, + }) + }) +} diff --git a/db/trailmerge/integration_merge.go b/db/trailmerge/integration_merge.go new file mode 100644 index 00000000..94d463e0 --- /dev/null +++ b/db/trailmerge/integration_merge.go @@ -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()) +} diff --git a/db/trailmerge/service.go b/db/trailmerge/service.go new file mode 100644 index 00000000..675c2402 --- /dev/null +++ b/db/trailmerge/service.go @@ -0,0 +1,1609 @@ +package trailmerge + +import ( + "bytes" + "errors" + "fmt" + pub "github.com/go-ap/activitypub" + "github.com/meilisearch/meilisearch-go" + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/core" + "github.com/pocketbase/pocketbase/tools/filesystem" + "io" + "slices" + "strings" + + "pocketbase/federation" + "pocketbase/util" +) + +const ( + SuggestModeManualSelection = "manual-selection" + SuggestModeAutoDiscovery = "auto-discovery" + SuggestModeMaintenance = "maintenance-groups" + maintenanceLocationBucketDegrees = 0.003 + maintenanceLocationToleranceMeters = 500.0 +) + +var ( + ErrUnknownSuggestMode = errors.New("trail_merge_unknown_suggest_mode") + ErrMissingActor = errors.New("trail_merge_missing_actor") + ErrMissingTrailID = errors.New("trail_merge_missing_trail_id") + ErrSameSourceAndTargetTrail = errors.New("trail_merge_same_source_target") + ErrRequiresMultipleTrails = errors.New("trail_merge_requires_multiple_trails") + ErrMissingSourceTrailID = errors.New("trail_merge_missing_source_trail_id") + ErrSourceActorMismatch = errors.New("trail_merge_source_actor_mismatch") +) + +type MergeSettings struct { + SummitLog bool `json:"summitLog"` + Photos bool `json:"photos"` + Comments bool `json:"comments"` + Delete bool `json:"delete"` + Tags bool `json:"tags"` + Likes bool `json:"likes"` +} + +type IntegrationAutoMergeSettings struct { + Enabled bool `json:"enabled"` +} + +type SuggestRequest struct { + Mode string `json:"mode"` + TrailIDs []string `json:"trailIds"` + SourceTrailID string `json:"sourceTrailId"` +} + +type SuggestCandidate struct { + TrailID string `json:"trailId"` + Score float64 `json:"score"` + Reason string `json:"reason"` + Warnings []string `json:"warnings"` + Selectable bool `json:"selectable"` +} + +type SuggestResponse struct { + TargetTrailID string `json:"targetTrailId"` + Reason string `json:"reason"` + Warnings []string `json:"warnings"` + Candidates []SuggestCandidate `json:"candidates"` +} + +type SuggestGroup struct { + GroupID string `json:"groupId"` + TrailIDs []string `json:"trailIds"` + TargetTrailID string `json:"targetTrailId"` + Reason string `json:"reason"` + Score float64 `json:"score"` + Indirect bool `json:"indirect"` +} + +type SuggestGroupsResponse struct { + Groups []SuggestGroup `json:"groups"` +} + +type mergeContext struct { + App core.App + Client meilisearch.ServiceManager + Actor *core.Record + ActorID string + Target *core.Record + Source *core.Record + Settings MergeSettings +} + +type mergeSideEffects struct { + CreatedSummitLogIDs []string + CreatedCommentIDs []string + TargetTrailID string +} + +type maintenanceTrailCandidate struct { + Trail *core.Record + Coords [][2]float64 + StartLat float64 + StartLon float64 + EndLat float64 + EndLon float64 + Distance float64 +} + +type targetSelectionStats struct { + TrailID string + SummitLogCount int64 + CommentCount int64 + PhotoCount int + LikeCount int64 + TagCount int + ExternalReferenceCount int64 + WaypointCount int64 + HasDescription bool + GeometryCentralityScore float64 + PriorityClass int + TotalScore float64 + CreatedAtUnix int64 +} + +type targetSelectionResult struct { + TrailID string + Reason string + Stats map[string]targetSelectionStats +} + +func DefaultIntegrationAutoMergeSettings() IntegrationAutoMergeSettings { + return IntegrationAutoMergeSettings{ + Enabled: false, + } +} + +func DefaultIntegrationAutoMergeMergeSettings() MergeSettings { + return MergeSettings{ + SummitLog: true, + Photos: true, + Comments: false, + Delete: true, + Tags: false, + Likes: false, + } +} + +// Suggest returns merge target suggestions for the requested mode. +// The mode only determines the candidate set; target ranking itself is +// delegated to the shared chooseTargetTrail selection strategy. +func Suggest(app core.App, actorID string, request SuggestRequest) (*SuggestResponse, error) { + switch request.Mode { + case SuggestModeManualSelection: + return suggestForManualSelection(app, actorID, request.TrailIDs) + case SuggestModeAutoDiscovery: + return suggestForAutoDiscovery(app, actorID, request.SourceTrailID) + default: + return nil, ErrUnknownSuggestMode + } +} + +// SuggestGroups returns temporary groups of potentially repeated or duplicate +// trails for maintenance workflows. Group members are discovered first and the +// suggested target trail is then selected by the shared chooseTargetTrail logic. +func SuggestGroups(app core.App, actorID string, request SuggestRequest) (*SuggestGroupsResponse, error) { + switch request.Mode { + case SuggestModeMaintenance: + return suggestMaintenanceGroups(app, actorID) + default: + return nil, ErrUnknownSuggestMode + } +} + +// Merge links a source trail into a target trail in a single transaction. +// It moves or recreates trail-related content according to the provided +// settings and keeps the target trail indexed and federated afterwards. +func Merge(app core.App, client meilisearch.ServiceManager, actor *core.Record, sourceTrailID string, targetTrailID string, settings MergeSettings) error { + if actor == nil { + return ErrMissingActor + } + if sourceTrailID == "" || targetTrailID == "" { + return ErrMissingTrailID + } + if sourceTrailID == targetTrailID { + return ErrSameSourceAndTargetTrail + } + + var effects mergeSideEffects + err := app.RunInTransaction(func(txApp core.App) error { + source, err := txApp.FindRecordById("trails", sourceTrailID) + if err != nil { + return err + } + target, err := txApp.FindRecordById("trails", targetTrailID) + if err != nil { + return err + } + + ctx := mergeContext{ + App: txApp, + Client: client, + Actor: actor, + ActorID: actor.Id, + Target: target, + Source: source, + Settings: settings, + } + + sideEffects, err := mergeTrailIntoTarget(ctx) + if err != nil { + return err + } + effects = sideEffects + + return nil + }) + if err != nil { + return err + } + + target, err := app.FindRecordById("trails", effects.TargetTrailID) + if err != nil { + return err + } + if err := util.IndexTrails(app, []*core.Record{target}, client); err != nil { + return err + } + + for _, summitLogID := range effects.CreatedSummitLogIDs { + record, err := app.FindRecordById("summit_logs", summitLogID) + if err != nil { + return err + } + logAuthor, err := app.FindRecordById("activitypub_actors", record.GetString("author")) + if err != nil { + return err + } + if err := federation.CreateSummitLogActivity(app, logAuthor, record, pub.CreateType); err != nil { + return err + } + } + + for _, commentID := range effects.CreatedCommentIDs { + record, err := app.FindRecordById("comments", commentID) + if err != nil { + return err + } + if err := federation.CreateCommentActivity(app, actor, record, pub.CreateType); err != nil { + return err + } + } + + return nil +} + +func CanMerge(app core.App, actorID string, source *core.Record, target *core.Record, deleteSource bool) bool { + if source == nil || target == nil { + return false + } + if source.Id == target.Id { + return false + } + if !canEditTrail(app, target, actorID) { + return false + } + if deleteSource && !canDeleteTrail(source, actorID) { + return false + } + + return true +} + +// chooseTargetTrail applies the shared target selection strategy used by +// manual selection, auto-discovery and maintenance suggestions. +// It computes trail-level stats, assigns a priority class, derives a weighted +// score and returns both the winning trail and an explainable reason code. +func chooseTargetTrail(app core.App, trails []*core.Record, referenceTrails []*core.Record) (*targetSelectionResult, error) { + if len(trails) == 0 { + return &targetSelectionResult{ + TrailID: "", + Reason: "deterministic_fallback", + Stats: map[string]targetSelectionStats{}, + }, nil + } + + coordsByTrailID := make(map[string][][2]float64, len(trails)+len(referenceTrails)) + for _, trail := range append(append([]*core.Record{}, trails...), referenceTrails...) { + if trail == nil { + continue + } + if _, exists := coordsByTrailID[trail.Id]; exists { + continue + } + coords, err := util.TrailCoordinates(app, trail) + if err != nil { + coordsByTrailID[trail.Id] = nil + continue + } + coordsByTrailID[trail.Id] = coords + } + + statsByTrailID := make(map[string]targetSelectionStats, len(trails)) + for _, trail := range trails { + stats, err := buildTargetSelectionStats(app, trail, trails, referenceTrails, coordsByTrailID) + if err != nil { + return nil, err + } + statsByTrailID[trail.Id] = stats + } + + bestTrail := trails[0] + bestStats := statsByTrailID[bestTrail.Id] + for _, trail := range trails[1:] { + stats := statsByTrailID[trail.Id] + if compareTargetSelectionStats(stats, bestStats) < 0 { + bestTrail = trail + bestStats = stats + } + } + + return &targetSelectionResult{ + TrailID: bestTrail.Id, + Reason: deriveTargetSelectionReason(bestTrail.Id, statsByTrailID), + Stats: statsByTrailID, + }, nil +} + +// buildTargetSelectionStats collects the data needed for trail target ranking. +// The score intentionally favors preserving trails that already carry more +// durable user value such as summit logs, external references and richer content. +func buildTargetSelectionStats( + app core.App, + trail *core.Record, + groupTrails []*core.Record, + referenceTrails []*core.Record, + coordsByTrailID map[string][][2]float64, +) (targetSelectionStats, error) { + summitLogCount, err := app.CountRecords("summit_logs", dbx.NewExp("trail={:trail}", dbx.Params{"trail": trail.Id})) + if err != nil { + return targetSelectionStats{}, err + } + commentCount, err := app.CountRecords("comments", dbx.NewExp("trail={:trail}", dbx.Params{"trail": trail.Id})) + if err != nil { + return targetSelectionStats{}, err + } + likeCount, err := app.CountRecords("trail_like", dbx.NewExp("trail={:trail}", dbx.Params{"trail": trail.Id})) + if err != nil { + return targetSelectionStats{}, err + } + externalReferenceCount, err := app.CountRecords("trail_external_reference", dbx.NewExp("trail={:trail}", dbx.Params{"trail": trail.Id})) + if err != nil { + return targetSelectionStats{}, err + } + waypointCount, err := app.CountRecords("waypoints", dbx.NewExp("trail={:trail}", dbx.Params{"trail": trail.Id})) + if err != nil { + return targetSelectionStats{}, err + } + + geometryCentralityScore := trailGeometryCentralityScore(trail, groupTrails, referenceTrails, coordsByTrailID) + hasDescription := strings.TrimSpace(trail.GetString("description")) != "" + photoCount := len(trail.GetStringSlice("photos")) + tagCount := len(trail.GetStringSlice("tags")) + priorityClass := targetPriorityClass(summitLogCount, externalReferenceCount, commentCount, photoCount, hasDescription) + + totalScore := float64(summitLogCount)*1000.0 + + float64(externalReferenceCount)*400.0 + + float64(commentCount)*120.0 + + float64(photoCount)*80.0 + + float64(likeCount)*20.0 + + float64(tagCount)*10.0 + + boolScore(hasDescription)*15.0 + + float64(waypointCount)*15.0 + + geometryCentralityScore*200.0 + + return targetSelectionStats{ + TrailID: trail.Id, + SummitLogCount: summitLogCount, + CommentCount: commentCount, + PhotoCount: photoCount, + LikeCount: likeCount, + TagCount: tagCount, + ExternalReferenceCount: externalReferenceCount, + WaypointCount: waypointCount, + HasDescription: hasDescription, + GeometryCentralityScore: geometryCentralityScore, + PriorityClass: priorityClass, + TotalScore: totalScore, + CreatedAtUnix: trail.GetDateTime("created").Time().Unix(), + }, nil +} + +// trailGeometryCentralityScore measures how well a trail fits geometrically +// within the provided candidate set. Higher values indicate that the trail is +// a more central representative of the group or of the source/candidate set. +func trailGeometryCentralityScore( + trail *core.Record, + groupTrails []*core.Record, + referenceTrails []*core.Record, + coordsByTrailID map[string][][2]float64, +) float64 { + trailCoords := coordsByTrailID[trail.Id] + if len(trailCoords) < 2 { + return 0 + } + + scoreSum := 0.0 + comparisons := 0 + for _, other := range groupTrails { + if other == nil || other.Id == trail.Id { + continue + } + metrics, err := util.CompareTrailCoordinates(trailCoords, coordsByTrailID[other.Id]) + if err != nil { + continue + } + scoreSum += geometryScore(metrics) + comparisons++ + } + + for _, other := range referenceTrails { + if other == nil || other.Id == trail.Id { + continue + } + metrics, err := util.CompareTrailCoordinates(trailCoords, coordsByTrailID[other.Id]) + if err != nil { + continue + } + scoreSum += geometryScore(metrics) + comparisons++ + } + + if comparisons == 0 { + return 0 + } + + return scoreSum / float64(comparisons) +} + +// targetPriorityClass creates a coarse ranking tier before weighted scoring. +// Trails with summit logs are preferred first, then trails with external +// references, then trails with richer content, and finally plain trails. +func targetPriorityClass( + summitLogCount int64, + externalReferenceCount int64, + commentCount int64, + photoCount int, + hasDescription bool, +) int { + switch { + case summitLogCount > 0: + return 4 + case externalReferenceCount > 0: + return 3 + case commentCount > 0 || photoCount > 0 || hasDescription: + return 2 + default: + return 1 + } +} + +func boolScore(v bool) float64 { + if v { + return 1 + } + return 0 +} + +func compareTargetSelectionStats(a targetSelectionStats, b targetSelectionStats) int { + switch { + case a.PriorityClass > b.PriorityClass: + return -1 + case a.PriorityClass < b.PriorityClass: + return 1 + case a.TotalScore > b.TotalScore: + return -1 + case a.TotalScore < b.TotalScore: + return 1 + case a.GeometryCentralityScore > b.GeometryCentralityScore: + return -1 + case a.GeometryCentralityScore < b.GeometryCentralityScore: + return 1 + case a.CreatedAtUnix < b.CreatedAtUnix: + return -1 + case a.CreatedAtUnix > b.CreatedAtUnix: + return 1 + default: + return strings.Compare(a.TrailID, b.TrailID) + } +} + +// deriveTargetSelectionReason returns a single explainable reason code for the +// selected trail. The reason reflects the strongest distinguishing factor that +// made the winner stand out against the remaining candidates. +func deriveTargetSelectionReason(selectedTrailID string, statsByTrailID map[string]targetSelectionStats) string { + selected, ok := statsByTrailID[selectedTrailID] + if !ok { + return "deterministic_fallback" + } + + allStats := make([]targetSelectionStats, 0, len(statsByTrailID)) + for _, stats := range statsByTrailID { + allStats = append(allStats, stats) + } + + if selected.SummitLogCount > maxOtherInt64(selectedTrailID, allStats, func(stats targetSelectionStats) int64 { + return stats.SummitLogCount + }) { + return "highest_summit_log_count" + } + if selected.ExternalReferenceCount > maxOtherInt64(selectedTrailID, allStats, func(stats targetSelectionStats) int64 { + return stats.ExternalReferenceCount + }) { + return "most_external_references" + } + selectedContentScore := trailContentScore(selected) + if selectedContentScore > maxOtherFloat64(selectedTrailID, allStats, trailContentScore) { + return "most_complete_content" + } + if selected.GeometryCentralityScore > maxOtherFloat64(selectedTrailID, allStats, func(stats targetSelectionStats) float64 { + return stats.GeometryCentralityScore + }) { + return "most_central_geometry" + } + if selected.CreatedAtUnix < minOtherInt64(selectedTrailID, allStats, func(stats targetSelectionStats) int64 { + return stats.CreatedAtUnix + }) { + return "oldest_trail" + } + + return "deterministic_fallback" +} + +func trailContentScore(stats targetSelectionStats) float64 { + return float64(stats.CommentCount)*120.0 + + float64(stats.PhotoCount)*80.0 + + float64(stats.LikeCount)*20.0 + + float64(stats.TagCount)*10.0 + + boolScore(stats.HasDescription)*15.0 + + float64(stats.WaypointCount)*15.0 +} + +func maxOtherInt64(selectedTrailID string, stats []targetSelectionStats, valueFn func(targetSelectionStats) int64) int64 { + var maxValue int64 + initialized := false + for _, stat := range stats { + if stat.TrailID == selectedTrailID { + continue + } + value := valueFn(stat) + if !initialized || value > maxValue { + maxValue = value + initialized = true + } + } + if !initialized { + return -1 + } + return maxValue +} + +func minOtherInt64(selectedTrailID string, stats []targetSelectionStats, valueFn func(targetSelectionStats) int64) int64 { + var minValue int64 + initialized := false + for _, stat := range stats { + if stat.TrailID == selectedTrailID { + continue + } + value := valueFn(stat) + if !initialized || value < minValue { + minValue = value + initialized = true + } + } + if !initialized { + return selectedOnlyMinInt64Fallback() + } + return minValue +} + +func selectedOnlyMinInt64Fallback() int64 { + return 1<<63 - 1 +} + +func maxOtherFloat64(selectedTrailID string, stats []targetSelectionStats, valueFn func(targetSelectionStats) float64) float64 { + maxValue := 0.0 + initialized := false + for _, stat := range stats { + if stat.TrailID == selectedTrailID { + continue + } + value := valueFn(stat) + if !initialized || value > maxValue { + maxValue = value + initialized = true + } + } + if !initialized { + return -1 + } + return maxValue +} + +func suggestForManualSelection(app core.App, actorID string, trailIDs []string) (*SuggestResponse, error) { + if len(trailIDs) < 2 { + return nil, ErrRequiresMultipleTrails + } + + trails := make([]*core.Record, 0, len(trailIDs)) + for _, id := range trailIDs { + trail, err := app.FindRecordById("trails", id) + if err != nil { + return nil, err + } + trails = append(trails, trail) + } + + selection, err := chooseTargetTrail(app, trails, nil) + if err != nil { + return nil, err + } + + suggestedTrailID := selection.TrailID + reason := selection.Reason + if suggestedTrailID == "" && len(trails) > 0 { + suggestedTrailID = trails[0].Id + reason = "deterministic_fallback" + } + + candidates := make([]SuggestCandidate, 0, len(trails)) + for _, candidate := range trails { + warnings := make([]string, 0) + for _, other := range trails { + if candidate.Id == other.Id { + continue + } + warnings = appendUniqueStrings(warnings, geometryWarnings(app, other, candidate)...) + } + + stats, ok := selection.Stats[candidate.Id] + score := 0.0 + if ok { + score = stats.TotalScore - float64(len(warnings))*0.1 + } + + candidates = append(candidates, SuggestCandidate{ + TrailID: candidate.Id, + Score: score, + Reason: reasonForCandidate(candidate.Id == suggestedTrailID, reason), + Warnings: warnings, + Selectable: canEditTrail(app, candidate, actorID), + }) + } + + return &SuggestResponse{ + TargetTrailID: suggestedTrailID, + Reason: reason, + Warnings: candidateWarningsForTrail(candidates, suggestedTrailID), + Candidates: candidates, + }, nil +} + +func suggestForAutoDiscovery(app core.App, actorID string, sourceTrailID string) (*SuggestResponse, error) { + if sourceTrailID == "" { + return nil, ErrMissingSourceTrailID + } + + source, err := app.FindRecordById("trails", sourceTrailID) + if err != nil { + return nil, err + } + if source.GetString("author") != actorID { + return nil, ErrSourceActorMismatch + } + + candidateTrails, err := app.FindRecordsByFilter( + "trails", + "author={:actor} && id!={:id} && gpx!=''", + "", + -1, + 0, + dbx.Params{ + "actor": actorID, + "id": sourceTrailID, + }, + ) + if err != nil { + return nil, err + } + + candidates := make([]SuggestCandidate, 0) + candidateTrailsByID := make(map[string]*core.Record) + for _, candidate := range candidateTrails { + metrics, err := util.TrailGeometrySimilarity(app, source, candidate) + if err != nil { + continue + } + if !isStrongGeometryMatch(metrics) { + continue + } + + candidates = append(candidates, SuggestCandidate{ + TrailID: candidate.Id, + Score: geometryScore(metrics), + Reason: "selected_trail", + Warnings: []string{}, + Selectable: canEditTrail(app, candidate, actorID), + }) + candidateTrailsByID[candidate.Id] = candidate + } + + response := &SuggestResponse{ + Candidates: candidates, + Reason: "no_geometry_match", + Warnings: []string{}, + } + if len(candidates) > 0 { + eligibleTrails := make([]*core.Record, 0, len(candidates)) + for _, candidate := range candidates { + if trail, ok := candidateTrailsByID[candidate.TrailID]; ok { + eligibleTrails = append(eligibleTrails, trail) + } + } + + selection, err := chooseTargetTrail(app, eligibleTrails, []*core.Record{source}) + if err != nil { + return nil, err + } + response.TargetTrailID = selection.TrailID + response.Reason = selection.Reason + + for i := range candidates { + candidates[i].Reason = reasonForCandidate(candidates[i].TrailID == selection.TrailID, selection.Reason) + if stats, ok := selection.Stats[candidates[i].TrailID]; ok { + candidates[i].Score = stats.TotalScore + } + } + + slices.SortFunc(candidates, func(a, b SuggestCandidate) int { + switch { + case a.Score > b.Score: + return -1 + case a.Score < b.Score: + return 1 + default: + return strings.Compare(a.TrailID, b.TrailID) + } + }) + } + + return response, nil +} + +func suggestMaintenanceGroups(app core.App, actorID string) (*SuggestGroupsResponse, error) { + trails, err := findMaintenanceCandidateTrails(app, actorID) + if err != nil { + return nil, err + } + + if len(trails) < 2 { + return &SuggestGroupsResponse{Groups: []SuggestGroup{}}, nil + } + + preparedTrails := make([]maintenanceTrailCandidate, 0, len(trails)) + for _, trail := range trails { + candidate, ok, err := prepareMaintenanceTrailCandidate(app, trail) + if err != nil { + return nil, err + } + if !ok { + continue + } + preparedTrails = append(preparedTrails, candidate) + } + + if len(preparedTrails) < 2 { + return &SuggestGroupsResponse{Groups: []SuggestGroup{}}, nil + } + + adjacency := make(map[string][]string, len(preparedTrails)) + trailByID := make(map[string]*core.Record, len(preparedTrails)) + trailScores := make(map[string]float64, len(preparedTrails)) + edgeCounts := make(map[string]int, len(preparedTrails)) + startBuckets := groupMaintenanceTrailsByLocation(preparedTrails, false) + endBuckets := groupMaintenanceTrailsByLocation(preparedTrails, true) + + for _, trail := range preparedTrails { + trailByID[trail.Trail.Id] = trail.Trail + } + + for i := range preparedTrails { + candidates := findMaintenanceComparisonCandidates(preparedTrails[i], startBuckets, endBuckets) + for _, j := range candidates { + if i >= j { + continue + } + + if !maintenanceDistanceCompatible(preparedTrails[i], preparedTrails[j]) { + continue + } + + metrics, err := util.CompareTrailCoordinates(preparedTrails[i].Coords, preparedTrails[j].Coords) + if err != nil || !isStrongGeometryMatch(metrics) { + continue + } + + score := geometryScore(metrics) + leftID := preparedTrails[i].Trail.Id + rightID := preparedTrails[j].Trail.Id + adjacency[leftID] = append(adjacency[leftID], rightID) + adjacency[rightID] = append(adjacency[rightID], leftID) + trailScores[leftID] += score + trailScores[rightID] += score + edgeCounts[leftID]++ + edgeCounts[rightID]++ + } + } + + visited := make(map[string]bool, len(trails)) + groups := make([]SuggestGroup, 0) + + for _, trail := range preparedTrails { + if visited[trail.Trail.Id] || len(adjacency[trail.Trail.Id]) == 0 { + continue + } + + componentIDs := collectConnectedTrailIDs(trail.Trail.Id, adjacency, visited) + if len(componentIDs) < 2 { + continue + } + + componentTrails := make([]*core.Record, 0, len(componentIDs)) + groupScore := 0.0 + for _, id := range componentIDs { + record, ok := trailByID[id] + if !ok { + continue + } + componentTrails = append(componentTrails, record) + if edgeCounts[id] > 0 { + groupScore += trailScores[id] / float64(edgeCounts[id]) + } + } + + if len(componentTrails) < 2 { + continue + } + + slices.SortFunc(componentTrails, func(a, b *core.Record) int { + nameCompare := strings.Compare(a.GetString("name"), b.GetString("name")) + if nameCompare != 0 { + return nameCompare + } + return strings.Compare(a.Id, b.Id) + }) + + targetTrailID, reason, err := chooseSuggestedGroupTarget(app, componentTrails) + if err != nil { + return nil, err + } + + trailIDs := make([]string, 0, len(componentTrails)) + for _, componentTrail := range componentTrails { + trailIDs = append(trailIDs, componentTrail.Id) + } + + groups = append(groups, SuggestGroup{ + GroupID: strings.Join(trailIDs, ":"), + TrailIDs: trailIDs, + TargetTrailID: targetTrailID, + Reason: reason, + Score: groupScore / float64(len(componentTrails)), + Indirect: isIndirectMaintenanceGroup(componentIDs, adjacency), + }) + } + + slices.SortFunc(groups, func(a, b SuggestGroup) int { + switch { + case len(a.TrailIDs) > len(b.TrailIDs): + return -1 + case len(a.TrailIDs) < len(b.TrailIDs): + return 1 + case a.Score > b.Score: + return -1 + case a.Score < b.Score: + return 1 + default: + return strings.Compare(a.GroupID, b.GroupID) + } + }) + + return &SuggestGroupsResponse{Groups: groups}, nil +} + +func isIndirectMaintenanceGroup(componentIDs []string, adjacency map[string][]string) bool { + if len(componentIDs) < 3 { + return false + } + + componentSet := make(map[string]struct{}, len(componentIDs)) + for _, id := range componentIDs { + componentSet[id] = struct{}{} + } + + for _, id := range componentIDs { + directMatches := 0 + for _, neighborID := range adjacency[id] { + if _, ok := componentSet[neighborID]; ok { + directMatches++ + } + } + + if directMatches < len(componentIDs)-1 { + return true + } + } + + return false +} + +func findMaintenanceCandidateTrails(app core.App, actorID string) ([]*core.Record, error) { + authoredTrails, err := app.FindRecordsByFilter( + "trails", + "author={:actor} && gpx!=''", + "", + -1, + 0, + dbx.Params{"actor": actorID}, + ) + if err != nil { + return nil, err + } + + sharedTrails, err := app.FindRecordsByFilter( + "trail_share", + "actor={:actor} && permission='edit'", + "", + -1, + 0, + dbx.Params{"actor": actorID}, + ) + if err != nil { + return nil, err + } + + trailMap := make(map[string]*core.Record, len(authoredTrails)) + for _, trail := range authoredTrails { + trailMap[trail.Id] = trail + } + + for _, share := range sharedTrails { + trailID := share.GetString("trail") + if trailID == "" { + continue + } + if _, exists := trailMap[trailID]; exists { + continue + } + trail, err := app.FindRecordById("trails", trailID) + if err != nil || trail.GetString("gpx") == "" { + continue + } + trailMap[trailID] = trail + } + + trails := make([]*core.Record, 0, len(trailMap)) + for _, trail := range trailMap { + trails = append(trails, trail) + } + + slices.SortFunc(trails, func(a, b *core.Record) int { + nameCompare := strings.Compare(a.GetString("name"), b.GetString("name")) + if nameCompare != 0 { + return nameCompare + } + return strings.Compare(a.Id, b.Id) + }) + + return trails, nil +} + +func prepareMaintenanceTrailCandidate(app core.App, trail *core.Record) (maintenanceTrailCandidate, bool, error) { + coords, err := util.TrailCoordinates(app, trail) + if err != nil { + return maintenanceTrailCandidate{}, false, nil + } + if len(coords) < 2 { + return maintenanceTrailCandidate{}, false, nil + } + + return maintenanceTrailCandidate{ + Trail: trail, + Coords: coords, + StartLat: coords[0][0], + StartLon: coords[0][1], + EndLat: coords[len(coords)-1][0], + EndLon: coords[len(coords)-1][1], + Distance: trail.GetFloat("distance"), + }, true, nil +} + +func groupMaintenanceTrailsByLocation(trails []maintenanceTrailCandidate, useEnd bool) map[string][]int { + buckets := make(map[string][]int, len(trails)) + for i, trail := range trails { + lat := trail.StartLat + lon := trail.StartLon + if useEnd { + lat = trail.EndLat + lon = trail.EndLon + } + + key := maintenanceLocationBucketKey(lat, lon) + buckets[key] = append(buckets[key], i) + } + + return buckets +} + +func findMaintenanceComparisonCandidates( + trail maintenanceTrailCandidate, + startBuckets map[string][]int, + endBuckets map[string][]int, +) []int { + startMatches := make(map[int]struct{}) + endMatches := make(map[int]struct{}) + + for _, startKey := range maintenanceNeighborBucketKeys(trail.StartLat, trail.StartLon) { + for _, index := range startBuckets[startKey] { + startMatches[index] = struct{}{} + } + } + + for _, endKey := range maintenanceNeighborBucketKeys(trail.EndLat, trail.EndLon) { + for _, index := range endBuckets[endKey] { + endMatches[index] = struct{}{} + } + } + + result := make([]int, 0, len(startMatches)) + for index := range startMatches { + if _, exists := endMatches[index]; exists { + result = append(result, index) + } + } + + return result +} + +func maintenanceDistanceCompatible(a maintenanceTrailCandidate, b maintenanceTrailCandidate) bool { + startDistance := util.HaversineDistanceMeters(a.StartLat, a.StartLon, b.StartLat, b.StartLon) + if startDistance > maintenanceLocationToleranceMeters { + return false + } + + endDistance := util.HaversineDistanceMeters(a.EndLat, a.EndLon, b.EndLat, b.EndLon) + if endDistance > maintenanceLocationToleranceMeters { + return false + } + + maxDistance := maxFloat64(a.Distance, b.Distance) + if maxDistance <= 0 { + return true + } + + minDistance := minFloat64(a.Distance, b.Distance) + absoluteGap := maxDistance - minDistance + relativeGap := absoluteGap / maxDistance + + return absoluteGap <= 2000 || relativeGap <= 0.2 +} + +func maintenanceLocationBucketKey(lat float64, lon float64) string { + latBucket := int(lat / maintenanceLocationBucketDegrees) + lonBucket := int(lon / maintenanceLocationBucketDegrees) + return fmt.Sprintf("%d:%d", latBucket, lonBucket) +} + +func maintenanceNeighborBucketKeys(lat float64, lon float64) []string { + latBucket := int(lat / maintenanceLocationBucketDegrees) + lonBucket := int(lon / maintenanceLocationBucketDegrees) + keys := make([]string, 0, 9) + + for latOffset := -1; latOffset <= 1; latOffset++ { + for lonOffset := -1; lonOffset <= 1; lonOffset++ { + keys = append(keys, fmt.Sprintf("%d:%d", latBucket+latOffset, lonBucket+lonOffset)) + } + } + + return keys +} + +func minFloat64(a float64, b float64) float64 { + if a < b { + return a + } + + return b +} + +func maxFloat64(a float64, b float64) float64 { + if a > b { + return a + } + + return b +} + +func collectConnectedTrailIDs(startID string, adjacency map[string][]string, visited map[string]bool) []string { + queue := []string{startID} + component := make([]string, 0) + visited[startID] = true + + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + component = append(component, current) + + for _, next := range adjacency[current] { + if visited[next] { + continue + } + visited[next] = true + queue = append(queue, next) + } + } + + return component +} + +func chooseSuggestedGroupTarget(app core.App, trails []*core.Record) (string, string, error) { + selection, err := chooseTargetTrail(app, trails, nil) + if err != nil { + return "", "", err + } + + return selection.TrailID, selection.Reason, nil +} + +func mergeTrailIntoTarget(ctx mergeContext) (mergeSideEffects, error) { + targetUpdated := false + sideEffects := mergeSideEffects{ + CreatedSummitLogIDs: []string{}, + CreatedCommentIDs: []string{}, + TargetTrailID: ctx.Target.Id, + } + + summitLogID, err := createTrailSummitLog(ctx) + if err != nil { + return sideEffects, err + } + sideEffects.CreatedSummitLogIDs = append(sideEffects.CreatedSummitLogIDs, summitLogID) + + if ctx.Settings.Tags { + currentTags := ctx.Target.GetStringSlice("tags") + mergedTags := appendUniqueStrings(currentTags, ctx.Source.GetStringSlice("tags")...) + if len(mergedTags) != len(currentTags) { + ctx.Target.Set("tags", mergedTags) + targetUpdated = true + } + } + + if ctx.Settings.Likes { + if err := mergeTrailLikes(ctx); err != nil { + return sideEffects, err + } + } + + if targetUpdated { + if err := ctx.App.Save(ctx.Target); err != nil { + return sideEffects, err + } + } + + if ctx.Settings.SummitLog { + summitLogIDs, err := mergeExistingSummitLogs(ctx) + if err != nil { + return sideEffects, err + } + sideEffects.CreatedSummitLogIDs = append(sideEffects.CreatedSummitLogIDs, summitLogIDs...) + } + + if ctx.Settings.Comments { + commentIDs, err := mergeTrailComments(ctx) + if err != nil { + return sideEffects, err + } + sideEffects.CreatedCommentIDs = append(sideEffects.CreatedCommentIDs, commentIDs...) + } + + if err := util.ReassignTrailExternalReferences(ctx.App, ctx.Source.Id, ctx.Target.Id); err != nil { + return sideEffects, err + } + + if ctx.Settings.Delete { + if err := ctx.App.Delete(ctx.Source); err != nil { + return sideEffects, err + } + } + + return sideEffects, nil +} + +func createTrailSummitLog(ctx mergeContext) (string, error) { + collection, err := ctx.App.FindCollectionByNameOrId("summit_logs") + if err != nil { + return "", err + } + + record := core.NewRecord(collection) + record.Load(map[string]any{ + "text": ctx.Source.GetString("description"), + "distance": ctx.Source.GetFloat("distance"), + "elevation_gain": ctx.Source.GetFloat("elevation_gain"), + "elevation_loss": ctx.Source.GetFloat("elevation_loss"), + "duration": ctx.Source.GetFloat("duration"), + "date": ctx.Source.GetDateTime("date"), + "author": ctx.ActorID, + "trail": ctx.Target.Id, + }) + + if gpxFile, err := cloneRecordFile(ctx.App, ctx.Source, "gpx"); err != nil { + return "", err + } else if gpxFile != nil { + record.Set("gpx", gpxFile) + } + + if ctx.Settings.Photos { + photos, err := cloneRecordFiles(ctx.App, ctx.Source, "photos") + if err != nil { + return "", err + } + if len(photos) > 0 { + record.Set("photos", photos) + } + } + + if err := ctx.App.Save(record); err != nil { + return "", err + } + + return record.Id, nil +} + +func mergeExistingSummitLogs(ctx mergeContext) ([]string, error) { + logs, err := ctx.App.FindRecordsByFilter( + "summit_logs", + "trail={:trail}", + "+date", + -1, + 0, + dbx.Params{"trail": ctx.Source.Id}, + ) + if err != nil { + return nil, err + } + createdIDs := make([]string, 0) + + for _, sourceLog := range logs { + if isPrimaryTrailSummitLog(ctx.Source, sourceLog) { + continue + } + + collection, err := ctx.App.FindCollectionByNameOrId("summit_logs") + if err != nil { + return nil, err + } + + record := core.NewRecord(collection) + record.Load(map[string]any{ + "text": sourceLog.GetString("text"), + "distance": sourceLog.GetFloat("distance"), + "elevation_gain": sourceLog.GetFloat("elevation_gain"), + "elevation_loss": sourceLog.GetFloat("elevation_loss"), + "duration": sourceLog.GetFloat("duration"), + "date": sourceLog.GetDateTime("date"), + "author": sourceLog.GetString("author"), + "trail": ctx.Target.Id, + }) + + if gpxFile, err := cloneRecordFile(ctx.App, sourceLog, "gpx"); err != nil { + return nil, err + } else if gpxFile != nil { + record.Set("gpx", gpxFile) + } + + photos, err := cloneRecordFiles(ctx.App, sourceLog, "photos") + if err != nil { + return nil, err + } + if len(photos) > 0 { + record.Set("photos", photos) + } + + if err := ctx.App.Save(record); err != nil { + return nil, err + } + createdIDs = append(createdIDs, record.Id) + } + + return createdIDs, nil +} + +func isPrimaryTrailSummitLog(source *core.Record, sourceLog *core.Record) bool { + if source == nil || sourceLog == nil { + return false + } + + if !sourceLog.GetDateTime("date").Time().Equal(source.GetDateTime("date").Time()) { + return false + } + + if sourceLog.GetString("text") != source.GetString("description") { + return false + } + + if sourceLog.GetFloat("distance") != source.GetFloat("distance") { + return false + } + + if sourceLog.GetFloat("elevation_gain") != source.GetFloat("elevation_gain") { + return false + } + + if sourceLog.GetFloat("elevation_loss") != source.GetFloat("elevation_loss") { + return false + } + + if sourceLog.GetFloat("duration") != source.GetFloat("duration") { + return false + } + + return true +} + +func mergeTrailComments(ctx mergeContext) ([]string, error) { + comments, err := ctx.App.FindRecordsByFilter( + "comments", + "trail={:trail}", + "+created", + -1, + 0, + dbx.Params{"trail": ctx.Source.Id}, + ) + if err != nil { + return nil, err + } + + collection, err := ctx.App.FindCollectionByNameOrId("comments") + if err != nil { + return nil, err + } + createdIDs := make([]string, 0, len(comments)) + + for _, sourceComment := range comments { + record := core.NewRecord(collection) + record.Load(map[string]any{ + "text": buildMergedCommentText(ctx.App, sourceComment), + "author": ctx.ActorID, + "trail": ctx.Target.Id, + }) + if err := ctx.App.Save(record); err != nil { + return nil, err + } + createdIDs = append(createdIDs, record.Id) + } + + return createdIDs, nil +} + +func mergeTrailLikes(ctx mergeContext) error { + existingLikes, err := ctx.App.FindRecordsByFilter( + "trail_like", + "trail={:trail}", + "", + -1, + 0, + dbx.Params{"trail": ctx.Target.Id}, + ) + if err != nil { + return err + } + + existingActors := make(map[string]struct{}, len(existingLikes)) + for _, like := range existingLikes { + existingActors[like.GetString("actor")] = struct{}{} + } + + sourceLikes, err := ctx.App.FindRecordsByFilter( + "trail_like", + "trail={:trail}", + "", + -1, + 0, + dbx.Params{"trail": ctx.Source.Id}, + ) + if err != nil { + return err + } + + collection, err := ctx.App.FindCollectionByNameOrId("trail_like") + if err != nil { + return err + } + + for _, like := range sourceLikes { + actorID := like.GetString("actor") + if _, exists := existingActors[actorID]; exists { + continue + } + + record := core.NewRecord(collection) + record.Load(map[string]any{ + "trail": ctx.Target.Id, + "actor": actorID, + }) + if err := ctx.App.Save(record); err != nil { + return err + } + existingActors[actorID] = struct{}{} + } + + return nil +} + +func geometryWarnings(app core.App, source *core.Record, target *core.Record) []string { + sourceCoords, err := util.TrailCoordinates(app, source) + if err != nil { + return []string{"missing_geometry"} + } + targetCoords, err := util.TrailCoordinates(app, target) + if err != nil { + return []string{"missing_geometry"} + } + + metrics, err := util.CompareTrailCoordinates(sourceCoords, targetCoords) + if err != nil { + return []string{"missing_geometry"} + } + + warnings := make([]string, 0) + if metrics.StartDistanceMeters > 500 { + warnings = append(warnings, "startpoints_far_apart") + } + if metrics.EndDistanceMeters > 500 { + warnings = append(warnings, "endpoints_far_apart") + } + if metrics.MeanDistanceMeters > 150 || metrics.MaxDistanceMeters > 750 { + warnings = append(warnings, "geometry_differs") + } + + return warnings +} + +func isStrongGeometryMatch(metrics *util.TrailGeometryMetrics) bool { + if metrics == nil { + return false + } + + return metrics.StartDistanceMeters <= 250 && + metrics.EndDistanceMeters <= 250 && + metrics.MeanDistanceMeters <= 80 && + metrics.MaxDistanceMeters <= 400 +} + +func geometryScore(metrics *util.TrailGeometryMetrics) float64 { + if metrics == nil { + return 0 + } + + return 1.0 / (1.0 + metrics.MeanDistanceMeters + metrics.MaxDistanceMeters*0.25) +} + +func reasonForCandidate(isSuggested bool, suggestedReason string) string { + if isSuggested { + return suggestedReason + } + + return "selected_trail" +} + +func candidateWarningsForTrail(candidates []SuggestCandidate, trailID string) []string { + for _, candidate := range candidates { + if candidate.TrailID == trailID { + return candidate.Warnings + } + } + + return []string{} +} + +func canEditTrail(app core.App, trail *core.Record, actorID string) bool { + if trail.GetString("author") == actorID { + return true + } + + shares, err := app.FindRecordsByFilter( + "trail_share", + "trail={:trail} && actor={:actor} && permission='edit'", + "", + 1, + 0, + dbx.Params{ + "trail": trail.Id, + "actor": actorID, + }, + ) + return err == nil && len(shares) > 0 +} + +func canDeleteTrail(trail *core.Record, actorID string) bool { + return trail.GetString("author") == actorID +} + +func buildMergedCommentText(app core.App, comment *core.Record) string { + authorHandle := "@someone" + if authorID := comment.GetString("author"); authorID != "" { + if author, err := app.FindRecordById("activitypub_actors", authorID); err == nil { + authorHandle = "@" + author.GetString("preferred_username") + if !author.GetBool("isLocal") && author.GetString("domain") != "" { + authorHandle += "@" + author.GetString("domain") + } + } + } + + createdDate := comment.GetDateTime("created").Time().Format("2006-01-02") + return fmt.Sprintf("%s (%s)\n\n%s", authorHandle, createdDate, comment.GetString("text")) +} + +func cloneRecordFiles(app core.App, record *core.Record, field string) ([]*filesystem.File, error) { + fileNames := record.GetStringSlice(field) + files := make([]*filesystem.File, 0, len(fileNames)) + for _, name := range fileNames { + file, err := cloneRecordFileByName(app, record, name) + if err != nil { + return nil, err + } + if file != nil { + files = append(files, file) + } + } + + return files, nil +} + +func cloneRecordFile(app core.App, record *core.Record, field string) (*filesystem.File, error) { + name := record.GetString(field) + if name == "" { + return nil, nil + } + + return cloneRecordFileByName(app, record, name) +} + +func cloneRecordFileByName(app core.App, record *core.Record, fileName string) (*filesystem.File, error) { + if fileName == "" { + return nil, nil + } + + fsys, err := app.NewFilesystem() + if err != nil { + return nil, err + } + defer fsys.Close() + + reader, err := fsys.GetReader(record.BaseFilesPath() + "/" + fileName) + if err != nil { + return nil, err + } + defer reader.Close() + + buf := new(bytes.Buffer) + if _, err := io.Copy(buf, reader); err != nil { + return nil, err + } + + return filesystem.NewFileFromBytes(buf.Bytes(), fileName) +} + +func appendUniqueStrings(values []string, additions ...string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)+len(additions)) + + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, exists := seen[trimmed]; exists { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + + for _, value := range additions { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, exists := seen[trimmed]; exists { + continue + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + + return result +} diff --git a/db/util/distance.go b/db/util/distance.go new file mode 100644 index 00000000..43610d79 --- /dev/null +++ b/db/util/distance.go @@ -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)) +} diff --git a/db/util/geometry.go b/db/util/geometry.go new file mode 100644 index 00000000..031779f7 --- /dev/null +++ b/db/util/geometry.go @@ -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] +} diff --git a/db/util/trail_external_reference.go b/db/util/trail_external_reference.go new file mode 100644 index 00000000..9a64f7e5 --- /dev/null +++ b/db/util/trail_external_reference.go @@ -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 +} diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index c2804410..2a2403b8 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -55,6 +55,10 @@ export default defineConfig({ label: 'Summit logs', link: '/use/summit-logs/' }, + { + label: 'Merge trails', + link: '/use/merge-trails/' + }, { label: 'Interact with the community', link: '/use/community-interaction/' @@ -152,4 +156,4 @@ export default defineConfig({ '/run/backup-server/': '/run/backend-configuration/backup-server/', '/run/custom-categories/': '/run/backend-configuration/custom-categories/', }, -}); \ No newline at end of file +}); diff --git a/docs/src/content/docs/use/merge-trails.md b/docs/src/content/docs/use/merge-trails.md new file mode 100644 index 00000000..3adc81af --- /dev/null +++ b/docs/src/content/docs/use/merge-trails.md @@ -0,0 +1,69 @@ +--- +title: Merge trails +description: Link repeated or duplicate trail recordings into a single target trail. +--- + +wanderer can link multiple trails into a single target trail. This is useful when the same route was recorded multiple times, imported from multiple services, or uploaded separately even though it belongs to the same underlying trail. + +When a trail is merged, the source trail is converted into a summit log entry on the selected target trail. Depending on the chosen options, additional data such as photos, comments, tags and likes can also be merged. + +## When to Use Trail Merging + +Trail merging is helpful when: + +- you recorded the same route multiple times and want one canonical trail page +- a route was uploaded as a new trail, even though it was actually supposed to be a further iteration of an existing trail + +If the route already exists and you simply want to log another outing, using a [summit log](/use/summit-logs) directly is usually the better choice. + +## Manual Merge + +You can merge trails manually from the trail actions menu: + +- select multiple trails and choose **Link** +- or open a single trail and choose **Merge with similar trail** + +Before the merge is executed, wanderer asks the backend for a suggested target trail. The backend uses the same target selection strategy in all merge modes and prefers trails that preserve the most useful information. + +The target suggestion currently considers: + +- existing summit logs +- external references from integrations +- content richness such as comments, photos, waypoints and descriptions +- how centrally the trail geometry fits within the candidate set +- trail age as a deterministic fallback + +Warnings are shown before the merge if the selected trails differ noticeably in geometry or location. + +## Automatic Matching for Similar Trails + +The **Merge with similar trail** action searches for strong geometric matches of the currently open trail. Only trails with a sufficiently similar forward direction are considered. Out-and-back reversals are not treated as the same trail. + +## Maintenance Page + +The maintenance page groups potentially repeated or duplicate trails so that you can review them in batches: + +- open **Settings → Repeated trails / duplicates** +- inspect each group on the map +- choose the target trail directly in the list +- merge the group once you are satisfied + +This page is especially useful after large imports or when you want to consolidate older data. + +## Integrations + +Integrations can optionally auto-merge imported trails, but only when the backend finds exactly one clear target candidate. This keeps imports conservative and avoids accidentally merging different routes. + +External references from integrations are preserved during merges, so future imports can still recognize already-linked trails correctly. + +## What Happens During a Merge + +At a high level, the backend: + +1. determines or receives a target trail +2. creates a new summit log from the source trail on the target trail +3. optionally merges existing summit logs, comments, likes, tags and photos +4. reassigns external references to the target trail +5. optionally deletes the source trail + +The merge itself runs transactionally so partially completed merges are avoided. diff --git a/docs/wanderer.openapi.json b/docs/wanderer.openapi.json index 7ed72ee5..0f111852 100644 --- a/docs/wanderer.openapi.json +++ b/docs/wanderer.openapi.json @@ -764,6 +764,125 @@ } } }, + "/api/v1/trail-merge": { + "post": { + "summary": "Merge trails", + "description": "Merges a source trail into a target trail using the backend merge logic.", + "tags": [ + "Trail Merge" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrailMergeExecuteRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Merge acknowledged", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrailMergeExecuteResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/api/v1/trail-merge/suggest": { + "post": { + "summary": "Suggest merge target or duplicate groups", + "description": "Returns merge target suggestions for manual selection or auto-discovery, or temporary duplicate groups for maintenance workflows.", + "tags": [ + "Trail Merge" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrailMergeSuggestRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Suggestion response", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/TrailMergeSuggestResponse" + }, + { + "$ref": "#/components/schemas/TrailMergeSuggestGroupsResponse" + } + ] + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/api/v1/trail-link-share": { "get": { "summary": "List trail link shares", @@ -6571,6 +6690,201 @@ } } }, + "TrailMergeSettings": { + "type": "object", + "required": [ + "summitLog", + "photos", + "comments", + "delete", + "tags", + "likes" + ], + "properties": { + "summitLog": { + "type": "boolean" + }, + "photos": { + "type": "boolean" + }, + "comments": { + "type": "boolean" + }, + "delete": { + "type": "boolean" + }, + "tags": { + "type": "boolean" + }, + "likes": { + "type": "boolean" + } + } + }, + "TrailMergeSuggestRequest": { + "type": "object", + "required": [ + "mode" + ], + "properties": { + "mode": { + "type": "string", + "enum": [ + "manual-selection", + "auto-discovery", + "maintenance-groups" + ] + }, + "trailIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Required for manual-selection" + }, + "sourceTrailId": { + "type": "string", + "description": "Required for auto-discovery" + } + } + }, + "TrailMergeSuggestCandidate": { + "type": "object", + "required": [ + "trailId", + "score", + "reason", + "warnings", + "selectable" + ], + "properties": { + "trailId": { + "type": "string" + }, + "score": { + "type": "number" + }, + "reason": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "selectable": { + "type": "boolean" + } + } + }, + "TrailMergeSuggestResponse": { + "type": "object", + "required": [ + "targetTrailId", + "reason", + "warnings", + "candidates" + ], + "properties": { + "targetTrailId": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "candidates": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrailMergeSuggestCandidate" + } + } + } + }, + "TrailMergeSuggestGroup": { + "type": "object", + "required": [ + "groupId", + "trailIds", + "targetTrailId", + "reason", + "score", + "indirect" + ], + "properties": { + "groupId": { + "type": "string" + }, + "trailIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "targetTrailId": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "score": { + "type": "number" + }, + "indirect": { + "type": "boolean" + } + } + }, + "TrailMergeSuggestGroupsResponse": { + "type": "object", + "required": [ + "groups" + ], + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrailMergeSuggestGroup" + } + } + } + }, + "TrailMergeExecuteRequest": { + "type": "object", + "required": [ + "sourceTrailId", + "targetTrailId", + "settings" + ], + "properties": { + "sourceTrailId": { + "type": "string" + }, + "targetTrailId": { + "type": "string" + }, + "settings": { + "$ref": "#/components/schemas/TrailMergeSettings" + } + } + }, + "TrailMergeExecuteResponse": { + "type": "object", + "required": [ + "acknowledged" + ], + "properties": { + "acknowledged": { + "type": "boolean" + } + } + }, "ListResult": { "type": "object", "properties": { diff --git a/web/src/lib/components/base/dropdown.svelte b/web/src/lib/components/base/dropdown.svelte index 4da1d3f1..3b64c82d 100644 --- a/web/src/lib/components/base/dropdown.svelte +++ b/web/src/lib/components/base/dropdown.svelte @@ -3,6 +3,7 @@ text: string; value: any; icon?: string; + separator?: boolean; }; @@ -130,16 +131,22 @@ bind:this={dropdownElement} > {#each items as item} -
+ {$_("integration-auto-merge-hint")} +
++ {$_("trail-merge-summary", { + values: { + remaining, + processed: mergeStore.completedMerges.length, + total: + mergeStore.enqueuedMerges.length + + mergeStore.completedMerges.length, + }, + })} +
++ {$_("trail-merge-result-summary", { + values: { + success: successfulMerges, + errors: errorMerges, + }, + })} +
++ {autoDiscoveryMode + ? $_("trail-merge-loading-similar-trails") + : $_("trail-merge-loading-targets")} +
++ {$_("trail-merge-similar-trails-found", { + values: { n: autoDiscoveryCandidateTrails.length }, + })} +
+ {/if} + {#if selectedReason} +{$_(`trail-merge-reason-${selectedReason}`)}
+ {/if} ++ {$_("similar-trails-maintenance-description")} +
+{$_("similar-trails-loading")}
++ {$_(`similar-trails-reason-${group.reason}`)} +
+ {/if} + {#if group.indirect} +{$_("similar-trails-map-loading")}
+