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

@@ -3,6 +3,7 @@
text: string;
value: any;
icon?: string;
separator?: boolean;
};
</script>
@@ -130,16 +131,22 @@
bind:this={dropdownElement}
>
{#each items as item}
<li
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
role="presentation"
onclick={(e) => handleItemClick(e, item)}
>
{#if item.icon}
<i class="fa fa-{item.icon} mr-3"></i>
{/if}
<span class="whitespace-nowrap">{item.text}</span>
</li>
{#if item.separator}
<li class="px-3 py-2" role="separator" aria-hidden="true">
<div class="border-t border-input-border"></div>
</li>
{:else}
<li
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
role="presentation"
onclick={(e) => handleItemClick(e, item as { text: string; value: any })}
>
{#if item.icon}
<i class="fa fa-{item.icon} mr-3"></i>
{/if}
<span class="whitespace-nowrap">{item.text}</span>
</li>
{/if}
{/each}
</ul>
{/if}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import Datepicker from "$lib/components/base/datepicker.svelte";
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
import Modal from "$lib/components/base/modal.svelte";
import TextField from "$lib/components/base/text_field.svelte";
import Toggle from "$lib/components/base/toggle.svelte";
@@ -33,6 +34,9 @@
planned: integration?.hammerhead?.planned ?? true,
active: integration?.hammerhead?.active ?? false,
after: integration?.hammerhead?.after,
merge: integration?.hammerhead?.merge ?? {
enabled: false,
},
});
const {
@@ -104,6 +108,8 @@
><i class="fa fa-close"></i></button
>
</div>
<IntegrationMergeSettings prefix="merge" />
</form>
{/snippet}
{#snippet footer()}

View File

@@ -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>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import Modal from "$lib/components/base/modal.svelte";
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
import Select, {
type SelectItem,
} from "$lib/components/base/select.svelte";
@@ -43,6 +44,9 @@
planned: integration?.komoot?.planned ?? true,
active: integration?.komoot?.active ?? false,
privacy: integration?.komoot?.privacy ?? "original",
merge: integration?.komoot?.merge ?? {
enabled: false,
},
});
const {
@@ -106,6 +110,8 @@
{$_("integration-privacy-hint-user")}
{/if}
</p>
<IntegrationMergeSettings prefix="merge" />
</form>
{/snippet}
{#snippet footer()}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import Datepicker from "$lib/components/base/datepicker.svelte";
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
import Modal from "$lib/components/base/modal.svelte";
import type { SelectItem } from "$lib/components/base/select.svelte";
import Select from "$lib/components/base/select.svelte";
@@ -43,7 +44,10 @@
activities: integration?.strava?.activities ?? true,
active: integration?.strava?.active ?? false,
after: integration?.strava?.after,
privacy: integration?.komoot?.privacy ?? "original",
privacy: integration?.strava?.privacy ?? "original",
merge: integration?.strava?.merge ?? {
enabled: false,
},
});
const {
@@ -130,6 +134,8 @@
>
{$_("strava-integration-after-date-hint")}
</p>
<IntegrationMergeSettings prefix="merge" />
</form>
{/snippet}
{#snippet footer()}

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) => {

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import Tabs from "$lib/components/base/tabs.svelte";
import TrailDropdown from "$lib/components/trail/trail_dropdown.svelte";
import TrailDropdown, { type MergeResult } from "$lib/components/trail/trail_dropdown.svelte";
import { Comment } from "$lib/models/comment";
import type { Trail } from "$lib/models/trail";
@@ -269,6 +270,21 @@
summitLogs.set(newSummitLogList);
}
function handleTrailMerge(result: MergeResult) {
if (!trail.id || !result.deletedTrailIds.includes(trail.id)) {
return;
}
const targetHandle = handleFromRecordWithIRI(result.targetTrail);
const targetPath =
mode === "map"
? `/map/trail/${targetHandle}/${result.targetTrail.id}`
: `/trail/view/${targetHandle}/${result.targetTrail.id}`;
const search = page.url.searchParams.toString();
goto(search ? `${targetPath}?${search}` : targetPath);
}
async function markTrailAsCompleted() {
trail.completed = true;
const updatedTrail: Trail = { ...trail };
@@ -430,6 +446,7 @@
trails={new Set<Trail>([trail])}
onDelete={() =>
history.length ? history.back() : goto("/trails")}
onMerge={handleTrailMerge}
{mode}
></TrailDropdown>
</div>

View File

@@ -298,6 +298,15 @@
else hoveredTrail = undefined;
}
async function handleTrailsMergeDone(resetSelection: boolean = false) {
if (resetSelection) {
selection?.clear();
hoveredTrail = undefined;
}
await tick();
onupdate?.(filter, selection);
}
async function handleTrailsEditDone(resetSelection: boolean = false) {
if (resetSelection) {
selection = new Set<Trail>();
@@ -346,6 +355,7 @@
mode={"multi-select"}
onDelete={() => handleTrailsEditDone(true)}
onShare={() => handleTrailsEditDone(false)}
onMerge={() => handleTrailsMergeDone(true)}
onUpdate={() => handleTrailsEditDone(true)}
/>
</div>

View 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}

View 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
>