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