Trail Edit / Waypoints from photos: prevent creating different waypoints for near-by locations (#457)

* trail edit / waypoints from photos: prevent creating different waypoints for near-by locations

* remove not necessary imports

* remove changes from gitignore

* patch gitignore

* waypoint merge radius as category property

* change datamodel for merge radius (use more generic settings field for future implementations)

* fix npm run check issues

* several improvements/fixes

* fix

* move geo cluster code to db

* fix compile issue

* fix docker issue

* merge with existing waypoints

---------

Co-authored-by: Christian Beutel <>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>
This commit is contained in:
slothful-vassal
2026-04-29 11:15:06 +02:00
committed by GitHub
parent 85e5fb6df7
commit 6d1d1236ef
28 changed files with 929 additions and 28 deletions

View File

@@ -0,0 +1,248 @@
<script module lang="ts">
import type { Waypoint } from "$lib/models/waypoint";
export type WaypointMerge = {
incoming: Waypoint;
existing: Waypoint;
};
export type WaypointMergeOptions = {
photos: boolean;
title: boolean;
description: boolean;
icon: boolean;
};
</script>
<script lang="ts">
import { browser } from "$app/environment";
import { _ } from "svelte-i18n";
import Modal from "../base/modal.svelte";
import RadioGroup, { type RadioItem } from "../base/radio_group.svelte";
interface Props {
merge?: WaypointMerge;
oncreate?: () => void;
onmerge?: (options: WaypointMergeOptions) => void;
oncancel?: () => void;
}
let { merge, oncreate, onmerge, oncancel }: Props = $props();
const waypointMergeActionItems: RadioItem[] = [
{
text: $_("create-waypoint-anyway"),
value: "create",
},
{
text: $_("add-to-existing-waypoint"),
value: "merge",
},
];
let modal: Modal;
let waypointMergeAction: "merge" | "create" = $state("create");
let waypointMergeActionIndex = $derived(
waypointMergeActionItems.findIndex(
(item) => item.value === waypointMergeAction,
),
);
let appendWaypointPhotos = $state(true);
let appendWaypointTitle = $state(true);
let appendWaypointDescription = $state(true);
let appendWaypointIcon = $state(false);
export function openModal() {
loadWaypointMergePreferences();
modal.openModal();
}
export function closeModal() {
modal.closeModal();
}
function saveDecision() {
if (waypointMergeAction === "create") {
oncreate?.();
return;
}
onmerge?.({
photos: appendWaypointPhotos,
title: appendWaypointTitle,
description: appendWaypointDescription,
icon: appendWaypointIcon,
});
}
function loadWaypointMergePreferences() {
waypointMergeAction = getWaypointMergePreference("action", false)
? "merge"
: "create";
appendWaypointPhotos = getWaypointMergePreference("photos", true);
appendWaypointTitle = getWaypointMergePreference("title", true);
appendWaypointDescription = getWaypointMergePreference(
"description",
true,
);
appendWaypointIcon = getWaypointMergePreference("icon", false);
}
function getWaypointMergePreference(key: string, fallback: boolean) {
if (!browser) {
return fallback;
}
const value = localStorage.getItem(`waypoint-merge-${key}`);
if (value == null) {
return fallback;
}
return value === "true";
}
function setWaypointMergePreference(key: string, value: boolean) {
if (browser) {
localStorage.setItem(`waypoint-merge-${key}`, value.toString());
}
}
</script>
<Modal
id="waypoint-merge-modal"
title={$_("nearby-waypoint-found")}
bind:this={modal}
>
{#snippet content()}
{#if merge}
<div class="space-y-4">
<div
class="flex items-center gap-3 rounded-md border border-input-border p-3"
>
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-surface"
>
<i class="fa fa-{merge.existing.icon ?? 'circle'}"></i>
</div>
<div>
<p class="font-medium">
{merge.existing.name ||
$_("waypoints", { values: { n: 1 } })}
</p>
<p class="text-sm text-gray-500">
{merge.existing.lat.toFixed(5)},
{merge.existing.lon.toFixed(5)}
</p>
</div>
</div>
<p>
{$_("nearby-waypoint-found-text", {
values: {
name:
merge.existing.name ||
$_("waypoints", { values: { n: 1 } }),
},
})}
</p>
<RadioGroup
name="waypoint-merge-action"
items={waypointMergeActionItems}
selected={waypointMergeActionIndex}
onchange={(item) => {
waypointMergeAction = item.value as "merge" | "create";
setWaypointMergePreference(
"action",
waypointMergeAction === "merge",
);
}}
></RadioGroup>
{#if waypointMergeAction === "merge"}
<div class="space-y-2 pl-6">
{#if merge.incoming._photos?.length}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={appendWaypointPhotos}
onchange={() =>
setWaypointMergePreference(
"photos",
appendWaypointPhotos,
)}
/>
<span>{$_("append-waypoint-photos")}</span>
</label>
{/if}
{#if merge.incoming.name?.trim()}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={appendWaypointTitle}
onchange={() =>
setWaypointMergePreference(
"title",
appendWaypointTitle,
)}
/>
<span>
{merge.existing.name?.trim()
? $_("append-waypoint-title")
: $_("use-waypoint-title")}
</span>
</label>
{/if}
{#if merge.incoming.description?.trim()}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={appendWaypointDescription}
onchange={() =>
setWaypointMergePreference(
"description",
appendWaypointDescription,
)}
/>
<span>
{merge.existing.description?.trim()
? $_("append-waypoint-description")
: $_("use-waypoint-description")}
</span>
</label>
{/if}
{#if merge.incoming.icon &&
merge.incoming.icon !== merge.existing.icon}
<label class="flex items-center gap-2">
<input
type="checkbox"
bind:checked={appendWaypointIcon}
onchange={() =>
setWaypointMergePreference(
"icon",
appendWaypointIcon,
)}
/>
<span>
{$_("use-new-waypoint-icon")}
<i
class="fa fa-{merge.incoming.icon} ml-1"
></i>
</span>
</label>
{/if}
</div>
{/if}
</div>
{/if}
{/snippet}
{#snippet footer()}
<div class="flex flex-wrap items-center gap-4">
<button class="btn-secondary" type="button" onclick={oncancel}
>{$_("cancel")}</button
>
<button class="btn-primary" type="button" onclick={saveDecision}
>{waypointMergeAction === "create"
? $_("save")
: $_("continue")}</button
>
</div>
{/snippet}
</Modal>

View File

@@ -21,7 +21,7 @@
interface Props {
children?: Snippet<[any]>;
onsave?: (waypoint: Waypoint) => void
onsave?: (waypoint: Waypoint) => boolean | Promise<boolean> | void
}
let { children, onsave }: Props = $props();
@@ -32,6 +32,10 @@
modal.openModal();
}
export function closeModal() {
modal.closeModal();
}
const ClientWaypointCreateSchema = WaypointCreateSchema.extend({
_photos: z.array(z.instanceof(File)).optional(),
});
@@ -42,9 +46,11 @@
initialValues: $waypoint,
extend: validator({ schema: ClientWaypointCreateSchema }),
onSubmit: async (form) => {
onsave?.(form);
const shouldClose = await onsave?.(form);
modal.closeModal!();
if (shouldClose !== false) {
modal.closeModal!();
}
},
transform: (values: unknown) => {
const v = values as any;

View File

@@ -301,6 +301,7 @@
"no-data": "Žádná data",
"no-description-for-now": "Zatím bez popisu",
"no-gps-data-in-image": "Obrázek neobsahuje GPS data",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "Bez mřížky",
"no-notifications": "Žádná upozornění",
"no-photos-here": "Zde nejsou žádné fotky ani videa",

View File

@@ -11,6 +11,7 @@
"activity": "{n, plural, =1 {Aktivität} other {Aktivitäten}}",
"add-bio": "Bio hinzufügen",
"add-entry": "Eintrag hinzufügen",
"add-to-existing-waypoint": "Zu bestehendem Wegpunkt hinzufügen",
"add-to-list": "Listen verwalten",
"add-waypoint": "Wegpunkt hinzufügen",
"added-trail-to": "Route hinzugefügt zu",
@@ -26,6 +27,9 @@
"api-documentation": "API Dokumentation",
"api-tokens": "",
"api-tokens-hint": "",
"append-waypoint-description": "Kommentar anhängen",
"append-waypoint-photos": "Fotos hinzufügen",
"append-waypoint-title": "Titel anhängen",
"apply-user-settings": "",
"attraction": "Sehenswürdigkeit",
"author": "Autor",
@@ -84,10 +88,12 @@
"confirm-publish": "Veröffentlichung bestätigen",
"confirm-share": "Teilen bestätigen",
"connect": "Verbinden",
"continue": "Fortfahren",
"contribute": "Mitwirken",
"copy-link": "Link kopieren",
"create-new-list": "Neue Liste erstellen",
"create-waypoint": "Wegpunkt erstellen",
"create-waypoint-anyway": "Trotzdem erstellen",
"creation-date": "Erstellungsdatum",
"crop": "Zuschneiden",
"cross": "Querfeldein",
@@ -286,6 +292,8 @@
"n-years-ago": "vor {n} Jahren",
"name": "Name",
"near": "Nahe",
"nearby-waypoint-found": "Wegpunkt in der Nähe gefunden",
"nearby-waypoint-found-text": "„{name}“ liegt nah genug, um mit diesem Wegpunkt zusammengeführt zu werden. Was möchtest du tun?",
"never": "",
"new-list": "Neue Liste",
"new-password": "Neues Passwort",
@@ -301,6 +309,7 @@
"no-data": "Keine Daten",
"no-description-for-now": "Noch keine Beschreibung",
"no-gps-data-in-image": "Keine GPS-Daten im Bild",
"waypoint-cluster-error": "Wegpunkt-Gruppen konnten nicht erstellt werden",
"no-grid": "Kein Gitter",
"no-notifications": "Keine Benachrichtigungen",
"no-photos-here": "Hier sind noch keine Fotos",
@@ -457,6 +466,9 @@
"upload-new-file": "Neue Datei hochladen",
"uploaded": "hochgeladen",
"uploaded-trail-to-hammerhead": "Route erfolgreich zu Hammerhead hochgeladen",
"use-new-waypoint-icon": "Icon ersetzen",
"use-waypoint-description": "Kommentar übernehmen",
"use-waypoint-title": "Titel übernehmen",
"use-hills": "Hügel einbeziehen",
"use-roads": "Nutze Straßen",
"username": "Nutzername",

View File

@@ -11,6 +11,7 @@
"activity": "{n, plural, =1 {Activity} other {Activities}}",
"add-bio": "Add Bio",
"add-entry": "Add Entry",
"add-to-existing-waypoint": "Add to existing waypoint",
"add-to-list": "Manage lists",
"add-waypoint": "Add Waypoint",
"added-trail-to": "Added trail to",
@@ -26,6 +27,9 @@
"api-documentation": "API Documentation",
"api-tokens": "API Tokens",
"api-tokens-hint": "API Tokens can be used to grant 3rd party applications access to your wanderer account.",
"append-waypoint-description": "Append description",
"append-waypoint-photos": "Add photos",
"append-waypoint-title": "Append title",
"apply-user-settings": "Apply user settings",
"attraction": "Attraction",
"author": "Author",
@@ -84,10 +88,12 @@
"confirm-publish": "Confirm publishing",
"confirm-share": "Confirm share",
"connect": "Connect",
"continue": "Continue",
"contribute": "Contribute",
"copy-link": "Copy Link",
"create-new-list": "Create new list",
"create-waypoint": "Create waypoint",
"create-waypoint-anyway": "Create anyway",
"creation-date": "Creation date",
"crop": "Crop",
"cross": "Cross",
@@ -286,6 +292,8 @@
"n-years-ago": "{n} years ago",
"name": "Name",
"near": "Near",
"nearby-waypoint-found": "Nearby waypoint found",
"nearby-waypoint-found-text": "\"{name}\" is close enough to merge with this waypoint. What would you like to do?",
"never": "Never",
"new-list": "New List",
"new-password": "New password",
@@ -301,6 +309,7 @@
"no-data": "No data",
"no-description-for-now": "No description for now",
"no-gps-data-in-image": "No GPS data in image",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "No Grid",
"no-notifications": "No notifications",
"no-photos-here": "No photos or videos here",
@@ -457,6 +466,9 @@
"upload-new-file": "Upload new file",
"uploaded": "uploaded",
"uploaded-trail-to-hammerhead": "Successfully uploaded trail to Hammerhead",
"use-new-waypoint-icon": "Replace icon",
"use-waypoint-description": "Use description",
"use-waypoint-title": "Use title",
"use-hills": "Use hills",
"use-roads": "Use Roads",
"username": "Username",

View File

@@ -301,6 +301,7 @@
"no-data": "No datos",
"no-description-for-now": "Ninguna descripción de momento",
"no-gps-data-in-image": "Sin datos GPS en la imagen",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "Ninguna cuadrícula",
"no-notifications": "No notificaciones",
"no-photos-here": "No fotos aquí",

View File

@@ -301,6 +301,7 @@
"no-data": "Ez dago daturik",
"no-description-for-now": "Ez dago deskribapenik",
"no-gps-data-in-image": "Ez dago GPS daturik irudian",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "Ez dago saretarik",
"no-notifications": "Ez dago jakinarazpenik",
"no-photos-here": "Ez dago argazki edo bideorik",

View File

@@ -301,6 +301,7 @@
"no-data": "Pas de données",
"no-description-for-now": "Pas de description pour le moment",
"no-gps-data-in-image": "Aucune donnée GPS dans l'image",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "Aucune grille",
"no-notifications": "Pas de notifications",
"no-photos-here": "Aucune photo ici",

View File

@@ -301,6 +301,7 @@
"no-data": "No data",
"no-description-for-now": "No description for now",
"no-gps-data-in-image": "No GPS data in image",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "No Grid",
"no-notifications": "No notifications",
"no-photos-here": "No photos here",

View File

@@ -301,6 +301,7 @@
"no-data": "Nessun dato",
"no-description-for-now": "Nessuna descrizione per il momento",
"no-gps-data-in-image": "No GPS data in image",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "Nessuna griglia",
"no-notifications": "Nessuna notifica",
"no-photos-here": "Nessuna foto qui",

View File

@@ -301,6 +301,7 @@
"no-data": "Geen data",
"no-description-for-now": "Voorlopig geen beschrijving",
"no-gps-data-in-image": "Geen GPS data in afbeelding",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "Geen raster",
"no-notifications": "Geen meldingen",
"no-photos-here": "No photos here",

View File

@@ -301,6 +301,7 @@
"no-data": "Ingen data",
"no-description-for-now": "Ingen beskrivelse ennå",
"no-gps-data-in-image": "Ingen GPS-data i bildet",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "Ingen rutenett",
"no-notifications": "Ingen varsler",
"no-photos-here": "Ingen bilder eller videoer her",

View File

@@ -301,6 +301,7 @@
"no-data": "Brak danych",
"no-description-for-now": "Nie ma jeszcze opisu",
"no-gps-data-in-image": "No GPS data in image",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "Brak Siatki",
"no-notifications": "Brak powiadomień",
"no-photos-here": "Nie ma tu zdjęć",

View File

@@ -301,6 +301,7 @@
"no-data": "Sem dados",
"no-description-for-now": "No description for now",
"no-gps-data-in-image": "No GPS data in image",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "No Grid",
"no-notifications": "No notifications",
"no-photos-here": "No photos here",

View File

@@ -301,6 +301,7 @@
"no-data": "Нет данных",
"no-description-for-now": "Пока нет описания",
"no-gps-data-in-image": "No GPS data in image",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "Без сетки",
"no-notifications": "Нет уведомлений",
"no-photos-here": "Здесь нет фото/видео",

View File

@@ -301,6 +301,7 @@
"no-data": "无数据",
"no-description-for-now": "暂无描述",
"no-gps-data-in-image": "图像中没有GPS数据",
"waypoint-cluster-error": "Could not create waypoint clusters",
"no-grid": "无网格",
"no-notifications": "没有通知",
"no-photos-here": "No photos here",

View File

@@ -2,6 +2,13 @@ interface Category {
id: string;
name: string;
img: string;
settings?: Settings | null;
}
export type {Category}
interface Settings {
wp_merge_enabled?: boolean;
wp_merge_radius?: number;
}
export type {Category}
export type {Settings}

View File

@@ -221,17 +221,37 @@ export async function trails_create(trail: Trail, photos: File[], gpx: File | Bl
let model: Trail = await r.json();
const createdSummitLogs: SummitLog[] = [];
for (const summitLog of trail.expand?.summit_logs_via_trail ?? []) {
summitLog.trail = model.id!;
await summit_logs_create(summitLog, f);
createdSummitLogs.push(await summit_logs_create(summitLog, f));
}
const createdWaypoints: Waypoint[] = [];
for (const wp of trail.expand?.waypoints_via_trail ?? []) {
wp.trail = model.id!;
await waypoints_create({
createdWaypoints.push(await waypoints_create({
...wp,
marker: undefined,
}, f, user);
}, f, user));
}
if (!model.expand) {
model.expand = {};
}
if (createdSummitLogs.length) {
model.expand.summit_logs_via_trail = [
...(model.expand.summit_logs_via_trail ?? []),
...createdSummitLogs,
];
}
if (createdWaypoints.length) {
model.expand.waypoints_via_trail = [
...(model.expand.waypoints_via_trail ?? []),
...createdWaypoints,
];
}
return model;