merge trails function added (#627)

* merge trails function added

* fix conflict resolve issues

* avoid incorrect authorship when merging trail comments

* require edit access for all selected trails before merge

* fix likes checkbox binding in trail merge modal

* improve error handling

* require editable target and deletable sources for merge

* avoid duplicate trail fetch during merge

* prevent duplicate likes when merging trails

* check photo download responses during trail merge

* add merge completion toast notifications

* cleanup

* small fixes

* move merge code to backend, option to merge single trail selection with similar one, automatic merge for integrations add as option, maintenance page to find and merge similar trails added

* fix build error

* fix

* docu, beautifying, refactoring

---------

Co-authored-by: Flomp <Flomp@users.noreply.github.com>
This commit is contained in:
slothful-vassal
2026-05-09 13:09:26 +02:00
committed by GitHub
parent d2268429c7
commit b5739cb72e
43 changed files with 5232 additions and 216 deletions

View File

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

View File

@@ -16,15 +16,19 @@ import (
"strings"
"time"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/tkrajina/gpxgo/gpx"
"pocketbase/trailmerge"
"pocketbase/util"
)
func SyncHammerhead(app core.App) error {
func SyncHammerhead(app core.App, client meilisearch.ServiceManager) error {
integrations, err := app.FindAllRecords("integrations", dbx.NewExp("true"))
if err != nil {
return err
@@ -44,12 +48,11 @@ func SyncHammerhead(app core.App) error {
app.Logger().Warn(warning)
continue
}
actorId := actor.Id
hammerheadString := i.GetString("hammerhead")
hammerheadIntegration := HammerheadIntegration{
Planned: true,
Completed: true,
Merge: trailmerge.DefaultIntegrationAutoMergeSettings(),
}
json.Unmarshal([]byte(hammerheadString), &hammerheadIntegration)
@@ -108,7 +111,7 @@ func SyncHammerhead(app core.App) error {
totalPages = curTotalPages
}
err, stopped = syncTrailWithTours(app, h, actorId, tours, after)
err, stopped = syncTrailWithTours(app, client, h, actor, hammerheadIntegration, tours, after)
if err != nil {
warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err)
fmt.Print(warning)
@@ -139,7 +142,7 @@ func SyncHammerhead(app core.App) error {
totalPages = curTotalPages
}
err, stopped = syncTrailWithActivities(app, h, actorId, tours, after)
err, stopped = syncTrailWithActivities(app, client, h, actor, hammerheadIntegration, tours, after)
if err != nil {
warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err)
fmt.Print(warning)
@@ -406,15 +409,13 @@ func (h *HammerheadApi) fetchDetailedTour(tour HammerheadTourResponse) (*Hammerh
return data, nil
}
func syncTrailWithTours(app core.App, k *HammerheadApi, actor string, tours []HammerheadTourResponse, after int64) (error, bool) {
func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, k *HammerheadApi, actor *core.Record, integration HammerheadIntegration, tours []HammerheadTourResponse, after int64) (error, bool) {
for _, tour := range tours {
trails, err := app.FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": tour.ID})
existingTrail, err := util.FindTrailByExternalReference(app, "hammerhead", tour.ID)
if err != nil {
return err, true
}
if len(trails) != 0 {
if existingTrail != nil {
continue
}
@@ -439,25 +440,26 @@ func syncTrailWithTours(app core.App, k *HammerheadApi, actor string, tours []Ha
continue
}
_, err = createTrailFromTour(app, detailedTour, gpx, actor)
trailID, err := createTrailFromTour(app, detailedTour, gpx, actor.Id)
if err != nil {
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
continue
}
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailID, integration.Merge); err != nil {
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Hammerhead tour '%s': %v", tour.Name, err))
}
}
return nil, false
}
func syncTrailWithActivities(app core.App, k *HammerheadApi, actor string, tours []HammerheadActivityResponse, after int64) (error, bool) {
func syncTrailWithActivities(app core.App, client meilisearch.ServiceManager, k *HammerheadApi, actor *core.Record, integration HammerheadIntegration, tours []HammerheadActivityResponse, after int64) (error, bool) {
for _, tour := range tours {
trails, err := app.FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": tour.ID})
existingTrail, err := util.FindTrailByExternalReference(app, "hammerhead", tour.ID)
if err != nil {
return err, true
}
if len(trails) != 0 {
if existingTrail != nil {
continue
}
@@ -483,11 +485,14 @@ func syncTrailWithActivities(app core.App, k *HammerheadApi, actor string, tours
continue
}
_, err = createTrailFromActivity(app, detailedTour, gpx, actor)
trailID, err := createTrailFromActivity(app, detailedTour, gpx, actor.Id)
if err != nil {
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
continue
}
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailID, integration.Merge); err != nil {
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Hammerhead activity '%s': %v", tour.Name, err))
}
}
return nil, false
@@ -565,6 +570,9 @@ func createTrailFromActivity(app core.App, detailedTour *HammerheadActivity, gpx
if err := app.Save(record); err != nil {
return "", err
}
if err := util.EnsureTrailExternalReference(app, trailid, "hammerhead", detailedTour.ActivityData.ID); err != nil {
return "", err
}
collection, err = app.FindCollectionByNameOrId("summit_logs")
if err != nil {
@@ -614,8 +622,6 @@ func createTrailFromTour(app core.App, detailedTour *HammerheadTour, gpx *filesy
"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,
@@ -630,6 +636,9 @@ func createTrailFromTour(app core.App, detailedTour *HammerheadTour, gpx *filesy
if err := app.Save(record); err != nil {
return "", err
}
if err := util.EnsureTrailExternalReference(app, trailid, "hammerhead", detailedTour.ID); err != nil {
return "", err
}
return trailid, nil
}

View File

@@ -2,6 +2,8 @@ package hammerhead
import (
"time"
"pocketbase/trailmerge"
)
type HammerheadToursResponse struct {
@@ -78,6 +80,7 @@ type HammerheadIntegration struct {
Planned bool `json:"planned"`
Completed bool `json:"completed"`
After string `json:"after,omitempty"`
Merge trailmerge.IntegrationAutoMergeSettings `json:"merge"`
}
type LoginResponse struct {

View File

@@ -12,14 +12,18 @@ import (
"strings"
"time"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/tkrajina/gpxgo/gpx"
"pocketbase/trailmerge"
"pocketbase/util"
)
func SyncKomoot(app core.App) error {
func SyncKomoot(app core.App, client meilisearch.ServiceManager) error {
integrations, err := app.FindAllRecords("integrations", dbx.NewExp("true"))
if err != nil {
return err
@@ -39,12 +43,11 @@ func SyncKomoot(app core.App) error {
app.Logger().Warn(warning)
continue
}
actorId := actor.Id
komootString := i.GetString("komoot")
komootIntegration := KomootIntegration{
Planned: true,
Completed: true,
Merge: trailmerge.DefaultIntegrationAutoMergeSettings(),
}
json.Unmarshal([]byte(komootString), &komootIntegration)
@@ -79,7 +82,7 @@ func SyncKomoot(app core.App) error {
}
totalPages = tp
allAlreadySynced, err := syncTrailWithTours(app, k, komootIntegration, userId, actorId, tours)
allAlreadySynced, err := syncTrailWithTours(app, client, k, komootIntegration, userId, actor, tours)
if err != nil {
warning := fmt.Sprintf("error syncing komoot tours with trails: %v\n", err)
fmt.Print(warning)
@@ -188,14 +191,14 @@ func (k *KomootApi) fetchDetailedTour(tour KomootTour) (*DetailedKomootTour, err
// when every tour on this page was already imported, so the caller can stop paginating
// early during incremental syncs. Tours skipped due to type filters do NOT count as
// synced - only tours already present in the DB do.
func syncTrailWithTours(app core.App, k *KomootApi, i KomootIntegration, user string, actor string, tours []KomootTour) (bool, error) {
func syncTrailWithTours(app core.App, client meilisearch.ServiceManager, k *KomootApi, i KomootIntegration, user string, actor *core.Record, tours []KomootTour) (bool, error) {
allAlreadySynced := true
for _, tour := range tours {
trails, err := app.FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": strconv.Itoa(int(tour.ID))})
existingTrail, err := util.FindTrailByExternalReference(app, "komoot", strconv.Itoa(int(tour.ID)))
if err != nil {
return false, err
}
if len(trails) != 0 {
if existingTrail != nil {
continue
}
// Tour is not yet in the DB - we must keep paginating regardless of type filter
@@ -213,7 +216,7 @@ func syncTrailWithTours(app core.App, k *KomootApi, i KomootIntegration, user st
app.Logger().Warn(fmt.Sprintf("Unable to generate GPX for tour '%s': %v", tour.Name, err))
continue
}
trailid, err := createTrailFromTour(app, k, detailedTour, gpx, user, actor, i.Privacy)
trailid, err := createTrailFromTour(app, k, detailedTour, gpx, user, actor.Id, i.Privacy)
if err != nil {
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
continue
@@ -223,6 +226,9 @@ func syncTrailWithTours(app core.App, k *KomootApi, i KomootIntegration, user st
app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for tour '%s': %v", tour.Name, err))
continue
}
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailid, i.Merge); err != nil {
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported komoot tour '%s': %v", tour.Name, err))
}
}
return allAlreadySynced, nil
@@ -317,6 +323,9 @@ func createTrailFromTour(app core.App, k *KomootApi, detailedTour *DetailedKomoo
if err := app.Save(record); err != nil {
return "", err
}
if err := util.EnsureTrailExternalReference(app, trailid, "komoot", strconv.Itoa(detailedTour.ID)); err != nil {
return "", err
}
if detailedTour.Type == "tour_recorded" {
collection, err := app.FindCollectionByNameOrId("summit_logs")

View File

@@ -1,6 +1,10 @@
package komoot
import "time"
import (
"time"
"pocketbase/trailmerge"
)
type KomootIntegration struct {
Active bool `json:"active"`
@@ -9,6 +13,7 @@ type KomootIntegration struct {
Planned bool `json:"planned"`
Completed bool `json:"completed"`
Privacy string `json:"privacy"`
Merge trailmerge.IntegrationAutoMergeSettings `json:"merge"`
}
type LoginResponse struct {

View File

@@ -1,6 +1,10 @@
package strava
import "time"
import (
"time"
"pocketbase/trailmerge"
)
type TokenRequest struct {
ClientID int32 `json:"client_id"`
@@ -31,6 +35,7 @@ type StravaIntegration struct {
ExpiresAt int64 `json:"expiresAt,omitempty"`
Privacy string `json:"privacy"`
After string `json:"after,omitempty"`
Merge trailmerge.IntegrationAutoMergeSettings `json:"merge"`
}
type StravaRoute struct {
Athlete Athlete `json:"athlete"`

View File

@@ -11,19 +11,23 @@ import (
"strconv"
"time"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/tkrajina/gpxgo/gpx"
"github.com/twpayne/go-polyline"
"pocketbase/trailmerge"
"pocketbase/util"
)
type StravaApi struct {
AceessToken string
}
func SyncStrava(app core.App) error {
func SyncStrava(app core.App, client meilisearch.ServiceManager) error {
integrations, err := app.FindAllRecords("integrations", dbx.NewExp("true"))
if err != nil {
return err
@@ -43,8 +47,6 @@ func SyncStrava(app core.App) error {
app.Logger().Warn(warning)
continue
}
actorId := actor.Id
stravaString := i.GetString("strava")
var stravaIntegration StravaIntegration
err = json.Unmarshal([]byte(stravaString), &stravaIntegration)
@@ -102,7 +104,7 @@ func SyncStrava(app core.App) error {
app.Logger().Warn(warning)
break
}
err = syncTrailsWithRoutes(app, stravaIntegration, r.AccessToken, userId, actorId, routes)
err = syncTrailsWithRoutes(app, client, stravaIntegration, r.AccessToken, userId, actor, routes)
if err != nil {
warning := fmt.Sprintf("error syncing strava routes with trails: %v\n", err)
fmt.Print(warning)
@@ -134,7 +136,7 @@ func SyncStrava(app core.App) error {
app.Logger().Warn(warning)
break
}
err = syncTrailsWithActivities(app, stravaIntegration, r.AccessToken, userId, actorId, activities)
err = syncTrailsWithActivities(app, client, stravaIntegration, r.AccessToken, userId, actor, activities)
if err != nil {
warning := fmt.Sprintf("error syncing strava activities with trails: %v", err)
@@ -248,13 +250,13 @@ func fetchStravaActivities(accessToken string, page int, after int64) ([]StravaA
return activities, nil
}
func syncTrailsWithRoutes(app core.App, i StravaIntegration, accessToken string, user string, actor string, routes []StravaRoute) error {
func syncTrailsWithRoutes(app core.App, client meilisearch.ServiceManager, i StravaIntegration, accessToken string, user string, actor *core.Record, routes []StravaRoute) error {
for _, route := range routes {
trails, err := app.FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": route.IDStr})
existingTrail, err := util.FindTrailByExternalReference(app, "strava", route.IDStr)
if err != nil {
return err
}
if len(trails) != 0 {
if existingTrail != nil {
continue
}
gpx, err := fetchRouteGPX(route, accessToken)
@@ -262,7 +264,7 @@ func syncTrailsWithRoutes(app core.App, i StravaIntegration, accessToken string,
app.Logger().Warn(fmt.Sprintf("Unable to fetch GPX for route '%s': %v", route.Name, err))
continue
}
trailid, err := createTrailFromRoute(app, route, gpx, user, actor, i.Privacy)
trailid, err := createTrailFromRoute(app, route, gpx, user, actor.Id, i.Privacy)
if err != nil {
app.Logger().Warn(fmt.Sprintf("Unable to create trail for route '%s': %v", route.Name, err))
continue
@@ -272,6 +274,9 @@ func syncTrailsWithRoutes(app core.App, i StravaIntegration, accessToken string,
app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for route '%s': %v", route.Name, err))
continue
}
if err := trailmerge.TryAutoMergeImportedTrail(app, client, actor, trailid, i.Merge); err != nil {
app.Logger().Warn(fmt.Sprintf("Unable to auto-merge imported Strava route '%s': %v", route.Name, err))
}
}
return nil
@@ -373,8 +378,6 @@ func createTrailFromRoute(app core.App, route StravaRoute, gpx *filesystem.File,
"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",
@@ -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,7 +561,7 @@ 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"
@@ -566,8 +575,6 @@ func createTrailFromActivity(app core.App, activity *DetailedStravaActivity, gpx
"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",
@@ -584,10 +591,13 @@ func createTrailFromActivity(app core.App, activity *DetailedStravaActivity, gpx
}
if err := app.Save(record); err != nil {
return err
return "", err
}
if err := util.EnsureTrailExternalReference(app, record.Id, "strava", strconv.Itoa(int(activity.ID))); err != nil {
return "", err
}
return nil
return record.Id, nil
}
func fetchActivityPhoto(activity *DetailedStravaActivity) (*filesystem.File, error) {

View File

@@ -1022,7 +1022,7 @@ func createAPITokenHandler() func(e *core.RecordEvent) error {
func onBeforeServeHandler(client meilisearch.ServiceManager) func(se *core.ServeEvent) error {
return func(se *core.ServeEvent) error {
registerRoutes(se, client)
registerCronJobs(se.App)
registerCronJobs(se.App, client)
bootstrapData(se.App, client)
return se.Next()
@@ -1073,6 +1073,7 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
})
registerTrailMergeRoutes(se, client)
se.Router.POST("/waypoint/cluster", waypointcluster.Handler)
se.Router.POST("/auth/token", func(e *core.RequestEvent) error {
@@ -1398,26 +1399,26 @@ func registerRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
})
}
func registerCronJobs(app core.App) {
func registerCronJobs(app core.App, client meilisearch.ServiceManager) {
schedule := os.Getenv("POCKETBASE_CRON_SYNC_SCHEDULE")
if len(schedule) == 0 {
schedule = "0 2 * * *"
}
app.Cron().MustAdd("integrations", schedule, func() {
err := strava.SyncStrava(app)
err := strava.SyncStrava(app, client)
if err != nil {
warning := fmt.Sprintf("Error syncing with strava: %v", err)
fmt.Println(warning)
app.Logger().Error(warning)
}
err = komoot.SyncKomoot(app)
err = komoot.SyncKomoot(app, client)
if err != nil {
warning := fmt.Sprintf("Error syncing with komoot: %v", err)
fmt.Println(warning)
app.Logger().Error(warning)
}
err = hammerhead.SyncHammerhead(app)
err = hammerhead.SyncHammerhead(app, client)
if err != nil {
warning := fmt.Sprintf("Error syncing with hammerhead: %v", err)
fmt.Println(warning)

View File

@@ -0,0 +1,178 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
jsonData := `{
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "e864strfxo14pm4",
"hidden": false,
"id": "relation420001001",
"maxSelect": 1,
"minSelect": 0,
"name": "trail",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "select420001002",
"maxSelect": 1,
"name": "provider",
"presentable": false,
"required": true,
"system": false,
"type": "select",
"values": [
"strava",
"komoot",
"hammerhead"
]
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text420001003",
"max": 255,
"min": 1,
"name": "external_id",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate420001004",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate420001005",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_420001000",
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `idx_trail_external_reference_provider_external_id` + "`" + ` ON ` + "`" + `trail_external_reference` + "`" + ` (` + "`" + `provider` + "`" + `, ` + "`" + `external_id` + "`" + `)",
"CREATE UNIQUE INDEX ` + "`" + `idx_trail_external_reference_trail_provider_external_id` + "`" + ` ON ` + "`" + `trail_external_reference` + "`" + ` (` + "`" + `trail` + "`" + `, ` + "`" + `provider` + "`" + `, ` + "`" + `external_id` + "`" + `)"
],
"listRule": null,
"name": "trail_external_reference",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
}`
collection := &core.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collection); err != nil {
return err
}
if err := app.Save(collection); err != nil {
return err
}
referenceCollection, err := app.FindCollectionByNameOrId("trail_external_reference")
if err != nil {
return err
}
trails, err := app.FindRecordsByFilter(
"trails",
"external_provider != '' && external_id != ''",
"",
-1,
0,
nil,
)
if err != nil {
return err
}
for _, trail := range trails {
provider := trail.GetString("external_provider")
externalID := trail.GetString("external_id")
if provider == "" || externalID == "" {
continue
}
existing, err := app.FindRecordsByFilter(
"trail_external_reference",
"provider={:provider} && external_id={:external_id}",
"",
1,
0,
dbx.Params{
"provider": provider,
"external_id": externalID,
},
)
if err != nil {
return err
}
if len(existing) > 0 {
app.Logger().Warn("Skipping duplicate trail external reference during migration", "provider", provider, "external_id", externalID, "trail", trail.Id)
continue
}
record := core.NewRecord(referenceCollection)
record.Load(map[string]any{
"trail": trail.Id,
"provider": provider,
"external_id": externalID,
})
if err := app.Save(record); err != nil {
return err
}
}
return nil
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("pbc_420001000")
if err != nil {
return err
}
return app.Delete(collection)
})
}

View File

@@ -0,0 +1,62 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4")
if err != nil {
return err
}
collection.Fields.RemoveById("sajmiuau")
collection.Fields.RemoveById("htr35nha")
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("e864strfxo14pm4")
if err != nil {
return err
}
if err := collection.Fields.AddMarshaledJSONAt(17, []byte(`{
"autogeneratePattern": "",
"hidden": false,
"id": "sajmiuau",
"max": 0,
"min": 0,
"name": "external_id",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}`)); err != nil {
return err
}
if err := collection.Fields.AddMarshaledJSONAt(18, []byte(`{
"hidden": false,
"id": "htr35nha",
"maxSelect": 1,
"name": "external_provider",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"strava",
"komoot",
"hammerhead"
]
}`)); err != nil {
return err
}
return app.Save(collection)
})
}

88
db/trail_merge_routes.go Normal file
View File

@@ -0,0 +1,88 @@
package main
import (
"net/http"
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"pocketbase/trailmerge"
)
type mergeExecuteRequest struct {
SourceTrailID string `json:"sourceTrailId"`
TargetTrailID string `json:"targetTrailId"`
Settings trailmerge.MergeSettings `json:"settings"`
}
func registerTrailMergeRoutes(se *core.ServeEvent, client meilisearch.ServiceManager) {
se.Router.POST("/trail-merge/suggest", func(e *core.RequestEvent) error {
if e.Auth == nil {
return apis.NewUnauthorizedError("trail_merge_auth_required", nil)
}
actor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
if err != nil {
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
}
var request trailmerge.SuggestRequest
if err := e.BindBody(&request); err != nil {
return apis.NewBadRequestError("trail_merge_invalid_request", err)
}
if request.Mode == trailmerge.SuggestModeMaintenance {
response, err := trailmerge.SuggestGroups(e.App, actor.Id, request)
if err != nil {
return apis.NewBadRequestError(err.Error(), err)
}
return e.JSON(http.StatusOK, response)
}
response, err := trailmerge.Suggest(e.App, actor.Id, request)
if err != nil {
return apis.NewBadRequestError(err.Error(), err)
}
return e.JSON(http.StatusOK, response)
})
se.Router.POST("/trail-merge", func(e *core.RequestEvent) error {
if e.Auth == nil {
return apis.NewUnauthorizedError("trail_merge_auth_required", nil)
}
actor, err := e.App.FindFirstRecordByData("activitypub_actors", "user", e.Auth.Id)
if err != nil {
return apis.NewBadRequestError("trail_merge_actor_not_found", err)
}
var request mergeExecuteRequest
if err := e.BindBody(&request); err != nil {
return apis.NewBadRequestError("trail_merge_invalid_request", err)
}
source, err := e.App.FindRecordById("trails", request.SourceTrailID)
if err != nil {
return apis.NewBadRequestError("trail_merge_source_not_found", err)
}
target, err := e.App.FindRecordById("trails", request.TargetTrailID)
if err != nil {
return apis.NewBadRequestError("trail_merge_target_not_found", err)
}
if !trailmerge.CanMerge(e.App, actor.Id, source, target, request.Settings.Delete) {
return apis.NewForbiddenError("trail_merge_not_allowed", nil)
}
if err := trailmerge.Merge(e.App, client, actor, request.SourceTrailID, request.TargetTrailID, request.Settings); err != nil {
return apis.NewBadRequestError(err.Error(), err)
}
return e.JSON(http.StatusOK, map[string]any{
"acknowledged": true,
})
})
}

View File

@@ -0,0 +1,44 @@
package trailmerge
import (
"github.com/meilisearch/meilisearch-go"
"github.com/pocketbase/pocketbase/core"
)
func TryAutoMergeImportedTrail(
app core.App,
client meilisearch.ServiceManager,
actor *core.Record,
sourceTrailID string,
settings IntegrationAutoMergeSettings,
) error {
if actor == nil || sourceTrailID == "" || !settings.Enabled {
return nil
}
response, err := Suggest(app, actor.Id, SuggestRequest{
Mode: SuggestModeAutoDiscovery,
SourceTrailID: sourceTrailID,
})
if err != nil {
return err
}
selectableCandidates := make([]SuggestCandidate, 0, len(response.Candidates))
for _, candidate := range response.Candidates {
if candidate.Selectable {
selectableCandidates = append(selectableCandidates, candidate)
}
}
if len(selectableCandidates) != 1 {
return nil
}
targetTrailID := selectableCandidates[0].TrailID
if targetTrailID == "" || targetTrailID == sourceTrailID {
return nil
}
return Merge(app, client, actor, sourceTrailID, targetTrailID, DefaultIntegrationAutoMergeMergeSettings())
}

1609
db/trailmerge/service.go Normal file

File diff suppressed because it is too large Load Diff

18
db/util/distance.go Normal file
View File

@@ -0,0 +1,18 @@
package util
import "math"
func HaversineDistanceMeters(lat1 float64, lon1 float64, lat2 float64, lon2 float64) float64 {
const earthRadius = 6371000.0
lat1Rad := lat1 * math.Pi / 180
lat2Rad := lat2 * math.Pi / 180
dLat := (lat2 - lat1) * math.Pi / 180
dLon := (lon2 - lon1) * math.Pi / 180
sinLat := math.Sin(dLat / 2)
sinLon := math.Sin(dLon / 2)
h := sinLat*sinLat + math.Cos(lat1Rad)*math.Cos(lat2Rad)*sinLon*sinLon
return 2 * earthRadius * math.Atan2(math.Sqrt(h), math.Sqrt(1-h))
}

173
db/util/geometry.go Normal file
View File

@@ -0,0 +1,173 @@
package util
import (
"bytes"
"fmt"
"io"
"github.com/pocketbase/pocketbase/core"
"github.com/tkrajina/gpxgo/gpx"
)
type TrailGeometryMetrics struct {
MeanDistanceMeters float64
MaxDistanceMeters float64
StartDistanceMeters float64
EndDistanceMeters float64
}
type trailPoint struct {
Lat float64
Lon float64
}
func TrailCoordinates(app core.App, r *core.Record) ([][2]float64, error) {
gpxPath := r.GetString("gpx")
if gpxPath == "" {
return nil, nil
}
fsys, err := app.NewFilesystem()
if err != nil {
return nil, err
}
defer fsys.Close()
reader, err := fsys.GetReader(r.BaseFilesPath() + "/" + gpxPath)
if err != nil {
return nil, err
}
defer reader.Close()
content := new(bytes.Buffer)
if _, err := io.Copy(content, reader); err != nil {
return nil, err
}
gpxData, err := gpx.Parse(content)
if err != nil {
return nil, err
}
points := make([][2]float64, 0)
for _, trk := range gpxData.Tracks {
for _, seg := range trk.Segments {
for _, pt := range seg.Points {
points = append(points, [2]float64{pt.Latitude, pt.Longitude})
}
}
}
return points, nil
}
func TrailGeometrySimilarity(app core.App, a *core.Record, b *core.Record) (*TrailGeometryMetrics, error) {
aCoords, err := TrailCoordinates(app, a)
if err != nil {
return nil, fmt.Errorf("load source geometry: %w", err)
}
bCoords, err := TrailCoordinates(app, b)
if err != nil {
return nil, fmt.Errorf("load target geometry: %w", err)
}
return CompareTrailCoordinates(aCoords, bCoords)
}
func CompareTrailCoordinates(aCoords [][2]float64, bCoords [][2]float64) (*TrailGeometryMetrics, error) {
if len(aCoords) < 2 || len(bCoords) < 2 {
return nil, fmt.Errorf("missing geometry")
}
a := resampleTrailCoordinates(aCoords, 64)
b := resampleTrailCoordinates(bCoords, 64)
if len(a) < 2 || len(b) < 2 {
return nil, fmt.Errorf("missing geometry")
}
return compareSampledTrails(a, b), nil
}
func compareSampledTrails(a []trailPoint, b []trailPoint) *TrailGeometryMetrics {
count := min(len(a), len(b))
if count == 0 {
return &TrailGeometryMetrics{}
}
sum := 0.0
maxDistance := 0.0
for i := 0; i < count; i++ {
distance := HaversineDistanceMeters(a[i].Lat, a[i].Lon, b[i].Lat, b[i].Lon)
sum += distance
if distance > maxDistance {
maxDistance = distance
}
}
return &TrailGeometryMetrics{
MeanDistanceMeters: sum / float64(count),
MaxDistanceMeters: maxDistance,
StartDistanceMeters: HaversineDistanceMeters(a[0].Lat, a[0].Lon, b[0].Lat, b[0].Lon),
EndDistanceMeters: HaversineDistanceMeters(a[count-1].Lat, a[count-1].Lon, b[count-1].Lat, b[count-1].Lon),
}
}
func resampleTrailCoordinates(coords [][2]float64, targetPoints int) []trailPoint {
points := make([]trailPoint, 0, len(coords))
for _, coord := range coords {
points = append(points, trailPoint{Lat: coord[0], Lon: coord[1]})
}
if len(points) <= 2 || targetPoints <= 2 {
return points
}
cumulative := make([]float64, len(points))
total := 0.0
for i := 1; i < len(points); i++ {
total += HaversineDistanceMeters(points[i-1].Lat, points[i-1].Lon, points[i].Lat, points[i].Lon)
cumulative[i] = total
}
if total == 0 {
return []trailPoint{points[0], points[len(points)-1]}
}
resampled := make([]trailPoint, 0, targetPoints)
for i := 0; i < targetPoints; i++ {
targetDistance := (float64(i) / float64(targetPoints-1)) * total
resampled = append(resampled, interpolateTrailPoint(points, cumulative, targetDistance))
}
return resampled
}
func interpolateTrailPoint(points []trailPoint, cumulative []float64, targetDistance float64) trailPoint {
if targetDistance <= 0 {
return points[0]
}
lastIndex := len(points) - 1
if targetDistance >= cumulative[lastIndex] {
return points[lastIndex]
}
for i := 1; i < len(points); i++ {
if cumulative[i] < targetDistance {
continue
}
prevDistance := cumulative[i-1]
nextDistance := cumulative[i]
if nextDistance == prevDistance {
return points[i]
}
ratio := (targetDistance - prevDistance) / (nextDistance - prevDistance)
return trailPoint{
Lat: points[i-1].Lat + (points[i].Lat-points[i-1].Lat)*ratio,
Lon: points[i-1].Lon + (points[i].Lon-points[i-1].Lon)*ratio,
}
}
return points[lastIndex]
}

View File

@@ -0,0 +1,130 @@
package util
import (
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
func FindTrailByExternalReference(app core.App, provider string, externalID string) (*core.Record, error) {
if provider == "" || externalID == "" {
return nil, nil
}
refs, err := app.FindRecordsByFilter(
"trail_external_reference",
"provider={:provider} && external_id={:external_id}",
"+created",
1,
0,
dbx.Params{
"provider": provider,
"external_id": externalID,
},
)
if err != nil || len(refs) == 0 {
return nil, err
}
trailID := refs[0].GetString("trail")
if trailID == "" {
return nil, nil
}
return app.FindRecordById("trails", trailID)
}
func EnsureTrailExternalReference(app core.App, trailID string, provider string, externalID string) error {
if trailID == "" || provider == "" || externalID == "" {
return nil
}
refs, err := app.FindRecordsByFilter(
"trail_external_reference",
"provider={:provider} && external_id={:external_id}",
"",
1,
0,
dbx.Params{
"provider": provider,
"external_id": externalID,
},
)
if err != nil {
return err
}
if len(refs) > 0 {
if refs[0].GetString("trail") == trailID {
return nil
}
return fmt.Errorf("trail external reference already exists for another trail")
}
collection, err := app.FindCollectionByNameOrId("trail_external_reference")
if err != nil {
return err
}
record := core.NewRecord(collection)
record.Load(map[string]any{
"trail": trailID,
"provider": provider,
"external_id": externalID,
})
return app.Save(record)
}
func ReassignTrailExternalReferences(app core.App, sourceTrailID string, targetTrailID string) error {
if sourceTrailID == "" || targetTrailID == "" || sourceTrailID == targetTrailID {
return nil
}
refs, err := app.FindRecordsByFilter(
"trail_external_reference",
"trail={:trail}",
"",
-1,
0,
dbx.Params{"trail": sourceTrailID},
)
if err != nil {
return err
}
for _, ref := range refs {
provider := ref.GetString("provider")
externalID := ref.GetString("external_id")
existing, err := app.FindRecordsByFilter(
"trail_external_reference",
"trail={:trail} && provider={:provider} && external_id={:external_id}",
"",
1,
0,
dbx.Params{
"trail": targetTrailID,
"provider": provider,
"external_id": externalID,
},
)
if err != nil {
return err
}
if len(existing) > 0 {
if err := app.Delete(ref); err != nil {
return err
}
continue
}
ref.Set("trail", targetTrailID)
if err := app.Save(ref); err != nil {
return err
}
}
return nil
}

View File

@@ -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/'

View File

@@ -0,0 +1,69 @@
---
title: Merge trails
description: Link repeated or duplicate trail recordings into a single target trail.
---
<span class="-tracking-[0.075em]">wanderer</span> 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, <span class="-tracking-[0.075em]">wanderer</span> 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.

View File

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

View File

@@ -3,6 +3,7 @@
text: string;
value: any;
icon?: string;
separator?: boolean;
};
</script>
@@ -130,16 +131,22 @@
bind:this={dropdownElement}
>
{#each items as item}
{#if item.separator}
<li class="px-3 py-2" role="separator" aria-hidden="true">
<div class="border-t border-input-border"></div>
</li>
{:else}
<li
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
role="presentation"
onclick={(e) => handleItemClick(e, item)}
onclick={(e) => handleItemClick(e, item as { text: string; value: any })}
>
{#if item.icon}
<i class="fa fa-{item.icon} mr-3"></i>
{/if}
<span class="whitespace-nowrap">{item.text}</span>
</li>
{/if}
{/each}
</ul>
{/if}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import Datepicker from "$lib/components/base/datepicker.svelte";
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
import Modal from "$lib/components/base/modal.svelte";
import TextField from "$lib/components/base/text_field.svelte";
import Toggle from "$lib/components/base/toggle.svelte";
@@ -33,6 +34,9 @@
planned: integration?.hammerhead?.planned ?? true,
active: integration?.hammerhead?.active ?? false,
after: integration?.hammerhead?.after,
merge: integration?.hammerhead?.merge ?? {
enabled: false,
},
});
const {
@@ -104,6 +108,8 @@
><i class="fa fa-close"></i></button
>
</div>
<IntegrationMergeSettings prefix="merge" />
</form>
{/snippet}
{#snippet footer()}

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import Toggle from "$lib/components/base/toggle.svelte";
import { _ } from "svelte-i18n";
interface Props {
prefix: string;
}
let { prefix }: Props = $props();
</script>
<div class="pt-4 border-t border-input-border space-y-2">
<Toggle
name={`${prefix}.enabled`}
label={$_("integration-auto-merge-label")}
/>
<p class="text-xs text-gray-500 max-w-lg">
{$_("integration-auto-merge-hint")}
</p>
</div>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import Modal from "$lib/components/base/modal.svelte";
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
import Select, {
type SelectItem,
} from "$lib/components/base/select.svelte";
@@ -43,6 +44,9 @@
planned: integration?.komoot?.planned ?? true,
active: integration?.komoot?.active ?? false,
privacy: integration?.komoot?.privacy ?? "original",
merge: integration?.komoot?.merge ?? {
enabled: false,
},
});
const {
@@ -106,6 +110,8 @@
{$_("integration-privacy-hint-user")}
{/if}
</p>
<IntegrationMergeSettings prefix="merge" />
</form>
{/snippet}
{#snippet footer()}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import Datepicker from "$lib/components/base/datepicker.svelte";
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
import Modal from "$lib/components/base/modal.svelte";
import type { SelectItem } from "$lib/components/base/select.svelte";
import Select from "$lib/components/base/select.svelte";
@@ -43,7 +44,10 @@
activities: integration?.strava?.activities ?? true,
active: integration?.strava?.active ?? false,
after: integration?.strava?.after,
privacy: integration?.komoot?.privacy ?? "original",
privacy: integration?.strava?.privacy ?? "original",
merge: integration?.strava?.merge ?? {
enabled: false,
},
});
const {
@@ -130,6 +134,8 @@
>
{$_("strava-integration-after-date-hint")}
</p>
<IntegrationMergeSettings prefix="merge" />
</form>
{/snippet}
{#snippet footer()}

View File

@@ -14,7 +14,11 @@
lists_remove_trail,
} from "$lib/stores/list_store";
import { show_toast } from "$lib/stores/toast_store.svelte";
import { trails_delete, trails_update } from "$lib/stores/trail_store";
import {
trails_delete,
trails_show,
trails_update,
} from "$lib/stores/trail_store";
import { currentUser } from "$lib/stores/user_store";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
import { getFileURL, saveAs } from "$lib/util/file_util";
@@ -30,6 +34,21 @@
import TrailExportModal from "./trail_export_modal.svelte";
import TrailSendModal from "./trail_send_modal.svelte";
import TrailShareModal from "./trail_share_modal.svelte";
import {
mergeStore,
processMergeQueue,
type Merge,
} from "$lib/stores/trail_merge_store.svelte";
import TrailMergeModal from "./trail_merge_modal.svelte";
import type { MergeSelection, MergeSettings } from "./trail_merge_modal.svelte";
import MergeDialog from "$lib/components/trail/trail_merge_dialog.svelte";
import { trail_merge } from "$lib/stores/trail_merge_api";
export interface MergeResult {
targetTrail: Trail;
deletedTrailIds: string[];
successfulMergeCount: number;
}
interface Props {
trails?: Set<Trail> | undefined;
@@ -38,14 +57,16 @@
onDelete?: () => void;
onShare?: () => void;
onUpdate?: () => void;
onMerge?: (result: MergeResult) => void;
}
let { trails, mode, toggle, onDelete, onShare, onUpdate }: Props = $props();
let { trails, mode, toggle, onDelete, onShare, onUpdate, onMerge }: Props = $props();
let confirmModal: ConfirmModal;
let listSelectModal: ListSearchModal;
let trailExportModal: TrailExportModal;
let trailShareModal: TrailShareModal;
let trailMergeModal: TrailMergeModal;
let trailSendModal: TrailSendModal;
const hammerheadIntegration = $derived(
@@ -99,18 +120,75 @@
let loading: boolean = $state(false);
function canEditTrail(candidate: Trail | undefined): boolean {
if (!candidate || !$currentUser) {
return false;
}
return (
candidate.expand?.author?.id === $currentUser.actor ||
Boolean(
candidate.expand?.trail_share_via_trail?.some(
(s) =>
s.permission == "edit" &&
s.actor == $currentUser.actor,
),
)
);
}
function getMergeTarget(): Trail | undefined {
if (!trails || trails.size === 0) {
return undefined;
}
for (const candidate of trails) {
if (
candidate.expand?.summit_logs_via_trail &&
candidate.expand.summit_logs_via_trail.length > 0
) {
return candidate;
}
}
return [...trails][0];
}
function allowEdit(): boolean {
return (
hasTrail() &&
!isMultiselectMode() &&
Boolean($currentUser) &&
(trail()!.expand?.author?.id === $currentUser?.actor ||
trail()!.expand?.trail_share_via_trail?.some(
(s) => s.permission == "edit",
))!
canEditTrail(trail())
);
}
function allowMerge(): boolean {
if (!hasTrail() || !isMultiselectMode() || !Boolean($currentUser)) {
return false;
}
const targetTrail = getMergeTarget();
if (!canEditTrail(targetTrail)) {
return false;
}
for (const cTrail of trails ?? []) {
if (cTrail.id === targetTrail?.id) {
continue;
}
if (!allowDeleteTrail(cTrail)) {
return false;
}
}
return true;
}
function allowFindSimilarTrails(): boolean {
return hasTrail() && !isMultiselectMode() && Boolean($currentUser) && hasGpx() && isFromCurrentUser(trail());
}
function majorityOfSelectedTrailsArePublic(): boolean {
if (trails === undefined || trails.size === 0) return false;
@@ -119,13 +197,7 @@
let publicCount = 0;
for (const cTrail of trails) {
if (cTrail.expand?.author === undefined) return false;
if (
cTrail.expand!.author!.id !== $currentUser?.actor &&
!cTrail.expand?.trail_share_via_trail?.some(
(s) => s.permission == "edit",
)
) {
if (!canEditTrail(cTrail)) {
return false;
}
@@ -152,13 +224,7 @@
}
for (const cTrail of trails) {
if (cTrail.expand?.author === undefined) return false;
if (
cTrail.expand!.author!.id !== $currentUser?.actor &&
!cTrail.expand?.trail_share_via_trail?.some(
(s) => s.permission == "edit",
)
) {
if (!canEditTrail(cTrail)) {
return false;
}
}
@@ -167,30 +233,22 @@
}
function dropdownItems(): DropdownItem[] {
const separator = (value: string): DropdownItem => ({
text: "",
value,
separator: true,
});
const allowListManagement = isFromCurrentUser();
const allowShareSingleTrail = !isMultiselectMode() && isFromCurrentUser();
const allowSingleOutput = canExport() || (!isMultiselectMode() && hammerheadIntegration && canExport());
if (isMultiselectMode()) {
return [
...(!isMultiselectMode()
? [
mode == "overview" || mode == "multi-select"
? {
text: $_("show-on-map"),
value: "show",
icon: "map",
}
: {
text: $_("show-in-overview"),
value: "show",
icon: "table-columns",
},
]
...(allowMerge()
? [{ text: $_("link"), value: "merge", icon: "link" }]
: []),
...(!isMultiselectMode()
? [
{
text: $_("directions"),
value: "direction",
icon: "car",
},
]
...(allowMerge() && (canExport() || allowListManagement || allowPublish() || allowDelete())
? [separator("sep-multi-actions")]
: []),
...(canExport()
? [
@@ -201,16 +259,7 @@
},
]
: []),
...(!isMultiselectMode()
? [
{
text: $_("print"),
value: "print",
icon: "print",
},
]
: []),
...(!isFromCurrentUser()
...(!allowListManagement
? []
: [
{
@@ -219,23 +268,8 @@
icon: "bookmark",
},
]),
...(isMultiselectMode() || !isFromCurrentUser()
? []
: [
{
text: $_("share"),
value: "share",
icon: "share",
},
]),
...(allowCopy()
? [
{
text: $_("duplicate"),
value: "copy",
icon: "copy",
},
]
...(((canExport() || allowListManagement) && (allowPublish() || allowDelete()))
? [separator("sep-multi-visibility")]
: []),
...(allowPublish()
? [
@@ -248,6 +282,43 @@
},
]
: []),
...(allowDelete()
? [
separator("sep-multi-danger"),
{
text: $_("delete"),
value: "delete",
icon: "trash",
},
]
: []),
];
}
return [
...(hasTrail()
? [
mode == "overview" || mode == "multi-select"
? {
text: $_("show-on-map"),
value: "show",
icon: "map",
}
: {
text: $_("show-in-overview"),
value: "show",
icon: "table-columns",
},
{
text: $_("directions"),
value: "direction",
icon: "car",
},
]
: []),
...((hasTrail() && (allowEdit() || allowFindSimilarTrails() || allowCopy()))
? [separator("sep-single-edit")]
: []),
...(allowEdit()
? [
{
@@ -257,12 +328,63 @@
},
]
: []),
...(allowDelete()
...(allowFindSimilarTrails()
? [{
text: $_("find-similar-trails"),
value: "find-similar-trails",
icon: "link",
}]
: []),
...(allowCopy()
? [
{
text: $_("delete"),
value: "delete",
icon: "trash",
text: $_("duplicate"),
value: "copy",
icon: "copy",
},
]
: []),
...((allowListManagement || allowShareSingleTrail || allowPublish())
? [separator("sep-single-organize")]
: []),
...(!allowListManagement
? []
: [
{
text: $_("add-to-list"),
value: "list",
icon: "bookmark",
},
]),
...(!allowShareSingleTrail
? []
: [
{
text: $_("share"),
value: "share",
icon: "share",
},
]),
...(allowPublish()
? [
{
text: `${majorityOfSelectedTrailsArePublic() ? $_("set-private") : $_("set-public")}`,
value: "publish",
icon: majorityOfSelectedTrailsArePublic()
? "lock"
: "globe",
},
]
: []),
...((allowSingleOutput || allowDelete())
? [separator("sep-single-output")]
: []),
...(canExport()
? [
{
text: $_("export"),
value: "download",
icon: "download",
},
]
: []),
@@ -275,6 +397,25 @@
},
]
: []),
...(!isMultiselectMode()
? [
{
text: $_("print"),
value: "print",
icon: "print",
},
]
: []),
...(allowDelete()
? [
separator("sep-single-danger"),
{
text: $_("delete"),
value: "delete",
icon: "trash",
},
]
: []),
];
}
@@ -399,11 +540,69 @@
updateTrailsVisibility();
} else if (ddVal == "delete") {
confirmModal.openModal();
} else if (item.value == "merge") {
await trailMergeModal.openModal(Array.from(trails ?? []));
} else if (item.value == "find-similar-trails") {
if (trail()) {
await trailMergeModal.openSimilarTrailsModal(trail()!);
}
} else if (item.value == "send-to") {
trailSendModal.openModal();
}
}
async function mergeTrails(settings: MergeSettings, selection: MergeSelection) {
let trailTarget = selection.targetTrail;
if (!trailTarget.id) {
return;
}
if (!trailTarget.expand) {
trailTarget = await trails_show(trailTarget.id);
}
for (const t of selection.sourceTrails) {
if (t.id === trailTarget.id) continue;
const u: Merge = {
trailTarget: trailTarget,
trailSource: t,
progress: 0,
status: "enqueued",
settings: settings,
function: trails_merge_backend
};
mergeStore.enqueuedMerges.push(u);
}
const completedBeforeRun = mergeStore.completedMerges.length;
await processMergeQueue();
const completedThisRun = mergeStore.completedMerges.slice(completedBeforeRun);
const successfulMerges = completedThisRun.filter(
(merge) => merge.status === "success",
);
const successfulThisRun = successfulMerges.length;
if (settings.delete && successfulThisRun > 0) {
onMerge?.({
targetTrail: trailTarget,
deletedTrailIds: successfulMerges
.map((merge) => merge.trailSource.id)
.filter((id): id is string => Boolean(id)),
successfulMergeCount: successfulThisRun,
});
}
}
async function trails_merge_backend(trailTarget: Trail, trailSource: Trail, settings: MergeSettings, onProgress?: (progress: number) => void) {
if (!trailTarget.id || !trailSource.id) {
throw new Error($_("error-merging-trail"));
}
onProgress?.(0.2);
await trail_merge(trailSource.id, trailTarget.id, settings);
onProgress?.(1);
}
async function uploadToHammerhead() {
if (!hammerheadIntegration || !hasTrail()) {
console.error("No Hammerhead integration found.");
@@ -725,6 +924,12 @@
onsave={handleShareUpdate}
bind:this={trailShareModal}
></TrailShareModal>
<TrailMergeModal
bind:this={trailMergeModal}
onmerge={(settings, selection) => mergeTrails(settings, selection)}
></TrailMergeModal>
<MergeDialog/>
<TrailSendModal
bind:this={trailSendModal}
onsend={async (settings) => {

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import Tabs from "$lib/components/base/tabs.svelte";
import TrailDropdown from "$lib/components/trail/trail_dropdown.svelte";
import TrailDropdown, { type MergeResult } from "$lib/components/trail/trail_dropdown.svelte";
import { Comment } from "$lib/models/comment";
import type { Trail } from "$lib/models/trail";
@@ -269,6 +270,21 @@
summitLogs.set(newSummitLogList);
}
function handleTrailMerge(result: MergeResult) {
if (!trail.id || !result.deletedTrailIds.includes(trail.id)) {
return;
}
const targetHandle = handleFromRecordWithIRI(result.targetTrail);
const targetPath =
mode === "map"
? `/map/trail/${targetHandle}/${result.targetTrail.id}`
: `/trail/view/${targetHandle}/${result.targetTrail.id}`;
const search = page.url.searchParams.toString();
goto(search ? `${targetPath}?${search}` : targetPath);
}
async function markTrailAsCompleted() {
trail.completed = true;
const updatedTrail: Trail = { ...trail };
@@ -430,6 +446,7 @@
trails={new Set<Trail>([trail])}
onDelete={() =>
history.length ? history.back() : goto("/trails")}
onMerge={handleTrailMerge}
{mode}
></TrailDropdown>
</div>

View File

@@ -298,6 +298,15 @@
else hoveredTrail = undefined;
}
async function handleTrailsMergeDone(resetSelection: boolean = false) {
if (resetSelection) {
selection?.clear();
hoveredTrail = undefined;
}
await tick();
onupdate?.(filter, selection);
}
async function handleTrailsEditDone(resetSelection: boolean = false) {
if (resetSelection) {
selection = new Set<Trail>();
@@ -346,6 +355,7 @@
mode={"multi-select"}
onDelete={() => handleTrailsEditDone(true)}
onShare={() => handleTrailsEditDone(false)}
onMerge={() => handleTrailsMergeDone(true)}
onUpdate={() => handleTrailsEditDone(true)}
/>
</div>

View File

@@ -0,0 +1,177 @@
<script lang="ts">
import {
processMergeQueue,
mergeStore,
type Merge,
} from "$lib/stores/trail_merge_store.svelte";
import { slide } from "svelte/transition";
import { _ } from "svelte-i18n";
import { goto } from "$app/navigation";
let minimized: boolean = $state(false);
let visibleMerges = $derived(
mergeStore.enqueuedMerges
.concat(mergeStore.completedMerges)
.sort((a, b) =>
(a.trailSource.name || a.trailSource.id || "").localeCompare(
b.trailSource.name || b.trailSource.id || "",
),
),
);
let remaining = $derived(mergeStore.enqueuedMerges.length);
let successfulMerges = $derived(
mergeStore.completedMerges.reduce(
(sum, u) => (sum += u.status == "success" ? 1 : 0),
0,
),
);
let errorMerges = $derived(
mergeStore.completedMerges.reduce(
(sum, u) => (sum += u.status == "error" ? 1 : 0),
0,
),
);
function dismissMerge(u: Merge) {
const index = mergeStore.completedMerges.indexOf(u);
mergeStore.completedMerges.splice(index, 1);
}
function dismissAllCompleted() {
mergeStore.completedMerges = [];
}
function cancelMerge(u: Merge) {
const index = mergeStore.enqueuedMerges.indexOf(u);
u.status = "cancelled";
mergeStore.enqueuedMerges.splice(index, 1);
mergeStore.completedMerges.push(u);
}
function reMerge(u: Merge) {
const index = mergeStore.completedMerges.indexOf(u);
u.status = "enqueued";
u.progress = 0;
u.error = undefined;
mergeStore.completedMerges.splice(index, 1);
mergeStore.enqueuedMerges.push(u);
processMergeQueue(undefined);
}
</script>
{#if visibleMerges.length}
<div
class="fixed bottom-4 right-4 z-10 p-4 bg-background rounded-xl border border-input-border shadow-xl"
class:cursor-pointer={minimized}
in:slide
out:slide
role="presentation"
onclick={(e) => {
e.stopPropagation();
minimized = false;
}}
>
<div class="flex gap-x-2 items-start justify-between">
<div>
<p class="font-medium">
{$_("trail-merge-summary", {
values: {
remaining,
processed: mergeStore.completedMerges.length,
total:
mergeStore.enqueuedMerges.length +
mergeStore.completedMerges.length,
},
})}
</p>
<p class="text-sm">
{$_("trail-merge-result-summary", {
values: {
success: successfulMerges,
errors: errorMerges,
},
})}
</p>
</div>
<div class="space-x-2">
<button title={$_('clear-all')} aria-label={$_('clear-all')} onclick={dismissAllCompleted}
><i class="fa fa-ban"></i></button
>
<button
aria-label={$_("minimize")}
onclick={(e) => {
e.stopPropagation();
minimized = true;
}}><i class="fa fa-minus"></i></button
>
</div>
</div>
<div
class="max-h-96 max-w-72 mt-4 overflow-y-auto space-y-2"
class:hidden={minimized}
>
{#each visibleMerges as u}
<div class="bg-menu-item-background-hover rounded-lg py-2 px-3">
<div class="flex items-center gap-2">
<div class="w-6 shrink-0">
{#if u.status === "enqueued" || u.status == "merging"}
<div class="spinner spinner-small"></div>
{:else}
<i
class={{
fa: true,
"fa-circle-exclamation text-red-400":
u.status == "error",
"fa-circle-check text-emerald-400":
u.status == "success",
"fa-ban text-gray-500":
u.status == "cancelled",
}}
></i>
{/if}
</div>
<p class="text-xs basis-full min-w-0 break-all mr-2">
{u.trailSource.name}
</p>
{#if u.status == "error" || u.status == "cancelled"}
<button
aria-label={$_("trail-merge-retry")}
onclick={() => reMerge(u)}
><i class="fa fa-redo text-sm"></i></button
>
{/if}
{#if u.status == "enqueued"}
<button
aria-label={$_("trail-merge-cancel")}
onclick={() => cancelMerge(u)}
><i class="fa fa-stop text-sm"></i></button
>
{/if}
{#if u.status != "enqueued" && u.status != "merging"}
<button
aria-label={$_("dismiss")}
onclick={() => dismissMerge(u)}
><i class="fa fa-close text-sm"></i></button
>
{/if}
</div>
{#if u.status == "merging"}
<div
class="progress-bar my-1 rounded-md"
style="height:2px; width:{u.progress * 100}%; background-color:#3549bb;transition: width 0.5s ease-in-out;"
></div>
{:else if u.error}
<p class="text-red-400 text-xs">
{u.error}
</p>
{/if}
</div>
{/each}
</div>
</div>
{/if}

View File

@@ -0,0 +1,514 @@
<script lang="ts">
import Modal from "$lib/components/base/modal.svelte";
import Select, { type SelectItem } from "$lib/components/base/select.svelte";
import { trails_show } from "$lib/stores/trail_store";
import type { Trail } from "$lib/models/trail";
import {
trail_merge_suggest_auto,
trail_merge_suggest_manual,
type TrailMergeSuggestCandidate,
} from "$lib/stores/trail_merge_api";
import { translateTrailMergeError } from "$lib/stores/trail_merge_i18n";
import { show_toast } from "$lib/stores/toast_store.svelte";
import { _ } from "svelte-i18n";
import { APIError } from "$lib/util/api_util";
export interface MergeSelection {
targetTrail: Trail;
sourceTrails: Trail[];
}
export interface OpenMergeModalOptions {
preferredTargetTrailId?: string;
fixedTargetTrailId?: string;
}
interface Props {
title?: string;
onmerge?: (settings: MergeSettings, selection: MergeSelection) => void;
}
let { title = $_("link-as-summit-log"), onmerge: onmerge }: Props = $props();
const STORAGE_KEY_SETTINGS = "trail_merge_settings";
const STORAGE_KEY_REMEMBER = "trail_merge_remember";
let modal: Modal;
let loading = $state(false);
let candidateWarnings: Record<string, string[]> = $state({});
let candidateReasons: Record<string, string> = $state({});
let selectableTargets: Trail[] = $state([]);
let targetTrailId = $state("");
let modalTitle = $state("");
let autoDiscoveryMode = $state(false);
let fixedTargetSelection = $state(false);
let mergeTrailsSelection: Trail[] = $state([]);
let autoDiscoverySourceTrail: Trail | undefined = $state();
let autoDiscoveryCandidateTrails: Trail[] = $state([]);
const defaultSettings: MergeSettings = {
summitLog: true,
photos: true,
comments: true,
delete: true,
tags: true,
likes: true,
};
let rememberSettings = $state(false);
function applySettings(next: MergeSettings) {
settings.summitLog = next.summitLog;
settings.photos = next.photos;
settings.comments = next.comments;
settings.delete = next.delete;
settings.tags = next.tags;
settings.likes = next.likes;
}
function loadStoredSettings() {
if (typeof localStorage === "undefined") {
applySettings(defaultSettings);
return;
}
rememberSettings = localStorage.getItem(STORAGE_KEY_REMEMBER) === "true";
if (!rememberSettings) {
applySettings(defaultSettings);
return;
}
const raw = localStorage.getItem(STORAGE_KEY_SETTINGS);
if (!raw) {
applySettings(defaultSettings);
return;
}
try {
const parsed = JSON.parse(raw) as Partial<MergeSettings>;
applySettings({
summitLog: parsed.summitLog ?? defaultSettings.summitLog,
photos: parsed.photos ?? defaultSettings.photos,
comments: parsed.comments ?? defaultSettings.comments,
delete: parsed.delete ?? defaultSettings.delete,
tags: parsed.tags ?? defaultSettings.tags,
likes: parsed.likes ?? defaultSettings.likes,
});
} catch {
applySettings(defaultSettings);
}
}
function persistSettingsPreference() {
if (typeof localStorage === "undefined") {
return;
}
localStorage.setItem(STORAGE_KEY_REMEMBER, rememberSettings ? "true" : "false");
if (!rememberSettings) {
localStorage.removeItem(STORAGE_KEY_SETTINGS);
return;
}
localStorage.setItem(
STORAGE_KEY_SETTINGS,
JSON.stringify({
summitLog: settings.summitLog,
photos: settings.photos,
comments: settings.comments,
delete: settings.delete,
tags: settings.tags,
likes: settings.likes,
} satisfies MergeSettings),
);
}
async function loadAutoDiscoveryTargets(
sourceTrail: Trail,
): Promise<{
targetTrailId: string;
warnings: Record<string, string[]>;
reasons: Record<string, string>;
selectableTargets: Trail[];
}> {
if (!sourceTrail.id) {
throw new Error($_("trail_merge_missing_source_trail_id"));
}
const response = await trail_merge_suggest_auto(sourceTrail.id);
const selectableCandidates = response.candidates.filter(
(candidate) => candidate.selectable,
);
const loadedTargets = await Promise.all(
selectableCandidates.map(async (candidate) => {
const trail = await trails_show(candidate.trailId);
return { candidate, trail };
}),
);
const warnings: Record<string, string[]> = {};
const reasons: Record<string, string> = {};
for (const { candidate } of loadedTargets) {
warnings[candidate.trailId] = candidate.warnings;
reasons[candidate.trailId] = candidate.reason;
}
const targets = loadedTargets.map(({ trail }) => trail);
return {
targetTrailId:
targets.find((trail) => trail.id === response.targetTrailId)?.id ??
targets[0]?.id ??
"",
warnings,
reasons,
selectableTargets: targets,
};
}
export async function openModal(trails: Trail[] = [], options: OpenMergeModalOptions = {}) {
loading = true;
modalTitle = title;
autoDiscoveryMode = false;
fixedTargetSelection = false;
mergeTrailsSelection = trails;
autoDiscoverySourceTrail = undefined;
autoDiscoveryCandidateTrails = [];
candidateWarnings = {};
candidateReasons = {};
selectableTargets = [];
targetTrailId = "";
loadStoredSettings();
modal.openModal();
if (options.fixedTargetTrailId) {
const fixedTargetTrail = trails.find((trail) => trail.id === options.fixedTargetTrailId);
if (!fixedTargetTrail?.id) {
show_toast({
type: "error",
icon: "close",
text: $_("trail-merge-no-editable-target"),
});
modal.closeModal();
loading = false;
return;
}
fixedTargetSelection = true;
selectableTargets = [fixedTargetTrail];
targetTrailId = fixedTargetTrail.id;
loading = false;
return;
}
try {
const response = await trail_merge_suggest_manual(
trails.map((trail) => trail.id!).filter(Boolean),
);
const nextWarnings: Record<string, string[]> = {};
const nextReasons: Record<string, string> = {};
const candidatesById = new Map<string, TrailMergeSuggestCandidate>(
response.candidates.map((candidate) => [candidate.trailId, candidate]),
);
const nextSelectableTargets = trails.filter((trail) => {
const candidate = candidatesById.get(trail.id!);
if (!candidate) {
return false;
}
nextWarnings[trail.id!] = candidate.warnings;
nextReasons[trail.id!] = candidate.reason;
return candidate.selectable;
});
candidateWarnings = nextWarnings;
candidateReasons = nextReasons;
selectableTargets = nextSelectableTargets;
if (selectableTargets.length === 0) {
show_toast({
type: "error",
icon: "close",
text: $_("trail-merge-no-editable-target"),
});
modal.closeModal();
return;
}
targetTrailId =
selectableTargets.find((trail) => trail.id === options.preferredTargetTrailId)?.id
?? selectableTargets.find((trail) => trail.id === response.targetTrailId)?.id
?? selectableTargets[0]?.id
?? "";
} catch (error) {
console.error("Failed to suggest trail merge target", error);
const text =
error instanceof APIError
? translateTrailMergeError(error.message)
: $_("trail-merge-suggest-error");
show_toast({
type: "error",
icon: "close",
text,
});
modal.closeModal();
return;
} finally {
loading = false;
}
}
export async function openSimilarTrailsModal(sourceTrail: Trail) {
loading = true;
modalTitle = $_("find-similar-trails");
autoDiscoveryMode = true;
mergeTrailsSelection = [sourceTrail];
autoDiscoverySourceTrail = sourceTrail;
candidateWarnings = {};
candidateReasons = {};
selectableTargets = [];
autoDiscoveryCandidateTrails = [];
targetTrailId = "";
loadStoredSettings();
modal.openModal();
try {
const result = await loadAutoDiscoveryTargets(sourceTrail);
candidateWarnings = result.warnings;
candidateReasons = result.reasons;
autoDiscoveryCandidateTrails = result.selectableTargets;
selectableTargets = [sourceTrail, ...result.selectableTargets];
candidateWarnings[sourceTrail.id!] = [];
candidateReasons[sourceTrail.id!] = "selected_trail";
targetTrailId = result.targetTrailId || sourceTrail.id || "";
if (autoDiscoveryCandidateTrails.length === 0) {
show_toast({
type: "warning",
icon: "triangle-exclamation",
text: $_("trail-merge-no-similar-trails"),
});
modal.closeModal();
return;
}
} catch (error) {
console.error("Failed to find similar trails", error);
const text =
error instanceof APIError
? translateTrailMergeError(error.message)
: $_("trail-merge-suggest-error");
show_toast({
type: "error",
icon: "close",
text,
});
modal.closeModal();
return;
} finally {
loading = false;
}
}
export interface MergeSettings {
summitLog: boolean;
photos: boolean;
comments: boolean;
delete: boolean;
tags: boolean;
likes: boolean;
}
const settings: MergeSettings = $state({
...defaultSettings,
});
function mergeTrail() {
if (!targetTrailId) {
return;
}
const targetTrail = selectableTargets.find((trail) => trail.id === targetTrailId);
if (!targetTrail) {
return;
}
let sourceTrails = mergeTrailsSelection.filter((trail) => trail.id !== targetTrail.id);
if (autoDiscoveryMode && autoDiscoverySourceTrail) {
sourceTrails =
targetTrail.id === autoDiscoverySourceTrail.id
? autoDiscoveryCandidateTrails.filter((trail) => trail.id !== targetTrail.id)
: [autoDiscoverySourceTrail];
}
if (sourceTrails.length === 0) {
return;
}
persistSettingsPreference();
onmerge?.(settings, {
targetTrail,
sourceTrails,
});
modal.closeModal();
}
let warnings = $derived(targetTrailId ? (candidateWarnings[targetTrailId] ?? []) : []);
let selectedReason = $derived(targetTrailId ? candidateReasons[targetTrailId] : "");
let targetTrailItems = $derived.by((): SelectItem[] =>
selectableTargets.map((trail) => ({
text: trail.name ?? "",
value: trail.id ?? "",
})),
);
</script>
<Modal id="merge-modal" title={modalTitle} size="min-w-md" bind:this={modal}>
{#snippet content()}
<div>
{#if loading}
<div class="py-6 flex flex-col items-center justify-center gap-3">
<div class="spinner light:spinner-dark"></div>
<p class="text-sm text-gray-500">
{autoDiscoveryMode
? $_("trail-merge-loading-similar-trails")
: $_("trail-merge-loading-targets")}
</p>
</div>
{:else}
<div class="mb-4">
{#if !fixedTargetSelection}
<h4 class="font-semibold mb-2">{$_("trail-merge-target")}</h4>
<Select
name="trail-merge-target"
items={targetTrailItems}
bind:value={targetTrailId}
disabled={selectableTargets.length <= 1}
/>
{/if}
{#if autoDiscoveryMode}
<p class="text-sm text-gray-500 mt-2">
{$_("trail-merge-similar-trails-found", {
values: { n: autoDiscoveryCandidateTrails.length },
})}
</p>
{/if}
{#if selectedReason}
<p class="text-sm text-gray-500 mt-2">{$_(`trail-merge-reason-${selectedReason}`)}</p>
{/if}
</div>
{#if warnings.length > 0}
<div class="mb-4 rounded-xl border border-yellow-500/40 bg-yellow-500/10 p-3">
<h4 class="font-semibold mb-2">{$_("trail-merge-warnings-title")}</h4>
<ul class="text-sm space-y-1">
{#each warnings as warning}
<li>{$_(`trail-merge-warning-${warning}`)}</li>
{/each}
</ul>
</div>
{/if}
<h4 class="font-semibold mb-2">{$_("copy-include-elements")}</h4>
<div class="mb-2">
<input
id="include-summit-log-checkbox"
type="checkbox"
bind:checked={settings.summitLog}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
/>
<label for="include-summit-log-checkbox" class="ms-2 text-sm"
>{$_("summit-log", { values: { n: 2 } })}</label
>
</div>
<div class="mb-2">
<input
id="include-photos-checkbox"
type="checkbox"
bind:checked={settings.photos}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
/>
<label for="include-photos-checkbox" class="ms-2 text-sm"
>{$_("photos")}</label
>
</div>
<div class="mb-2">
<input
id="include-comments-checkbox"
type="checkbox"
bind:checked={settings.comments}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
/>
<label for="include-comments-checkbox" class="ms-2 text-sm"
>{$_("comment", { values: { n: 2 } })}</label
>
</div>
<div class="mb-2">
<input
id="include-tags-checkbox"
type="checkbox"
bind:checked={settings.tags}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
/>
<label for="include-tags-checkbox" class="ms-2 text-sm"
>{$_("tags")}</label
>
</div>
<div class="mb-2">
<input
id="include-likes-checkbox"
type="checkbox"
bind:checked={settings.likes}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
/>
<label for="include-likes-checkbox" class="ms-2 text-sm"
>{$_("likes")}</label
>
</div>
<h4 class="font-semibold mt-4 mb-2">{$_("linked-trails")}</h4>
<div class="mb-2">
<input
id="include-trail-delete-checkbox"
type="checkbox"
bind:checked={settings.delete}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
/>
<label for="include-trail-delete-checkbox" class="ms-2 text-sm"
>{$_("delete-linked-trails")}</label
>
</div>
{/if}
</div>
{/snippet}
{#snippet footer()}
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<label for="remember-merge-settings-checkbox" class="flex items-center gap-2 text-sm">
<input
id="remember-merge-settings-checkbox"
type="checkbox"
bind:checked={rememberSettings}
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
/>
<span>{$_("trail-merge-remember-settings-short")}</span>
</label>
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button
>
<button
class="btn-primary"
type="button"
disabled={loading || !targetTrailId}
onclick={mergeTrail}
name="save">{$_("link")}</button
>
</div>
</div>
{/snippet}</Modal
>

View File

@@ -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": "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",

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
}

View File

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

View File

@@ -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<unknown>
};
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<unknown>[] = [];
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;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,356 @@
<script lang="ts">
import { goto } from "$app/navigation";
import MergeDialog from "$lib/components/trail/trail_merge_dialog.svelte";
import TrailListItem from "$lib/components/trail/trail_list_item.svelte";
import TrailMergeModal, {
type MergeSelection,
type MergeSettings,
} from "$lib/components/trail/trail_merge_modal.svelte";
import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte";
import type { Trail } from "$lib/models/trail";
import {
type TrailMergeSuggestGroup,
trail_merge,
trail_merge_suggest_groups,
} from "$lib/stores/trail_merge_api";
import { translateTrailMergeError } from "$lib/stores/trail_merge_i18n";
import {
mergeStore,
processMergeQueue,
type Merge,
} from "$lib/stores/trail_merge_store.svelte";
import { trails_show } from "$lib/stores/trail_store";
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
import { APIError } from "$lib/util/api_util";
import { _ } from "svelte-i18n";
import { onMount } from "svelte";
type SimilarTrailGroupView = TrailMergeSuggestGroup & {
trails: Trail[];
targetTrail?: Trail;
suggestedTargetTrailId: string;
};
let loading = $state(true);
let groups = $state<SimilarTrailGroupView[]>([]);
let loadError = $state("");
let openMapGroupId = $state<string | null>(null);
let mapLoadingGroupId = $state<string | null>(null);
let mapTrailsByGroupId = $state<Record<string, Trail[]>>({});
let trailById = $state<Record<string, Trail>>({});
let trailWithGpxById = $state<Record<string, Trail>>({});
let trailMergeModal: TrailMergeModal;
onMount(async () => {
await loadGroups();
});
async function loadGroups() {
loading = true;
loadError = "";
openMapGroupId = null;
try {
const response = await trail_merge_suggest_groups();
const uniqueTrailIds = Array.from(
new Set(response.groups.flatMap((group) => group.trailIds)),
);
const missingTrailIds = uniqueTrailIds.filter((trailId) => !trailById[trailId]);
if (missingTrailIds.length > 0) {
const loadedTrails = await Promise.all(
missingTrailIds.map((trailId) => trails_show(trailId)),
);
trailById = {
...trailById,
...Object.fromEntries(
loadedTrails
.filter((trail) => Boolean(trail.id))
.map((trail) => [trail.id!, trail]),
),
};
}
groups = await Promise.all(
response.groups.map(async (group) => {
const trails = group.trailIds
.map((trailId) => trailById[trailId])
.filter((trail): trail is Trail => Boolean(trail));
return {
...group,
trails,
targetTrail: trails.find((trail) => trail.id === group.targetTrailId),
suggestedTargetTrailId: group.targetTrailId,
} satisfies SimilarTrailGroupView;
}),
);
} catch (error) {
console.error("Failed to load similar trail groups", error);
loadError =
error instanceof APIError
? translateTrailMergeError(error.message)
: $_("trail-merge-unknown-error");
} finally {
loading = false;
}
}
async function toggleMap(group: SimilarTrailGroupView) {
if (openMapGroupId === group.groupId) {
openMapGroupId = null;
return;
}
openMapGroupId = group.groupId;
if (mapTrailsByGroupId[group.groupId]) {
return;
}
mapLoadingGroupId = group.groupId;
try {
const trailIdsToLoad = group.trails
.map((trail) => trail.id)
.filter((trailId): trailId is string => Boolean(trailId))
.filter((trailId) => !trailWithGpxById[trailId]);
if (trailIdsToLoad.length > 0) {
const loadedTrails = await Promise.all(
trailIdsToLoad.map((trailId) =>
trails_show(trailId, undefined, undefined, true),
),
);
trailWithGpxById = {
...trailWithGpxById,
...Object.fromEntries(
loadedTrails
.filter((trail) => Boolean(trail.id))
.map((trail) => [trail.id!, trail]),
),
};
}
const groupMapTrails = group.trails
.map((trail) => trail.id)
.filter((trailId): trailId is string => Boolean(trailId))
.map((trailId) => trailWithGpxById[trailId])
.filter((trail): trail is Trail => Boolean(trail));
mapTrailsByGroupId = {
...mapTrailsByGroupId,
[group.groupId]: groupMapTrails,
};
} finally {
mapLoadingGroupId = null;
}
}
async function openMergeGroupModal(group: SimilarTrailGroupView) {
await trailMergeModal.openModal(group.trails, {
fixedTargetTrailId: group.targetTrailId,
});
}
function updateGroupTarget(groupId: string, targetTrailId: string) {
groups = groups.map((group) => {
if (group.groupId !== groupId) {
return group;
}
return {
...group,
targetTrailId,
targetTrail: group.trails.find((trail) => trail.id === targetTrailId),
};
});
}
async function mergeGroup(settings: MergeSettings, selection: MergeSelection) {
let trailTarget = selection.targetTrail;
if (!trailTarget.id) {
return;
}
if (!trailTarget.expand) {
trailTarget = await trails_show(trailTarget.id);
}
for (const sourceTrail of selection.sourceTrails) {
if (sourceTrail.id === trailTarget.id) {
continue;
}
const mergeJob: Merge = {
trailTarget,
trailSource: sourceTrail,
progress: 0,
status: "enqueued",
settings,
function: async (target, source, mergeSettings, onProgress) => {
if (!target.id || !source.id) {
throw new Error($_("error-merging-trail"));
}
onProgress?.(0.2);
await trail_merge(source.id, target.id, mergeSettings);
onProgress?.(1);
},
};
mergeStore.enqueuedMerges.push(mergeJob);
}
const completedBeforeRun = mergeStore.completedMerges.length;
await processMergeQueue();
const completedThisRun = mergeStore.completedMerges.slice(completedBeforeRun);
const successfulThisRun = completedThisRun.filter(
(merge) => merge.status === "success",
).length;
if (successfulThisRun > 0 && settings.delete) {
await loadGroups();
}
}
</script>
<svelte:head>
<title>{$_("similar-trails-maintenance-title")} | wanderer</title>
</svelte:head>
<div class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="space-y-2">
<h1 class="text-3xl font-bold">{$_("similar-trails-maintenance-title")}</h1>
<p class="text-sm text-gray-500 max-w-3xl">
{$_("similar-trails-maintenance-description")}
</p>
</div>
<button class="btn-secondary shrink-0" onclick={loadGroups} disabled={loading}>
{$_("similar-trails-scan")}
</button>
</div>
{#if loading}
<div class="rounded-2xl border border-input-border p-6 flex items-center gap-3">
<div class="spinner light:spinner-dark"></div>
<p class="text-sm text-gray-500">{$_("similar-trails-loading")}</p>
</div>
{:else if loadError}
<div class="rounded-2xl border border-red-500/40 bg-red-500/10 p-4 text-sm text-red-300">
{loadError}
</div>
{:else if groups.length === 0}
<div class="rounded-2xl border border-input-border p-6 text-sm text-gray-500">
{$_("similar-trails-empty")}
</div>
{:else}
<div class="space-y-6">
{#each groups as group}
<section class="rounded-2xl border border-input-border overflow-hidden">
<div class="p-5 flex flex-col gap-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-3">
<h2 class="text-xl font-semibold">
{$_("similar-trails-group-size", {
values: { n: group.trails.length },
})}
</h2>
</div>
{#if group.targetTrailId === group.suggestedTargetTrailId}
<p class="text-sm text-gray-500">
{$_(`similar-trails-reason-${group.reason}`)}
</p>
{/if}
{#if group.indirect}
<div class="rounded-xl border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-800 dark:text-yellow-200">
{$_("similar-trails-indirect-hint")}
</div>
{/if}
</div>
<div class="flex flex-col items-end gap-3 shrink-0">
<button class="btn-secondary" onclick={() => toggleMap(group)}>
{openMapGroupId === group.groupId
? $_("similar-trails-hide-map")
: $_("similar-trails-show-map")}
</button>
<button class="btn-primary" onclick={() => openMergeGroupModal(group)}>
{$_("similar-trails-merge-group")}
</button>
</div>
</div>
{#if openMapGroupId === group.groupId}
<div class="border-t border-input-border p-5 space-y-3">
<h3 class="text-lg font-semibold">{$_("similar-trails-map-title")}</h3>
{#if mapLoadingGroupId === group.groupId}
<div class="flex items-center gap-3 py-6">
<div class="spinner light:spinner-dark"></div>
<p class="text-sm text-gray-500">{$_("similar-trails-map-loading")}</p>
</div>
{:else if mapTrailsByGroupId[group.groupId]}
<div class="h-[26rem] rounded-2xl overflow-hidden border border-input-border">
<MapWithElevationMaplibre
trails={mapTrailsByGroupId[group.groupId]}
showTerrain={true}
/>
</div>
{/if}
</div>
{/if}
<div class="space-y-2">
{#each group.trails as trail}
<div class="relative group">
<a
class="block"
href={`/trail/view/${handleFromRecordWithIRI(trail)}/${trail.id}`}
onclick={(event) => {
event.preventDefault();
goto(`/trail/view/${handleFromRecordWithIRI(trail)}/${trail.id}`);
}}
>
<TrailListItem
{trail}
selected={false}
hovered={false}
showDescription={false}
/>
</a>
<button
type="button"
class={`absolute bottom-4 right-4 z-10 flex h-9 w-9 items-center justify-center rounded-full border shadow-sm transition-all ${
trail.id === group.targetTrailId
? "bg-primary text-white border-primary opacity-100"
: "bg-background/95 text-gray-500 border-input-border opacity-0 group-hover:opacity-100 hover:border-primary hover:text-primary"
}`}
onclick={(event) => {
event.preventDefault();
event.stopPropagation();
if (trail.id) {
updateGroupTarget(group.groupId, trail.id);
}
}}
aria-label={$_("similar-trails-set-target")}
title={$_("similar-trails-set-target")}
>
<i class="fa fa-flag-checkered"></i>
</button>
</div>
{/each}
</div>
</div>
</section>
{/each}
</div>
{/if}
</div>
<TrailMergeModal
bind:this={trailMergeModal}
onmerge={(settings, selection) => mergeGroup(settings, selection)}
/>
<MergeDialog />

View File

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