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.*
|
!go.*
|
||||||
!integrations
|
!integrations
|
||||||
!main.go
|
!main.go
|
||||||
|
!trail_merge_routes.go
|
||||||
!migrations
|
!migrations
|
||||||
!templates
|
!templates
|
||||||
|
!trailmerge
|
||||||
!waypointcluster
|
!waypointcluster
|
||||||
!waypointcluster/**
|
!waypointcluster/**
|
||||||
!util
|
!util
|
||||||
|
|||||||
@@ -16,15 +16,19 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/meilisearch/meilisearch-go"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
"github.com/pocketbase/pocketbase/tools/security"
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
"github.com/tkrajina/gpxgo/gpx"
|
"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"))
|
integrations, err := app.FindAllRecords("integrations", dbx.NewExp("true"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -44,12 +48,11 @@ func SyncHammerhead(app core.App) error {
|
|||||||
app.Logger().Warn(warning)
|
app.Logger().Warn(warning)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
actorId := actor.Id
|
|
||||||
|
|
||||||
hammerheadString := i.GetString("hammerhead")
|
hammerheadString := i.GetString("hammerhead")
|
||||||
hammerheadIntegration := HammerheadIntegration{
|
hammerheadIntegration := HammerheadIntegration{
|
||||||
Planned: true,
|
Planned: true,
|
||||||
Completed: true,
|
Completed: true,
|
||||||
|
Merge: trailmerge.DefaultIntegrationAutoMergeSettings(),
|
||||||
}
|
}
|
||||||
json.Unmarshal([]byte(hammerheadString), &hammerheadIntegration)
|
json.Unmarshal([]byte(hammerheadString), &hammerheadIntegration)
|
||||||
|
|
||||||
@@ -108,7 +111,7 @@ func SyncHammerhead(app core.App) error {
|
|||||||
totalPages = curTotalPages
|
totalPages = curTotalPages
|
||||||
}
|
}
|
||||||
|
|
||||||
err, stopped = syncTrailWithTours(app, h, actorId, tours, after)
|
err, stopped = syncTrailWithTours(app, client, h, actor, hammerheadIntegration, tours, after)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err)
|
warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err)
|
||||||
fmt.Print(warning)
|
fmt.Print(warning)
|
||||||
@@ -139,7 +142,7 @@ func SyncHammerhead(app core.App) error {
|
|||||||
totalPages = curTotalPages
|
totalPages = curTotalPages
|
||||||
}
|
}
|
||||||
|
|
||||||
err, stopped = syncTrailWithActivities(app, h, actorId, tours, after)
|
err, stopped = syncTrailWithActivities(app, client, h, actor, hammerheadIntegration, tours, after)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err)
|
warning := fmt.Sprintf("error syncing Hammerhead tours with trails: %v\n", err)
|
||||||
fmt.Print(warning)
|
fmt.Print(warning)
|
||||||
@@ -406,15 +409,13 @@ func (h *HammerheadApi) fetchDetailedTour(tour HammerheadTourResponse) (*Hammerh
|
|||||||
return data, nil
|
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 {
|
for _, tour := range tours {
|
||||||
|
existingTrail, err := util.FindTrailByExternalReference(app, "hammerhead", tour.ID)
|
||||||
trails, err := app.FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": tour.ID})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err, true
|
return err, true
|
||||||
}
|
}
|
||||||
|
if existingTrail != nil {
|
||||||
if len(trails) != 0 {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,25 +440,26 @@ func syncTrailWithTours(app core.App, k *HammerheadApi, actor string, tours []Ha
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = createTrailFromTour(app, detailedTour, gpx, actor)
|
trailID, err := createTrailFromTour(app, detailedTour, gpx, actor.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
|
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
|
||||||
continue
|
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
|
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 {
|
for _, tour := range tours {
|
||||||
|
existingTrail, err := util.FindTrailByExternalReference(app, "hammerhead", tour.ID)
|
||||||
trails, err := app.FindRecordsByFilter("trails", "external_id = {:id}", "", 1, 0, dbx.Params{"id": tour.ID})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err, true
|
return err, true
|
||||||
}
|
}
|
||||||
|
if existingTrail != nil {
|
||||||
if len(trails) != 0 {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,11 +485,14 @@ func syncTrailWithActivities(app core.App, k *HammerheadApi, actor string, tours
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = createTrailFromActivity(app, detailedTour, gpx, actor)
|
trailID, err := createTrailFromActivity(app, detailedTour, gpx, actor.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
|
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
|
||||||
continue
|
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
|
return nil, false
|
||||||
@@ -565,6 +570,9 @@ func createTrailFromActivity(app core.App, detailedTour *HammerheadActivity, gpx
|
|||||||
if err := app.Save(record); err != nil {
|
if err := app.Save(record); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if err := util.EnsureTrailExternalReference(app, trailid, "hammerhead", detailedTour.ActivityData.ID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
collection, err = app.FindCollectionByNameOrId("summit_logs")
|
collection, err = app.FindCollectionByNameOrId("summit_logs")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -607,20 +615,18 @@ func createTrailFromTour(app core.App, detailedTour *HammerheadTour, gpx *filesy
|
|||||||
diffculty := "easy" // ToDo: calculate difficulty
|
diffculty := "easy" // ToDo: calculate difficulty
|
||||||
|
|
||||||
record.Load(map[string]any{
|
record.Load(map[string]any{
|
||||||
"id": trailid,
|
"id": trailid,
|
||||||
"name": detailedTour.Name,
|
"name": detailedTour.Name,
|
||||||
"public": detailedTour.IsPublic,
|
"public": detailedTour.IsPublic,
|
||||||
"distance": detailedTour.Distance,
|
"distance": detailedTour.Distance,
|
||||||
"elevation_gain": detailedTour.Elevation.Gain,
|
"elevation_gain": detailedTour.Elevation.Gain,
|
||||||
"elevation_loss": detailedTour.Elevation.Loss,
|
"elevation_loss": detailedTour.Elevation.Loss,
|
||||||
"date": detailedTour.CreatedAt,
|
"date": detailedTour.CreatedAt,
|
||||||
"external_provider": "hammerhead",
|
"lat": detailedTour.StartLocation.Lat,
|
||||||
"external_id": detailedTour.ID,
|
"lon": detailedTour.StartLocation.Lng,
|
||||||
"lat": detailedTour.StartLocation.Lat,
|
"difficulty": diffculty,
|
||||||
"lon": detailedTour.StartLocation.Lng,
|
"category": categoryId,
|
||||||
"difficulty": diffculty,
|
"author": actor,
|
||||||
"category": categoryId,
|
|
||||||
"author": actor,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if gpx != nil {
|
if gpx != nil {
|
||||||
@@ -630,6 +636,9 @@ func createTrailFromTour(app core.App, detailedTour *HammerheadTour, gpx *filesy
|
|||||||
if err := app.Save(record); err != nil {
|
if err := app.Save(record); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if err := util.EnsureTrailExternalReference(app, trailid, "hammerhead", detailedTour.ID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
return trailid, nil
|
return trailid, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package hammerhead
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"pocketbase/trailmerge"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HammerheadToursResponse struct {
|
type HammerheadToursResponse struct {
|
||||||
@@ -72,12 +74,13 @@ type HammerheadTour struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HammerheadIntegration struct {
|
type HammerheadIntegration struct {
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Planned bool `json:"planned"`
|
Planned bool `json:"planned"`
|
||||||
Completed bool `json:"completed"`
|
Completed bool `json:"completed"`
|
||||||
After string `json:"after,omitempty"`
|
After string `json:"after,omitempty"`
|
||||||
|
Merge trailmerge.IntegrationAutoMergeSettings `json:"merge"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginResponse struct {
|
type LoginResponse struct {
|
||||||
|
|||||||
@@ -12,14 +12,18 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/meilisearch/meilisearch-go"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
"github.com/pocketbase/pocketbase/tools/security"
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
"github.com/tkrajina/gpxgo/gpx"
|
"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"))
|
integrations, err := app.FindAllRecords("integrations", dbx.NewExp("true"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -39,12 +43,11 @@ func SyncKomoot(app core.App) error {
|
|||||||
app.Logger().Warn(warning)
|
app.Logger().Warn(warning)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
actorId := actor.Id
|
|
||||||
|
|
||||||
komootString := i.GetString("komoot")
|
komootString := i.GetString("komoot")
|
||||||
komootIntegration := KomootIntegration{
|
komootIntegration := KomootIntegration{
|
||||||
Planned: true,
|
Planned: true,
|
||||||
Completed: true,
|
Completed: true,
|
||||||
|
Merge: trailmerge.DefaultIntegrationAutoMergeSettings(),
|
||||||
}
|
}
|
||||||
json.Unmarshal([]byte(komootString), &komootIntegration)
|
json.Unmarshal([]byte(komootString), &komootIntegration)
|
||||||
|
|
||||||
@@ -79,7 +82,7 @@ func SyncKomoot(app core.App) error {
|
|||||||
}
|
}
|
||||||
totalPages = tp
|
totalPages = tp
|
||||||
|
|
||||||
allAlreadySynced, err := syncTrailWithTours(app, k, komootIntegration, userId, actorId, tours)
|
allAlreadySynced, err := syncTrailWithTours(app, client, k, komootIntegration, userId, actor, tours)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
warning := fmt.Sprintf("error syncing komoot tours with trails: %v\n", err)
|
warning := fmt.Sprintf("error syncing komoot tours with trails: %v\n", err)
|
||||||
fmt.Print(warning)
|
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
|
// 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
|
// early during incremental syncs. Tours skipped due to type filters do NOT count as
|
||||||
// synced - only tours already present in the DB do.
|
// 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
|
allAlreadySynced := true
|
||||||
for _, tour := range tours {
|
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 {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if len(trails) != 0 {
|
if existingTrail != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Tour is not yet in the DB - we must keep paginating regardless of type filter
|
// 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))
|
app.Logger().Warn(fmt.Sprintf("Unable to generate GPX for tour '%s': %v", tour.Name, err))
|
||||||
continue
|
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 {
|
if err != nil {
|
||||||
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
|
app.Logger().Warn(fmt.Sprintf("Unable to create trail for tour '%s': %v", tour.Name, err))
|
||||||
continue
|
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))
|
app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for tour '%s': %v", tour.Name, err))
|
||||||
continue
|
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
|
return allAlreadySynced, nil
|
||||||
@@ -317,6 +323,9 @@ func createTrailFromTour(app core.App, k *KomootApi, detailedTour *DetailedKomoo
|
|||||||
if err := app.Save(record); err != nil {
|
if err := app.Save(record); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if err := util.EnsureTrailExternalReference(app, trailid, "komoot", strconv.Itoa(detailedTour.ID)); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
if detailedTour.Type == "tour_recorded" {
|
if detailedTour.Type == "tour_recorded" {
|
||||||
collection, err := app.FindCollectionByNameOrId("summit_logs")
|
collection, err := app.FindCollectionByNameOrId("summit_logs")
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
package komoot
|
package komoot
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pocketbase/trailmerge"
|
||||||
|
)
|
||||||
|
|
||||||
type KomootIntegration struct {
|
type KomootIntegration struct {
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Planned bool `json:"planned"`
|
Planned bool `json:"planned"`
|
||||||
Completed bool `json:"completed"`
|
Completed bool `json:"completed"`
|
||||||
Privacy string `json:"privacy"`
|
Privacy string `json:"privacy"`
|
||||||
|
Merge trailmerge.IntegrationAutoMergeSettings `json:"merge"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginResponse struct {
|
type LoginResponse struct {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package strava
|
package strava
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"pocketbase/trailmerge"
|
||||||
|
)
|
||||||
|
|
||||||
type TokenRequest struct {
|
type TokenRequest struct {
|
||||||
ClientID int32 `json:"client_id"`
|
ClientID int32 `json:"client_id"`
|
||||||
@@ -21,16 +25,17 @@ type RefreshTokenResponse struct {
|
|||||||
ExpiresAt int64 `json:"expires_at"`
|
ExpiresAt int64 `json:"expires_at"`
|
||||||
}
|
}
|
||||||
type StravaIntegration struct {
|
type StravaIntegration struct {
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
Routes bool `json:"routes"`
|
Routes bool `json:"routes"`
|
||||||
Activities bool `json:"activities"`
|
Activities bool `json:"activities"`
|
||||||
ClientID int32 `json:"clientId"`
|
ClientID int32 `json:"clientId"`
|
||||||
ClientSecret string `json:"clientSecret"`
|
ClientSecret string `json:"clientSecret"`
|
||||||
AccessToken string `json:"accessToken,omitempty"`
|
AccessToken string `json:"accessToken,omitempty"`
|
||||||
RefreshToken string `json:"refreshToken,omitempty"`
|
RefreshToken string `json:"refreshToken,omitempty"`
|
||||||
ExpiresAt int64 `json:"expiresAt,omitempty"`
|
ExpiresAt int64 `json:"expiresAt,omitempty"`
|
||||||
Privacy string `json:"privacy"`
|
Privacy string `json:"privacy"`
|
||||||
After string `json:"after,omitempty"`
|
After string `json:"after,omitempty"`
|
||||||
|
Merge trailmerge.IntegrationAutoMergeSettings `json:"merge"`
|
||||||
}
|
}
|
||||||
type StravaRoute struct {
|
type StravaRoute struct {
|
||||||
Athlete Athlete `json:"athlete"`
|
Athlete Athlete `json:"athlete"`
|
||||||
|
|||||||
@@ -11,19 +11,23 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/meilisearch/meilisearch-go"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/filesystem"
|
"github.com/pocketbase/pocketbase/tools/filesystem"
|
||||||
"github.com/pocketbase/pocketbase/tools/security"
|
"github.com/pocketbase/pocketbase/tools/security"
|
||||||
"github.com/tkrajina/gpxgo/gpx"
|
"github.com/tkrajina/gpxgo/gpx"
|
||||||
"github.com/twpayne/go-polyline"
|
"github.com/twpayne/go-polyline"
|
||||||
|
|
||||||
|
"pocketbase/trailmerge"
|
||||||
|
"pocketbase/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StravaApi struct {
|
type StravaApi struct {
|
||||||
AceessToken string
|
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"))
|
integrations, err := app.FindAllRecords("integrations", dbx.NewExp("true"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -43,8 +47,6 @@ func SyncStrava(app core.App) error {
|
|||||||
app.Logger().Warn(warning)
|
app.Logger().Warn(warning)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
actorId := actor.Id
|
|
||||||
|
|
||||||
stravaString := i.GetString("strava")
|
stravaString := i.GetString("strava")
|
||||||
var stravaIntegration StravaIntegration
|
var stravaIntegration StravaIntegration
|
||||||
err = json.Unmarshal([]byte(stravaString), &stravaIntegration)
|
err = json.Unmarshal([]byte(stravaString), &stravaIntegration)
|
||||||
@@ -102,7 +104,7 @@ func SyncStrava(app core.App) error {
|
|||||||
app.Logger().Warn(warning)
|
app.Logger().Warn(warning)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
err = syncTrailsWithRoutes(app, stravaIntegration, r.AccessToken, userId, actorId, routes)
|
err = syncTrailsWithRoutes(app, client, stravaIntegration, r.AccessToken, userId, actor, routes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
warning := fmt.Sprintf("error syncing strava routes with trails: %v\n", err)
|
warning := fmt.Sprintf("error syncing strava routes with trails: %v\n", err)
|
||||||
fmt.Print(warning)
|
fmt.Print(warning)
|
||||||
@@ -134,7 +136,7 @@ func SyncStrava(app core.App) error {
|
|||||||
app.Logger().Warn(warning)
|
app.Logger().Warn(warning)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
err = syncTrailsWithActivities(app, stravaIntegration, r.AccessToken, userId, actorId, activities)
|
err = syncTrailsWithActivities(app, client, stravaIntegration, r.AccessToken, userId, actor, activities)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
warning := fmt.Sprintf("error syncing strava activities with trails: %v", err)
|
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
|
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 {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(trails) != 0 {
|
if existingTrail != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
gpx, err := fetchRouteGPX(route, accessToken)
|
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))
|
app.Logger().Warn(fmt.Sprintf("Unable to fetch GPX for route '%s': %v", route.Name, err))
|
||||||
continue
|
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 {
|
if err != nil {
|
||||||
app.Logger().Warn(fmt.Sprintf("Unable to create trail for route '%s': %v", route.Name, err))
|
app.Logger().Warn(fmt.Sprintf("Unable to create trail for route '%s': %v", route.Name, err))
|
||||||
continue
|
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))
|
app.Logger().Warn(fmt.Sprintf("Unable to create waypoints for route '%s': %v", route.Name, err))
|
||||||
continue
|
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
|
return nil
|
||||||
@@ -365,21 +370,19 @@ func createTrailFromRoute(app core.App, route StravaRoute, gpx *filesystem.File,
|
|||||||
}
|
}
|
||||||
|
|
||||||
record.Load(map[string]any{
|
record.Load(map[string]any{
|
||||||
"id": trailid,
|
"id": trailid,
|
||||||
"name": route.Name,
|
"name": route.Name,
|
||||||
"description": route.Description,
|
"description": route.Description,
|
||||||
"public": public,
|
"public": public,
|
||||||
"distance": route.Distance,
|
"distance": route.Distance,
|
||||||
"elevation_gain": route.ElevationGain,
|
"elevation_gain": route.ElevationGain,
|
||||||
"duration": route.EstimatedMovingTime,
|
"duration": route.EstimatedMovingTime,
|
||||||
"date": time.Unix(int64(route.Timestamp), 0),
|
"date": time.Unix(int64(route.Timestamp), 0),
|
||||||
"external_provider": "strava",
|
"lat": lat,
|
||||||
"external_id": route.IDStr,
|
"lon": lon,
|
||||||
"lat": lat,
|
"difficulty": "easy",
|
||||||
"lon": lon,
|
"category": category,
|
||||||
"difficulty": "easy",
|
"author": actor,
|
||||||
"category": category,
|
|
||||||
"author": actor,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if gpx != nil {
|
if gpx != nil {
|
||||||
@@ -389,6 +392,9 @@ func createTrailFromRoute(app core.App, route StravaRoute, gpx *filesystem.File,
|
|||||||
if err := app.Save(record); err != nil {
|
if err := app.Save(record); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if err := util.EnsureTrailExternalReference(app, trailid, "strava", route.IDStr); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
return trailid, err
|
return trailid, err
|
||||||
}
|
}
|
||||||
@@ -420,13 +426,13 @@ func createWaypointsFromRoute(app core.App, route StravaRoute, user string, trai
|
|||||||
return nil
|
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 {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(trails) != 0 {
|
if existingTrail != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
detailedActivity, err := fetchDetailedActivity(activity, accessToken)
|
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))
|
app.Logger().Warn(fmt.Sprintf("Unable to fetch GPX for activity '%s': %v", activity.Name, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
err = createTrailFromActivity(app, detailedActivity, gpx, user, actor, i.Privacy)
|
trailID, err := createTrailFromActivity(app, detailedActivity, gpx, user, actor.Id, i.Privacy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
app.Logger().Warn(fmt.Sprintf("Unable to create trail from activity '%s': %v", activity.Name, err))
|
app.Logger().Warn(fmt.Sprintf("Unable to create trail from activity '%s': %v", activity.Name, err))
|
||||||
continue
|
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
|
return nil
|
||||||
@@ -476,21 +485,21 @@ func fetchDetailedActivity(activity StravaActivity, accessToken string) (*Detail
|
|||||||
return &detailedActivity, nil
|
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 {
|
if len(activity.StartLatlng) < 2 {
|
||||||
return nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
collection, err := app.FindCollectionByNameOrId("trails")
|
collection, err := app.FindCollectionByNameOrId("trails")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var photo *filesystem.File
|
var photo *filesystem.File
|
||||||
if len(activity.Photos.Primary.Urls.Num600) > 0 {
|
if len(activity.Photos.Primary.Urls.Num600) > 0 {
|
||||||
photo, err = fetchActivityPhoto(activity)
|
photo, err = fetchActivityPhoto(activity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,27 +561,25 @@ func createTrailFromActivity(app core.App, activity *DetailedStravaActivity, gpx
|
|||||||
settings, _ := app.FindFirstRecordByData("settings", "user", user)
|
settings, _ := app.FindFirstRecordByData("settings", "user", user)
|
||||||
err = settings.UnmarshalJSONField("privacy", &privacySettings)
|
err = settings.UnmarshalJSONField("privacy", &privacySettings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
public = privacySettings.Trails == "public"
|
public = privacySettings.Trails == "public"
|
||||||
}
|
}
|
||||||
|
|
||||||
record.Load(map[string]any{
|
record.Load(map[string]any{
|
||||||
"name": activity.Name,
|
"name": activity.Name,
|
||||||
"description": activity.Description,
|
"description": activity.Description,
|
||||||
"public": public,
|
"public": public,
|
||||||
"distance": activity.Distance,
|
"distance": activity.Distance,
|
||||||
"elevation_gain": activity.TotalElevationGain,
|
"elevation_gain": activity.TotalElevationGain,
|
||||||
"duration": activity.ElapsedTime,
|
"duration": activity.ElapsedTime,
|
||||||
"date": activity.StartDate,
|
"date": activity.StartDate,
|
||||||
"external_provider": "strava",
|
"lat": activity.StartLatlng[0],
|
||||||
"external_id": activity.ID,
|
"lon": activity.StartLatlng[1],
|
||||||
"lat": activity.StartLatlng[0],
|
"difficulty": "easy",
|
||||||
"lon": activity.StartLatlng[1],
|
"category": categoryId,
|
||||||
"difficulty": "easy",
|
"author": actor,
|
||||||
"category": categoryId,
|
|
||||||
"author": actor,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if photo != nil {
|
if photo != nil {
|
||||||
@@ -584,10 +591,13 @@ func createTrailFromActivity(app core.App, activity *DetailedStravaActivity, gpx
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := app.Save(record); err != nil {
|
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) {
|
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 {
|
func onBeforeServeHandler(client meilisearch.ServiceManager) func(se *core.ServeEvent) error {
|
||||||
return func(se *core.ServeEvent) error {
|
return func(se *core.ServeEvent) error {
|
||||||
registerRoutes(se, client)
|
registerRoutes(se, client)
|
||||||
registerCronJobs(se.App)
|
registerCronJobs(se.App, client)
|
||||||
bootstrapData(se.App, client)
|
bootstrapData(se.App, client)
|
||||||
|
|
||||||
return se.Next()
|
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"})
|
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
registerTrailMergeRoutes(se, client)
|
||||||
se.Router.POST("/waypoint/cluster", waypointcluster.Handler)
|
se.Router.POST("/waypoint/cluster", waypointcluster.Handler)
|
||||||
|
|
||||||
se.Router.POST("/auth/token", func(e *core.RequestEvent) error {
|
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")
|
schedule := os.Getenv("POCKETBASE_CRON_SYNC_SCHEDULE")
|
||||||
if len(schedule) == 0 {
|
if len(schedule) == 0 {
|
||||||
schedule = "0 2 * * *"
|
schedule = "0 2 * * *"
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Cron().MustAdd("integrations", schedule, func() {
|
app.Cron().MustAdd("integrations", schedule, func() {
|
||||||
err := strava.SyncStrava(app)
|
err := strava.SyncStrava(app, client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
warning := fmt.Sprintf("Error syncing with strava: %v", err)
|
warning := fmt.Sprintf("Error syncing with strava: %v", err)
|
||||||
fmt.Println(warning)
|
fmt.Println(warning)
|
||||||
app.Logger().Error(warning)
|
app.Logger().Error(warning)
|
||||||
}
|
}
|
||||||
err = komoot.SyncKomoot(app)
|
err = komoot.SyncKomoot(app, client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
warning := fmt.Sprintf("Error syncing with komoot: %v", err)
|
warning := fmt.Sprintf("Error syncing with komoot: %v", err)
|
||||||
fmt.Println(warning)
|
fmt.Println(warning)
|
||||||
app.Logger().Error(warning)
|
app.Logger().Error(warning)
|
||||||
}
|
}
|
||||||
err = hammerhead.SyncHammerhead(app)
|
err = hammerhead.SyncHammerhead(app, client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
warning := fmt.Sprintf("Error syncing with hammerhead: %v", err)
|
warning := fmt.Sprintf("Error syncing with hammerhead: %v", err)
|
||||||
fmt.Println(warning)
|
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',
|
label: 'Summit logs',
|
||||||
link: '/use/summit-logs/'
|
link: '/use/summit-logs/'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Merge trails',
|
||||||
|
link: '/use/merge-trails/'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Interact with the community',
|
label: 'Interact with the community',
|
||||||
link: '/use/community-interaction/'
|
link: '/use/community-interaction/'
|
||||||
@@ -152,4 +156,4 @@ export default defineConfig({
|
|||||||
'/run/backup-server/': '/run/backend-configuration/backup-server/',
|
'/run/backup-server/': '/run/backend-configuration/backup-server/',
|
||||||
'/run/custom-categories/': '/run/backend-configuration/custom-categories/',
|
'/run/custom-categories/': '/run/backend-configuration/custom-categories/',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
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": {
|
"/api/v1/trail-link-share": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "List trail link shares",
|
"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": {
|
"ListResult": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
text: string;
|
text: string;
|
||||||
value: any;
|
value: any;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
separator?: boolean;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -130,16 +131,22 @@
|
|||||||
bind:this={dropdownElement}
|
bind:this={dropdownElement}
|
||||||
>
|
>
|
||||||
{#each items as item}
|
{#each items as item}
|
||||||
<li
|
{#if item.separator}
|
||||||
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"
|
<li class="px-3 py-2" role="separator" aria-hidden="true">
|
||||||
role="presentation"
|
<div class="border-t border-input-border"></div>
|
||||||
onclick={(e) => handleItemClick(e, item)}
|
</li>
|
||||||
>
|
{:else}
|
||||||
{#if item.icon}
|
<li
|
||||||
<i class="fa fa-{item.icon} mr-3"></i>
|
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"
|
||||||
{/if}
|
role="presentation"
|
||||||
<span class="whitespace-nowrap">{item.text}</span>
|
onclick={(e) => handleItemClick(e, item as { text: string; value: any })}
|
||||||
</li>
|
>
|
||||||
|
{#if item.icon}
|
||||||
|
<i class="fa fa-{item.icon} mr-3"></i>
|
||||||
|
{/if}
|
||||||
|
<span class="whitespace-nowrap">{item.text}</span>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
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 Modal from "$lib/components/base/modal.svelte";
|
||||||
import TextField from "$lib/components/base/text_field.svelte";
|
import TextField from "$lib/components/base/text_field.svelte";
|
||||||
import Toggle from "$lib/components/base/toggle.svelte";
|
import Toggle from "$lib/components/base/toggle.svelte";
|
||||||
@@ -33,6 +34,9 @@
|
|||||||
planned: integration?.hammerhead?.planned ?? true,
|
planned: integration?.hammerhead?.planned ?? true,
|
||||||
active: integration?.hammerhead?.active ?? false,
|
active: integration?.hammerhead?.active ?? false,
|
||||||
after: integration?.hammerhead?.after,
|
after: integration?.hammerhead?.after,
|
||||||
|
merge: integration?.hammerhead?.merge ?? {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -104,6 +108,8 @@
|
|||||||
><i class="fa fa-close"></i></button
|
><i class="fa fa-close"></i></button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<IntegrationMergeSettings prefix="merge" />
|
||||||
</form>
|
</form>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet footer()}
|
{#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">
|
<script lang="ts">
|
||||||
import Modal from "$lib/components/base/modal.svelte";
|
import Modal from "$lib/components/base/modal.svelte";
|
||||||
|
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
|
||||||
import Select, {
|
import Select, {
|
||||||
type SelectItem,
|
type SelectItem,
|
||||||
} from "$lib/components/base/select.svelte";
|
} from "$lib/components/base/select.svelte";
|
||||||
@@ -43,6 +44,9 @@
|
|||||||
planned: integration?.komoot?.planned ?? true,
|
planned: integration?.komoot?.planned ?? true,
|
||||||
active: integration?.komoot?.active ?? false,
|
active: integration?.komoot?.active ?? false,
|
||||||
privacy: integration?.komoot?.privacy ?? "original",
|
privacy: integration?.komoot?.privacy ?? "original",
|
||||||
|
merge: integration?.komoot?.merge ?? {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -106,6 +110,8 @@
|
|||||||
{$_("integration-privacy-hint-user")}
|
{$_("integration-privacy-hint-user")}
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<IntegrationMergeSettings prefix="merge" />
|
||||||
</form>
|
</form>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet footer()}
|
{#snippet footer()}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
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 Modal from "$lib/components/base/modal.svelte";
|
||||||
import type { SelectItem } from "$lib/components/base/select.svelte";
|
import type { SelectItem } from "$lib/components/base/select.svelte";
|
||||||
import Select from "$lib/components/base/select.svelte";
|
import Select from "$lib/components/base/select.svelte";
|
||||||
@@ -43,7 +44,10 @@
|
|||||||
activities: integration?.strava?.activities ?? true,
|
activities: integration?.strava?.activities ?? true,
|
||||||
active: integration?.strava?.active ?? false,
|
active: integration?.strava?.active ?? false,
|
||||||
after: integration?.strava?.after,
|
after: integration?.strava?.after,
|
||||||
privacy: integration?.komoot?.privacy ?? "original",
|
privacy: integration?.strava?.privacy ?? "original",
|
||||||
|
merge: integration?.strava?.merge ?? {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -130,6 +134,8 @@
|
|||||||
>
|
>
|
||||||
{$_("strava-integration-after-date-hint")}
|
{$_("strava-integration-after-date-hint")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<IntegrationMergeSettings prefix="merge" />
|
||||||
</form>
|
</form>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet footer()}
|
{#snippet footer()}
|
||||||
|
|||||||
@@ -14,7 +14,11 @@
|
|||||||
lists_remove_trail,
|
lists_remove_trail,
|
||||||
} from "$lib/stores/list_store";
|
} from "$lib/stores/list_store";
|
||||||
import { show_toast } from "$lib/stores/toast_store.svelte";
|
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 { currentUser } from "$lib/stores/user_store";
|
||||||
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
|
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
|
||||||
import { getFileURL, saveAs } from "$lib/util/file_util";
|
import { getFileURL, saveAs } from "$lib/util/file_util";
|
||||||
@@ -30,6 +34,21 @@
|
|||||||
import TrailExportModal from "./trail_export_modal.svelte";
|
import TrailExportModal from "./trail_export_modal.svelte";
|
||||||
import TrailSendModal from "./trail_send_modal.svelte";
|
import TrailSendModal from "./trail_send_modal.svelte";
|
||||||
import TrailShareModal from "./trail_share_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 {
|
interface Props {
|
||||||
trails?: Set<Trail> | undefined;
|
trails?: Set<Trail> | undefined;
|
||||||
@@ -38,14 +57,16 @@
|
|||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onShare?: () => void;
|
onShare?: () => void;
|
||||||
onUpdate?: () => 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 confirmModal: ConfirmModal;
|
||||||
let listSelectModal: ListSearchModal;
|
let listSelectModal: ListSearchModal;
|
||||||
let trailExportModal: TrailExportModal;
|
let trailExportModal: TrailExportModal;
|
||||||
let trailShareModal: TrailShareModal;
|
let trailShareModal: TrailShareModal;
|
||||||
|
let trailMergeModal: TrailMergeModal;
|
||||||
let trailSendModal: TrailSendModal;
|
let trailSendModal: TrailSendModal;
|
||||||
|
|
||||||
const hammerheadIntegration = $derived(
|
const hammerheadIntegration = $derived(
|
||||||
@@ -99,18 +120,75 @@
|
|||||||
|
|
||||||
let loading: boolean = $state(false);
|
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 {
|
function allowEdit(): boolean {
|
||||||
return (
|
return (
|
||||||
hasTrail() &&
|
hasTrail() &&
|
||||||
!isMultiselectMode() &&
|
!isMultiselectMode() &&
|
||||||
Boolean($currentUser) &&
|
canEditTrail(trail())
|
||||||
(trail()!.expand?.author?.id === $currentUser?.actor ||
|
|
||||||
trail()!.expand?.trail_share_via_trail?.some(
|
|
||||||
(s) => s.permission == "edit",
|
|
||||||
))!
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
function majorityOfSelectedTrailsArePublic(): boolean {
|
||||||
if (trails === undefined || trails.size === 0) return false;
|
if (trails === undefined || trails.size === 0) return false;
|
||||||
|
|
||||||
@@ -119,13 +197,7 @@
|
|||||||
let publicCount = 0;
|
let publicCount = 0;
|
||||||
|
|
||||||
for (const cTrail of trails) {
|
for (const cTrail of trails) {
|
||||||
if (cTrail.expand?.author === undefined) return false;
|
if (!canEditTrail(cTrail)) {
|
||||||
if (
|
|
||||||
cTrail.expand!.author!.id !== $currentUser?.actor &&
|
|
||||||
!cTrail.expand?.trail_share_via_trail?.some(
|
|
||||||
(s) => s.permission == "edit",
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,13 +224,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const cTrail of trails) {
|
for (const cTrail of trails) {
|
||||||
if (cTrail.expand?.author === undefined) return false;
|
if (!canEditTrail(cTrail)) {
|
||||||
if (
|
|
||||||
cTrail.expand!.author!.id !== $currentUser?.actor &&
|
|
||||||
!cTrail.expand?.trail_share_via_trail?.some(
|
|
||||||
(s) => s.permission == "edit",
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,8 +233,70 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dropdownItems(): DropdownItem[] {
|
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 [
|
||||||
|
...(allowMerge()
|
||||||
|
? [{ text: $_("link"), value: "merge", icon: "link" }]
|
||||||
|
: []),
|
||||||
|
...(allowMerge() && (canExport() || allowListManagement || allowPublish() || allowDelete())
|
||||||
|
? [separator("sep-multi-actions")]
|
||||||
|
: []),
|
||||||
|
...(canExport()
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
text: $_("export"),
|
||||||
|
value: "download",
|
||||||
|
icon: "download",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(!allowListManagement
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
text: $_("add-to-list"),
|
||||||
|
value: "list",
|
||||||
|
icon: "bookmark",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
...(((canExport() || allowListManagement) && (allowPublish() || allowDelete()))
|
||||||
|
? [separator("sep-multi-visibility")]
|
||||||
|
: []),
|
||||||
|
...(allowPublish()
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
text: `${majorityOfSelectedTrailsArePublic() ? $_("set-private") : $_("set-public")}`,
|
||||||
|
value: "publish",
|
||||||
|
icon: majorityOfSelectedTrailsArePublic()
|
||||||
|
? "lock"
|
||||||
|
: "globe",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(allowDelete()
|
||||||
|
? [
|
||||||
|
separator("sep-multi-danger"),
|
||||||
|
{
|
||||||
|
text: $_("delete"),
|
||||||
|
value: "delete",
|
||||||
|
icon: "trash",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...(!isMultiselectMode()
|
...(hasTrail()
|
||||||
? [
|
? [
|
||||||
mode == "overview" || mode == "multi-select"
|
mode == "overview" || mode == "multi-select"
|
||||||
? {
|
? {
|
||||||
@@ -181,10 +309,6 @@
|
|||||||
value: "show",
|
value: "show",
|
||||||
icon: "table-columns",
|
icon: "table-columns",
|
||||||
},
|
},
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(!isMultiselectMode()
|
|
||||||
? [
|
|
||||||
{
|
{
|
||||||
text: $_("directions"),
|
text: $_("directions"),
|
||||||
value: "direction",
|
value: "direction",
|
||||||
@@ -192,6 +316,69 @@
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...((hasTrail() && (allowEdit() || allowFindSimilarTrails() || allowCopy()))
|
||||||
|
? [separator("sep-single-edit")]
|
||||||
|
: []),
|
||||||
|
...(allowEdit()
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
text: $_("edit"),
|
||||||
|
value: "edit",
|
||||||
|
icon: "pen",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(allowFindSimilarTrails()
|
||||||
|
? [{
|
||||||
|
text: $_("find-similar-trails"),
|
||||||
|
value: "find-similar-trails",
|
||||||
|
icon: "link",
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
...(allowCopy()
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
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()
|
...(canExport()
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -201,6 +388,15 @@
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(!isMultiselectMode() && hammerheadIntegration && canExport()
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
text: $_("send-to"),
|
||||||
|
value: "send-to",
|
||||||
|
icon: "upload",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(!isMultiselectMode()
|
...(!isMultiselectMode()
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -210,55 +406,9 @@
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(!isFromCurrentUser()
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
text: $_("add-to-list"),
|
|
||||||
value: "list",
|
|
||||||
icon: "bookmark",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
...(isMultiselectMode() || !isFromCurrentUser()
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
{
|
|
||||||
text: $_("share"),
|
|
||||||
value: "share",
|
|
||||||
icon: "share",
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
...(allowCopy()
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
text: $_("duplicate"),
|
|
||||||
value: "copy",
|
|
||||||
icon: "copy",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(allowPublish()
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
text: `${majorityOfSelectedTrailsArePublic() ? $_("set-private") : $_("set-public")}`,
|
|
||||||
value: "publish",
|
|
||||||
icon: majorityOfSelectedTrailsArePublic()
|
|
||||||
? "lock"
|
|
||||||
: "globe",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(allowEdit()
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
text: $_("edit"),
|
|
||||||
value: "edit",
|
|
||||||
icon: "pen",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...(allowDelete()
|
...(allowDelete()
|
||||||
? [
|
? [
|
||||||
|
separator("sep-single-danger"),
|
||||||
{
|
{
|
||||||
text: $_("delete"),
|
text: $_("delete"),
|
||||||
value: "delete",
|
value: "delete",
|
||||||
@@ -266,15 +416,6 @@
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(!isMultiselectMode() && hammerheadIntegration && canExport()
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
text: $_("send-to"),
|
|
||||||
value: "send-to",
|
|
||||||
icon: "upload",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,11 +540,69 @@
|
|||||||
updateTrailsVisibility();
|
updateTrailsVisibility();
|
||||||
} else if (ddVal == "delete") {
|
} else if (ddVal == "delete") {
|
||||||
confirmModal.openModal();
|
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") {
|
} else if (item.value == "send-to") {
|
||||||
trailSendModal.openModal();
|
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() {
|
async function uploadToHammerhead() {
|
||||||
if (!hammerheadIntegration || !hasTrail()) {
|
if (!hammerheadIntegration || !hasTrail()) {
|
||||||
console.error("No Hammerhead integration found.");
|
console.error("No Hammerhead integration found.");
|
||||||
@@ -725,6 +924,12 @@
|
|||||||
onsave={handleShareUpdate}
|
onsave={handleShareUpdate}
|
||||||
bind:this={trailShareModal}
|
bind:this={trailShareModal}
|
||||||
></TrailShareModal>
|
></TrailShareModal>
|
||||||
|
<TrailMergeModal
|
||||||
|
bind:this={trailMergeModal}
|
||||||
|
onmerge={(settings, selection) => mergeTrails(settings, selection)}
|
||||||
|
></TrailMergeModal>
|
||||||
|
|
||||||
|
<MergeDialog/>
|
||||||
<TrailSendModal
|
<TrailSendModal
|
||||||
bind:this={trailSendModal}
|
bind:this={trailSendModal}
|
||||||
onsend={async (settings) => {
|
onsend={async (settings) => {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
import Tabs from "$lib/components/base/tabs.svelte";
|
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 { Comment } from "$lib/models/comment";
|
||||||
import type { Trail } from "$lib/models/trail";
|
import type { Trail } from "$lib/models/trail";
|
||||||
|
|
||||||
@@ -269,6 +270,21 @@
|
|||||||
summitLogs.set(newSummitLogList);
|
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() {
|
async function markTrailAsCompleted() {
|
||||||
trail.completed = true;
|
trail.completed = true;
|
||||||
const updatedTrail: Trail = { ...trail };
|
const updatedTrail: Trail = { ...trail };
|
||||||
@@ -430,6 +446,7 @@
|
|||||||
trails={new Set<Trail>([trail])}
|
trails={new Set<Trail>([trail])}
|
||||||
onDelete={() =>
|
onDelete={() =>
|
||||||
history.length ? history.back() : goto("/trails")}
|
history.length ? history.back() : goto("/trails")}
|
||||||
|
onMerge={handleTrailMerge}
|
||||||
{mode}
|
{mode}
|
||||||
></TrailDropdown>
|
></TrailDropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -298,6 +298,15 @@
|
|||||||
else hoveredTrail = undefined;
|
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) {
|
async function handleTrailsEditDone(resetSelection: boolean = false) {
|
||||||
if (resetSelection) {
|
if (resetSelection) {
|
||||||
selection = new Set<Trail>();
|
selection = new Set<Trail>();
|
||||||
@@ -346,6 +355,7 @@
|
|||||||
mode={"multi-select"}
|
mode={"multi-select"}
|
||||||
onDelete={() => handleTrailsEditDone(true)}
|
onDelete={() => handleTrailsEditDone(true)}
|
||||||
onShare={() => handleTrailsEditDone(false)}
|
onShare={() => handleTrailsEditDone(false)}
|
||||||
|
onMerge={() => handleTrailsMergeDone(true)}
|
||||||
onUpdate={() => handleTrailsEditDone(true)}
|
onUpdate={() => handleTrailsEditDone(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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",
|
"changelog": "Änderungshistorie",
|
||||||
"chinese": "Chinesisch (vereinfacht)",
|
"chinese": "Chinesisch (vereinfacht)",
|
||||||
"clear-all": "Alle ausblenden",
|
"clear-all": "Alle ausblenden",
|
||||||
|
"dismiss": "Schließen",
|
||||||
"climbing": "Klettern",
|
"climbing": "Klettern",
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"collapse-trail-list": "",
|
"collapse-trail-list": "",
|
||||||
@@ -90,7 +91,11 @@
|
|||||||
"connect": "Verbinden",
|
"connect": "Verbinden",
|
||||||
"continue": "Fortfahren",
|
"continue": "Fortfahren",
|
||||||
"contribute": "Mitwirken",
|
"contribute": "Mitwirken",
|
||||||
|
"copy-comments": "Kommentare übernehmen",
|
||||||
|
"copy-include-elements": "Zu übernehmende Elemente",
|
||||||
"copy-link": "Link kopieren",
|
"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-new-list": "Neue Liste erstellen",
|
||||||
"create-waypoint": "Wegpunkt erstellen",
|
"create-waypoint": "Wegpunkt erstellen",
|
||||||
"create-waypoint-anyway": "Trotzdem erstellen",
|
"create-waypoint-anyway": "Trotzdem erstellen",
|
||||||
@@ -108,6 +113,7 @@
|
|||||||
"degrees": "Grad",
|
"degrees": "Grad",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"delete-account": "Konto 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-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-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.",
|
"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-liking-trail": "Error liking trail",
|
||||||
"error-logging-in-to-hammerhead": "Fehler bei der Anmeldung bei Hammerhead",
|
"error-logging-in-to-hammerhead": "Fehler bei der Anmeldung bei Hammerhead",
|
||||||
"error-logging-in-to-komoot": "Fehler bei der Anmeldung bei komoot",
|
"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-posting-comment": "Fehler beim Posten des Kommentars",
|
||||||
"error-printing-map": "Fehler beim Drucken der Karte",
|
"error-printing-map": "Fehler beim Drucken der Karte",
|
||||||
"error-reading-file": "Fehler beim Lesen der Datei",
|
"error-reading-file": "Fehler beim Lesen der Datei",
|
||||||
@@ -186,6 +193,7 @@
|
|||||||
"filter-categories": "Kategorien filtern",
|
"filter-categories": "Kategorien filtern",
|
||||||
"filter-difficulty": "Schwierigkeit filtern",
|
"filter-difficulty": "Schwierigkeit filtern",
|
||||||
"filter-tags": "Tags filtern",
|
"filter-tags": "Tags filtern",
|
||||||
|
"find-similar-trails": "Mit ähnlicher Route verknüpfen",
|
||||||
"finish": "Ziel",
|
"finish": "Ziel",
|
||||||
"fixed-speed": "Fixe Geschwindigkeit",
|
"fixed-speed": "Fixe Geschwindigkeit",
|
||||||
"focus-map-on": "Karte fokussieren auf",
|
"focus-map-on": "Karte fokussieren auf",
|
||||||
@@ -233,6 +241,8 @@
|
|||||||
"integration-description-hammerhead": "Synchronisiert Deine Hammerhead-Touren regelmäßig mit wanderer.",
|
"integration-description-hammerhead": "Synchronisiert Deine Hammerhead-Touren regelmäßig mit wanderer.",
|
||||||
"integration-description-komoot": "Synchronisiert Deine komoot-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-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-disabled": "Integration deaktiviert",
|
||||||
"integration-enabled": "Integration aktiviert",
|
"integration-enabled": "Integration aktiviert",
|
||||||
"integration-privacy-hint-original": "",
|
"integration-privacy-hint-original": "",
|
||||||
@@ -253,8 +263,12 @@
|
|||||||
"liked": "Gefällt mir",
|
"liked": "Gefällt mir",
|
||||||
"likes": "Likes",
|
"likes": "Likes",
|
||||||
"limited": "Begrenzt",
|
"limited": "Begrenzt",
|
||||||
"link-copied": "Link kopiert!",
|
"link": "Verknüpfen",
|
||||||
|
"link-as-summit-log": "Als Gipfelbuch-Eintrag verknüpfen",
|
||||||
|
"link-copied": "Link kopiert!",
|
||||||
|
"link-trails": "Routen verknüpfen",
|
||||||
"linked-lists": "Verknüpfte Listen",
|
"linked-lists": "Verknüpfte Listen",
|
||||||
|
"linked-trails": "Verknüpfte Routen",
|
||||||
"list": "{n, plural, =1 {Liste} other {Listen}}",
|
"list": "{n, plural, =1 {Liste} other {Listen}}",
|
||||||
"list-not-shared": "Mit niemandem geteilt",
|
"list-not-shared": "Mit niemandem geteilt",
|
||||||
"list-public-warning": "Alle Routen in dieser Liste werden veröffentlicht.",
|
"list-public-warning": "Alle Routen in dieser Liste werden veröffentlicht.",
|
||||||
@@ -427,6 +441,7 @@
|
|||||||
"show-in-overview": "In der Übersicht anzeigen",
|
"show-in-overview": "In der Übersicht anzeigen",
|
||||||
"show-less": "Weniger anzeigen",
|
"show-less": "Weniger anzeigen",
|
||||||
"show-on-map": "Auf der Karte anzeigen",
|
"show-on-map": "Auf der Karte anzeigen",
|
||||||
|
"similar-trails": "Wiederholte Routen / Duplikate",
|
||||||
"shower": "Dusche",
|
"shower": "Dusche",
|
||||||
"skiing": "Skifahren",
|
"skiing": "Skifahren",
|
||||||
"slogan": "Speichere deine Abenteuer!",
|
"slogan": "Speichere deine Abenteuer!",
|
||||||
@@ -443,6 +458,8 @@
|
|||||||
"subway-stop": "U-Bahn Eingang",
|
"subway-stop": "U-Bahn Eingang",
|
||||||
"summit": "Gipfel",
|
"summit": "Gipfel",
|
||||||
"summit-book": "Gipfelbuch",
|
"summit-book": "Gipfelbuch",
|
||||||
|
"summit-log": "{n, plural, =1 {Gipfelbuch-Eintrag} other {Gipfelbuch-Einträge}}",
|
||||||
|
"summit-log-photos": "Photos aus Gipfelbuch-Einträgen",
|
||||||
"table": "Tabelle",
|
"table": "Tabelle",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
@@ -456,6 +473,70 @@
|
|||||||
"trail-not-in-list": "Trail gehört zu keiner Liste.",
|
"trail-not-in-list": "Trail gehört zu keiner Liste.",
|
||||||
"trail-not-shared": "Mit niemandem geteilt",
|
"trail-not-shared": "Mit niemandem geteilt",
|
||||||
"trail-saved-successfully": "Route gespeichert",
|
"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",
|
"trails-for-you": "Routen für dich",
|
||||||
"tram-stop": "Tram Haltestelle",
|
"tram-stop": "Tram Haltestelle",
|
||||||
"unchanged": "unverändert",
|
"unchanged": "unverändert",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"changelog": "Changelog",
|
"changelog": "Changelog",
|
||||||
"chinese": "Chinese (simplified)",
|
"chinese": "Chinese (simplified)",
|
||||||
"clear-all": "Clear all",
|
"clear-all": "Clear all",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
"climbing": "Climbing",
|
"climbing": "Climbing",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"collapse-trail-list": "Collapse trail list",
|
"collapse-trail-list": "Collapse trail list",
|
||||||
@@ -90,7 +91,11 @@
|
|||||||
"connect": "Connect",
|
"connect": "Connect",
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"contribute": "Contribute",
|
"contribute": "Contribute",
|
||||||
|
"copy-comments": "Copy comments",
|
||||||
|
"copy-include-elements": "Elements to be copied",
|
||||||
"copy-link": "Copy Link",
|
"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-new-list": "Create new list",
|
||||||
"create-waypoint": "Create waypoint",
|
"create-waypoint": "Create waypoint",
|
||||||
"create-waypoint-anyway": "Create anyway",
|
"create-waypoint-anyway": "Create anyway",
|
||||||
@@ -108,6 +113,7 @@
|
|||||||
"degrees": "Degrees",
|
"degrees": "Degrees",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"delete-account": "Delete Account",
|
"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-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-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.",
|
"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-liking-trail": "Error liking trail",
|
||||||
"error-logging-in-to-hammerhead": "Error logging in to Hammerhead",
|
"error-logging-in-to-hammerhead": "Error logging in to Hammerhead",
|
||||||
"error-logging-in-to-komoot": "Error logging in to komoot",
|
"error-logging-in-to-komoot": "Error logging in to komoot",
|
||||||
|
"error-merging-trail": "Error linking trails",
|
||||||
"error-posting-comment": "Error posting comment",
|
"error-posting-comment": "Error posting comment",
|
||||||
"error-printing-map": "Error printing map",
|
"error-printing-map": "Error printing map",
|
||||||
"error-reading-file": "Error reading file",
|
"error-reading-file": "Error reading file",
|
||||||
@@ -186,6 +193,7 @@
|
|||||||
"filter-categories": "Filter categories",
|
"filter-categories": "Filter categories",
|
||||||
"filter-difficulty": "Filter difficulty",
|
"filter-difficulty": "Filter difficulty",
|
||||||
"filter-tags": "Filter tags",
|
"filter-tags": "Filter tags",
|
||||||
|
"find-similar-trails": "Merge with similar trail",
|
||||||
"finish": "Finish",
|
"finish": "Finish",
|
||||||
"fixed-speed": "Fixed Speed",
|
"fixed-speed": "Fixed Speed",
|
||||||
"focus-map-on": "Focus map on",
|
"focus-map-on": "Focus map on",
|
||||||
@@ -233,6 +241,8 @@
|
|||||||
"integration-description-hammerhead": "Syncs your Hammerhead tours with wanderer in regular intervals.",
|
"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-komoot": "Syncs your komoot tours with wanderer in regular intervals.",
|
||||||
"integration-description-strava": "Syncs your Strava routes & activities 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-disabled": "integration disabled",
|
||||||
"integration-enabled": "integration enabled",
|
"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.",
|
"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",
|
"liked": "Liked",
|
||||||
"likes": "Likes",
|
"likes": "Likes",
|
||||||
"limited": "Limited",
|
"limited": "Limited",
|
||||||
|
"link": "Link",
|
||||||
|
"link-as-summit-log": "Link as summit log",
|
||||||
"link-copied": "Link copied!",
|
"link-copied": "Link copied!",
|
||||||
|
"link-trails": "Link trails",
|
||||||
"linked-lists": "Linked lists",
|
"linked-lists": "Linked lists",
|
||||||
|
"linked-trails": "Linked trails",
|
||||||
"list": "{n, plural, =1 {List} other {Lists}}",
|
"list": "{n, plural, =1 {List} other {Lists}}",
|
||||||
"list-not-shared": "Not shared with anyone",
|
"list-not-shared": "Not shared with anyone",
|
||||||
"list-public-warning": "All trails in this list will become public.",
|
"list-public-warning": "All trails in this list will become public.",
|
||||||
@@ -427,6 +441,7 @@
|
|||||||
"show-in-overview": "Show in overview",
|
"show-in-overview": "Show in overview",
|
||||||
"show-less": "Show less",
|
"show-less": "Show less",
|
||||||
"show-on-map": "Show on map",
|
"show-on-map": "Show on map",
|
||||||
|
"similar-trails": "Repeated trails / duplicates",
|
||||||
"shower": "Shower",
|
"shower": "Shower",
|
||||||
"skiing": "Skiing",
|
"skiing": "Skiing",
|
||||||
"slogan": "Save your adventures!",
|
"slogan": "Save your adventures!",
|
||||||
@@ -443,6 +458,8 @@
|
|||||||
"subway-stop": "Subway entrance",
|
"subway-stop": "Subway entrance",
|
||||||
"summit": "Summit",
|
"summit": "Summit",
|
||||||
"summit-book": "Summit Book",
|
"summit-book": "Summit Book",
|
||||||
|
"summit-log": "{n, plural, =1 {Summit log} other {Summit logs}}",
|
||||||
|
"summit-log-photos": "Summit log photos",
|
||||||
"table": "Table",
|
"table": "Table",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
@@ -456,6 +473,70 @@
|
|||||||
"trail-not-in-list": "Trail is not in any list",
|
"trail-not-in-list": "Trail is not in any list",
|
||||||
"trail-not-shared": "Not shared with anyone",
|
"trail-not-shared": "Not shared with anyone",
|
||||||
"trail-saved-successfully": "Trail saved successfully",
|
"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",
|
"trails-for-you": "Trails for you",
|
||||||
"tram-stop": "Tram stop",
|
"tram-stop": "Tram stop",
|
||||||
"unchanged": "unchanged",
|
"unchanged": "unchanged",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { z, ZodType } from "zod";
|
import { z, ZodType } from "zod";
|
||||||
import type { Integration } from "../integration";
|
import type { Integration } from "../integration";
|
||||||
|
|
||||||
|
const IntegrationMergeSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
const StravaSchema = z.object({
|
const StravaSchema = z.object({
|
||||||
clientId: z.number({ coerce: true }).int().nonnegative(),
|
clientId: z.number({ coerce: true }).int().nonnegative(),
|
||||||
clientSecret: z.string().length(40).optional().or(z.literal('')),
|
clientSecret: z.string().length(40).optional().or(z.literal('')),
|
||||||
@@ -8,7 +12,8 @@ const StravaSchema = z.object({
|
|||||||
activities: z.boolean(),
|
activities: z.boolean(),
|
||||||
active: z.boolean(),
|
active: z.boolean(),
|
||||||
after: z.string().date().optional(),
|
after: z.string().date().optional(),
|
||||||
privacy: z.enum(["original", "settings"])
|
privacy: z.enum(["original", "settings"]),
|
||||||
|
merge: IntegrationMergeSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
const KomootSchema = z.object({
|
const KomootSchema = z.object({
|
||||||
@@ -17,7 +22,8 @@ const KomootSchema = z.object({
|
|||||||
completed: z.boolean(),
|
completed: z.boolean(),
|
||||||
planned: z.boolean(),
|
planned: z.boolean(),
|
||||||
active: z.boolean(),
|
active: z.boolean(),
|
||||||
privacy: z.enum(["original", "settings"])
|
privacy: z.enum(["original", "settings"]),
|
||||||
|
merge: IntegrationMergeSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
const HammerheadSchema = z.object({
|
const HammerheadSchema = z.object({
|
||||||
@@ -27,6 +33,7 @@ const HammerheadSchema = z.object({
|
|||||||
planned: z.boolean(),
|
planned: z.boolean(),
|
||||||
active: z.boolean(),
|
active: z.boolean(),
|
||||||
after: z.string().date().optional(),
|
after: z.string().date().optional(),
|
||||||
|
merge: IntegrationMergeSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
const IntegrationCreateSchema = z.object({
|
const IntegrationCreateSchema = z.object({
|
||||||
|
|||||||
@@ -1193,6 +1193,146 @@
|
|||||||
* enum: [public, private]
|
* enum: [public, private]
|
||||||
* nullable: true
|
* 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:
|
* ListResult:
|
||||||
* type: object
|
* type: object
|
||||||
* properties:
|
* properties:
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ export interface BaseIntegration {
|
|||||||
active: boolean
|
active: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IntegrationMergeSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StravaIntegration extends BaseIntegration {
|
export interface StravaIntegration extends BaseIntegration {
|
||||||
clientId: string | number;
|
clientId: string | number;
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
@@ -13,6 +17,7 @@ export interface StravaIntegration extends BaseIntegration {
|
|||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
after?: string
|
after?: string
|
||||||
privacy: "original" | "settings"
|
privacy: "original" | "settings"
|
||||||
|
merge: IntegrationMergeSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KomootIntegration extends BaseIntegration {
|
export interface KomootIntegration extends BaseIntegration {
|
||||||
@@ -21,6 +26,7 @@ export interface KomootIntegration extends BaseIntegration {
|
|||||||
completed: boolean,
|
completed: boolean,
|
||||||
planned: boolean
|
planned: boolean
|
||||||
privacy: "original" | "settings"
|
privacy: "original" | "settings"
|
||||||
|
merge: IntegrationMergeSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HammerheadIntegration extends BaseIntegration {
|
export interface HammerheadIntegration extends BaseIntegration {
|
||||||
@@ -29,6 +35,7 @@ export interface HammerheadIntegration extends BaseIntegration {
|
|||||||
completed: boolean,
|
completed: boolean,
|
||||||
planned: boolean,
|
planned: boolean,
|
||||||
after?: string
|
after?: string
|
||||||
|
merge: IntegrationMergeSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -45,4 +52,4 @@ export class Integration {
|
|||||||
this.komoot = komoot;
|
this.komoot = komoot;
|
||||||
this.hammerhead = hammerhead;
|
this.hammerhead = hammerhead;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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: $_("notifications"), value: "/settings/notifications" },
|
||||||
{ text: $_("map"), value: "/settings/map" },
|
{ text: $_("map"), value: "/settings/map" },
|
||||||
{ text: $_("integrations"), value: "/settings/integrations" },
|
{ text: $_("integrations"), value: "/settings/integrations" },
|
||||||
|
{ text: $_("similar-trails"), value: "/settings/maintenance/similar-trails" },
|
||||||
{ text: `${$_("import")}/${$_("export")}`, value: "/settings/export" },
|
{ text: `${$_("import")}/${$_("export")}`, value: "/settings/export" },
|
||||||
{
|
{
|
||||||
text: $_("help"),
|
text: $_("help"),
|
||||||
|
|||||||
@@ -105,6 +105,7 @@
|
|||||||
routes: integration.strava.routes,
|
routes: integration.strava.routes,
|
||||||
activities: integration.strava.activities,
|
activities: integration.strava.activities,
|
||||||
privacy: integration.strava.privacy,
|
privacy: integration.strava.privacy,
|
||||||
|
merge: integration.strava.merge,
|
||||||
accessToken: undefined,
|
accessToken: undefined,
|
||||||
refreshToken: undefined,
|
refreshToken: undefined,
|
||||||
expiresAt: 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": {
|
"/api/v1/trail-link-share": {
|
||||||
"get": {
|
"get": {
|
||||||
"summary": "List trail link shares",
|
"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": {
|
"ListResult": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
Reference in New Issue
Block a user