merge trails function added (#627)

* merge trails function added

* fix conflict resolve issues

* avoid incorrect authorship when merging trail comments

* require edit access for all selected trails before merge

* fix likes checkbox binding in trail merge modal

* improve error handling

* require editable target and deletable sources for merge

* avoid duplicate trail fetch during merge

* prevent duplicate likes when merging trails

* check photo download responses during trail merge

* add merge completion toast notifications

* cleanup

* small fixes

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

* fix build error

* fix

* docu, beautifying, refactoring

---------

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

View File

@@ -55,6 +55,10 @@ export default defineConfig({
label: 'Summit logs',
link: '/use/summit-logs/'
},
{
label: 'Merge trails',
link: '/use/merge-trails/'
},
{
label: 'Interact with the community',
link: '/use/community-interaction/'
@@ -152,4 +156,4 @@ export default defineConfig({
'/run/backup-server/': '/run/backend-configuration/backup-server/',
'/run/custom-categories/': '/run/backend-configuration/custom-categories/',
},
});
});

View File

@@ -0,0 +1,69 @@
---
title: Merge trails
description: Link repeated or duplicate trail recordings into a single target trail.
---
<span class="-tracking-[0.075em]">wanderer</span> can link multiple trails into a single target trail. This is useful when the same route was recorded multiple times, imported from multiple services, or uploaded separately even though it belongs to the same underlying trail.
When a trail is merged, the source trail is converted into a summit log entry on the selected target trail. Depending on the chosen options, additional data such as photos, comments, tags and likes can also be merged.
## When to Use Trail Merging
Trail merging is helpful when:
- you recorded the same route multiple times and want one canonical trail page
- a route was uploaded as a new trail, even though it was actually supposed to be a further iteration of an existing trail
If the route already exists and you simply want to log another outing, using a [summit log](/use/summit-logs) directly is usually the better choice.
## Manual Merge
You can merge trails manually from the trail actions menu:
- select multiple trails and choose **Link**
- or open a single trail and choose **Merge with similar trail**
Before the merge is executed, <span class="-tracking-[0.075em]">wanderer</span> asks the backend for a suggested target trail. The backend uses the same target selection strategy in all merge modes and prefers trails that preserve the most useful information.
The target suggestion currently considers:
- existing summit logs
- external references from integrations
- content richness such as comments, photos, waypoints and descriptions
- how centrally the trail geometry fits within the candidate set
- trail age as a deterministic fallback
Warnings are shown before the merge if the selected trails differ noticeably in geometry or location.
## Automatic Matching for Similar Trails
The **Merge with similar trail** action searches for strong geometric matches of the currently open trail. Only trails with a sufficiently similar forward direction are considered. Out-and-back reversals are not treated as the same trail.
## Maintenance Page
The maintenance page groups potentially repeated or duplicate trails so that you can review them in batches:
- open **Settings → Repeated trails / duplicates**
- inspect each group on the map
- choose the target trail directly in the list
- merge the group once you are satisfied
This page is especially useful after large imports or when you want to consolidate older data.
## Integrations
Integrations can optionally auto-merge imported trails, but only when the backend finds exactly one clear target candidate. This keeps imports conservative and avoids accidentally merging different routes.
External references from integrations are preserved during merges, so future imports can still recognize already-linked trails correctly.
## What Happens During a Merge
At a high level, the backend:
1. determines or receives a target trail
2. creates a new summit log from the source trail on the target trail
3. optionally merges existing summit logs, comments, likes, tags and photos
4. reassigns external references to the target trail
5. optionally deletes the source trail
The merge itself runs transactionally so partially completed merges are avoided.

View File

@@ -764,6 +764,125 @@
}
}
},
"/api/v1/trail-merge": {
"post": {
"summary": "Merge trails",
"description": "Merges a source trail into a target trail using the backend merge logic.",
"tags": [
"Trail Merge"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TrailMergeExecuteRequest"
}
}
}
},
"responses": {
"200": {
"description": "Merge acknowledged",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TrailMergeExecuteResponse"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/trail-merge/suggest": {
"post": {
"summary": "Suggest merge target or duplicate groups",
"description": "Returns merge target suggestions for manual selection or auto-discovery, or temporary duplicate groups for maintenance workflows.",
"tags": [
"Trail Merge"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TrailMergeSuggestRequest"
}
}
}
},
"responses": {
"200": {
"description": "Suggestion response",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/TrailMergeSuggestResponse"
},
{
"$ref": "#/components/schemas/TrailMergeSuggestGroupsResponse"
}
]
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
}
}
},
"/api/v1/trail-link-share": {
"get": {
"summary": "List trail link shares",
@@ -6571,6 +6690,201 @@
}
}
},
"TrailMergeSettings": {
"type": "object",
"required": [
"summitLog",
"photos",
"comments",
"delete",
"tags",
"likes"
],
"properties": {
"summitLog": {
"type": "boolean"
},
"photos": {
"type": "boolean"
},
"comments": {
"type": "boolean"
},
"delete": {
"type": "boolean"
},
"tags": {
"type": "boolean"
},
"likes": {
"type": "boolean"
}
}
},
"TrailMergeSuggestRequest": {
"type": "object",
"required": [
"mode"
],
"properties": {
"mode": {
"type": "string",
"enum": [
"manual-selection",
"auto-discovery",
"maintenance-groups"
]
},
"trailIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "Required for manual-selection"
},
"sourceTrailId": {
"type": "string",
"description": "Required for auto-discovery"
}
}
},
"TrailMergeSuggestCandidate": {
"type": "object",
"required": [
"trailId",
"score",
"reason",
"warnings",
"selectable"
],
"properties": {
"trailId": {
"type": "string"
},
"score": {
"type": "number"
},
"reason": {
"type": "string"
},
"warnings": {
"type": "array",
"items": {
"type": "string"
}
},
"selectable": {
"type": "boolean"
}
}
},
"TrailMergeSuggestResponse": {
"type": "object",
"required": [
"targetTrailId",
"reason",
"warnings",
"candidates"
],
"properties": {
"targetTrailId": {
"type": "string"
},
"reason": {
"type": "string"
},
"warnings": {
"type": "array",
"items": {
"type": "string"
}
},
"candidates": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrailMergeSuggestCandidate"
}
}
}
},
"TrailMergeSuggestGroup": {
"type": "object",
"required": [
"groupId",
"trailIds",
"targetTrailId",
"reason",
"score",
"indirect"
],
"properties": {
"groupId": {
"type": "string"
},
"trailIds": {
"type": "array",
"items": {
"type": "string"
}
},
"targetTrailId": {
"type": "string"
},
"reason": {
"type": "string"
},
"score": {
"type": "number"
},
"indirect": {
"type": "boolean"
}
}
},
"TrailMergeSuggestGroupsResponse": {
"type": "object",
"required": [
"groups"
],
"properties": {
"groups": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TrailMergeSuggestGroup"
}
}
}
},
"TrailMergeExecuteRequest": {
"type": "object",
"required": [
"sourceTrailId",
"targetTrailId",
"settings"
],
"properties": {
"sourceTrailId": {
"type": "string"
},
"targetTrailId": {
"type": "string"
},
"settings": {
"$ref": "#/components/schemas/TrailMergeSettings"
}
}
},
"TrailMergeExecuteResponse": {
"type": "object",
"required": [
"acknowledged"
],
"properties": {
"acknowledged": {
"type": "boolean"
}
}
},
"ListResult": {
"type": "object",
"properties": {