merge trails function added (#627)

* merge trails function added

* fix conflict resolve issues

* avoid incorrect authorship when merging trail comments

* require edit access for all selected trails before merge

* fix likes checkbox binding in trail merge modal

* improve error handling

* require editable target and deletable sources for merge

* avoid duplicate trail fetch during merge

* prevent duplicate likes when merging trails

* check photo download responses during trail merge

* add merge completion toast notifications

* cleanup

* small fixes

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

* fix build error

* fix

* docu, beautifying, refactoring

---------

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

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

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

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

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

View File

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