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>
This commit is contained in:
slothful-vassal
2026-06-22 15:00:44 +02:00
committed by GitHub
parent 2e3d8537b8
commit 485ec53f6d
196 changed files with 20947 additions and 5454 deletions

View File

@@ -6,11 +6,14 @@
</script>
<script lang="ts">
import { _ } from "svelte-i18n";
interface Props {
name?: string;
items?: SelectItem[];
value?: any;
label?: string;
error?: string | string[] | null;
disabled?: boolean;
onchange?: (value: any) => void
}
@@ -20,6 +23,7 @@
items = [],
value = $bindable(items.at(0)?.value ?? ""),
label = "",
error = "",
disabled = false,
onchange
}: Props = $props();
@@ -38,6 +42,8 @@
<select
{name}
class="block bg-input-background h-10 px-4 border-r-8 border-transparent outline-1 outline-input-border rounded-md focus:outline-input-border-focus transition-colors"
class:outline-red-400={(error?.length ?? 0) > 0}
class:bg-input-background-error={(error?.length ?? 0) > 0}
class:text-gray-500={disabled}
{disabled}
bind:value
@@ -47,4 +53,10 @@
<option value={item.value}>{item.text}</option>
{/each}
</select>
{#if error}
<span class="select-error text-xs text-red-400">
{error instanceof Array ? $_(error[0]) : error}
</span>
{/if}
</div>

View File

@@ -0,0 +1,257 @@
<script module lang="ts">
export type SingleSelectItem = {
text: string;
value: any;
};
</script>
<script lang="ts">
import { onMount, tick } from "svelte";
interface Props {
name?: string;
items?: SingleSelectItem[];
value?: any;
label?: string;
ariaLabel?: string;
placeholder?: string;
disabled?: boolean;
onchange?: (value: any) => void;
}
let {
name = "",
items = [],
value = $bindable(""),
label = "",
ariaLabel = "",
placeholder = "",
disabled = false,
onchange,
}: Props = $props();
let open = $state(false);
let trigger: HTMLButtonElement | undefined = $state();
let menu: HTMLUListElement | undefined = $state();
let menuStyle = $state("");
let activeIndex = $state(-1);
let selectedItem = $derived(items.find((item) => item.value === value));
let selectedIndex = $derived(items.findIndex((item) => item.value === value));
async function positionMenu() {
await tick();
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const gap = 4;
const viewportPadding = 12;
const maxHeight = 256;
const spaceBelow = window.innerHeight - rect.bottom - viewportPadding;
const spaceAbove = rect.top - viewportPadding;
const openAbove = spaceBelow < 160 && spaceAbove > spaceBelow;
const availableHeight = Math.max(
80,
Math.min(maxHeight, openAbove ? spaceAbove - gap : spaceBelow - gap),
);
const top = openAbove ? rect.top - gap - availableHeight : rect.bottom + gap;
menuStyle = [
`left: ${rect.left}px`,
`top: ${top}px`,
`width: ${rect.width}px`,
`max-height: ${availableHeight}px`,
].join("; ");
}
async function scrollActiveItemIntoView() {
await tick();
menu?.querySelector<HTMLElement>(`[data-single-select-index="${activeIndex}"]`)
?.scrollIntoView({ block: "nearest" });
}
function selectItem(item: SingleSelectItem) {
if (disabled) return;
value = item.value;
open = false;
onchange?.(item.value);
}
async function openMenu(index = selectedIndex >= 0 ? selectedIndex : 0) {
if (disabled) return;
activeIndex = items.length ? Math.max(0, Math.min(index, items.length - 1)) : -1;
open = true;
await positionMenu();
await scrollActiveItemIntoView();
}
function closeMenu() {
open = false;
activeIndex = -1;
}
async function toggle() {
if (disabled) return;
if (open) {
closeMenu();
} else {
await openMenu();
}
}
async function setActiveIndex(index: number) {
if (!items.length) return;
activeIndex = (index + items.length) % items.length;
await scrollActiveItemIntoView();
}
async function handleKeydown(event: KeyboardEvent) {
if (disabled) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
if (!open) {
await openMenu(selectedIndex >= 0 ? selectedIndex : 0);
} else {
await setActiveIndex(activeIndex + 1);
}
break;
case "ArrowUp":
event.preventDefault();
if (!open) {
await openMenu(selectedIndex >= 0 ? selectedIndex : items.length - 1);
} else {
await setActiveIndex(activeIndex - 1);
}
break;
case "Home":
if (!open) return;
event.preventDefault();
await setActiveIndex(0);
break;
case "End":
if (!open) return;
event.preventDefault();
await setActiveIndex(items.length - 1);
break;
case "Enter":
case " ":
event.preventDefault();
if (!open) {
await openMenu();
} else if (activeIndex >= 0 && items[activeIndex]) {
selectItem(items[activeIndex]);
}
break;
case "Escape":
if (!open) return;
event.preventDefault();
closeMenu();
break;
}
}
onMount(() => {
function handleDocumentMouseDown(event: MouseEvent) {
const target = event.target as Node | null;
if (!target || trigger?.contains(target) || menu?.contains(target)) {
return;
}
open = false;
}
function handleDocumentKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
closeMenu();
}
}
function handleDocumentScroll(event: Event) {
if (!open) return;
const target = event.target as Node | null;
if (target && menu?.contains(target)) {
return;
}
closeMenu();
}
function handleWindowResize() {
if (!open) return;
positionMenu();
}
document.addEventListener("mousedown", handleDocumentMouseDown);
document.addEventListener("keydown", handleDocumentKeyDown);
document.addEventListener("scroll", handleDocumentScroll, true);
window.addEventListener("resize", handleWindowResize);
return () => {
document.removeEventListener("mousedown", handleDocumentMouseDown);
document.removeEventListener("keydown", handleDocumentKeyDown);
document.removeEventListener("scroll", handleDocumentScroll, true);
window.removeEventListener("resize", handleWindowResize);
};
});
</script>
<div class="relative">
{#if label.length}
<label for={name} class="text-sm font-medium pb-1">
{label}
</label>
{/if}
<button
bind:this={trigger}
id={name}
type="button"
aria-label={ariaLabel || label || name}
aria-haspopup="listbox"
aria-expanded={open}
class="relative flex h-10 w-full items-center rounded-md border border-input-border bg-input-background px-3 text-left transition-colors focus:border-input-border-focus focus:outline-none focus:ring-0"
class:opacity-75={disabled}
class:cursor-default={disabled}
disabled={disabled}
onclick={toggle}
onkeydown={handleKeydown}
>
{#if selectedItem}
<span class="truncate">{selectedItem.text}</span>
{:else}
<span class="truncate text-gray-400">{placeholder}</span>
{/if}
<i
class="fa fa-caret-down absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 transition-transform"
class:rotate-180={open}
></i>
</button>
{#if open}
<ul
bind:this={menu}
role="listbox"
class="fixed z-[1001] overflow-y-auto rounded-md border border-input-border bg-menu-background shadow-lg"
style={menuStyle}
>
{#each items as item, i}
<li
role="option"
aria-selected={item.value === value}
tabindex="-1"
class="flex cursor-pointer items-center justify-between px-3 py-2 hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus"
class:bg-menu-item-background-focus={activeIndex === i}
data-single-select-index={i}
onmousedown={(event) => {
event.preventDefault();
selectItem(item);
}}
>
<span class="truncate">{item.text}</span>
{#if item.value === value}
<i class="fa fa-check ml-3"></i>
{/if}
</li>
{/each}
</ul>
{/if}
</div>

View File

@@ -11,7 +11,7 @@
error?: string | string[] | null;
icon?: string;
extraClasses?: string;
type?: "text" | "password" | "search";
type?: "text" | "password" | "search" | "url";
autocomplete?: "on" | "off";
onchange?: ChangeEventHandler<HTMLInputElement>;
oninput?: FormEventHandler<HTMLInputElement>;

View File

@@ -7,9 +7,11 @@
text: string;
action?: string;
deny?: string;
alternative?: string;
id?: string;
onconfirm?: () => void
oncancel?: () => void
onalternative?: () => void
}
let {
@@ -17,9 +19,11 @@
text,
action = "delete",
deny ="cancel",
alternative,
id = "confirm-modal",
onconfirm,
oncancel
oncancel,
onalternative
}: Props = $props();
let modal: Modal;
@@ -29,13 +33,18 @@
}
function cancel() {
oncancel?.();
modal.closeModal!();
oncancel?.();
}
function alternativeAction() {
modal.closeModal!();
onalternative?.();
}
function confirm() {
onconfirm?.()
modal.closeModal!();
onconfirm?.()
}
</script>
@@ -48,6 +57,11 @@
<button class="btn-secondary" onclick={cancel}
>{$_(deny)}</button
>
{#if alternative}
<button class="btn-secondary" type="button" onclick={alternativeAction}
>{$_(alternative)}</button
>
{/if}
<button
id="confirm"
class={action === "delete" ? "btn-danger" : "btn-primary"}

View File

@@ -1,128 +0,0 @@
<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";
import { HammerheadSchema } from "$lib/models/api/integration_schema";
import type {
Integration,
HammerheadIntegration,
} from "$lib/models/integration";
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { _ } from "svelte-i18n";
interface Props {
integration?: Integration;
onsave?: (hammerheadIntegration: HammerheadIntegration) => void;
}
let { integration, onsave }: Props = $props();
let modal: Modal;
export function openModal() {
errors.set({});
modal.openModal();
}
const getInitialFormValues = () => ({
email: integration?.hammerhead?.email ?? "",
password: integration?.hammerhead?.password ?? "",
completed: integration?.hammerhead?.completed ?? true,
planned: integration?.hammerhead?.planned ?? true,
active: integration?.hammerhead?.active ?? false,
after: integration?.hammerhead?.after,
merge: integration?.hammerhead?.merge ?? {
enabled: false,
},
});
const {
form,
errors,
data: formData,
} = createForm({
initialValues: getInitialFormValues(),
extend: validator({
schema: HammerheadSchema,
}),
onSubmit: async (form) => {
form.active = integration?.hammerhead?.active ?? form.active;
onsave?.(form);
modal.closeModal();
},
});
function clearAfterDate() {
($formData as any).after = undefined;
}
</script>
<Modal
id="hammerhead-settings-modal"
size="md:max-w-lg"
title={"Hammerhead " + $_("settings")}
bind:this={modal}
>
{#snippet content()}
<form id="hammerhead-settings-form" class="space-y-2" use:form>
<TextField
label={$_("email")}
placeholder="user@example.com"
name="email"
error={$errors.email}
></TextField>
<TextField
label={$_("password")}
placeholder={integration?.hammerhead ? `(${$_("unchanged")})` : ""}
name="password"
type="password"
error={$errors.password}
></TextField>
<div class="flex flex-wrap gap-x-4">
<Toggle name="planned" label={$_("planned-tours", { values: { n: 2 } })}
></Toggle>
<Toggle
name="completed"
label={$_("completed-tours", { values: { n: 2 } })}
></Toggle>
</div>
<p
class="text-xs text-gray-500 max-w-lg pt-4 pb-1 border-t border-input-border"
>
{$_("hammerhead-integration-after-date-hint")}
</p>
<div class="flex items-end relative gap-x-2">
<Datepicker
error={$errors.after}
label={$_("ignore-trails-before-date")}
bind:value={$formData.after}
></Datepicker>
<button
class="btn-icon mb-[10px]"
type="button"
onclick={clearAfterDate}
aria-label="Clear 'after' date"
><i class="fa fa-close"></i></button
>
</div>
<IntegrationMergeSettings prefix="merge" />
</form>
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button
>
<button
class="btn-primary"
form="hammerhead-settings-form"
type="submit"
name="save">{$_("save")}</button
>
</div>
{/snippet}</Modal
>

View File

@@ -1,39 +0,0 @@
<script lang="ts">
import Toggle from "$lib/components/base/toggle.svelte";
import { _ } from "svelte-i18n";
interface Props {
onclick: () => void;
ontoggle: (value: boolean) => void;
active: boolean;
disabled: boolean;
img: string;
title: string;
description: string;
}
let {
onclick,
ontoggle,
active = $bindable(),
disabled,
img,
title,
description,
}: Props = $props();
</script>
<div class="border border-input-border rounded-lg p-4 space-y-4 flex flex-col justify-between">
<img class="h-20" src={img} alt="integration logo"/>
<div>
<h5 class="text-xl font-semibold">{title}</h5>
<p class="text-sm text-gray-500">
{description}
</p>
</div>
<div class="flex items-center justify-between">
<button class="btn-secondary" {onclick}
><i class="fa fa-cogs mr-2"></i>{$_("settings")}</button
>
<Toggle bind:value={active} onchange={ontoggle} {disabled}></Toggle>
</div>
</div>

View File

@@ -1,130 +0,0 @@
<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";
import TextField from "$lib/components/base/text_field.svelte";
import Toggle from "$lib/components/base/toggle.svelte";
import { KomootSchema } from "$lib/models/api/integration_schema";
import type {
Integration,
KomootIntegration,
} from "$lib/models/integration";
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { _ } from "svelte-i18n";
interface Props {
integration?: Integration;
onsave?: (komootIntegration: KomootIntegration) => void;
}
let { integration, onsave }: Props = $props();
let modal: Modal;
const privacySelectItems: SelectItem[] = [
{
text: $_("keep-original"),
value: "original",
},
{ text: $_("apply-user-settings"), value: "settings" },
];
export function openModal() {
errors.set({});
modal.openModal();
}
const getInitialFormValues = () => ({
email: integration?.komoot?.email ?? "",
password: integration?.komoot?.password ?? "",
completed: integration?.komoot?.completed ?? true,
planned: integration?.komoot?.planned ?? true,
active: integration?.komoot?.active ?? false,
privacy: integration?.komoot?.privacy ?? "original",
merge: integration?.komoot?.merge ?? {
enabled: false,
},
});
const {
form,
errors,
data: d,
} = createForm({
initialValues: getInitialFormValues(),
extend: validator({
schema: KomootSchema,
}),
onSubmit: async (form) => {
form.active = integration?.komoot?.active ?? form.active;
onsave?.(form);
modal.closeModal();
},
});
</script>
<Modal
id="komoot-settings-modal"
size="md:max-w-lg"
title={"komoot " + $_("settings")}
bind:this={modal}
>
{#snippet content()}
<form id="komoot-settings-form" class="space-y-2" use:form>
<TextField
label={$_("email")}
placeholder="user@example.com"
name="email"
error={$errors.email}
></TextField>
<TextField
label={$_("password")}
placeholder={integration?.komoot ? `(${$_("unchanged")})` : ""}
name="password"
type="password"
error={$errors.password}
></TextField>
<div class="flex flex-wrap gap-x-4">
<Toggle
name="planned"
label={$_("planned-tours", { values: { n: 2 } })}
></Toggle>
<Toggle
name="completed"
label={$_("completed-tours", { values: { n: 2 } })}
></Toggle>
</div>
<Select
label={$_("privacy")}
items={privacySelectItems}
name="privacy"
></Select>
<p class="text-xs text-gray-500 max-w-lg">
{#if $d.privacy == "original"}
{$_("integration-privacy-hint-original")}
{:else}
{$_("integration-privacy-hint-user")}
{/if}
</p>
<IntegrationMergeSettings prefix="merge" />
</form>
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button
>
<button
class="btn-primary"
form="komoot-settings-form"
type="submit"
name="save">{$_("save")}</button
>
</div>
{/snippet}</Modal
>

View File

@@ -1,154 +0,0 @@
<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";
import TextField from "$lib/components/base/text_field.svelte";
import Toggle from "$lib/components/base/toggle.svelte";
import { StravaSchema } from "$lib/models/api/integration_schema";
import type {
Integration,
StravaIntegration,
} from "$lib/models/integration";
import { validator } from "@felte/validator-zod";
import { createForm } from "felte";
import { _ } from "svelte-i18n";
interface Props {
integration?: Integration;
onsave?: (stravaIntegration: StravaIntegration) => void;
}
let { integration, onsave }: Props = $props();
let modal: Modal;
const privacySelectItems: SelectItem[] = [
{
text: $_("keep-original"),
value: "original",
},
{ text: $_("apply-user-settings"), value: "settings" },
];
export function openModal() {
errors.set({});
modal.openModal();
}
const getInitialFormValues = () => ({
clientId: integration?.strava?.clientId ?? "",
clientSecret: integration?.strava?.clientSecret ?? "",
routes: integration?.strava?.routes ?? true,
activities: integration?.strava?.activities ?? true,
active: integration?.strava?.active ?? false,
after: integration?.strava?.after,
privacy: integration?.strava?.privacy ?? "original",
merge: integration?.strava?.merge ?? {
enabled: false,
},
});
const {
form,
errors,
data: formData,
} = createForm({
initialValues: getInitialFormValues(),
extend: validator({
schema: StravaSchema,
}),
onSubmit: async (form) => {
form.active = integration?.strava?.active ?? form.active;
onsave?.(form);
modal.closeModal();
},
});
function clearAfterDate() {
($formData as any).after = undefined;
}
</script>
<Modal
id="strava-settings-modal"
size="md:min-w-lg"
title={"Strava " + $_("settings")}
bind:this={modal}
>
{#snippet content()}
<form id="strava-settings-form" class="space-y-2" use:form>
<TextField
label="Client-ID"
placeholder="000000"
name="clientId"
error={$errors.clientId}
></TextField>
<TextField
label="Client Secret"
placeholder={integration?.strava
? `(${$_("unchanged")})`
: "de8b3789bd7116d..."}
name="clientSecret"
type="password"
error={$errors.clientSecret}
></TextField>
<div class="flex gap-x-4">
<Toggle name="routes" label={$_("route", { values: { n: 2 } })}
></Toggle>
<Toggle
name="activities"
label={$_("activity", { values: { n: 2 } })}
></Toggle>
</div>
<Select
label={$_("privacy")}
items={privacySelectItems}
name="privacy"
></Select>
<p class="text-xs text-gray-500 max-w-lg">
{#if $formData.privacy == "original"}
{$_("integration-privacy-hint-original")}
{:else}
{$_("integration-privacy-hint-user")}
{/if}
</p>
<div class="flex items-end relative gap-x-2 pt-2 border-t border-input-border">
<Datepicker
error={$errors.after}
label={$_("ignore-trails-before-date")}
bind:value={$formData.after}
></Datepicker>
<button
class="btn-icon mb-[10px]"
type="button"
onclick={clearAfterDate}
aria-label="Clear 'after' date"
><i class="fa fa-close"></i></button
>
</div>
<p
class="text-xs text-gray-500 max-w-lg"
>
{$_("strava-integration-after-date-hint")}
</p>
<IntegrationMergeSettings prefix="merge" />
</form>
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()}
>{$_("cancel")}</button
>
<button
class="btn-primary"
form="strava-settings-form"
type="submit"
name="save">{$_("save")}</button
>
</div>
{/snippet}</Modal
>

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import Toggle from "$lib/components/base/toggle.svelte";
import { _ } from "svelte-i18n";
interface Props {
onclick: () => void;
ontoggle: (value: boolean) => void;
active: boolean;
toggleDisabled?: boolean;
settingsDisabled?: boolean;
img?: string;
title: string;
description?: string;
lastSyncAt?: string;
error?: string;
}
let {
onclick,
ontoggle,
active = $bindable(),
toggleDisabled = false,
settingsDisabled = false,
img,
title,
description = "",
lastSyncAt = "",
error = "",
}: Props = $props();
function formatLastSyncAt(value: string) {
return new Date(value).toLocaleString(undefined, {
dateStyle: "short",
timeStyle: "short",
});
}
</script>
<div
class="flex flex-col gap-4 rounded-xl border border-input-border p-4 transition-colors hover:bg-secondary-hover md:flex-row md:items-center md:justify-between"
>
<div class="flex min-w-0 items-start gap-6">
{#if img}
<img
class="h-16 w-24 shrink-0 object-contain object-left"
src={img}
alt="plugin logo"
/>
{:else}
<div
class="flex h-16 w-24 shrink-0 items-center justify-center rounded border border-input-border bg-input-background text-sm font-semibold"
aria-hidden="true"
>
{title.slice(0, 2).toUpperCase()}
</div>
{/if}
<div class="min-w-0">
<h5 class="truncate text-lg font-semibold">{title}</h5>
{#if description}
<p class="line-clamp-2 text-sm text-gray-500">{description}</p>
{/if}
</div>
</div>
<div class="flex shrink-0 flex-col gap-2 md:items-start">
<div class="flex items-center justify-between gap-4 md:justify-end">
<button
class="btn-secondary"
class:btn-disabled={settingsDisabled}
{onclick}
disabled={settingsDisabled}
><i class="fa fa-cogs mr-2"></i>{$_("settings")}</button
>
<div class="plugin-card-toggle">
<Toggle bind:value={active} onchange={ontoggle} disabled={toggleDisabled}></Toggle>
</div>
</div>
<div class="min-h-5 max-w-56 text-xs text-gray-500">
{#if lastSyncAt}
<span
class:text-red-400={error}
class="inline-flex min-w-0 items-center gap-2"
title={error
? $_("plugin-setup-error")
: `${$_("last-sync")}: ${formatLastSyncAt(lastSyncAt)}`}
>
{#if error}
<i
class="fa fa-triangle-exclamation shrink-0 text-[0.8rem]"
aria-hidden="true"
></i>
{:else}
<i
class="fa fa-clock shrink-0 text-[0.8rem]"
aria-hidden="true"
></i>
{/if}
<span class="truncate">{formatLastSyncAt(lastSyncAt)}</span>
</span>
{:else if error}
<span class="inline-flex items-center gap-2 text-red-400" title={$_("plugin-setup-error")}>
<i
class="fa fa-triangle-exclamation shrink-0 text-[0.8rem]"
aria-hidden="true"
></i>
<span class="truncate">{$_("plugin-setup-error")}</span>
</span>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,753 @@
<script lang="ts">
import Datepicker from "$lib/components/base/datepicker.svelte";
import Modal from "$lib/components/base/modal.svelte";
import Select, { type SelectItem } from "$lib/components/base/select.svelte";
import SingleSelect from "$lib/components/base/single_select.svelte";
import TextField from "$lib/components/base/text_field.svelte";
import Toggle from "$lib/components/base/toggle.svelte";
import PluginMergeSettings from "$lib/components/settings/plugins/plugin_merge_settings.svelte";
import type { Category } from "$lib/models/category";
import type { PluginInstance } from "$lib/models/plugin_instance";
import type { ConfigField, PluginProvider } from "$lib/models/plugin_provider";
import { plugin_auth_validate, plugin_oauth_start } from "$lib/stores/plugin_instance_store";
import { show_toast } from "$lib/stores/toast_store.svelte";
import { translatePluginAPIError } from "$lib/util/plugin_error_i18n";
import {
configFieldDescription,
configFieldLabel,
configFieldOptionLabel,
pluginTitle as localizedPluginTitle,
providerCategoryLabel,
} from "$lib/util/plugin_i18n";
import { tick } from "svelte";
import { _, locale } from "svelte-i18n";
interface CategoryMappingRow {
providerCategory: string;
category: string;
}
type PluginInstanceForm = Partial<PluginInstance> & {
mappingChanged?: boolean;
};
interface Props {
plugin: PluginProvider;
categories?: Category[];
instance?: PluginInstance;
onbeforecategorymappingsave?: (instance: PluginInstanceForm) => Promise<boolean> | boolean;
onsave?: (instance: Partial<PluginInstance>) => Promise<PluginInstance | void> | PluginInstance | void;
}
let { plugin, categories = [], instance, onbeforecategorymappingsave, onsave }: Props = $props();
let modal: Modal;
let auth: Record<string, string> = $state(initialAuth());
let planned = $state(true);
let completed = $state(true);
let mergeEnabled = $state(false);
let privacy = $state("original");
let categoryMappingRows: CategoryMappingRow[] = $state(initialCategoryMappingRows());
let categoryMappingList: HTMLDivElement | undefined = $state();
let authFields = $derived(plugin.auth.fields ?? []);
let secretFields = $derived(new Set(plugin.auth.secretFields ?? plugin.auth.fields ?? []));
let isOAuthPlugin = $derived(plugin.auth.type === "oauth2");
let isSessionPlugin = $derived(plugin.auth.type === "session");
let isConnected = $derived(instance?.status === "configured");
let isSaving = $state(false);
let needsOAuthConnect = $derived(isOAuthPlugin && (!isConnected || authChanged()));
let plugin_id = $derived(plugin.id);
let configSchema = $derived(plugin.configSchema ?? []);
let visibleConfigSchema = $derived(configSchema.filter((field) => !field.hidden));
let extraConfig: Record<string, any> = $state(initialExtraConfig());
let configErrors: Record<string, string> = $state({});
let supportsPlanned = $derived(
plugin.capabilities?.includes("list_routes.v1") ?? false,
);
let supportsCompleted = $derived(
plugin.capabilities?.includes("list_activities.v1") ?? false,
);
let hasTourKindChoice = $derived(supportsPlanned && supportsCompleted);
let supportsSourcePrivacy = $derived(
plugin.capabilities?.includes("source_privacy") ?? false,
);
let mergeAvailable = $derived((hostConfig().merge as any)?.available !== false);
let supportsCategoryMapping = $derived(supportsPlanned || supportsCompleted);
let categorySelectItems: SelectItem[] = $derived(
categories
.map((category) => ({
text: $_(category.name),
value: category.id,
}))
.sort((a, b) => a.text.localeCompare(b.text, $locale ?? undefined)),
);
let providerCategorySelectItems: SelectItem[] = $derived(providerCategoryItems());
let canAddCategoryMappingRow = $derived(
categoryMappingRows.every((row) => row.providerCategory && row.category) &&
providerCategorySelectItems.some(
(item) => !categoryMappingRows.some((row) => row.providerCategory === item.value),
),
);
const configLabels: Record<string, string> = {
after: "ignore-trails-before-date",
};
const configHints: Record<string, string> = {
after: "plugin-after-date-hint",
};
const privacySelectItems: SelectItem[] = [
{ text: $_("keep-original"), value: "original" },
{ text: $_("apply-user-settings"), value: "settings" },
];
const authLabels: Record<string, string> = {
email: $_("email"),
password: $_("password"),
clientId: "Client ID",
clientSecret: "Client Secret",
};
function initialAuth() {
return Object.fromEntries(
(plugin.auth.fields ?? []).map((field) => [
field,
instance?.auth?.[field] == null ? "" : String(instance.auth[field]),
]),
);
}
function initialExtraConfig(): Record<string, any> {
const config = pluginConfig();
return Object.fromEntries(
configSchema.map((field) => {
const saved = config[field.key];
if (saved !== undefined) return [field.key, saved];
if (field.type === "boolean") {
return [field.key, booleanDefault(field.default, false)];
}
if (field.default !== undefined && field.default !== null) {
return [field.key, String(field.default)];
}
if (field.type === "select" && field.options?.length) {
return [field.key, field.options[0].value];
}
if (field.type === "select") {
return [field.key, ""];
}
if (field.type === "text" || field.type === "url") {
return [field.key, ""];
}
return [field.key, undefined];
}),
);
}
function initialCategoryMappingRows(): CategoryMappingRow[] {
return Object.entries(categoryMapping())
.filter(([, category]) => category !== "")
.map(([providerCategory, category]) => ({
providerCategory,
category: categoryTargetValue(category),
}));
}
function manifestCategoryMapping(): Record<string, string> {
const raw = plugin.hostConfig?.categoryMapping;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
return stringMapping(raw as Record<string, unknown>);
}
function categoryMapping(): Record<string, string> {
const raw = hostConfig().categoryMapping;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {};
}
return stringMapping(raw as Record<string, unknown>);
}
function categoryMappingsEqual(
left: Record<string, string>,
right: Record<string, string>,
) {
const leftKeys = Object.keys(left).sort();
const rightKeys = Object.keys(right).sort();
if (leftKeys.length !== rightKeys.length) {
return false;
}
return leftKeys.every((key, index) => key === rightKeys[index] && left[key] === right[key]);
}
function stringMapping(raw: Record<string, unknown>): Record<string, string> {
return Object.fromEntries(
Object.entries(raw)
.filter(([, value]) => typeof value === "string")
.map(([key, value]) => [key, value as string]),
);
}
function categoryTargetValue(value: string): string {
const match = categories.find((category) => category.id === value || category.name === value);
return match?.id ?? value;
}
function providerCategoryItems(): SelectItem[] {
const values = new Set<string>([
...Object.keys(manifestCategoryMapping()),
...Object.keys(categoryMapping()),
...categoryMappingRows.map((row) => row.providerCategory).filter(Boolean),
]);
return [...values]
.map((value) => ({
text: providerCategoryLabel(plugin, value, $locale),
value,
}))
.sort((a, b) => a.text.localeCompare(b.text, $locale ?? undefined));
}
function providerCategoryItemsForRow(index: number): SelectItem[] {
const currentValue = categoryMappingRows[index]?.providerCategory;
const assignedValues = new Set(
categoryMappingRows
.filter((_, i) => i !== index)
.map((row) => row.providerCategory)
.filter(Boolean),
);
return providerCategorySelectItems.filter(
(item) => item.value === currentValue || !assignedValues.has(item.value),
);
}
function booleanDefault(value: unknown, fallback: boolean): boolean {
if (typeof value === "boolean") return value;
if (typeof value === "string") return value.trim().toLowerCase() === "true";
return fallback;
}
function selectItems(field: ConfigField): SelectItem[] {
return (field.options ?? []).map((o) => ({
text: configFieldOptionLabel(o, $locale, $_(o.value)),
value: o.value,
}));
}
function fieldLabel(field: ConfigField): string {
return configFieldLabel(field, $locale, $_(configLabels[field.key] ?? field.key));
}
function fieldHint(field: ConfigField): string | undefined {
const description = configFieldDescription(field, $locale);
if (description) return description;
const hint = configHints[field.key];
return hint ? $_(hint) : undefined;
}
function fieldError(field: ConfigField): string {
return configErrors[field.key] ?? "";
}
async function addCategoryMappingRow() {
const newIndex = categoryMappingRows.length;
categoryMappingRows = [
...categoryMappingRows,
{
providerCategory: "",
category: "",
},
];
await tick();
categoryMappingList?.querySelector<HTMLElement>(`[data-category-mapping-row="${newIndex}"]`)
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
function removeCategoryMappingRow(index: number) {
categoryMappingRows = categoryMappingRows.filter((_, i) => i !== index);
}
function validateConfig(): boolean {
const errors: Record<string, string> = {};
const hiddenMissing: ConfigField[] = [];
for (const field of configSchema) {
if (!field.required) continue;
const value = extraConfig[field.key];
if (value === undefined || value === null || value === "") {
if (field.hidden) {
hiddenMissing.push(field);
continue;
}
errors[field.key] = $_("required");
}
}
configErrors = errors;
if (hiddenMissing.length > 0) {
show_toast({
text: `${hiddenMissing.map((field) => fieldLabel(field)).join(", ")}: ${$_("required")}`,
icon: "close",
type: "error",
});
}
return Object.keys(errors).length === 0 && hiddenMissing.length === 0;
}
function authLabel(field: string): string {
return authLabels[field] ?? field;
}
function authFieldType(field: string): "password" | "text" {
return secretFields.has(field) ? "password" : "text";
}
function configSection(key: string): Record<string, any> {
const section = instance?.config?.[key];
if (section && typeof section === "object" && !Array.isArray(section)) {
return section as Record<string, any>;
}
return {};
}
function pluginConfig(): Record<string, any> {
return configSection("plugin");
}
function hostConfig(): Record<string, any> {
const manifestConfig = plugin.hostConfig ?? {};
const instanceConfig = configSection("host");
const manifestMerge =
manifestConfig.merge && typeof manifestConfig.merge === "object"
? (manifestConfig.merge as Record<string, unknown>)
: {};
const instanceMerge =
instanceConfig.merge && typeof instanceConfig.merge === "object"
? (instanceConfig.merge as Record<string, unknown>)
: {};
return {
...manifestConfig,
...instanceConfig,
merge: {
...manifestMerge,
...instanceMerge,
},
};
}
function authChanged() {
for (const field of authFields) {
const value = auth[field] ?? "";
if (secretFields.has(field)) {
if (value !== "") {
return true;
}
continue;
}
if (value !== ((instance?.auth?.[field] as string | undefined) ?? "")) {
return true;
}
}
return false;
}
export function openModal() {
auth = initialAuth();
const config = hostConfig();
planned = supportsPlanned && (!hasTourKindChoice || ((config.planned as boolean | undefined) ?? true));
completed =
supportsCompleted && (!hasTourKindChoice || ((config.completed as boolean | undefined) ?? true));
mergeEnabled = mergeAvailable && Boolean((config.merge as any)?.enabled);
privacy = (config.privacy as string | undefined) ?? "original";
extraConfig = initialExtraConfig();
categoryMappingRows = initialCategoryMappingRows();
configErrors = {};
modal.openModal();
}
function pluginInstanceFromForm(): PluginInstanceForm {
const submittedAuth: Record<string, string> = {};
for (const field of authFields) {
submittedAuth[field] = auth[field] ?? "";
}
const pluginRuntimeConfig: Record<string, unknown> = { ...pluginConfig() };
const pluginHostConfig: Record<string, unknown> = { ...hostConfig() };
if (supportsPlanned) {
pluginHostConfig.planned = hasTourKindChoice ? planned : true;
} else {
delete pluginHostConfig.planned;
}
if (supportsCompleted) {
pluginHostConfig.completed = hasTourKindChoice ? completed : true;
} else {
delete pluginHostConfig.completed;
}
if (supportsSourcePrivacy) {
pluginHostConfig.privacy = privacy;
}
const currentMergeConfig =
pluginHostConfig.merge && typeof pluginHostConfig.merge === "object"
? (pluginHostConfig.merge as Record<string, unknown>)
: {};
pluginHostConfig.merge = {
...currentMergeConfig,
enabled: mergeAvailable && mergeEnabled,
};
const categoryMappingConfig: Record<string, string> = {};
const assignedProviderCategories = new Set<string>();
for (const row of categoryMappingRows) {
const providerCategory = row.providerCategory.trim();
if (!providerCategory || !row.category) {
continue;
}
assignedProviderCategories.add(providerCategory);
categoryMappingConfig[providerCategory] = row.category;
}
for (const providerCategory of [
...Object.keys(manifestCategoryMapping()),
...Object.keys(categoryMapping()),
]) {
if (!assignedProviderCategories.has(providerCategory)) {
categoryMappingConfig[providerCategory] = "";
}
}
const mappingChanged = !categoryMappingsEqual(categoryMapping(), categoryMappingConfig);
if (mappingChanged) {
pluginHostConfig.categoryMappingUpdatedAt = new Date().toISOString();
}
pluginHostConfig.categoryMapping = categoryMappingConfig;
for (const field of configSchema) {
const val = extraConfig[field.key];
if (val !== undefined && val !== "") {
pluginRuntimeConfig[field.key] = val;
} else {
delete pluginRuntimeConfig[field.key];
}
}
const config: Record<string, unknown> = {
plugin: pluginRuntimeConfig,
host: pluginHostConfig,
};
const status = isOAuthPlugin
? !instance?.id
? "needs_auth"
: authChanged()
? isConnected
? "needs_reauth"
: "needs_auth"
: instance.status
: instance?.enabled
? "configured"
: "disabled";
return {
...instance,
plugin_id,
enabled: isOAuthPlugin && status !== "configured" ? false : (instance?.enabled ?? false),
auth: submittedAuth,
config,
status,
mappingChanged,
};
}
async function validateAuthIfNeeded() {
if (!isSessionPlugin || !authChanged()) {
return;
}
await plugin_auth_validate({
pluginId: plugin.id,
instanceId: instance?.id,
auth,
});
}
async function submit() {
if (!validateConfig()) {
return;
}
try {
isSaving = true;
await validateAuthIfNeeded();
} catch (e) {
show_toast({
text: translatePluginAPIError(e, $_("error-setting-up-plugin", { values: { provider: pluginTitle() } })),
icon: "close",
type: "error",
});
isSaving = false;
return;
}
try {
const candidate = pluginInstanceFromForm();
if (candidate.mappingChanged) {
const shouldSave = await onbeforecategorymappingsave?.(candidate);
if (shouldSave === false) {
return;
}
delete candidate.mappingChanged;
}
await onsave?.(candidate as Partial<PluginInstance>);
modal.closeModal();
} catch {
// onsave owns persistence error reporting so the modal does not
// duplicate toasts.
} finally {
isSaving = false;
}
}
function pluginTitle() {
return localizedPluginTitle(plugin, $locale);
}
async function startOAuth() {
if (!validateConfig()) {
return;
}
try {
const candidate = pluginInstanceFromForm();
delete candidate.mappingChanged;
const saved = await onsave?.(candidate);
const instanceId = saved?.id ?? instance?.id;
if (!instanceId) {
return;
}
const redirectUri = `${window.location.origin}/settings/plugins/oauth/callback`;
const result = await plugin_oauth_start({
pluginId: plugin.id,
instanceId,
redirectUri,
});
sessionStorage.setItem(`wanderer_plugin_oauth_${result.state}`, result.instanceId);
window.location.href = result.url;
} catch (e) {
show_toast({
text: e instanceof Error ? e.message : $_("error-starting-oauth"),
icon: "close",
type: "error",
});
}
}
</script>
<Modal
id="{plugin_id}-plugin-settings-modal"
size="md:min-w-xl lg:min-w-2xl"
title={pluginTitle() + " " + $_("settings")}
bind:this={modal}
>
{#snippet content()}
<form
id="{plugin_id}-plugin-settings-form"
class="space-y-3"
onsubmit={(event) => {
event.preventDefault();
submit();
}}
>
{#each authFields as field}
<TextField
label={authLabel(field)}
placeholder={secretFields.has(field)
? instance?.id
? `(${$_("unchanged")})`
: ""
: field == "email"
? "user@example.com"
: ""}
bind:value={auth[field]}
name={field}
type={authFieldType(field)}
></TextField>
{/each}
{#if isOAuthPlugin}
<p class="text-xs text-gray-500 max-w-lg">
{#if isConnected}
{$_("plugin-oauth-connected-hint")}
{:else}
{$_("plugin-oauth-needs-connect-hint")}
{/if}
</p>
{/if}
{#if hasTourKindChoice}
<div class="flex flex-wrap gap-x-4">
<Toggle
bind:value={planned}
label={$_("planned-tours", { values: { n: 2 } })}
></Toggle>
<Toggle
bind:value={completed}
label={$_("completed-tours", { values: { n: 2 } })}
></Toggle>
</div>
{/if}
{#if supportsSourcePrivacy}
<Select
label={$_("privacy")}
items={privacySelectItems}
bind:value={privacy}
></Select>
<p class="text-xs text-gray-500 max-w-lg">
{#if privacy == "original"}
{$_("plugin-privacy-hint-original")}
{:else}
{$_("plugin-privacy-hint-user")}
{/if}
</p>
{/if}
{#each visibleConfigSchema as field}
{#if field.type === "select"}
<Select
label={fieldLabel(field)}
items={selectItems(field)}
bind:value={extraConfig[field.key] as string}
error={fieldError(field)}
></Select>
{:else if field.type === "boolean"}
<Toggle
bind:value={extraConfig[field.key]}
label={fieldLabel(field)}
error={fieldError(field)}
></Toggle>
{@const hint = fieldHint(field)}
{#if hint}
<p class="text-xs text-gray-500 max-w-lg">{hint}</p>
{/if}
{:else if field.type === "date"}
{@const hint = fieldHint(field)}
{#if hint}
<p
class="text-xs text-gray-500 max-w-lg pt-4 pb-1 border-t border-input-border"
>
{hint}
</p>
{/if}
<div class="flex items-end relative gap-x-2">
<Datepicker
label={fieldLabel(field)}
bind:value={extraConfig[field.key]}
error={fieldError(field)}
></Datepicker>
<button
class="btn-icon mb-[10px]"
type="button"
onclick={() => {
extraConfig[field.key] = undefined;
}}
aria-label={$_("clear")}
><i class="fa fa-close"></i></button>
</div>
{:else if field.type === "text" || field.type === "url"}
<TextField
label={fieldLabel(field)}
bind:value={extraConfig[field.key]}
name={field.key}
type={field.type === "url" ? "url" : "text"}
error={fieldError(field)}
></TextField>
{/if}
{/each}
{#if supportsCategoryMapping && categorySelectItems.length > 0}
<div class="space-y-2 pt-4 border-t border-input-border">
<div class="flex items-center justify-between gap-3">
<div>
<h4 class="text-sm font-medium">{$_("category-mapping")}</h4>
<p class="text-xs text-secondary mt-1">
{$_("category-mapping-help")}
</p>
</div>
<button
class="btn-primary text-sm"
class:btn-disabled={!canAddCategoryMappingRow}
type="button"
disabled={!canAddCategoryMappingRow}
onclick={addCategoryMappingRow}
>{$_("add-entry")}</button>
</div>
{#if categoryMappingRows.length > 0}
<div class="hidden md:grid grid-cols-[minmax(0,1.2fr)_minmax(14rem,1fr)_2.75rem] gap-3 text-sm font-medium">
<span>{$_("provider-category")}</span>
<span>{$_("category")}</span>
<span></span>
</div>
<div
bind:this={categoryMappingList}
class="space-y-3 pr-1 scroll-smooth"
class:max-h-[300px]={categoryMappingRows.length > 6}
class:overflow-y-auto={categoryMappingRows.length > 6}
class:overflow-y-visible={categoryMappingRows.length <= 6}
>
{#each categoryMappingRows as row, i}
{@const providerItems = providerCategoryItemsForRow(i)}
<div
class="grid grid-cols-1 md:grid-cols-[minmax(0,1.2fr)_minmax(14rem,1fr)_2.75rem] items-end gap-3"
data-category-mapping-row={i}
>
<SingleSelect
ariaLabel={$_("provider-category")}
placeholder={$_("select-provider-category")}
items={providerItems}
bind:value={row.providerCategory}
disabled={row.providerCategory !== "" && providerItems.length <= 1}
></SingleSelect>
<SingleSelect
ariaLabel={$_("category")}
placeholder={$_("select-category")}
items={categorySelectItems}
bind:value={row.category}
disabled={row.category !== "" && categorySelectItems.length <= 1}
></SingleSelect>
<button
class="btn-icon h-10"
type="button"
onclick={() => removeCategoryMappingRow(i)}
aria-label={$_("remove")}
><i class="fa fa-close"></i></button>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{#if mergeAvailable}
<PluginMergeSettings prefix="merge" bind:value={mergeEnabled} />
{/if}
</form>
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
<button class="btn-secondary" onclick={() => modal.closeModal()} disabled={isSaving}
>{$_("cancel")}</button
>
{#if isOAuthPlugin}
{#if needsOAuthConnect}
<button class="btn-primary" type="button" onclick={startOAuth} disabled={isSaving}
>{isConnected ? $_("save-and-reconnect") : $_("save-and-connect")}</button
>
{:else}
<button
class="btn-primary"
form="{plugin_id}-plugin-settings-form"
type="submit"
name="save"
disabled={isSaving}>{$_("save")}</button
>
{/if}
{:else}
<button
class="btn-primary"
form="{plugin_id}-plugin-settings-form"
type="submit"
name="save"
disabled={isSaving}
>{isSessionPlugin && authChanged() ? $_("save-and-validate") : $_("save")}</button
>
{/if}
</div>
{/snippet}
</Modal>

View File

@@ -4,17 +4,19 @@
interface Props {
prefix: string;
value?: boolean;
}
let { prefix }: Props = $props();
let { prefix, value = $bindable(false) }: Props = $props();
</script>
<div class="pt-4 border-t border-input-border space-y-2">
<Toggle
name={`${prefix}.enabled`}
label={$_("integration-auto-merge-label")}
bind:value
label={$_("plugin-auto-merge-label")}
/>
<p class="text-xs text-gray-500 max-w-lg">
{$_("integration-auto-merge-hint")}
{$_("plugin-auto-merge-hint")}
</p>
</div>

View File

@@ -3,11 +3,6 @@
import { page } from "$app/state";
import type { List } from "$lib/models/list";
import type { Trail } from "$lib/models/trail";
import {
integrations,
integrations_index,
uploadGpx,
} from "$lib/stores/integration_store";
import {
lists_add_trail,
lists_index,
@@ -25,9 +20,8 @@
import { trail2gpx } from "$lib/util/gpx_util";
import { gpx } from "$lib/vendor/toGeoJSON/toGeoJSON";
import JSZip from "jszip";
import { onMount, type Snippet } from "svelte";
import { type Snippet } from "svelte";
import { _ } from "svelte-i18n";
import { get } from "svelte/store";
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
import ConfirmModal from "../confirm_modal.svelte";
import ListSearchModal from "../list/list_search_modal.svelte";
@@ -43,6 +37,7 @@
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;
@@ -65,58 +60,11 @@
let confirmModal: ConfirmModal;
let listSelectModal: ListSearchModal;
let trailExportModal: TrailExportModal;
let trailSendModal: TrailSendModal;
let trailShareModal: TrailShareModal;
let trailMergeModal: TrailMergeModal;
let trailSendModal: TrailSendModal;
const hammerheadIntegration = $derived(
$integrations.find((integration) =>
Boolean(integration.hammerhead?.active)
)
);
let lists: List[] = $state([]);
let integrationsLoading = false;
let integrationsLoadedForUser: string | undefined;
onMount(() => {
const unsubscribe = currentUser.subscribe(async (user) => {
if (!user) {
integrationsLoadedForUser = undefined;
return;
}
const existing = get(integrations);
if (
existing.length &&
existing[0]?.user === user.id
) {
integrationsLoadedForUser = user.id;
return;
}
if (
integrationsLoading ||
integrationsLoadedForUser === user.id
) {
return;
}
integrationsLoading = true;
try {
await integrations_index();
integrationsLoadedForUser = user.id;
} catch (error) {
console.error("Failed to load integrations", error);
} finally {
integrationsLoading = false;
}
});
return () => {
unsubscribe();
};
});
let loading: boolean = $state(false);
@@ -214,6 +162,16 @@
return !isMultiselectMode();
}
function allowSend(): boolean {
return (
hasTrail() &&
!isMultiselectMode() &&
hasGpx() &&
Boolean($currentUser) &&
$hasSendCapablePlugin
);
}
function allowPublish(): boolean {
if (mode !== "multi-select") return false;
@@ -240,7 +198,7 @@
});
const allowListManagement = isFromCurrentUser();
const allowShareSingleTrail = !isMultiselectMode() && isFromCurrentUser();
const allowSingleOutput = canExport() || (!isMultiselectMode() && hammerheadIntegration && canExport());
const allowSingleOutput = canExport();
if (isMultiselectMode()) {
return [
@@ -389,7 +347,7 @@
},
]
: []),
...(!isMultiselectMode() && hammerheadIntegration && canExport()
...(allowSend()
? [
{
text: $_("send-to"),
@@ -528,6 +486,8 @@
}
} else if (ddVal == "share") {
trailShareModal.openModal();
} else if (ddVal == "send-to") {
trailSendModal.openModal();
} else if (ddVal == "download") {
trailExportModal.openModal();
} else if (ddVal == "edit") {
@@ -548,8 +508,6 @@
if (trail()) {
await trailMergeModal.openSimilarTrailsModal(trail()!);
}
} else if (item.value == "send-to") {
trailSendModal.openModal();
}
}
@@ -605,49 +563,6 @@
await trail_merge(trailSource.id, trailTarget.id, settings);
onProgress?.(1);
}
async function uploadToHammerhead() {
if (!hammerheadIntegration || !hasTrail()) {
console.error("No Hammerhead integration found.");
return;
}
for (const uTrail of trails!) {
try {
if (uTrail.gpx) {
const gpxData = await trail2gpx(uTrail, $currentUser);
const formData = new FormData();
const gpxFile = new File(
[gpxData],
`${uTrail.name || "trail"}.gpx`,
{ type: "application/gpx+xml" },
);
formData.append("file", gpxFile);
await uploadGpx("hammerhead", gpxFile);
show_toast({
type: "success",
icon: "check",
text: $_("uploaded-trail-to-hammerhead"),
});
} else {
show_toast({
type: "error",
icon: "close",
text: $_("trail-has-no-gpx"),
});
}
} catch (e) {
console.error(e);
show_toast({
type: "error",
icon: "close",
text: $_("error-uploading-trail-to-hammerhead"),
});
}
}
}
async function updateTrailsVisibility() {
const newVisibility = !majorityOfSelectedTrailsArePublic();
@@ -926,17 +841,14 @@
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/>
<TrailSendModal
bind:this={trailSendModal}
onsend={async (settings) => {
if (settings.integrationName === "hammerhead") {
await uploadToHammerhead();
}
}}
></TrailSendModal>

View File

@@ -1,42 +1,145 @@
<script lang="ts">
import Modal from "$lib/components/base/modal.svelte";
import { _ } from "svelte-i18n";
import hammerheadLogoWhite from "$lib/assets/svgs/logos/hammerhead_white.svg";
import hammerheadLogoDark from "$lib/assets/svgs/logos/hammerhead_dark.svg";
import type { PluginInstance } from "$lib/models/plugin_instance";
import type { PluginProvider } from "$lib/models/plugin_provider";
import type { Trail } from "$lib/models/trail";
import { plugin_instances_index } from "$lib/stores/plugin_instance_store";
import { plugins_index } from "$lib/stores/plugin_store";
import { theme } from "$lib/stores/theme_store";
import { show_toast } from "$lib/stores/toast_store.svelte";
import { pluginTitle as localizedPluginTitle } from "$lib/util/plugin_i18n";
import { onMount } from "svelte";
import { _, locale } from "svelte-i18n";
interface Props {
title?: string;
onsend?: (settings: typeof sendSettings) => void;
trail?: Trail;
share?: string;
}
let { title = $_("send-to"), onsend: onexport }: Props = $props();
let { trail, share }: Props = $props();
let modal: Modal;
let loading = $state(false);
let sending = $state("");
let eligible: PluginProvider[] = $state([]);
let currentTheme: "dark" | "light" = $state("light");
export function openModal() {
modal.openModal();
}
const sendSettings: {
integrationName: string;
} = $state({
integrationName: "hammerhead",
onMount(() => {
currentTheme = document.documentElement.classList.contains("dark") ? "dark" : "light";
return theme.subscribe((value) => {
currentTheme = value;
});
});
function sendTrail() {
onexport?.(sendSettings);
modal.closeModal();
export async function openModal() {
modal.openModal();
await loadEligible();
}
async function loadEligible() {
loading = true;
eligible = [];
try {
const [plugins, instances] = await Promise.all([
plugins_index(),
plugin_instances_index(),
]);
const enabledProviders = new Set(
instances
.filter((a: PluginInstance) => a.enabled)
.map((a) => a.plugin_id),
);
eligible = plugins.filter(
(p) =>
p.status === "available" &&
(p.capabilities ?? []).includes("prepare_trail_send.v1") &&
enabledProviders.has(p.id),
);
} catch (e) {
console.error(e);
} finally {
loading = false;
}
}
function pluginLogo(plugin: PluginProvider): string | undefined {
if (currentTheme === "dark" && plugin.iconDark) {
return plugin.iconDark;
}
return plugin.icon || undefined;
}
function pluginTitle(plugin: PluginProvider): string {
return localizedPluginTitle(plugin, $locale);
}
async function send(plugin: PluginProvider) {
if (!trail?.id) {
return;
}
sending = plugin.id;
try {
const response = await fetch("/api/v1/plugin-system/trail-send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pluginId: plugin.id, trailId: trail.id, share }),
});
if (!response.ok) {
throw new Error(await response.text());
}
show_toast({
type: "success",
icon: "check",
text: $_("trail-sent"),
});
modal.closeModal();
} catch (e) {
console.error(e);
show_toast({
type: "error",
icon: "close",
text: $_("error-sending-trail"),
});
} finally {
sending = "";
}
}
</script>
<Modal id="send-modal" {title} size="md:min-w-sm" bind:this={modal}>
<Modal id="send-modal" title={$_("send-to")} size="md:min-w-sm" bind:this={modal}>
{#snippet content()}
<div>
<button class="btn-secondary" onclick={sendTrail}>
<img class="h-20" src={$theme == "light" ? hammerheadLogoDark : hammerheadLogoWhite} alt="integration logo"/>
</button>
</div>
{#if loading}
<div class="flex justify-center p-4">
<div class="spinner light:spinner-dark"></div>
</div>
{:else if eligible.length === 0}
<p class="text-sm text-gray-500">{$_("no-send-plugins")}</p>
<a class="btn-secondary mt-4 inline-block" href="/settings/plugins"
>{$_("plugins")}</a
>
{:else}
<div class="flex flex-wrap items-center gap-4">
{#each eligible as plugin (plugin.id)}
<button
class="btn-secondary"
disabled={sending !== ""}
onclick={() => send(plugin)}
>
{#if sending === plugin.id}
<div class="spinner light:spinner-dark h-20"></div>
{:else if pluginLogo(plugin)}
<img
class="h-20"
src={pluginLogo(plugin)}
alt={pluginTitle(plugin)}
/>
{:else}
<span>{pluginTitle(plugin)}</span>
{/if}
</button>
{/each}
</div>
{/if}
{/snippet}
{#snippet footer()}
<div class="flex items-center gap-4">
@@ -44,5 +147,5 @@
>{$_("cancel")}</button
>
</div>
{/snippet}</Modal
>
{/snippet}
</Modal>