Files
wanderer/web/src/lib/components/trail/trail_dropdown.svelte
slothful-vassal 485ec53f6d feat: add plugin system (#1034)
* feat: add plugin system

* fix db docker build

* fix hammerhead readme, add strava subscription news to docs

* fixes and sdk improvements

* fix: reduce Meilisearch load, debounce federation sync (#1012)

* optimize meili trail index

* several fixes

---------

Co-authored-by: Flomp <Flomp@users.noreply.github.com>

* Bump svelte from 5.55.5 to 5.56.0 in /docs (#1032)

Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.55.5 to 5.56.0.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.56.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>

* Release v0.19.2 (#1035)

* chore: release v0.19.2

* add changelog

---------

Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
Co-authored-by: Christian Beutel <>

* speed up plugin sync and several small fixes

* concepts for security improvements and process stability

* improve concept

* security concept implemented

* remove insecure TLS

* worker concept implemented

* fixes and cleanup

* fixes

* docu

* mermaid, namings

* WASM plugin host improvements, plugin logging

* fix db migration

* Improve plugin config and category mapping UI

* fixes

* further fixes

* remove manual test sync

* fix db migration and strava mapping

* type added, UI improvements

* fix plugin card toggle clickable area

* optimize synch status card layout

* plugin type 'trails' instead of 'integration'

* session auth validation in UI

* fix komoot date and waypoints

* improve category mapping

* fix send to hammerhead: trail name

* plugin setup error handling improved

* fix review findings

* re-mapping added

* rename remote_category

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
2026-06-22 15:00:44 +02:00

855 lines
27 KiB
Svelte

<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import type { List } from "$lib/models/list";
import type { Trail } from "$lib/models/trail";
import {
lists_add_trail,
lists_index,
lists_remove_trail,
} from "$lib/stores/list_store";
import { show_toast } from "$lib/stores/toast_store.svelte";
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";
import { trail2gpx } from "$lib/util/gpx_util";
import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON";
import JSZip from "jszip";
import { type Snippet } from "svelte";
import { _ } from "svelte-i18n";
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
import ConfirmModal from "../confirm_modal.svelte";
import ListSearchModal from "../list/list_search_modal.svelte";
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";
import { hasSendCapablePlugin } from "$lib/stores/plugin_store";
export interface MergeResult {
targetTrail: Trail;
deletedTrailIds: string[];
successfulMergeCount: number;
}
interface Props {
trails?: Set<Trail> | undefined;
mode: "overview" | "map" | "list" | "multi-select";
toggle?: Snippet<[any]>;
onDelete?: () => void;
onShare?: () => void;
onUpdate?: () => void;
onMerge?: (result: MergeResult) => void;
}
let { trails, mode, toggle, onDelete, onShare, onUpdate, onMerge }: Props = $props();
let confirmModal: ConfirmModal;
let listSelectModal: ListSearchModal;
let trailExportModal: TrailExportModal;
let trailSendModal: TrailSendModal;
let trailShareModal: TrailShareModal;
let trailMergeModal: TrailMergeModal;
let lists: List[] = $state([]);
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() &&
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;
if (!Boolean($currentUser)) return false;
let publicCount = 0;
for (const cTrail of trails) {
if (!canEditTrail(cTrail)) {
return false;
}
if (cTrail.public) {
publicCount += 1;
}
}
return publicCount >= trails.size / 2;
}
function allowCopy(): boolean {
if ((trails?.size ?? 0) > 1) return false;
return !isMultiselectMode();
}
function allowSend(): boolean {
return (
hasTrail() &&
!isMultiselectMode() &&
hasGpx() &&
Boolean($currentUser) &&
$hasSendCapablePlugin
);
}
function allowPublish(): boolean {
if (mode !== "multi-select") return false;
if (trails === undefined || trails.size === 0) return false;
if (!Boolean($currentUser)) {
return false;
}
for (const cTrail of trails) {
if (!canEditTrail(cTrail)) {
return false;
}
}
return true;
}
function dropdownItems(): DropdownItem[] {
const separator = (value: string): DropdownItem => ({
text: "",
value,
separator: true,
});
const allowListManagement = isFromCurrentUser();
const allowShareSingleTrail = !isMultiselectMode() && isFromCurrentUser();
const allowSingleOutput = 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",
danger: true,
},
]
: []),
];
}
return [
...(hasTrail()
? [
mode == "overview" || mode == "multi-select"
? {
text: $_("show-on-map"),
value: "show",
icon: "map",
}
: {
text: $_("show-in-overview"),
value: "show",
icon: "table-columns",
},
{
text: $_("directions"),
value: "direction",
icon: "car",
},
]
: []),
...((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()
? [
{
text: $_("export"),
value: "download",
icon: "download",
},
]
: []),
...(allowSend()
? [
{
text: $_("send-to"),
value: "send-to",
icon: "upload",
},
]
: []),
...(!isMultiselectMode()
? [
{
text: $_("print"),
value: "print",
icon: "print",
},
]
: []),
...(allowDelete()
? [
separator("sep-single-danger"),
{
text: $_("delete"),
value: "delete",
icon: "trash",
danger: true,
},
]
: []),
];
}
function isMultiselectMode(): boolean {
return trails !== undefined && trails.size > 1;
}
function hasTrail(): boolean {
return (
trails !== undefined &&
trails.size > 0 &&
[...trails][0] !== undefined
);
}
function hasGpx(): boolean {
if (!hasTrail()) return false;
for (const gTrail of trails!) {
if (gTrail.gpx) return true;
}
return false;
}
function canExport(): boolean {
return hasGpx();
}
function trailId(): string | undefined {
return trail()?.id;
}
function getTrails(): Set<Trail> | undefined {
return trails;
}
function trail(): Trail | undefined {
return hasTrail() ? [...trails!][0] : undefined;
}
function isFromCurrentUser(uTrail?: Trail): boolean {
if (!$currentUser) {
return false;
}
if (uTrail !== undefined) {
return uTrail.expand?.author?.id === $currentUser?.actor;
} else if (trails !== undefined && trails.size > 0) {
for (const sTrail of trails) {
if (sTrail.expand?.author?.id === $currentUser?.actor) {
return true;
}
}
}
return false;
}
function allowDelete(): boolean {
return isFromCurrentUser();
}
function allowDeleteTrail(dTrail?: Trail): boolean {
return isFromCurrentUser(dTrail);
}
async function handleDropdownClick(item: { text: string; value: any }) {
if (!trail()) {
return;
}
const handle = page.params.handle ?? handleFromRecordWithIRI(trail());
const ddVal = item.value as string;
if (ddVal == "show") {
if (hasTrail()) {
const url =
mode == "overview" || mode == "multi-select"
? `/map/trail/${handle}/${trailId()}`
: `/trail/view/${handle}/${trailId()}`;
goto(url + "?" + page.url.searchParams);
}
} else if (ddVal == "list") {
lists = (
await lists_index(
{ q: "", author: $currentUser?.actor ?? "" },
1,
-1,
)
).items;
listSelectModal.openModal();
} else if (ddVal == "direction") {
if (hasTrail()) {
window
.open(
`https://www.openstreetmap.org/directions?to=${trail()!.lat},${trail()!.lon}`,
"_blank",
)
?.focus();
}
} else if (ddVal == "print") {
if (hasTrail()) {
goto(
`/map/trail/${handle}/${trailId()}/print?${page.url.searchParams}`,
);
}
} else if (ddVal == "share") {
trailShareModal.openModal();
} else if (ddVal == "send-to") {
trailSendModal.openModal();
} else if (ddVal == "download") {
trailExportModal.openModal();
} else if (ddVal == "edit") {
if (hasTrail()) {
goto(`/trail/edit/${trailId()}`);
}
} else if (ddVal == "copy") {
if (hasTrail()) {
goto("/trail/edit/new?orig=" + trail()?.id);
}
} else if (ddVal == "publish") {
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()!);
}
}
}
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 updateTrailsVisibility() {
const newVisibility = !majorityOfSelectedTrailsArePublic();
loading = true;
for (const cTrail of trails ?? []) {
if (!cTrail) continue;
if (!cTrail.expand?.author?.id) continue;
const origTrail: Trail = {
...cTrail,
author: cTrail.expand!.author!.id,
};
const updatedTrail: Trail = {
...origTrail,
public: newVisibility,
};
try {
await trails_update(
origTrail,
updatedTrail,
undefined,
undefined,
["tags", "category"],
);
} catch (e) {
console.error(e);
show_toast({
type: "error",
icon: "close",
text: `${$_("error-saving-trail")}: ${cTrail.name}`,
});
}
}
loading = false;
onUpdate?.();
}
async function exportTrails(exportSettings: {
fileFormat: "gpx" | "json";
photos: boolean;
summitLog: boolean;
}) {
if (trails !== undefined && trails.size > 0) {
for (const cTrail of trails) {
await doExportTrail(exportSettings, cTrail);
}
}
}
async function doExportTrail(
exportSettings: {
fileFormat: "gpx" | "json";
photos: boolean;
summitLog: boolean;
},
eTrail: Trail,
) {
try {
if (eTrail !== undefined) {
let fileData: string = await trail2gpx(eTrail, $currentUser);
if (exportSettings.fileFormat == "json") {
fileData = JSON.stringify(
gpx(
new DOMParser().parseFromString(
fileData,
"application/gpx+xml" as any,
),
),
);
}
if (!exportSettings.photos && !exportSettings.summitLog) {
const blob = new Blob([fileData], {
type:
exportSettings.fileFormat == "json"
? "application/json"
: "application/gpx+xml",
});
saveAs(blob, `${eTrail.name}.${exportSettings.fileFormat}`);
} else {
const zip = new JSZip();
zip.file(
`${eTrail.name}.${exportSettings.fileFormat}`,
fileData,
);
if (exportSettings.photos) {
const photoFolder = zip.folder($_("photos"));
for (const photo of eTrail.photos) {
const photoURL = getFileURL(eTrail, photo);
const photoBlob = await fetch(photoURL).then(
(response) => response.blob(),
);
const photoData = new File([photoBlob], photo);
photoFolder?.file(photo, photoData, {
base64: true,
});
}
}
if (exportSettings.summitLog) {
let summitLogString = "";
for (const summitLog of eTrail.expand
?.summit_logs_via_trail ?? []) {
summitLogString += `${summitLog.date},${summitLog.text}\n`;
}
zip.file(
`${eTrail.name} - ${$_("summit-book")}.csv`,
summitLogString,
);
}
const blob = await zip.generateAsync({ type: "blob" });
saveAs(blob, `${eTrail.name}.zip`);
}
}
} catch (e) {
console.error(e);
show_toast({
type: "error",
icon: "close",
text: $_("error-exporting-trail"),
});
}
}
async function deleteTrails() {
if (hasTrail()) {
loading = true;
for (const dTrail of trails!) {
await doDeleteTrail(dTrail);
}
loading = false;
onDelete?.();
}
}
async function doDeleteTrail(dTrail: Trail) {
if (dTrail === undefined) return;
if (!allowDeleteTrail(dTrail)) return;
await trails_delete(dTrail);
}
async function handleShareUpdate() {
onShare?.();
}
async function handleListSelection(list: List) {
try {
let deleted = false;
let multiple = false;
if (hasTrail()) {
multiple = true;
for (const lTrail of trails!) {
if (await doHandleListSelection(list, lTrail)) {
deleted = true;
}
}
}
if (deleted) {
show_toast({
type: "success",
icon: "check",
text: multiple
? `${$_("removed-trails-from")} "${list.name}"`
: `${$_("removed-trail-from")} "${list.name}"`,
});
} else {
show_toast({
type: "success",
icon: "check",
text: multiple
? `${$_("added-trails-to")} "${list.name}"`
: `${$_("added-trail-to")} "${list.name}"`,
});
}
} catch (e) {
console.error(e);
show_toast({
type: "error",
icon: "close",
text: "Error adding trail to list.",
});
}
}
async function doHandleListSelection(
list: List,
lTrail: Trail,
): Promise<boolean> {
if (list.trails?.includes(lTrail.id!)) {
if (listContainsAllTrails(list)) {
await lists_remove_trail(list, lTrail);
return true;
}
} else {
await lists_add_trail(list, lTrail);
}
return false;
}
function listContainsAllTrails(list: List): boolean {
if (trails === undefined) {
return false;
} else if (list.trails !== undefined) {
for (const lTrail of trails) {
if (!list.trails!.includes(lTrail.id!)) return false;
}
return true;
}
return false;
}
</script>
<Dropdown
items={dropdownItems()}
onchange={(item) => handleDropdownClick(item)}
>
{#snippet children({ toggleMenu: openDropdown })}
{#if toggle}{@render toggle({
toggleMenu: openDropdown,
})}
{:else if loading}
<div class:w-16={isMultiselectMode()}></div>
<div class="spinner light:spinner-dark"></div>
{:else if mode == "multi-select"}
<button
aria-label="Open dropdown"
class="btn-primary shrink-0 font-medium!"
onclick={openDropdown}
>
<span
>{trails?.size}
{$_("selected")}
<i class="fa fa-caret-down ml-1"></i></span
>
</button>
{:else}
<button
aria-label="Open dropdown"
class="btn-primary rounded-full! h-12 w-12"
onclick={openDropdown}
>
<i class="fa fa-ellipsis-vertical"></i>
</button>
{/if}
{/snippet}
</Dropdown>
<ConfirmModal
text={$_("delete-trail-confirm")}
bind:this={confirmModal}
onconfirm={deleteTrails}
></ConfirmModal>
<ListSearchModal
{lists}
trails={getTrails()}
bind:this={listSelectModal}
onchange={(list) => handleListSelection(list)}
></ListSearchModal>
<TrailExportModal
bind:this={trailExportModal}
onexport={(settings) => exportTrails(settings)}
></TrailExportModal>
<TrailShareModal
trail={trail()}
onsave={handleShareUpdate}
bind:this={trailShareModal}
></TrailShareModal>
<TrailSendModal
trail={trail()}
share={page.url.searchParams.get("share") ?? undefined}
bind:this={trailSendModal}
></TrailSendModal>
<TrailMergeModal
bind:this={trailMergeModal}
onmerge={(settings, selection) => mergeTrails(settings, selection)}
></TrailMergeModal>
<MergeDialog/>