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} - + {#if item.separator} + + {:else} + + {/if} {/each} {/if} diff --git a/web/src/lib/components/settings/integrations/hammerhead_settings_modal.svelte b/web/src/lib/components/settings/integrations/hammerhead_settings_modal.svelte index 5f8ff1d1..d72e9ae1 100644 --- a/web/src/lib/components/settings/integrations/hammerhead_settings_modal.svelte +++ b/web/src/lib/components/settings/integrations/hammerhead_settings_modal.svelte @@ -1,5 +1,6 @@ + +
+ +

+ {$_("integration-auto-merge-hint")} +

+
diff --git a/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte b/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte index 29aa150f..c15b538f 100644 --- a/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte +++ b/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte @@ -1,5 +1,6 @@ + +{#if visibleMerges.length} + +{/if} diff --git a/web/src/lib/components/trail/trail_merge_modal.svelte b/web/src/lib/components/trail/trail_merge_modal.svelte new file mode 100644 index 00000000..eff12618 --- /dev/null +++ b/web/src/lib/components/trail/trail_merge_modal.svelte @@ -0,0 +1,514 @@ + + + + {#snippet content()} +
+ {#if loading} +
+
+

+ {autoDiscoveryMode + ? $_("trail-merge-loading-similar-trails") + : $_("trail-merge-loading-targets")} +

+
+ {:else} +
+ {#if !fixedTargetSelection} +

{$_("trail-merge-target")}

+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

{$_("linked-trails")}

+
+ + +
+ {/if} +
+ {/snippet} + {#snippet footer()} +
+ +
+ + +
+
+ {/snippet}
diff --git a/web/src/lib/i18n/locales/de.json b/web/src/lib/i18n/locales/de.json index e210d153..6cc6ea82 100644 --- a/web/src/lib/i18n/locales/de.json +++ b/web/src/lib/i18n/locales/de.json @@ -75,6 +75,7 @@ "changelog": "Änderungshistorie", "chinese": "Chinesisch (vereinfacht)", "clear-all": "Alle ausblenden", + "dismiss": "Schließen", "climbing": "Klettern", "close": "Schließen", "collapse-trail-list": "", @@ -90,7 +91,11 @@ "connect": "Verbinden", "continue": "Fortfahren", "contribute": "Mitwirken", + "copy-comments": "Kommentare übernehmen", + "copy-include-elements": "Zu übernehmende Elemente", "copy-link": "Link kopieren", + "copy-summit-log-photos": "Photos aus Gipfelbuch-Einträgen übernehmen", + "copy-summit-logs": "Gipfelbuch-Einträge übernehmen", "create-new-list": "Neue Liste erstellen", "create-waypoint": "Wegpunkt erstellen", "create-waypoint-anyway": "Trotzdem erstellen", @@ -108,6 +113,7 @@ "degrees": "Grad", "delete": "Löschen", "delete-account": "Konto löschen", + "delete-linked-trails": "Verknüpfte Routen löschen", "delete-list-confirm": "Möchtest Du diese Liste wirklich löschen? Die Routen in der Liste sind danach weiterhin verfügbar.", "delete-summit-log-confirm": "Möchtest du diesen Gipfelbuch-Eintrag wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", "delete-trail-confirm": "Möchtest Du diese Route wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.", @@ -158,6 +164,7 @@ "error-liking-trail": "Error liking trail", "error-logging-in-to-hammerhead": "Fehler bei der Anmeldung bei Hammerhead", "error-logging-in-to-komoot": "Fehler bei der Anmeldung bei komoot", + "error-merging-trail": "Fehler beim Verknüpfen der Routen", "error-posting-comment": "Fehler beim Posten des Kommentars", "error-printing-map": "Fehler beim Drucken der Karte", "error-reading-file": "Fehler beim Lesen der Datei", @@ -186,6 +193,7 @@ "filter-categories": "Kategorien filtern", "filter-difficulty": "Schwierigkeit filtern", "filter-tags": "Tags filtern", + "find-similar-trails": "Mit ähnlicher Route verknüpfen", "finish": "Ziel", "fixed-speed": "Fixe Geschwindigkeit", "focus-map-on": "Karte fokussieren auf", @@ -233,6 +241,8 @@ "integration-description-hammerhead": "Synchronisiert Deine Hammerhead-Touren regelmäßig mit wanderer.", "integration-description-komoot": "Synchronisiert Deine komoot-Touren regelmäßig mit wanderer.", "integration-description-strava": "Synchronisiert Deine Strava-Routen und -Aktivitäten regelmäßig mit wanderer.", + "integration-auto-merge-label": "Automatisch mergen", + "integration-auto-merge-hint": "Nach dem Import wird nur dann automatisch gemergt, wenn ein eindeutiger Treffer gefunden wird. Der importierte Trail wird in diesem Fall nicht separat gespeichert.", "integration-disabled": "Integration deaktiviert", "integration-enabled": "Integration aktiviert", "integration-privacy-hint-original": "", @@ -253,8 +263,12 @@ "liked": "Gefällt mir", "likes": "Likes", "limited": "Begrenzt", - "link-copied": "Link kopiert!", + "link": "Verknüpfen", + "link-as-summit-log": "Als Gipfelbuch-Eintrag verknüpfen", + "link-copied": "Link kopiert!", + "link-trails": "Routen verknüpfen", "linked-lists": "Verknüpfte Listen", + "linked-trails": "Verknüpfte Routen", "list": "{n, plural, =1 {Liste} other {Listen}}", "list-not-shared": "Mit niemandem geteilt", "list-public-warning": "Alle Routen in dieser Liste werden veröffentlicht.", @@ -427,6 +441,7 @@ "show-in-overview": "In der Übersicht anzeigen", "show-less": "Weniger anzeigen", "show-on-map": "Auf der Karte anzeigen", + "similar-trails": "Wiederholte Routen / Duplikate", "shower": "Dusche", "skiing": "Skifahren", "slogan": "Speichere deine Abenteuer!", @@ -443,6 +458,8 @@ "subway-stop": "U-Bahn Eingang", "summit": "Gipfel", "summit-book": "Gipfelbuch", + "summit-log": "{n, plural, =1 {Gipfelbuch-Eintrag} other {Gipfelbuch-Einträge}}", + "summit-log-photos": "Photos aus Gipfelbuch-Einträgen", "table": "Tabelle", "tags": "Tags", "text": "Text", @@ -456,6 +473,70 @@ "trail-not-in-list": "Trail gehört zu keiner Liste.", "trail-not-shared": "Mit niemandem geteilt", "trail-saved-successfully": "Route gespeichert", + "trail-merge-complete": "{success} Routen-Verknüpfungen abgeschlossen.", + "trail-merge-complete-with-errors": "{success} Routen-Verknüpfungen abgeschlossen, {errors} fehlgeschlagen.", + "trail-merge-summary": "Verbleibend {remaining} - Verarbeitet {processed}/{total}", + "trail-merge-result-summary": "Verknüpft {success} - Fehler {errors}", + "trail-merge-retry": "Merge erneut ausführen", + "trail-merge-cancel": "Merge abbrechen", + "trail-merge-unknown-error": "Unbekannter Merge-Fehler", + "trail-merge-target": "Zielroute", + "trail-merge-remember-settings": "Letzte Merge-Einstellungen merken", + "trail-merge-remember-settings-short": "Einstellungen merken", + "trail-merge-warnings-title": "Warnungen", + "trail-merge-suggest-error": "Der Merge-Vorschlag konnte nicht geladen werden.", + "trail-merge-no-editable-target": "Es ist keine bearbeitbare Zielroute verfügbar.", + "trail-merge-no-similar-trails": "Es wurden keine ausreichend ähnlichen Routen gefunden.", + "trail-merge-loading-targets": "Geeignete Zielroute wird ermittelt...", + "trail-merge-loading-similar-trails": "Ähnliche Routen werden gesucht...", + "trail-merge-similar-trails-found": "{n, plural, =1 {1 ähnliche Route gefunden.} other {{n} ähnliche Routen gefunden.}}", + "similar-trails-maintenance-title": "Wiederholte Routen / Duplikate", + "similar-trails-maintenance-description": "Hier werden Gruppen von Routen angezeigt, die dieselbe oder eine sehr ähnliche Strecke mehrfach abbilden. Du kannst jede Gruppe auf der Karte prüfen und anschließend zu einer Zielroute zusammenführen.", + "similar-trails-scan": "Gruppen neu ermitteln", + "similar-trails-loading": "Ähnliche Routengruppen werden ermittelt...", + "similar-trails-empty": "Es wurden aktuell keine ähnlichen Routengruppen gefunden.", + "similar-trails-group-size": "{n, plural, =1 {1 Route} other {{n} Routen}}", + "similar-trails-suggested-target": "Vorgeschlagene Zielroute", + "similar-trails-target": "Zielroute", + "similar-trails-set-target": "Als Zielroute festlegen", + "similar-trails-show-map": "Auf Karte anzeigen", + "similar-trails-hide-map": "Karte ausblenden", + "similar-trails-merge-group": "Routen verknüpfen", + "similar-trails-open-trail": "Route öffnen", + "similar-trails-map-loading": "Kartendaten werden geladen...", + "similar-trails-map-title": "Gruppenansicht", + "similar-trails-indirect-hint": "Nicht jedes Routenpaar dieser Gruppe erfüllt die Ähnlichkeitsanforderungen. Bitte vor dem Verknüpfen kurz auf der Karte prüfen, ob wirklich alle Routen zusammenpassen.", + "similar-trails-reason-highest_summit_log_count": "Zielroute vorgeschlagen, weil sie die meisten Gipfelbuch-Einträge enthält.", + "similar-trails-reason-most_external_references": "Zielroute vorgeschlagen, weil sie die meisten externen Referenzen enthält.", + "similar-trails-reason-most_complete_content": "Zielroute vorgeschlagen, weil sie die vollständigsten Inhalte enthält.", + "similar-trails-reason-most_central_geometry": "Zielroute vorgeschlagen, weil sie geometrisch am besten zur Gruppe passt.", + "similar-trails-reason-oldest_trail": "Zielroute vorgeschlagen, weil sie die älteste Route der Gruppe ist.", + "similar-trails-reason-deterministic_fallback": "Zielroute vorgeschlagen, weil kein stärkeres Unterscheidungsmerkmal gefunden wurde.", + "trail-merge-reason-highest_summit_log_count": "Diese Route wird vorgeschlagen, weil sie die meisten Gipfelbuch-Einträge enthält.", + "trail-merge-reason-most_external_references": "Diese Route wird vorgeschlagen, weil sie die meisten externen Referenzen enthält.", + "trail-merge-reason-most_complete_content": "Diese Route wird vorgeschlagen, weil sie die vollständigsten Inhalte enthält.", + "trail-merge-reason-most_central_geometry": "Diese Route wird vorgeschlagen, weil sie geometrisch am besten zu den anderen Routen passt.", + "trail-merge-reason-oldest_trail": "Diese Route wird vorgeschlagen, weil sie die älteste Route in der Auswahl ist.", + "trail-merge-reason-deterministic_fallback": "Diese Route wird als stabiler Fallback vorgeschlagen.", + "trail-merge-reason-selected_trail": "Ausgewählte Route", + "trail-merge-reason-no_geometry_match": "Es wurde keine passende Routengeometrie gefunden.", + "trail-merge-warning-missing_geometry": "Die Routengeometrie konnte nicht verglichen werden, weil GPX-Daten fehlen.", + "trail-merge-warning-startpoints_far_apart": "Die Startpunkte der Routen liegen weit auseinander.", + "trail-merge-warning-endpoints_far_apart": "Die Endpunkte der Routen liegen weit auseinander.", + "trail-merge-warning-geometry_differs": "Die Routengeometrie unterscheidet sich deutlich.", + "trail_merge_auth_required": "Du musst angemeldet sein, um Routen zu verknüpfen.", + "trail_merge_actor_not_found": "Dein Actor-Profil konnte für den Merge nicht geladen werden.", + "trail_merge_invalid_request": "Die Merge-Anfrage ist ungültig.", + "trail_merge_unknown_suggest_mode": "Der Modus für den Merge-Vorschlag ist ungültig.", + "trail_merge_missing_actor": "Für den Merge konnte kein aktueller Actor ermittelt werden.", + "trail_merge_missing_trail_id": "Für den Merge fehlt eine erforderliche Routen-ID.", + "trail_merge_same_source_target": "Quell- und Zielroute müssen unterschiedlich sein.", + "trail_merge_requires_multiple_trails": "Wähle mindestens zwei Routen zum Verknüpfen aus.", + "trail_merge_missing_source_trail_id": "Für automatische Merge-Vorschläge ist eine Quellroute erforderlich.", + "trail_merge_source_actor_mismatch": "Die gewählte Quellroute gehört nicht zum aktuellen Actor.", + "trail_merge_source_not_found": "Die gewählte Quellroute konnte nicht gefunden werden.", + "trail_merge_target_not_found": "Die gewählte Zielroute konnte nicht gefunden werden.", + "trail_merge_not_allowed": "Du hast keine Berechtigung, diese Routen zu verknüpfen.", "trails-for-you": "Routen für dich", "tram-stop": "Tram Haltestelle", "unchanged": "unverändert", diff --git a/web/src/lib/i18n/locales/en.json b/web/src/lib/i18n/locales/en.json index 6ec9bd97..34f3186e 100644 --- a/web/src/lib/i18n/locales/en.json +++ b/web/src/lib/i18n/locales/en.json @@ -75,6 +75,7 @@ "changelog": "Changelog", "chinese": "Chinese (simplified)", "clear-all": "Clear all", + "dismiss": "Dismiss", "climbing": "Climbing", "close": "Close", "collapse-trail-list": "Collapse trail list", @@ -90,7 +91,11 @@ "connect": "Connect", "continue": "Continue", "contribute": "Contribute", + "copy-comments": "Copy comments", + "copy-include-elements": "Elements to be copied", "copy-link": "Copy Link", + "copy-summit-log-photos": "Copy summit log photos", + "copy-summit-logs": "Copy summit logs", "create-new-list": "Create new list", "create-waypoint": "Create waypoint", "create-waypoint-anyway": "Create anyway", @@ -108,6 +113,7 @@ "degrees": "Degrees", "delete": "Delete", "delete-account": "Delete Account", + "delete-linked-trails": "Delete linked trails", "delete-list-confirm": "Do you really want to delete this list? The trails in the list will still be available.", "delete-summit-log-confirm": "Do you really want to delete this summit log? This action cannot be undone.", "delete-trail-confirm": "Do you really want to delete this trail? This action cannot be undone.", @@ -158,6 +164,7 @@ "error-liking-trail": "Error liking trail", "error-logging-in-to-hammerhead": "Error logging in to Hammerhead", "error-logging-in-to-komoot": "Error logging in to komoot", + "error-merging-trail": "Error linking trails", "error-posting-comment": "Error posting comment", "error-printing-map": "Error printing map", "error-reading-file": "Error reading file", @@ -186,6 +193,7 @@ "filter-categories": "Filter categories", "filter-difficulty": "Filter difficulty", "filter-tags": "Filter tags", + "find-similar-trails": "Merge with similar trail", "finish": "Finish", "fixed-speed": "Fixed Speed", "focus-map-on": "Focus map on", @@ -233,6 +241,8 @@ "integration-description-hammerhead": "Syncs your Hammerhead tours with wanderer in regular intervals.", "integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.", "integration-description-strava": "Syncs your Strava routes & activities with wanderer in regular intervals.", + "integration-auto-merge-label": "Auto-merge", + "integration-auto-merge-hint": "Imported trails are only merged automatically when exactly one clear match is found. The imported trail is always deleted afterwards.", "integration-disabled": "integration disabled", "integration-enabled": "integration enabled", "integration-privacy-hint-original": "Imported trails will maintain the same visibility they have on the external platform. For example, if the original trail was public, it will be public in wanderer, even if trails are private by default according to your privacy settings.", @@ -253,8 +263,12 @@ "liked": "Liked", "likes": "Likes", "limited": "Limited", + "link": "Link", + "link-as-summit-log": "Link as summit log", "link-copied": "Link copied!", + "link-trails": "Link trails", "linked-lists": "Linked lists", + "linked-trails": "Linked trails", "list": "{n, plural, =1 {List} other {Lists}}", "list-not-shared": "Not shared with anyone", "list-public-warning": "All trails in this list will become public.", @@ -427,6 +441,7 @@ "show-in-overview": "Show in overview", "show-less": "Show less", "show-on-map": "Show on map", + "similar-trails": "Repeated trails / duplicates", "shower": "Shower", "skiing": "Skiing", "slogan": "Save your adventures!", @@ -443,6 +458,8 @@ "subway-stop": "Subway entrance", "summit": "Summit", "summit-book": "Summit Book", + "summit-log": "{n, plural, =1 {Summit log} other {Summit logs}}", + "summit-log-photos": "Summit log photos", "table": "Table", "tags": "Tags", "text": "Text", @@ -456,6 +473,70 @@ "trail-not-in-list": "Trail is not in any list", "trail-not-shared": "Not shared with anyone", "trail-saved-successfully": "Trail saved successfully", + "trail-merge-complete": "{success} trail links completed.", + "trail-merge-complete-with-errors": "{success} trail links completed, {errors} failed.", + "trail-merge-summary": "Remaining {remaining} - Processed {processed}/{total}", + "trail-merge-result-summary": "Merged {success} - Error {errors}", + "trail-merge-retry": "Retry merge", + "trail-merge-cancel": "Cancel merge", + "trail-merge-unknown-error": "Unknown merge error", + "trail-merge-target": "Merge target", + "trail-merge-remember-settings": "Remember last merge settings", + "trail-merge-remember-settings-short": "Remember settings", + "trail-merge-warnings-title": "Warnings", + "trail-merge-suggest-error": "Failed to load merge suggestion.", + "trail-merge-no-editable-target": "No editable merge target is available.", + "trail-merge-no-similar-trails": "No sufficiently similar trails were found.", + "trail-merge-loading-targets": "Finding a suitable target trail...", + "trail-merge-loading-similar-trails": "Searching for similar trails...", + "trail-merge-similar-trails-found": "{n, plural, =1 {1 similar trail found.} other {{n} similar trails found.}}", + "similar-trails-maintenance-title": "Repeated trails / duplicates", + "similar-trails-maintenance-description": "This page shows groups of trails that represent the same or a very similar route multiple times. Review each group on the map and merge it into a single target trail when appropriate.", + "similar-trails-scan": "Refresh groups", + "similar-trails-loading": "Finding groups of similar trails...", + "similar-trails-empty": "No groups of similar trails were found right now.", + "similar-trails-group-size": "{n, plural, =1 {1 trail} other {{n} trails}}", + "similar-trails-suggested-target": "Suggested target trail", + "similar-trails-target": "Target trail", + "similar-trails-set-target": "Set as target trail", + "similar-trails-show-map": "Show on map", + "similar-trails-hide-map": "Hide map", + "similar-trails-merge-group": "Merge trails", + "similar-trails-open-trail": "Open trail", + "similar-trails-map-loading": "Loading map data...", + "similar-trails-map-title": "Group map", + "similar-trails-indirect-hint": "Not every trail pair in this group meets the similarity requirements. Please check the map before merging to confirm that all trails really belong together.", + "similar-trails-reason-highest_summit_log_count": "Suggested as the target trail because it has the most summit logs.", + "similar-trails-reason-most_external_references": "Suggested as the target trail because it has the most external references.", + "similar-trails-reason-most_complete_content": "Suggested as the target trail because it contains the richest content.", + "similar-trails-reason-most_central_geometry": "Suggested as the target trail because it is geometrically the best fit within the group.", + "similar-trails-reason-oldest_trail": "Suggested as the target trail because it is the oldest trail in the group.", + "similar-trails-reason-deterministic_fallback": "Suggested as the target trail because no stronger distinguishing signal was found.", + "trail-merge-reason-highest_summit_log_count": "This trail is suggested because it has the most summit logs.", + "trail-merge-reason-most_external_references": "This trail is suggested because it has the most external references.", + "trail-merge-reason-most_complete_content": "This trail is suggested because it contains the richest content.", + "trail-merge-reason-most_central_geometry": "This trail is suggested because it is geometrically the best fit among the selected trails.", + "trail-merge-reason-oldest_trail": "This trail is suggested because it is the oldest trail in the selection.", + "trail-merge-reason-deterministic_fallback": "This trail is suggested as a stable fallback.", + "trail-merge-reason-selected_trail": "Selected trail", + "trail-merge-reason-no_geometry_match": "No suitable trail geometry match was found.", + "trail-merge-warning-missing_geometry": "Could not compare trail geometry because GPX data is missing.", + "trail-merge-warning-startpoints_far_apart": "Trail start points are far apart.", + "trail-merge-warning-endpoints_far_apart": "Trail end points are far apart.", + "trail-merge-warning-geometry_differs": "Trail geometry differs noticeably.", + "trail_merge_auth_required": "You need to be signed in to merge trails.", + "trail_merge_actor_not_found": "Your actor profile could not be loaded for the merge.", + "trail_merge_invalid_request": "The merge request is invalid.", + "trail_merge_unknown_suggest_mode": "The merge suggestion mode is invalid.", + "trail_merge_missing_actor": "The merge could not determine the current actor.", + "trail_merge_missing_trail_id": "The merge is missing a required trail id.", + "trail_merge_same_source_target": "Source and target trail must be different.", + "trail_merge_requires_multiple_trails": "Select at least two trails to merge.", + "trail_merge_missing_source_trail_id": "A source trail is required for automatic merge suggestions.", + "trail_merge_source_actor_mismatch": "The selected source trail does not belong to the current actor.", + "trail_merge_source_not_found": "The selected source trail could not be found.", + "trail_merge_target_not_found": "The selected target trail could not be found.", + "trail_merge_not_allowed": "You do not have permission to merge these trails.", "trails-for-you": "Trails for you", "tram-stop": "Tram stop", "unchanged": "unchanged", diff --git a/web/src/lib/models/api/integration_schema.ts b/web/src/lib/models/api/integration_schema.ts index b5ef283f..8ad8a765 100644 --- a/web/src/lib/models/api/integration_schema.ts +++ b/web/src/lib/models/api/integration_schema.ts @@ -1,6 +1,10 @@ import { z, ZodType } from "zod"; import type { Integration } from "../integration"; +const IntegrationMergeSchema = z.object({ + enabled: z.boolean(), +}) + const StravaSchema = z.object({ clientId: z.number({ coerce: true }).int().nonnegative(), clientSecret: z.string().length(40).optional().or(z.literal('')), @@ -8,7 +12,8 @@ const StravaSchema = z.object({ activities: z.boolean(), active: z.boolean(), after: z.string().date().optional(), - privacy: z.enum(["original", "settings"]) + privacy: z.enum(["original", "settings"]), + merge: IntegrationMergeSchema, }) const KomootSchema = z.object({ @@ -17,7 +22,8 @@ const KomootSchema = z.object({ completed: z.boolean(), planned: z.boolean(), active: z.boolean(), - privacy: z.enum(["original", "settings"]) + privacy: z.enum(["original", "settings"]), + merge: IntegrationMergeSchema, }) const HammerheadSchema = z.object({ @@ -27,6 +33,7 @@ const HammerheadSchema = z.object({ planned: z.boolean(), active: z.boolean(), after: z.string().date().optional(), + merge: IntegrationMergeSchema, }) const IntegrationCreateSchema = z.object({ diff --git a/web/src/lib/models/api/openapi_schemas.ts b/web/src/lib/models/api/openapi_schemas.ts index f0d3fb54..bfb8585f 100644 --- a/web/src/lib/models/api/openapi_schemas.ts +++ b/web/src/lib/models/api/openapi_schemas.ts @@ -1193,6 +1193,146 @@ * enum: [public, private] * nullable: true * + * 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/models/integration.ts b/web/src/lib/models/integration.ts index 509bca30..fdbf389e 100644 --- a/web/src/lib/models/integration.ts +++ b/web/src/lib/models/integration.ts @@ -3,6 +3,10 @@ export interface BaseIntegration { active: boolean } +export interface IntegrationMergeSettings { + enabled: boolean; +} + export interface StravaIntegration extends BaseIntegration { clientId: string | number; clientSecret?: string; @@ -13,6 +17,7 @@ export interface StravaIntegration extends BaseIntegration { expiresAt?: number; after?: string privacy: "original" | "settings" + merge: IntegrationMergeSettings; } export interface KomootIntegration extends BaseIntegration { @@ -21,6 +26,7 @@ export interface KomootIntegration extends BaseIntegration { completed: boolean, planned: boolean privacy: "original" | "settings" + merge: IntegrationMergeSettings; } export interface HammerheadIntegration extends BaseIntegration { @@ -29,6 +35,7 @@ export interface HammerheadIntegration extends BaseIntegration { completed: boolean, planned: boolean, after?: string + merge: IntegrationMergeSettings; } @@ -45,4 +52,4 @@ export class Integration { this.komoot = komoot; this.hammerhead = hammerhead; } -} \ No newline at end of file +} diff --git a/web/src/lib/stores/trail_merge_api.ts b/web/src/lib/stores/trail_merge_api.ts new file mode 100644 index 00000000..662ba151 --- /dev/null +++ b/web/src/lib/stores/trail_merge_api.ts @@ -0,0 +1,110 @@ +import type { MergeSettings } from "$lib/components/trail/trail_merge_modal.svelte"; +import { APIError } from "$lib/util/api_util"; + +export interface TrailMergeSuggestCandidate { + trailId: string; + score: number; + reason: string; + warnings: string[]; + selectable: boolean; +} + +export interface TrailMergeSuggestResponse { + targetTrailId: string; + reason: string; + warnings: string[]; + candidates: TrailMergeSuggestCandidate[]; +} + +export interface TrailMergeSuggestGroup { + groupId: string; + trailIds: string[]; + targetTrailId: string; + reason: string; + score: number; + indirect: boolean; +} + +export interface TrailMergeSuggestGroupsResponse { + groups: TrailMergeSuggestGroup[]; +} + +export async function trail_merge_suggest_manual(trailIds: string[]) { + const response = await fetch("/api/v1/trail-merge/suggest", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + mode: "manual-selection", + trailIds, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new APIError(response.status, error.message, error.detail); + } + + return await response.json() as TrailMergeSuggestResponse; +} + +export async function trail_merge_suggest_auto(sourceTrailId: string) { + const response = await fetch("/api/v1/trail-merge/suggest", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + mode: "auto-discovery", + sourceTrailId, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new APIError(response.status, error.message, error.detail); + } + + return await response.json() as TrailMergeSuggestResponse; +} + +export async function trail_merge_suggest_groups() { + const response = await fetch("/api/v1/trail-merge/suggest", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + mode: "maintenance-groups", + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new APIError(response.status, error.message, error.detail); + } + + return await response.json() as TrailMergeSuggestGroupsResponse; +} + +export async function trail_merge(sourceTrailId: string, targetTrailId: string, settings: MergeSettings) { + const response = await fetch("/api/v1/trail-merge", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + sourceTrailId, + targetTrailId, + settings, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new APIError(response.status, error.message, error.detail); + } + + return await response.json(); +} diff --git a/web/src/lib/stores/trail_merge_i18n.ts b/web/src/lib/stores/trail_merge_i18n.ts new file mode 100644 index 00000000..3fb92246 --- /dev/null +++ b/web/src/lib/stores/trail_merge_i18n.ts @@ -0,0 +1,26 @@ +import { get } from "svelte/store"; +import { _ } from "svelte-i18n"; + +const mergeErrorCodes = new Set([ + "trail_merge_auth_required", + "trail_merge_actor_not_found", + "trail_merge_invalid_request", + "trail_merge_unknown_suggest_mode", + "trail_merge_missing_actor", + "trail_merge_missing_trail_id", + "trail_merge_same_source_target", + "trail_merge_requires_multiple_trails", + "trail_merge_missing_source_trail_id", + "trail_merge_source_actor_mismatch", + "trail_merge_source_not_found", + "trail_merge_target_not_found", + "trail_merge_not_allowed", +]); + +export function translateTrailMergeError(message: string): string { + if (mergeErrorCodes.has(message)) { + return get(_)(message); + } + + return message; +} diff --git a/web/src/lib/stores/trail_merge_store.svelte.ts b/web/src/lib/stores/trail_merge_store.svelte.ts new file mode 100644 index 00000000..915575eb --- /dev/null +++ b/web/src/lib/stores/trail_merge_store.svelte.ts @@ -0,0 +1,81 @@ +import type { MergeSettings } from "$lib/components/trail/trail_merge_modal.svelte"; +import type { Trail } from "$lib/models/trail"; +import { APIError } from "$lib/util/api_util"; +import { get } from "svelte/store"; +import { _ } from "svelte-i18n"; +import { translateTrailMergeError } from "./trail_merge_i18n"; + +export type Merge = { + trailTarget: Trail, + trailSource: Trail; + status: "enqueued" | "merging" | "cancelled" | "success" | "error"; + error?: string; + progress: number; + settings: MergeSettings; + function: (t: Trail, t2: Trail, settings: MergeSettings, onProgress?: (p: number) => void) => Promise +}; + +class MergeStore { + enqueuedMerges: Merge[] = $state([]); + completedMerges: Merge[] = $state([]); + merging: boolean = $state(false); +} + +export const mergeStore = new MergeStore(); + +function getMergeErrorMessage(error: unknown): string { + if (error instanceof APIError) { + return translateTrailMergeError(error.message); + } + + if (error instanceof Error) { + return translateTrailMergeError(error.message); + } + + if (typeof error === "string") { + return translateTrailMergeError(error); + } + + return get(_)("trail-merge-unknown-error"); +} + +export async function processMergeQueue(batchSize: number = 3) { + if (mergeStore.merging) { + return; + } + mergeStore.merging = true; + const completedBeforeRun = mergeStore.completedMerges.length; + + try { + while (mergeStore.enqueuedMerges.length > 0) { + const batch = mergeStore.enqueuedMerges.slice(0, batchSize); + const mergePromises: Promise[] = []; + for (const b of batch) { + b.status = "merging"; + mergePromises.push( + b.function(b.trailTarget, b.trailSource, b.settings, (p: number) => { + b.progress = p + }) + ); + } + const results = await Promise.all( + mergePromises.map((p) => p.catch((e) => e)), + ); + results.forEach((r, i) => { + const u = batch[i]; + if (r instanceof Error || typeof r === "string") { + u.status = "error"; + u.error = getMergeErrorMessage(r); + } else { + u.status = "success"; + u.error = undefined; + } + mergeStore.completedMerges.push(u); + }); + mergeStore.enqueuedMerges.splice(0, batchSize) + } + } finally { + mergeStore.merging = false; + } + void completedBeforeRun; +} diff --git a/web/src/routes/api/v1/trail-merge/+server.ts b/web/src/routes/api/v1/trail-merge/+server.ts new file mode 100644 index 00000000..72fb84e2 --- /dev/null +++ b/web/src/routes/api/v1/trail-merge/+server.ts @@ -0,0 +1,57 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /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' + */ +export async function POST(event: RequestEvent) { + try { + const body = await event.request.json(); + const response = await event.locals.pb.send("/trail-merge", { + method: "POST", + body, + fetch: event.fetch, + }); + + return json(response); + } catch (e: any) { + return handleError(e); + } +} diff --git a/web/src/routes/api/v1/trail-merge/suggest/+server.ts b/web/src/routes/api/v1/trail-merge/suggest/+server.ts new file mode 100644 index 00000000..78a8f198 --- /dev/null +++ b/web/src/routes/api/v1/trail-merge/suggest/+server.ts @@ -0,0 +1,53 @@ +import { handleError } from "$lib/util/api_util"; +import { json, type RequestEvent } from "@sveltejs/kit"; + +/** + * @swagger + * /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' + */ +export async function POST(event: RequestEvent) { + try { + const body = await event.request.json(); + const response = await event.locals.pb.send("/trail-merge/suggest", { + method: "POST", + body, + fetch: event.fetch, + }); + + return json(response); + } catch (e: any) { + return handleError(e); + } +} diff --git a/web/src/routes/settings/+layout.svelte b/web/src/routes/settings/+layout.svelte index f8bb9edd..5791d7e4 100644 --- a/web/src/routes/settings/+layout.svelte +++ b/web/src/routes/settings/+layout.svelte @@ -22,6 +22,7 @@ { text: $_("notifications"), value: "/settings/notifications" }, { text: $_("map"), value: "/settings/map" }, { text: $_("integrations"), value: "/settings/integrations" }, + { text: $_("similar-trails"), value: "/settings/maintenance/similar-trails" }, { text: `${$_("import")}/${$_("export")}`, value: "/settings/export" }, { text: $_("help"), diff --git a/web/src/routes/settings/integrations/+page.svelte b/web/src/routes/settings/integrations/+page.svelte index 55f5943d..88a3d16f 100644 --- a/web/src/routes/settings/integrations/+page.svelte +++ b/web/src/routes/settings/integrations/+page.svelte @@ -105,6 +105,7 @@ routes: integration.strava.routes, activities: integration.strava.activities, privacy: integration.strava.privacy, + merge: integration.strava.merge, accessToken: undefined, refreshToken: undefined, expiresAt: undefined, diff --git a/web/src/routes/settings/maintenance/similar-trails/+page.svelte b/web/src/routes/settings/maintenance/similar-trails/+page.svelte new file mode 100644 index 00000000..64aacf93 --- /dev/null +++ b/web/src/routes/settings/maintenance/similar-trails/+page.svelte @@ -0,0 +1,356 @@ + + + + {$_("similar-trails-maintenance-title")} | wanderer + + +
+
+
+

{$_("similar-trails-maintenance-title")}

+

+ {$_("similar-trails-maintenance-description")} +

+
+ +
+ + {#if loading} +
+
+

{$_("similar-trails-loading")}

+
+ {:else if loadError} +
+ {loadError} +
+ {:else if groups.length === 0} +
+ {$_("similar-trails-empty")} +
+ {:else} +
+ {#each groups as group} +
+
+
+
+
+

+ {$_("similar-trails-group-size", { + values: { n: group.trails.length }, + })} +

+
+ {#if group.targetTrailId === group.suggestedTargetTrailId} +

+ {$_(`similar-trails-reason-${group.reason}`)} +

+ {/if} + {#if group.indirect} +
+ {$_("similar-trails-indirect-hint")} +
+ {/if} +
+
+ + +
+
+ + {#if openMapGroupId === group.groupId} +
+

{$_("similar-trails-map-title")}

+ {#if mapLoadingGroupId === group.groupId} +
+
+

{$_("similar-trails-map-loading")}

+
+ {:else if mapTrailsByGroupId[group.groupId]} +
+ +
+ {/if} +
+ {/if} + +
+ {#each group.trails as trail} +
+ { + event.preventDefault(); + goto(`/trail/view/${handleFromRecordWithIRI(trail)}/${trail.id}`); + }} + > + + + +
+ {/each} +
+
+
+ {/each} +
+ {/if} +
+ + mergeGroup(settings, selection)} +/> + diff --git a/web/static/docs/api/wanderer.openapi.json b/web/static/docs/api/wanderer.openapi.json index f9028bfa..6dd11b4f 100644 --- a/web/static/docs/api/wanderer.openapi.json +++ b/web/static/docs/api/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": {