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

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