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

@@ -7,15 +7,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light">
<script>
localStorage.theme === 'dark' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)
? document.documentElement.classList.add('dark')
: document.documentElement.classList.add('light');
localStorage.theme === 'dark' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)
? document.querySelector("meta[name='color-scheme'" )?.setAttribute("content", 'dark')
: document.querySelector("meta[name='color-scheme'" )?.setAttribute("content", 'light')
const storedTheme = localStorage.theme;
const theme =
storedTheme === 'dark' || storedTheme === 'light'
? storedTheme
: window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
document.documentElement.classList.add(theme);
document.querySelector("meta[name='color-scheme']")?.setAttribute("content", theme);
</script>
%sveltekit.head%
</head>
@@ -24,4 +24,4 @@
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
</html>

View File

@@ -129,4 +129,4 @@
.mention {
@apply bg-blue-100 dark:bg-slate-700 rounded-md text-sm;
padding: 0.1rem 0.3rem;
}
}

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>

View File

@@ -3,6 +3,7 @@
"Canoeing": "Kanoistika",
"Climbing": "Horolezectví",
"Hiking": "Turistika",
"Other": "Ostatní",
"Skiing": "",
"Walking": "Chůze",
"about": "O aplikaci",
@@ -144,7 +145,7 @@
"error-copying-trail": "",
"error-creating-user": "Chyba při vytváření uživatele",
"error-deleting-token": "",
"error-disabling-strava-integration": "Chyba při deaktivaci integrace strava",
"error-disabling-strava-plugin": "Chyba při deaktivaci pluginu strava",
"error-during-login": "Během přihlášení došlo k chybě",
"error-during-password-reset": "Nelze odeslat e-mail pro obnovu hesla",
"error-exporting-trail": "Chyba při exportování trasy",
@@ -157,11 +158,11 @@
"error-reading-file": "Soubor nelze načíst",
"error-saving-list": "Chyba při ukládání seznamu",
"error-saving-trail": "Chyba při ukládání trasy",
"error-setting-up-integration": "Chyba během nastavování {provider} integrace",
"error-updating-hammerhead-integration": "",
"error-updating-komoot-integration": "",
"error-setting-up-plugin": "Chyba během nastavování pluginu {provider}",
"error-updating-hammerhead-plugin": "",
"error-updating-komoot-plugin": "",
"error-updating-password": "Chyba během aktualizace hesla",
"error-updating-strava-integration": "Chyba při aktualizaci Komoot integrace",
"error-updating-strava-plugin": "Chyba při aktualizaci pluginu Strava",
"error-uploading-trail-to-hammerhead": "",
"est-duration": "Odhadovaná doba trvání",
"everyone-with-the-link": "Každý, kdo má odkaz",
@@ -202,7 +203,6 @@
"get-started": "Začněte",
"grid": "Mřížka",
"grocery-store": "Potraviny",
"hammerhead-integration-after-date-hint": "",
"heading": "Nadpis",
"height": "Výška",
"help": "Nápověda",
@@ -224,14 +224,11 @@
"import-hint": "Vyberte nebo sem přetáhněte soubory GPX, FIT, KML, či TCX...",
"include-description": "Zahrnout popis",
"include-waypoints": "Zahrnout body trasy",
"integration-description-hammerhead": "",
"integration-description-komoot": "Synchronizuje vaše trasy z aplikace Komoot s Wandererem v pravidelných intervalech.",
"integration-description-strava": "Synchronizuje vaše trasy a aktivity z aplikace Strava s Wandererem v pravidelných intervalech.",
"integration-disabled": "integrace zakázána",
"integration-enabled": "integrace povolena",
"integration-privacy-hint-original": "",
"integration-privacy-hint-user": "",
"integrations": "Integrace",
"plugin-disabled": "plugin zakázán",
"plugin-enabled": "plugin povolen",
"plugin-privacy-hint-original": "",
"plugin-privacy-hint-user": "",
"plugins": "Pluginy",
"invalid-date": "Neplatné datum",
"invalid-username": "Neplatné uživatelské jméno",
"italian": "Italština",
@@ -434,7 +431,6 @@
"statistics": "Statistiky",
"stop-drawing": "Ukončit kreslení",
"stop-editing": "Ukončit úpravy",
"strava-integration-after-date-hint": "Pokud váš účet obsahuje velké množství aktivit, můžete narazit na limit API služby Strava, což znemožní synchronizaci všech aktivit najednou. Tomuto problému předejdete nastavením data \"Od\" a dále - synchronizují se tak pouze aktivity zaznamenané po tomto datu.",
"subway-stop": "Vstup do metra",
"summit": "Vrchol",
"summit-book": "Vrcholová kniha",

View File

@@ -3,6 +3,7 @@
"Canoeing": "Kanufahren",
"Climbing": "Klettern",
"Hiking": "Wandern",
"Other": "Sonstiges",
"Skiing": "Skifahren",
"Walking": "Laufen",
"about": "Über",
@@ -30,7 +31,7 @@
"append-waypoint-description": "Kommentar anhängen",
"append-waypoint-photos": "Fotos hinzufügen",
"append-waypoint-title": "Titel anhängen",
"apply-user-settings": "",
"apply-user-settings": "Benutzereinstellungen anwenden",
"attraction": "Sehenswürdigkeit",
"author": "Autor",
"avatar": "Avatar",
@@ -69,11 +70,14 @@
"card": "{n, plural, =1 {Karte} other {Karten}}",
"categories": "Kategorien",
"category": "Kategorie",
"category-mapping": "Kategorie-Zuordnung",
"category-mapping-help": "Entfernte Provider-Kategorien werden bewusst nicht zugeordnet und erhalten beim Import keine Kategorie.",
"change": "Ändern",
"change-email": "Email ändern",
"change-password": "Passwort ändern",
"changelog": "Änderungshistorie",
"chinese": "Chinesisch (vereinfacht)",
"clear": "Leeren",
"clear-all": "Alle ausblenden",
"dismiss": "Schließen",
"climbing": "Klettern",
@@ -158,7 +162,7 @@
"error-copying-trail": "Fehler beim Kopieren der Route",
"error-creating-user": "Fehler beim Erstellen des Nutzers",
"error-deleting-token": "",
"error-disabling-strava-integration": "Fehler beim Deaktivieren der Stravaintegration",
"error-disabling-strava-plugin": "Fehler beim Deaktivieren des Strava-Plugins",
"error-during-login": "Fehler beim Login",
"error-during-password-reset": "Email zum Zurücksetzen des Passworts konnte nicht versandt werden",
"error-exporting-trail": "Fehler beim Exportieren der Route",
@@ -172,11 +176,12 @@
"error-reading-file": "Fehler beim Lesen der Datei",
"error-saving-list": "Fehler beim Speichern der Liste",
"error-saving-trail": "Fehler beim Speichern der Route",
"error-setting-up-integration": "Fehler beim Einrichten der {provider}-Integration",
"error-updating-hammerhead-integration": "Fehler bei Aktualisierung der Hammerhead-Integration",
"error-updating-komoot-integration": "Fehler bei Aktualisierung der komoot-Integration",
"error-setting-up-plugin": "Fehler beim Einrichten des {provider}-Plugins",
"error-starting-oauth": "Fehler beim Starten der OAuth-Verbindung",
"error-updating-hammerhead-plugin": "Fehler bei Aktualisierung des Hammerhead-Plugins",
"error-updating-komoot-plugin": "Fehler bei Aktualisierung des komoot-Plugins",
"error-updating-password": "Fehler beim Aktualisieren des Passworts",
"error-updating-strava-integration": "Fehler bei Aktualisierung der Strava-Integration",
"error-updating-strava-plugin": "Fehler bei Aktualisierung des Strava-Plugins",
"error-uploading-trail-to-hammerhead": "Fehler beim Hochladen der Route zu Hammerhead",
"est-duration": "Gesch. Dauer",
"everyone-with-the-link": "Jeder mit dem Link",
@@ -218,7 +223,6 @@
"get-started": "Los geht’s",
"grid": "Gitter",
"grocery-store": "Lebensmittelgeschäft",
"hammerhead-integration-after-date-hint": "Wenn Ihr Hammerhead Konto bereits mit anderen Trail-Datenbanken wie komoot oder Strava synchronisiert ist, kann die zusätzliche Synchronisierung Ihrer Hammerhead-Daten zu Duplikaten führen. Um dies zu vermeiden, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.",
"heading": "Überschrift",
"height": "Höhe",
"help": "Hilfe",
@@ -240,23 +244,46 @@
"import-hint": "GPX, FIT, KML oder TCX Dateien auswählen oder hierher ziehen...",
"include-description": "Beschreibung übernehmen",
"include-waypoints": "Wegpunkte einbeziehen",
"integration-description-hammerhead": "Synchronisiert Deine Hammerhead-Touren regelmäßig mit wanderer.",
"integration-description-komoot": "Synchronisiert Deine komoot-Touren regelmäßig mit wanderer.",
"integration-description-strava": "Synchronisiert Deine Strava-Routen und -Aktivitäten regelmäßig mit wanderer.",
"integration-auto-merge-label": "Automatisch mergen",
"integration-auto-merge-hint": "Nach dem Import wird nur dann automatisch gemergt, wenn ein eindeutiger Treffer gefunden wird. Der importierte Trail wird in diesem Fall nicht separat gespeichert.",
"integration-disabled": "Integration deaktiviert",
"integration-enabled": "Integration aktiviert",
"integration-privacy-hint-original": "",
"integration-privacy-hint-user": "",
"integrations": "Integrationen",
"plugin-auto-merge-label": "Automatisch mergen",
"plugin-auto-merge-hint": "Nach dem Import wird nur dann automatisch gemergt, wenn ein eindeutiger Treffer gefunden wird. Der importierte Trail wird in diesem Fall nicht separat gespeichert.",
"plugin-after-date-hint": "Lege ein Startdatum fest, um nur Routen zu synchronisieren, die nach diesem Datum aufgezeichnet wurden. Das kann helfen, Duplikate zu vermeiden, wenn ältere Routen bereits in wanderer vorhanden sind.",
"plugin-category-remap-action": "Kategorien aktualisieren",
"plugin-category-remap-back-to-settings": "Zurück zu den Einstellungen",
"plugin-category-remap-backfilled-confirm": "Für {count} importierte Routen wurde die Provider-Kategorie erst nach deiner letzten Änderung der wanderer Kategorie-Zuordnung ermittelt. Soll die Zuordnung für diese Routen jetzt nachgeholt werden? Andere Routendaten bleiben unverändert.",
"plugin-category-remap-confirm": "{count} Routen sind von den Zuordnungsanpassungen betroffen. Soll die aktualisierte Zuordnung jetzt auf diese Routen angewendet werden? Andere Routendaten bleiben unverändert.",
"plugin-category-remap-confirm-unspecified": "Die Kategorie-Zuordnung wurde geändert. Aktuell sind keine importierten Routen bekannt, die sofort aktualisiert werden können. Änderungen speichern?",
"plugin-category-remap-continue-without-remap": "Ohne Remapping fortfahren",
"plugin-category-remap-dismiss-error": "Remap-Erinnerung konnte nicht gespeichert werden",
"plugin-category-remap-error": "Kategorien importierter Routen konnten nicht aktualisiert werden",
"plugin-category-remap-ignore": "Ignorieren",
"plugin-category-remap-preview-error": "Importierte Routen konnten nicht auf Kategorie-Updates geprüft werden",
"plugin-category-remap-success": "Kategorien für {count} importierte Routen aktualisiert",
"plugin-category-remap-sync-hint": "Einige dieser Treffer wurden erst nach der letzten Änderung der Zuordnung durch eine Synchronisierung gefunden.",
"plugin-category-remap-title": "Kategorien importierter Routen aktualisieren?",
"plugin-connect-before-enabling": "Verbinde dieses Plugin, bevor du es aktivierst.",
"plugin-disabled": "Plugin deaktiviert",
"plugin-enabled": "Plugin aktiviert",
"plugins-empty-title": "Keine Plugins installiert",
"plugins-empty-description": "Auf diesem Server sind noch keine Plugins verfügbar. Kontaktiere deinen Administrator oder lies die Installationsanleitung.",
"plugins-empty-docs-link": "Plugins installieren",
"plugin-oauth-connected-hint": "Dieses Plugin ist verbunden. Wenn du die OAuth-Zugangsdaten änderst, verbinde es erneut, bevor du die Synchronisierung wieder aktivierst.",
"plugin-oauth-needs-connect-hint": "Speichere und verbinde dieses Plugin, bevor du die Synchronisierung aktivierst.",
"plugin-privacy-hint-original": "Importierte Routen behalten dieselbe Sichtbarkeit wie auf der externen Plattform. Wenn die ursprüngliche Route öffentlich war, ist sie auch in wanderer öffentlich, selbst wenn Routen laut deinen Privatsphäre-Einstellungen standardmäßig privat sind.",
"plugin-privacy-hint-user": "Die ursprüngliche Sichtbarkeit der Route wird verworfen. Stattdessen werden die lokalen Privatsphäre-Einstellungen für Routen auf alle importierten Routen angewendet.",
"plugin-setup-error": "Setup-Fehler",
"plugin-error-rate-limited": "Der Anbieter begrenzt gerade Anfragen. Versuche es später erneut.",
"plugin-error-provider-unavailable": "Der Anbieter ist momentan nicht erreichbar. Versuche es später erneut.",
"plugin-error-invalid-request": "Das Plugin hat eine ungültige Anfrage gesendet.",
"plugin-error-internal": "Das Plugin ist unerwartet fehlgeschlagen.",
"plugin-error-reconnect-required": "Die Plugin-Autorisierung ist abgelaufen. Verbinde das Plugin erneut.",
"invalid-date": "Ungültiges Datum",
"invalid-username": "Ungültiger Nutzername",
"italian": "Italienisch",
"joined": "Beigetreten",
"keep-original": "",
"keep-original": "Original beibehalten",
"keep-private": "Ohne Veröffentlichung fortfahren",
"language": "Sprache",
"last-sync": "Letzte Synchronisierung",
"last-used": "",
"latitude": "Breitengrad",
"layer": "{n, plural, =1 {Ebene} other {Ebenen}}",
@@ -378,6 +405,7 @@
"privacy": "Privatsphäre",
"private": "Privat",
"profile": "Profil",
"provider-category": "Provider-Kategorie",
"public": "Öffentlich",
"public-access": "Öffentlicher Zugriff",
"public-share-everyone": "Jeder im Internet mit dem Link kann diese Route sehen",
@@ -393,6 +421,7 @@
"remote-users-cannot-edit": "Remote-Benutzer können nicht bearbeiten",
"removed-trail-from": "Route entfernt aus",
"removed-trails-from": "Routen entfernt aus",
"remove": "Entfernen",
"required": "Pflichtfeld",
"reset": "Zurücksetzen",
"reset-password": "Passwort zurücksetzen",
@@ -414,8 +443,16 @@
"search-places": "Orte suchen",
"search-trails": "Route suchen",
"select-list": "Liste auswählen",
"select-category": "Kategorie auswählen",
"select-provider-category": "Provider-Kategorie auswählen",
"selected": "ausgewählt",
"send-to": "Senden an...",
"trail-sent": "Trail gesendet",
"error-sending-trail": "Fehler beim Senden des Trails",
"no-send-plugins": "Kein verbundenes Plugin unterstützt das Senden von Trails. Verbinde zuerst eines in den Plugin-Einstellungen.",
"plugins": "Plugins",
"plugin-type-trails": "Trails",
"plugin-type-trails-description": "Trail-Plugins verbinden wanderer mit externen Diensten und synchronisieren Routen oder Aktivitäten automatisch im Hintergrund.",
"set-private": "Verbergen",
"set-public": "Veröffentlichen",
"settings": "Einstellungen",
@@ -437,6 +474,9 @@
"settings-privacy-trails-private": "Deine Routen sind standardmäßig privat. Niemand außer Dir kann sie sehen. Du kannst diese Einstellung jederzeit für einzelne Routen ändern.",
"settings-privacy-trails-public": "Deine Trails sind standardmäßig öffentlich. Jeder kann sie sehen. Du kannst diese Einstellung jederzeit für einzelne Routen ändern.",
"settings-saved": "Einstellungen gespeichert",
"save-and-connect": "Speichern & verbinden",
"save-and-reconnect": "Speichern & neu verbinden",
"save-and-validate": "Speichern & prüfen",
"share": "Teilen",
"share-profile": "Profil teilen",
"share-this-list": "Diese Liste teilen",
@@ -462,7 +502,6 @@
"statistics": "Statistiken",
"stop-drawing": "Zeichnen beenden",
"stop-editing": "Bearbeiten beenden",
"strava-integration-after-date-hint": "Wenn Ihr Konto eine große Anzahl von Aktivitäten enthält, kann es vorkommen, dass Sie aufgrund der API-Restriktionen von Strava nicht alle Aktivitäten auf einmal synchronisieren können. Um dieses Problem zu umgehen, können Sie unten ein Startdatum festlegen, sodass nur Aktivitäten synchronisiert werden, die nach diesem Datum aufgezeichnet wurden.",
"subway-stop": "U-Bahn Eingang",
"summit": "Gipfel",
"summit-book": "Gipfelbuch",

View File

@@ -3,6 +3,7 @@
"Canoeing": "Canoeing",
"Climbing": "Climbing",
"Hiking": "Hiking",
"Other": "Other",
"Skiing": "",
"Walking": "Walking",
"about": "About",
@@ -69,11 +70,14 @@
"card": "{n, plural, =1 {Card} other {Cards}}",
"categories": "Categories",
"category": "Category",
"category-mapping": "Category mapping",
"category-mapping-help": "Removed provider categories are intentionally left unmapped and will be imported without a category.",
"change": "Change",
"change-email": "Change email",
"change-password": "Change password",
"changelog": "Changelog",
"chinese": "Chinese (simplified)",
"clear": "Clear",
"clear-all": "Clear all",
"dismiss": "Dismiss",
"climbing": "Climbing",
@@ -158,7 +162,7 @@
"error-copying-trail": "Error copying trail",
"error-creating-user": "Error creating user",
"error-deleting-token": "Error deleting token",
"error-disabling-strava-integration": "Error disabling strava integration",
"error-disabling-strava-plugin": "Error disabling strava plugin",
"error-during-login": "Error during login",
"error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Error exporting trail",
@@ -172,11 +176,12 @@
"error-reading-file": "Error reading file",
"error-saving-list": "Error saving list",
"error-saving-trail": "Error saving trail",
"error-setting-up-integration": "Error setting up {provider} integration",
"error-updating-hammerhead-integration": "Error updating Hammerhead integration",
"error-updating-komoot-integration": "Error updating komoot integration",
"error-setting-up-plugin": "Error setting up {provider} plugin",
"error-starting-oauth": "Error starting OAuth connection",
"error-updating-hammerhead-plugin": "Error updating Hammerhead plugin",
"error-updating-komoot-plugin": "Error updating komoot plugin",
"error-updating-password": "Error updating password",
"error-updating-strava-integration": "Error updating Strava integration",
"error-updating-strava-plugin": "Error updating Strava plugin",
"error-uploading-trail-to-hammerhead": "Error uploading trail to Hammerhead",
"est-duration": "Est. duration",
"everyone-with-the-link": "Everyone with the link",
@@ -218,7 +223,6 @@
"get-started": "Get started",
"grid": "Grid",
"grocery-store": "Grocery store",
"hammerhead-integration-after-date-hint": "If your hammerhead account is already synced with other trail databases, such as komoot or Strava, start syncing your Hammerhead data may result in duplicates. To avoid this, you can set an start date below, meaning only activities recorded after this date will be synced.",
"heading": "Heading",
"height": "Height",
"help": "Help",
@@ -240,16 +244,38 @@
"import-hint": "Select or drag GPX, FIT, KML or TCX files here...",
"include-description": "Include description",
"include-waypoints": "Include waypoints",
"integration-description-hammerhead": "Syncs your Hammerhead tours with wanderer in regular intervals.",
"integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.",
"integration-description-strava": "Syncs your Strava routes & activities with wanderer in regular intervals.",
"integration-auto-merge-label": "Auto-merge",
"integration-auto-merge-hint": "Imported trails are only merged automatically when exactly one clear match is found. The imported trail is always deleted afterwards.",
"integration-disabled": "integration disabled",
"integration-enabled": "integration enabled",
"integration-privacy-hint-original": "Imported trails will maintain the same visibility they have on the external platform. For example, if the original trail was public, it will be public in wanderer, even if trails are private by default according to your privacy settings.",
"integration-privacy-hint-user": "The original trail's visibility is discarded. Instead, the local privacy settings for trails are applied to all imported trails.",
"integrations": "Integrations",
"plugin-auto-merge-label": "Auto-merge",
"plugin-auto-merge-hint": "Imported trails are only merged automatically when exactly one clear match is found. The imported trail is always deleted afterwards.",
"plugin-after-date-hint": "Set a start date to sync only trails recorded after that date. This can help avoid duplicates when older trails are already available in wanderer.",
"plugin-category-remap-action": "Update categories",
"plugin-category-remap-back-to-settings": "Back to settings",
"plugin-category-remap-backfilled-confirm": "For {count} imported trails, the provider category was detected only after you last changed the wanderer category mapping. Apply the mapping to these trails now? Other trail data stays unchanged.",
"plugin-category-remap-confirm": "{count} trails are affected by the category mapping changes. Apply the updated mapping to these trails now? Other trail data stays unchanged.",
"plugin-category-remap-confirm-unspecified": "The category mapping was changed. There are currently no imported trails that can be updated immediately. Save changes?",
"plugin-category-remap-continue-without-remap": "Continue without remapping",
"plugin-category-remap-dismiss-error": "Could not save remap reminder preference",
"plugin-category-remap-error": "Could not update imported trail categories",
"plugin-category-remap-ignore": "Ignore",
"plugin-category-remap-preview-error": "Could not check imported trails for category updates",
"plugin-category-remap-success": "Updated categories for {count} imported trails",
"plugin-category-remap-sync-hint": "Some of these matches were found during a sync after the mapping was last changed.",
"plugin-category-remap-title": "Update imported trail categories?",
"plugin-connect-before-enabling": "Connect this plugin before enabling it.",
"plugin-disabled": "plugin disabled",
"plugin-enabled": "plugin enabled",
"plugins-empty-title": "No plugins installed",
"plugins-empty-description": "No plugins are available on this server yet. Contact your administrator or read the installation guide.",
"plugins-empty-docs-link": "How to install plugins",
"plugin-oauth-connected-hint": "This plugin is connected. If you change the OAuth credentials, reconnect it before enabling sync again.",
"plugin-oauth-needs-connect-hint": "Save and connect this plugin before enabling sync.",
"plugin-privacy-hint-original": "Imported trails will maintain the same visibility they have on the external platform. For example, if the original trail was public, it will be public in wanderer, even if trails are private by default according to your privacy settings.",
"plugin-privacy-hint-user": "The original trail's visibility is discarded. Instead, the local privacy settings for trails are applied to all imported trails.",
"plugin-setup-error": "Setup error",
"plugin-error-rate-limited": "Provider rate limit reached. Try again later.",
"plugin-error-provider-unavailable": "Provider is currently unavailable. Try again later.",
"plugin-error-invalid-request": "Plugin sent an invalid request.",
"plugin-error-internal": "Plugin failed unexpectedly.",
"plugin-error-reconnect-required": "Plugin authorization expired. Reconnect the plugin.",
"invalid-date": "Invalid Date",
"invalid-username": "Invalid username",
"italian": "Italian",
@@ -257,6 +283,7 @@
"keep-original": "Keep original",
"keep-private": "Keep private",
"language": "Language",
"last-sync": "Last sync",
"last-used": "Last used",
"latitude": "Latitude",
"layer": "{n, plural, =1 {Layer} other {Layers}}",
@@ -378,6 +405,7 @@
"privacy": "Privacy",
"private": "Private",
"profile": "Profile",
"provider-category": "Provider category",
"public": "Public",
"public-access": "Public access",
"public-share-everyone": "Everyone on the internet with the link can see this trail",
@@ -393,6 +421,7 @@
"remote-users-cannot-edit": "Remote users cannot edit",
"removed-trail-from": "Removed trail from",
"removed-trails-from": "Removed trails from",
"remove": "Remove",
"required": "Required",
"reset": "Reset",
"reset-password": "Reset Password",
@@ -414,8 +443,16 @@
"search-places": "Search places",
"search-trails": "Search trails",
"select-list": "Select List",
"select-category": "Select category",
"select-provider-category": "Select provider category",
"selected": "selected",
"send-to": "Send to...",
"trail-sent": "Trail sent",
"error-sending-trail": "Error sending trail",
"no-send-plugins": "No connected plugin supports sending trails. Connect one in the plugin settings first.",
"plugins": "Plugins",
"plugin-type-trails": "Trails",
"plugin-type-trails-description": "Trail plugins connect wanderer with external services and automatically sync routes or activities in the background.",
"set-private": "Set private",
"set-public": "Set public",
"settings": "Settings",
@@ -437,6 +474,9 @@
"settings-privacy-trails-private": "Your trails are private by default. No one except you will be able to see them. You can change this setting at any point for individual trails.",
"settings-privacy-trails-public": "Your trails are public by default. Everyone will be able to see them. You can change this setting at any point for individual trails.",
"settings-saved": "Settings saved",
"save-and-connect": "Save & connect",
"save-and-reconnect": "Save & reconnect",
"save-and-validate": "Save & validate",
"share": "Share",
"share-profile": "Share profile",
"share-this-list": "Share this list",
@@ -462,7 +502,6 @@
"statistics": "Statistics",
"stop-drawing": "Stop drawing",
"stop-editing": "Stop editing",
"strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an start date below so that only activities that were recorded after this date are synced.",
"subway-stop": "Subway entrance",
"summit": "Summit",
"summit-book": "Summit Book",

View File

@@ -3,6 +3,7 @@
"Canoeing": "Remo",
"Climbing": "Escalada",
"Hiking": "Senderismo",
"Other": "Otro",
"Skiing": "",
"Walking": "Paseo",
"about": "Sobre",
@@ -144,7 +145,7 @@
"error-copying-trail": "",
"error-creating-user": "Error creando el usuario",
"error-deleting-token": "",
"error-disabling-strava-integration": "Error al desactivar la integración de strava",
"error-disabling-strava-plugin": "Error al desactivar el plugin de Strava",
"error-during-login": "Error durante el acceso",
"error-during-password-reset": "Imposible enviar la contraseña de restablecimiento al correo electrónico",
"error-exporting-trail": "Error exportando la ruta",
@@ -157,11 +158,11 @@
"error-reading-file": "Error leyendo el archivo",
"error-saving-list": "Error guardando la lista",
"error-saving-trail": "Error guardando la ruta",
"error-setting-up-integration": "Error al configurar la integración con {provider}",
"error-updating-hammerhead-integration": "",
"error-updating-komoot-integration": "",
"error-setting-up-plugin": "Error al configurar el plugin {provider}",
"error-updating-hammerhead-plugin": "",
"error-updating-komoot-plugin": "",
"error-updating-password": "Error actualizando la contraseña",
"error-updating-strava-integration": "Error al actualizar la integración con komoot",
"error-updating-strava-plugin": "Error al actualizar el plugin Strava",
"error-uploading-trail-to-hammerhead": "",
"est-duration": "Duración estimada",
"everyone-with-the-link": "Cualquier persona con el enlace",
@@ -202,7 +203,6 @@
"get-started": "Iniciar",
"grid": "Cuadricula",
"grocery-store": "Supermercado",
"hammerhead-integration-after-date-hint": "",
"heading": "Título",
"height": "Altura",
"help": "Ayuda",
@@ -224,14 +224,11 @@
"import-hint": "Selecciona o arrastra aquí archivos GPX, FIT, KML o TCX...",
"include-description": "Incluir descripción",
"include-waypoints": "Incluir puntos de interés",
"integration-description-hammerhead": "",
"integration-description-komoot": "Sincroniza tus recorridos de Komoot con Wanderer en intervalos regulares.",
"integration-description-strava": "Sincroniza tus recorridos y actividades de Strava con Wanderer en intervalos regulares.",
"integration-disabled": "integración desactivada",
"integration-enabled": "integración activada",
"integration-privacy-hint-original": "",
"integration-privacy-hint-user": "",
"integrations": "Integraciones",
"plugin-disabled": "plugin desactivado",
"plugin-enabled": "plugin activado",
"plugin-privacy-hint-original": "",
"plugin-privacy-hint-user": "",
"plugins": "Plugins",
"invalid-date": "Fecha no válida",
"invalid-username": "Usuario no válido",
"italian": "Italiano",
@@ -434,7 +431,6 @@
"statistics": "Estadísticas",
"stop-drawing": "Parar de diseñar",
"stop-editing": "Parar de editar",
"strava-integration-after-date-hint": "Si tu cuenta tiene una gran cantidad de actividades, es posible que alcances el límite de peticiones de la API de Strava, lo que impedirá la sincronización de todas las actividades a la vez. Para mitigar este problema, puedes establecer una fecha \"Posterior a\" a continuación, de modo que solo se sincronicen las actividades que se registraron después de esa fecha.",
"subway-stop": "Entrada de metro",
"summit": "Cumbre",
"summit-book": "Libro de ascensos",

View File

@@ -3,6 +3,7 @@
"Canoeing": "Kanoa",
"Climbing": "Eskalada",
"Hiking": "Mendi-ibilaldia",
"Other": "Besteak",
"Skiing": "",
"Walking": "Oinez",
"about": "Honi buruz",
@@ -144,7 +145,7 @@
"error-copying-trail": "",
"error-creating-user": "Errorea erabiltzailea sortzen",
"error-deleting-token": "",
"error-disabling-strava-integration": "Errorea stravarekin integrazioa desaktibatzean",
"error-disabling-strava-plugin": "Errorea strava plugina desaktibatzean",
"error-during-login": "Errorea sartzean",
"error-during-password-reset": "Ezin izan da pasahitza berrezartzeko mezua bidali",
"error-exporting-trail": "Errorea ibilbidea esportatzean",
@@ -157,11 +158,11 @@
"error-reading-file": "Errorea fitxategia irakurtzean",
"error-saving-list": "Errorea zerrenda gordetzean",
"error-saving-trail": "Errorea ibilbidea gordetzean",
"error-setting-up-integration": "Errorea {provider} integrazioa egitean",
"error-updating-hammerhead-integration": "",
"error-updating-komoot-integration": "",
"error-setting-up-plugin": "Errorea {provider} plugina konfiguratzean",
"error-updating-hammerhead-plugin": "",
"error-updating-komoot-plugin": "",
"error-updating-password": "Errorea pasahitza eguneratzean",
"error-updating-strava-integration": "Errorea komoot integrazioa eguneratzean",
"error-updating-strava-plugin": "Errorea Strava plugina eguneratzean",
"error-uploading-trail-to-hammerhead": "",
"est-duration": "Ustezko iraupena",
"everyone-with-the-link": "Esteka duen edonor",
@@ -202,7 +203,6 @@
"get-started": "Hasi",
"grid": "Sareta",
"grocery-store": "Janari-denda",
"hammerhead-integration-after-date-hint": "",
"heading": "Goiburukoa",
"height": "Altuera",
"help": "Laguntza",
@@ -224,14 +224,11 @@
"import-hint": "Aukeratu edo arrastatu hona GPX, FIT, KML edo TCX fitxategiak...",
"include-description": "Gehitu deskribapena",
"include-waypoints": "Gehitu bidepuntuak",
"integration-description-hammerhead": "",
"integration-description-komoot": "Zure komooteko ibilbideak wandererekin sinkronizatzen ditu aldian behin.",
"integration-description-strava": "Zure stravako ibilbideak wandererekin sinkronizatzen ditu aldian behin.",
"integration-disabled": "integrazioa desaktibatuta",
"integration-enabled": "integrazioa aktibatuta",
"integration-privacy-hint-original": "",
"integration-privacy-hint-user": "",
"integrations": "Integrazioak",
"plugin-disabled": "plugina desaktibatuta",
"plugin-enabled": "plugina aktibatuta",
"plugin-privacy-hint-original": "",
"plugin-privacy-hint-user": "",
"plugins": "Pluginak",
"invalid-date": "Data ez da zuzena",
"invalid-username": "Erabiltzailea ez da zuzena",
"italian": "Italiera",
@@ -434,7 +431,6 @@
"statistics": "Estatistikak",
"stop-drawing": "Utzi marrazteari",
"stop-editing": "Utzi editatzeari",
"strava-integration-after-date-hint": "Zure kontuak ekintza esko baditu Stravaren APIaren mugekin topo egin dezakezu eta agian ezingo dituzu zure ekintza guztiak aldi berean inportatu. Horretarako data jakin batetik aurrerako ekintzak sinkronizatzeko aukera duzu.",
"subway-stop": "Metro sarbidea",
"summit": "Gailurra",
"summit-book": "Igoeren liburua",

View File

@@ -3,6 +3,7 @@
"Canoeing": "Canoë",
"Climbing": "Escalade",
"Hiking": "Randonnée",
"Other": "Autre",
"Skiing": "",
"Walking": "Marche",
"about": "Informations",
@@ -144,7 +145,7 @@
"error-copying-trail": "",
"error-creating-user": "Erreur durant la création de l'utilisateur",
"error-deleting-token": "",
"error-disabling-strava-integration": "Erreur lors de la désactivation de l'intégration Strava",
"error-disabling-strava-plugin": "Erreur lors de la désactivation du plugin Strava",
"error-during-login": "Erreur durant la connexion",
"error-during-password-reset": "Impossible d'envoyer l'e-mail de réinitialisation du mot de passe",
"error-exporting-trail": "Erreur lors de l'export de l'itinéraire",
@@ -157,11 +158,11 @@
"error-reading-file": "Erreur de lecture du fichier",
"error-saving-list": "Erreur lors de l'enregistrement de la liste",
"error-saving-trail": "Erreur lors de l'enregistrement de l'itinéraire",
"error-setting-up-integration": "Error setting up strava integration",
"error-updating-hammerhead-integration": "",
"error-updating-komoot-integration": "",
"error-setting-up-plugin": "Erreur lors de la configuration du plugin {provider}",
"error-updating-hammerhead-plugin": "",
"error-updating-komoot-plugin": "",
"error-updating-password": "Erreur lors de la mise à jour du mot de passe",
"error-updating-strava-integration": "Erreur lors de la mise à jour de l'intégration Komoot",
"error-updating-strava-plugin": "Erreur lors de la mise à jour du plugin Strava",
"error-uploading-trail-to-hammerhead": "",
"est-duration": "Temps estimé",
"everyone-with-the-link": "Tout le monde avec ce lien",
@@ -202,7 +203,6 @@
"get-started": "C'est parti",
"grid": "Grille",
"grocery-store": "Épicerie",
"hammerhead-integration-after-date-hint": "",
"heading": "Titre",
"height": "Hauteur",
"help": "Aide",
@@ -224,14 +224,11 @@
"import-hint": "Sélectionnez ou glissez des fichiers GPX, FIT, KML ou TCX ici...",
"include-description": "Inclure la description",
"include-waypoints": "Inclure les points de passage",
"integration-description-hammerhead": "",
"integration-description-komoot": "Synchronisez vos Tours Komoot avec wanderer à intervalles réguliers.",
"integration-description-strava": "Synchronisez vos itinéraires et vos activités Strava avec wanderer à intervalles réguliers.",
"integration-disabled": "Intégration désactivée",
"integration-enabled": "Intégration activée",
"integration-privacy-hint-original": "",
"integration-privacy-hint-user": "",
"integrations": "Intégrations",
"plugin-disabled": "Plugin désactivé",
"plugin-enabled": "Plugin activé",
"plugin-privacy-hint-original": "",
"plugin-privacy-hint-user": "",
"plugins": "Plugins",
"invalid-date": "Date invalide",
"invalid-username": "Nom d'utilisateur invalide",
"italian": "Italien",
@@ -434,7 +431,6 @@
"statistics": "Statistiques",
"stop-drawing": "Arrêter de tracer",
"stop-editing": "Arrêter la modification",
"strava-integration-after-date-hint": "Si votre compte a une grande quantité d'activités, vous pouvez rencontrer la limite d'utilisation de l'API de Strava vous empêchant de synchroniser toutes les activités en même temps. Pour atténuer ce problème, vous pouvez définir une date \"Après-\" ci-dessous afin que seules les activités qui ont été enregistrées après cette date soient synchronisées.",
"subway-stop": "Bouche de métro",
"summit": "Sommet",
"summit-book": "Liste des ascensions",

View File

@@ -3,6 +3,7 @@
"Canoeing": "Canoeing",
"Climbing": "Climbing",
"Hiking": "Hiking",
"Other": "Egyéb",
"Skiing": "",
"Walking": "Walking",
"about": "A programról",
@@ -144,7 +145,7 @@
"error-copying-trail": "",
"error-creating-user": "Hiba felhasználó hozzáadása közben",
"error-deleting-token": "",
"error-disabling-strava-integration": "Error disabling strava integration",
"error-disabling-strava-plugin": "Error disabling strava plugin",
"error-during-login": "Hiba bejelentkezés közben",
"error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Error exporting trail",
@@ -157,11 +158,11 @@
"error-reading-file": "Hiba a fájl olvasása közben",
"error-saving-list": "Error saving list",
"error-saving-trail": "Error saving trail",
"error-setting-up-integration": "Error setting up strava integration",
"error-updating-hammerhead-integration": "",
"error-updating-komoot-integration": "",
"error-setting-up-plugin": "Error setting up {provider} plugin",
"error-updating-hammerhead-plugin": "",
"error-updating-komoot-plugin": "",
"error-updating-password": "Error updating password",
"error-updating-strava-integration": "Error updating komoot integration",
"error-updating-strava-plugin": "Error updating Strava plugin",
"error-uploading-trail-to-hammerhead": "",
"est-duration": "Becsült időtartam",
"everyone-with-the-link": "Everyone with the link",
@@ -202,7 +203,6 @@
"get-started": "Get started",
"grid": "Grid",
"grocery-store": "Grocery store",
"hammerhead-integration-after-date-hint": "",
"heading": "Heading",
"height": "Height",
"help": "Help",
@@ -224,14 +224,11 @@
"import-hint": "Select or drag GPX, FIT, KML or TCX files here...",
"include-description": "Include description",
"include-waypoints": "Útpontok hozzáadása",
"integration-description-hammerhead": "",
"integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.",
"integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.",
"integration-disabled": "integration disabled",
"integration-enabled": "integration enabled",
"integration-privacy-hint-original": "",
"integration-privacy-hint-user": "",
"integrations": "Integrations",
"plugin-disabled": "plugin disabled",
"plugin-enabled": "plugin enabled",
"plugin-privacy-hint-original": "",
"plugin-privacy-hint-user": "",
"plugins": "Bővítmények",
"invalid-date": "Érvénytelen dátum",
"invalid-username": "Érvénytelen felhasználó",
"italian": "Olasz",
@@ -434,7 +431,6 @@
"statistics": "Statistics",
"stop-drawing": "Stop drawing",
"stop-editing": "Stop editing",
"strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
"subway-stop": "Subway entrance",
"summit": "Summit",
"summit-book": "Csúcspont könyv",

View File

@@ -3,6 +3,7 @@
"Canoeing": "Canoa",
"Climbing": "Arrampicata",
"Hiking": "Escursionismo",
"Other": "Altro",
"Skiing": "",
"Walking": "Camminare",
"about": "Su di noi",
@@ -144,7 +145,7 @@
"error-copying-trail": "",
"error-creating-user": "Errore nella creazione dell'utente",
"error-deleting-token": "",
"error-disabling-strava-integration": "Error disabling strava integration",
"error-disabling-strava-plugin": "Error disabling strava plugin",
"error-during-login": "Errore durante il login",
"error-during-password-reset": "Impossibile inviare email per ripristinare la password",
"error-exporting-trail": "Errore durante l'esportazione del percorso",
@@ -157,11 +158,11 @@
"error-reading-file": "Errore durante la lettura del file",
"error-saving-list": "Errore salvando la lista",
"error-saving-trail": "Errore nel salvataggio del percorso",
"error-setting-up-integration": "Error setting up strava integration",
"error-updating-hammerhead-integration": "",
"error-updating-komoot-integration": "",
"error-setting-up-plugin": "Errore durante la configurazione del plugin {provider}",
"error-updating-hammerhead-plugin": "",
"error-updating-komoot-plugin": "",
"error-updating-password": "Errore nell'aggiornamento della password",
"error-updating-strava-integration": "Error updating komoot integration",
"error-updating-strava-plugin": "Errore durante l'aggiornamento del plugin Strava",
"error-uploading-trail-to-hammerhead": "",
"est-duration": "Durata stimata",
"everyone-with-the-link": "Everyone with the link",
@@ -202,7 +203,6 @@
"get-started": "Get started",
"grid": "Griglia",
"grocery-store": "Grocery store",
"hammerhead-integration-after-date-hint": "",
"heading": "Heading",
"height": "Height",
"help": "Aiuto",
@@ -224,14 +224,11 @@
"import-hint": "Seleziona o trascina qui i file GPX, FIT, KML o TCX...",
"include-description": "Adotta descrizione",
"include-waypoints": "Includi waypoint",
"integration-description-hammerhead": "",
"integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.",
"integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.",
"integration-disabled": "integration disabled",
"integration-enabled": "integration enabled",
"integration-privacy-hint-original": "",
"integration-privacy-hint-user": "",
"integrations": "Integrations",
"plugin-disabled": "plugin disattivato",
"plugin-enabled": "plugin attivato",
"plugin-privacy-hint-original": "",
"plugin-privacy-hint-user": "",
"plugins": "Plugin",
"invalid-date": "Data non valida",
"invalid-username": "Nome utente non valido",
"italian": "Italiano",
@@ -434,7 +431,6 @@
"statistics": "Statistiche",
"stop-drawing": "Smettere di disegnare",
"stop-editing": "Stop editing",
"strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
"subway-stop": "Subway entrance",
"summit": "Summit",
"summit-book": "Libro di vetta",

View File

@@ -3,6 +3,7 @@
"Canoeing": "Kanoën",
"Climbing": "Klimmen",
"Hiking": "Hiken",
"Other": "Overig",
"Skiing": "",
"Walking": "Wandelen",
"about": "Over",
@@ -144,7 +145,7 @@
"error-copying-trail": "",
"error-creating-user": "Fout bij aanmaken gebruiker",
"error-deleting-token": "",
"error-disabling-strava-integration": "Fout bij het uitschakelen van Strava-integratie",
"error-disabling-strava-plugin": "Fout bij het uitschakelen van Strava-plug-in",
"error-during-login": "Het inloggen is mislukt",
"error-during-password-reset": "Kan geen e-mail voor wachtwoordherstel verzenden",
"error-exporting-trail": "Fout bij exporteren van parcours",
@@ -157,11 +158,11 @@
"error-reading-file": "Fout bij inlezen bestand",
"error-saving-list": "Fout bij bewaren van lijst",
"error-saving-trail": "Fout bij bewaren van route",
"error-setting-up-integration": "Fout bij opzetten {provider} integratie",
"error-updating-hammerhead-integration": "",
"error-updating-komoot-integration": "",
"error-setting-up-plugin": "Fout bij instellen van {provider}-plug-in",
"error-updating-hammerhead-plugin": "",
"error-updating-komoot-plugin": "",
"error-updating-password": "Fout bij bijwerken van wachtwoord",
"error-updating-strava-integration": "Fout bij bijwerken van Komoot integratie",
"error-updating-strava-plugin": "Fout bij bijwerken van Strava-plug-in",
"error-uploading-trail-to-hammerhead": "",
"est-duration": "Geschatte duur",
"everyone-with-the-link": "Iedereen met de link",
@@ -202,7 +203,6 @@
"get-started": "Aan de slag",
"grid": "Rooster",
"grocery-store": "Kruidenier",
"hammerhead-integration-after-date-hint": "",
"heading": "Titel",
"height": "Hoogte",
"help": "Help",
@@ -224,14 +224,11 @@
"import-hint": "Selecteer of sleep GPX-, FIT-, KML- of TCX-bestanden hierheen...",
"include-description": "Inclusief beschrijving",
"include-waypoints": "Waypoints toevoegen",
"integration-description-hammerhead": "",
"integration-description-komoot": "Synchroniseert je Komoot-tochten met Wanderer op regelmatige tijdstippen.",
"integration-description-strava": "Synchroniseert je Strava-routes en -activiteiten met Wanderer op regelmatige tijdstippen.",
"integration-disabled": "Integratie uitgeschakeld",
"integration-enabled": "Integratie ingeschakeld",
"integration-privacy-hint-original": "",
"integration-privacy-hint-user": "",
"integrations": "Integraties",
"plugin-disabled": "Plug-in uitgeschakeld",
"plugin-enabled": "Plug-in ingeschakeld",
"plugin-privacy-hint-original": "",
"plugin-privacy-hint-user": "",
"plugins": "Plug-ins",
"invalid-date": "Ongeldige datum",
"invalid-username": "Ongeldige gebruikersnaam",
"italian": "Italiaans",
@@ -434,7 +431,6 @@
"statistics": "Statistieken",
"stop-drawing": "Stop met tekenen",
"stop-editing": "Stop met bewerken",
"strava-integration-after-date-hint": "Als uw account een grote hoeveelheid activiteiten heeft, kunt u op de API-limiet van Strava botsen voorkomend dat u alle activiteiten tegelijk synchroniseert. Om dit probleem te omzeilen kunt u een \"Later\" datum hieronder instellen, zodat alleen activiteiten die na deze datum werden opgenomen worden gesynchroniseerd.",
"subway-stop": "Metro toegang",
"summit": "Top",
"summit-book": "Bergtopboek",

View File

@@ -3,6 +3,7 @@
"Canoeing": "Padling",
"Climbing": "Klatring",
"Hiking": "Vandring",
"Other": "Annet",
"Skiing": "Skisport",
"Walking": "Gåtur",
"about": "Om",
@@ -144,7 +145,7 @@
"error-copying-trail": "Feil ved kopiering av sti",
"error-creating-user": "Feil ved oppretting av bruker",
"error-deleting-token": "Feil ved sletting av token",
"error-disabling-strava-integration": "Feil ved deaktivering av Strava-integrasjon",
"error-disabling-strava-plugin": "Feil ved deaktivering av Strava-programtillegg",
"error-during-login": "Feil under innlogging",
"error-during-password-reset": "Kunne ikke sende e-post for tilbakestilling av passord",
"error-exporting-trail": "Feil ved eksport av sti",
@@ -157,11 +158,11 @@
"error-reading-file": "Feil ved lesing av fil",
"error-saving-list": "Feil ved lagring av liste",
"error-saving-trail": "Feil ved lagring av sti",
"error-setting-up-integration": "Feil ved oppsett av {provider}-integrasjon",
"error-updating-hammerhead-integration": "Feil ved oppdatering av Hammerhead-integrasjon",
"error-updating-komoot-integration": "Feil ved oppdatering av Komoot-integrasjon",
"error-setting-up-plugin": "Feil ved oppsett av {provider}-programtillegg",
"error-updating-hammerhead-plugin": "Feil ved oppdatering av Hammerhead-programtillegg",
"error-updating-komoot-plugin": "Feil ved oppdatering av Komoot-programtillegg",
"error-updating-password": "Feil ved oppdatering av passord",
"error-updating-strava-integration": "Feil ved oppdatering av Komoot-integrasjon",
"error-updating-strava-plugin": "Feil ved oppdatering av Strava-programtillegg",
"error-uploading-trail-to-hammerhead": "Feil ved opplasting av sti til Hammerhead",
"est-duration": "Est. varighet",
"everyone-with-the-link": "Alle med lenken",
@@ -202,7 +203,6 @@
"get-started": "Kom i gang",
"grid": "Rutenett",
"grocery-store": "Dagligvarebutikk",
"hammerhead-integration-after-date-hint": "Hvis Hammerhead-kontoen din allerede er synkronisert med andre stidatabaser, som Komoot eller Strava, kan synkronisering av Hammerhead-data føre til duplikater. For å unngå dette kan du angi en startdato nedenfor, slik at bare aktiviteter registrert etter denne datoen vil bli synkronisert.",
"heading": "Overskrift",
"height": "Høyde",
"help": "Hjelp",
@@ -224,14 +224,13 @@
"import-hint": "Velg eller dra GPX, FIT, KML eller TCX-filer hit...",
"include-description": "Inkluder beskrivelse",
"include-waypoints": "Inkluder veipunkter",
"integration-description-hammerhead": "Synkroniserer Hammerhead-turene dine med Wanderer med jevne mellomrom.",
"integration-description-komoot": "Synkroniserer dine Komoot-turer med Wanderer med jevne mellomrom.",
"integration-description-strava": "Synkroniserer dine Strava-ruter og aktiviteter med Wanderer med jevne mellomrom.",
"integration-disabled": "integrasjon deaktivert",
"integration-enabled": "integrasjon aktivert",
"integration-privacy-hint-original": "Importerte stier vil beholde samme synlighet som de har på den eksterne plattformen. For eksempel, hvis den opprinnelige stien var offentlig, vil den være offentlig i Wanderer, selv om stier er private som standard i henhold til personverninnstillingene dine.",
"integration-privacy-hint-user": "Den opprinnelige stiens synlighet blir forkastet. I stedet blir de lokale personverninnstillingene for stier brukt på alle importerte stier.",
"integrations": "Integrasjoner",
"plugin-disabled": "programtillegg deaktivert",
"plugin-enabled": "programtillegg aktivert",
"plugin-privacy-hint-original": "Importerte stier vil beholde samme synlighet som de har på den eksterne plattformen. For eksempel, hvis den opprinnelige stien var offentlig, vil den være offentlig i Wanderer, selv om stier er private som standard i henhold til personverninnstillingene dine.",
"plugin-privacy-hint-user": "Den opprinnelige stiens synlighet blir forkastet. I stedet blir de lokale personverninnstillingene for stier brukt på alle importerte stier.",
"plugins": "Programtillegg",
"plugin-type-trails": "Trails",
"plugin-type-trails-description": "Trail-programtillegg kobler Wanderer til eksterne tjenester og synkroniserer ruter eller aktiviteter automatisk i bakgrunnen.",
"invalid-date": "Ugyldig dato",
"invalid-username": "Ugyldig brukernavn",
"italian": "Italiensk",
@@ -434,7 +433,6 @@
"statistics": "Statistikk",
"stop-drawing": "Stopp tegning",
"stop-editing": "Stopp redigering",
"strava-integration-after-date-hint": "Hvis kontoen din har en stor mengde aktiviteter kan du støte på Stravas API-hastighetsgrense som hindrer deg i å synkronisere alle aktiviteter samtidig. For å redusere dette problemet kan du sette en \"Etter\" dato nedenfor slik at bare aktiviteter som ble registrert etter denne datoen blir synkronisert.",
"subway-stop": "T-baneinngang",
"summit": "Topp",
"summit-book": "Toppbok",

View File

@@ -3,6 +3,7 @@
"Canoeing": "Kajak",
"Climbing": "Wspinaczka",
"Hiking": "Wędrówka",
"Other": "Inne",
"Skiing": "",
"Walking": "Spacer",
"about": "Na temat",
@@ -144,7 +145,7 @@
"error-copying-trail": "",
"error-creating-user": "Błąd tworzenia użytkownika",
"error-deleting-token": "",
"error-disabling-strava-integration": "Błąd przy wyłączaniu integracji strava",
"error-disabling-strava-plugin": "Błąd przy wyłączaniu wtyczki strava",
"error-during-login": "Błąd podczas logowania",
"error-during-password-reset": "Nie udało się wysłać e-maila z resetowaniem hasła",
"error-exporting-trail": "Błąd podczas eksportowania szlaku",
@@ -157,11 +158,11 @@
"error-reading-file": "Błąd wczytywania pliku",
"error-saving-list": "Błąd przy zapisywaniu listy",
"error-saving-trail": "Błąd podczas zapisywania szlaku",
"error-setting-up-integration": "Error setting up strava integration",
"error-updating-hammerhead-integration": "",
"error-updating-komoot-integration": "",
"error-setting-up-plugin": "Błąd konfiguracji wtyczki {provider}",
"error-updating-hammerhead-plugin": "",
"error-updating-komoot-plugin": "",
"error-updating-password": "Błąd podczas aktualizacji hasła",
"error-updating-strava-integration": "Błąd aktualizacji integracji kamoot",
"error-updating-strava-plugin": "Błąd aktualizacji wtyczki Strava",
"error-uploading-trail-to-hammerhead": "",
"est-duration": "Szacowany czas",
"everyone-with-the-link": "Everyone with the link",
@@ -202,7 +203,6 @@
"get-started": "Get started",
"grid": "Siatka",
"grocery-store": "Grocery store",
"hammerhead-integration-after-date-hint": "",
"heading": "Heading",
"height": "Wysokość",
"help": "Pomoc",
@@ -224,14 +224,11 @@
"import-hint": "Wybierz lub przeciągnij tutaj plik GPX, FIT, KML lub TCX...",
"include-description": "Dołącz opis",
"include-waypoints": "Uwzględnij punkty trasy",
"integration-description-hammerhead": "",
"integration-description-komoot": "Synchronizuje trasy kamoot z wanderer w równych odstępach.",
"integration-description-strava": "Synchronizuje trasy i aktywność z wanderer w równych odstępach.",
"integration-disabled": "integracja wyłączona",
"integration-enabled": "integracja włączona",
"integration-privacy-hint-original": "",
"integration-privacy-hint-user": "",
"integrations": "Integracje",
"plugin-disabled": "wtyczka wyłączona",
"plugin-enabled": "wtyczka włączona",
"plugin-privacy-hint-original": "",
"plugin-privacy-hint-user": "",
"plugins": "Wtyczki",
"invalid-date": "Nieprawidłowa data",
"invalid-username": "Błędna nazwa użytkownika",
"italian": "Włoski",
@@ -434,7 +431,6 @@
"statistics": "Statystyki",
"stop-drawing": "Przestań rysować",
"stop-editing": "Zakończ edycję",
"strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
"subway-stop": "Subway entrance",
"summit": "Summit",
"summit-book": "Logbook",

View File

@@ -3,6 +3,7 @@
"Canoeing": "Canoagem",
"Climbing": "Escalada",
"Hiking": "Montanhismo",
"Other": "Outro",
"Skiing": "",
"Walking": "Caminhada",
"about": "Sobre",
@@ -144,7 +145,7 @@
"error-copying-trail": "",
"error-creating-user": "Erro ao criar utilizador",
"error-deleting-token": "",
"error-disabling-strava-integration": "Error disabling strava integration",
"error-disabling-strava-plugin": "Error disabling strava plugin",
"error-during-login": "Erro durante o ‘login’",
"error-during-password-reset": "Unable to send password reset email",
"error-exporting-trail": "Erro na exportação do percurso",
@@ -157,11 +158,11 @@
"error-reading-file": "Erro ao ler o arquivo",
"error-saving-list": "Erro ao gravar lista",
"error-saving-trail": "Erro ao gravar percurso",
"error-setting-up-integration": "Error setting up strava integration",
"error-updating-hammerhead-integration": "",
"error-updating-komoot-integration": "",
"error-setting-up-plugin": "Error setting up {provider} plugin",
"error-updating-hammerhead-plugin": "",
"error-updating-komoot-plugin": "",
"error-updating-password": "Erro ao atualizar password",
"error-updating-strava-integration": "Error updating komoot integration",
"error-updating-strava-plugin": "Error updating Strava plugin",
"error-uploading-trail-to-hammerhead": "",
"est-duration": "Duração prevista",
"everyone-with-the-link": "Everyone with the link",
@@ -202,7 +203,6 @@
"get-started": "Get started",
"grid": "Grelha",
"grocery-store": "Grocery store",
"hammerhead-integration-after-date-hint": "",
"heading": "Heading",
"height": "Height",
"help": "Ajuda",
@@ -224,14 +224,11 @@
"import-hint": "Selecionar ou arrastar ficheiros GPX, FIT, KML ou TCX para aqui...",
"include-description": "Incluir descrição",
"include-waypoints": "Incluir pontos de passagem",
"integration-description-hammerhead": "",
"integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.",
"integration-description-strava": "Syncs your strava routes & activities with wanderer in regular intervals.",
"integration-disabled": "integration disabled",
"integration-enabled": "integration enabled",
"integration-privacy-hint-original": "",
"integration-privacy-hint-user": "",
"integrations": "Integrations",
"plugin-disabled": "plugin disabled",
"plugin-enabled": "plugin enabled",
"plugin-privacy-hint-original": "",
"plugin-privacy-hint-user": "",
"plugins": "Plugins",
"invalid-date": "Data inválida",
"invalid-username": "Nome de usuário inválido",
"italian": "Italiano",
@@ -434,7 +431,6 @@
"statistics": "Statistics",
"stop-drawing": "Parar desenho",
"stop-editing": "Stop editing",
"strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
"subway-stop": "Subway entrance",
"summit": "Summit",
"summit-book": "Livro da cimeira",

View File

@@ -3,6 +3,7 @@
"Canoeing": "Каякинг",
"Climbing": "Скалолазание",
"Hiking": "Пеший туризм",
"Other": "Другое",
"Skiing": "",
"Walking": "Прогулка",
"about": "О",
@@ -144,7 +145,7 @@
"error-copying-trail": "",
"error-creating-user": "Ошибка создания пользователя",
"error-deleting-token": "",
"error-disabling-strava-integration": "Ошибка отключения Strava",
"error-disabling-strava-plugin": "Ошибка отключения Strava",
"error-during-login": "Ошибка входа",
"error-during-password-reset": "Не удалось отправить email сброса пароля",
"error-exporting-trail": "Ошибка экспорта трека",
@@ -157,11 +158,11 @@
"error-reading-file": "Ошибка чтения файла",
"error-saving-list": "Ошибка сохранения списка",
"error-saving-trail": "Ошибка сохранения трека",
"error-setting-up-integration": "Ошибка настройки {provider}",
"error-updating-hammerhead-integration": "",
"error-updating-komoot-integration": "",
"error-setting-up-plugin": "Ошибка настройки плагина {provider}",
"error-updating-hammerhead-plugin": "",
"error-updating-komoot-plugin": "",
"error-updating-password": "Ошибка изменения пароля",
"error-updating-strava-integration": "Ошибка обновления Strava",
"error-updating-strava-plugin": "Ошибка обновления плагина Strava",
"error-uploading-trail-to-hammerhead": "",
"est-duration": "Продолжительность",
"everyone-with-the-link": "Everyone with the link",
@@ -202,7 +203,6 @@
"get-started": "Get started",
"grid": "Сетка",
"grocery-store": "Продуктовый магазин",
"hammerhead-integration-after-date-hint": "",
"heading": "Heading",
"height": "Высота",
"help": "Помощь",
@@ -224,14 +224,11 @@
"import-hint": "Перетащите GPX, FIT, KML или TCX файлы сюда...",
"include-description": "Добавить описание",
"include-waypoints": "Include waypoints",
"integration-description-hammerhead": "",
"integration-description-komoot": "Синхронизирует ваши данные с Komoot.",
"integration-description-strava": "Синхронизирует ваши данные со Strava.",
"integration-disabled": "интеграция отключена",
"integration-enabled": "интеграция включена",
"integration-privacy-hint-original": "",
"integration-privacy-hint-user": "",
"integrations": "Интеграции",
"plugin-disabled": "плагин отключен",
"plugin-enabled": "плагин включен",
"plugin-privacy-hint-original": "",
"plugin-privacy-hint-user": "",
"plugins": "Плагины",
"invalid-date": "Неверная дата",
"invalid-username": "Некорректное имя пользователя",
"italian": "Итальянский",
@@ -434,7 +431,6 @@
"statistics": "Статистика",
"stop-drawing": "Закончить рисование",
"stop-editing": "Закончить редактирование",
"strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
"subway-stop": "Вход в метро",
"summit": "Вершина",
"summit-book": "История поездок",

View File

@@ -3,6 +3,7 @@
"Canoeing": "划艇",
"Climbing": "攀岩",
"Hiking": "徒步",
"Other": "其他",
"Skiing": "",
"Walking": "步行",
"about": "关于",
@@ -144,7 +145,7 @@
"error-copying-trail": "",
"error-creating-user": "创建用户错误",
"error-deleting-token": "",
"error-disabling-strava-integration": "禁用strava集成时出错",
"error-disabling-strava-plugin": "禁用strava 插件时出错",
"error-during-login": "登录错误",
"error-during-password-reset": "无法发送密码重置邮件",
"error-exporting-trail": "导出路线失败",
@@ -157,11 +158,11 @@
"error-reading-file": "读取文件错误",
"error-saving-list": "保存列表失败",
"error-saving-trail": "保存路线失败",
"error-setting-up-integration": "Error setting up strava integration",
"error-updating-hammerhead-integration": "",
"error-updating-komoot-integration": "",
"error-setting-up-plugin": "设置 {provider} 插件时出错",
"error-updating-hammerhead-plugin": "",
"error-updating-komoot-plugin": "",
"error-updating-password": "更新密码失败",
"error-updating-strava-integration": "更新 komoot 集成出错",
"error-updating-strava-plugin": "更新 Strava 插件时出错",
"error-uploading-trail-to-hammerhead": "",
"est-duration": "预计时长",
"everyone-with-the-link": "Everyone with the link",
@@ -202,7 +203,6 @@
"get-started": "Get started",
"grid": "网格",
"grocery-store": "杂货店",
"hammerhead-integration-after-date-hint": "",
"heading": "标题",
"height": "高度",
"help": "帮助",
@@ -224,14 +224,11 @@
"import-hint": "在此选择或拖拽GPX、FIT、KML或TCX文件...",
"include-description": "包含描述",
"include-waypoints": "包括途径点",
"integration-description-hammerhead": "",
"integration-description-komoot": "定期与komoot同步您的wanderer。",
"integration-description-strava": "定期与strava同步您的wanderer路线和活动。",
"integration-disabled": "整合已停用",
"integration-enabled": "整合已启用",
"integration-privacy-hint-original": "",
"integration-privacy-hint-user": "",
"integrations": "整合",
"plugin-disabled": "插件已停用",
"plugin-enabled": "插件已启用",
"plugin-privacy-hint-original": "",
"plugin-privacy-hint-user": "",
"plugins": "插件",
"invalid-date": "无效日期",
"invalid-username": "无效用户名",
"italian": "意大利语",
@@ -434,7 +431,6 @@
"statistics": "统计",
"stop-drawing": "停止绘制",
"stop-editing": "停止编辑",
"strava-integration-after-date-hint": "If your account has a large amount of acitivities you may run into Strava's API rate limit preventing you from syncing all activities at once. To mitigate this issue you can set an \"After\" date below so that only activities that were recorded after this date are synced.",
"subway-stop": "地铁入口",
"summit": "山峰",
"summit-book": "详细日程",

View File

@@ -1,53 +0,0 @@
import { z, ZodType } from "zod";
import type { Integration } from "../integration";
const IntegrationMergeSchema = z.object({
enabled: z.boolean(),
})
const StravaSchema = z.object({
clientId: z.number({ coerce: true }).int().nonnegative(),
clientSecret: z.string().length(40).optional().or(z.literal('')),
routes: z.boolean(),
activities: z.boolean(),
active: z.boolean(),
after: z.string().date().optional(),
privacy: z.enum(["original", "settings"]),
merge: IntegrationMergeSchema,
})
const KomootSchema = z.object({
email: z.string().email(),
password: z.string(),
completed: z.boolean(),
planned: z.boolean(),
active: z.boolean(),
privacy: z.enum(["original", "settings"]),
merge: IntegrationMergeSchema,
})
const HammerheadSchema = z.object({
email: z.string().email(),
password: z.string(),
completed: z.boolean(),
planned: z.boolean(),
active: z.boolean(),
after: z.string().date().optional(),
merge: IntegrationMergeSchema,
})
const IntegrationCreateSchema = z.object({
user: z.string().length(15),
strava: StravaSchema.optional(),
komoot: KomootSchema.optional(),
hammerhead: HammerheadSchema.optional(),
}) satisfies ZodType<Integration>
const IntegrationUpdateSchema = z.object({
strava: StravaSchema.optional().nullable(),
komoot: KomootSchema.optional().nullable(),
hammerhead: HammerheadSchema.optional().nullable(),
}) satisfies ZodType<Partial<Integration>>
export { StravaSchema, IntegrationCreateSchema, IntegrationUpdateSchema, KomootSchema, HammerheadSchema };

View File

@@ -918,106 +918,6 @@
* type: string
* description: Followee user ID (15 chars)
*
* Integration:
* type: object
* required:
* - id
* - user
* properties:
* id:
* type: string
* description: Integration ID (15 chars)
* user:
* type: string
* description: User ID (15 chars)
* strava:
* type: object
* properties:
* clientId:
* type: integer
* clientSecret:
* type: string
* routes:
* type: boolean
* activities:
* type: boolean
* active:
* type: boolean
* after:
* type: string
* format: date
* privacy:
* type: string
* enum: [original, settings]
* komoot:
* type: object
* properties:
* email:
* type: string
* format: email
* password:
* type: string
* completed:
* type: boolean
* planned:
* type: boolean
* active:
* type: boolean
* privacy:
* type: string
* enum: [original, settings]
* hammerhead:
* type: object
* properties:
* email:
* type: string
* format: email
* password:
* type: string
* completed:
* type: boolean
* planned:
* type: boolean
* active:
* type: boolean
* after:
* type: string
* format: date
* created:
* type: string
* format: date-time
* updated:
* type: string
* format: date-time
*
* IntegrationInput:
* type: object
* required:
* - user
* properties:
* user:
* type: string
* description: User ID (15 chars)
* strava:
* type: object
* komoot:
* type: object
* hammerhead:
* type: object
*
* IntegrationUpdateInput:
* type: object
* properties:
* strava:
* type: object
* nullable: true
* komoot:
* type: object
* nullable: true
* hammerhead:
* type: object
* nullable: true
*
* Notification:
* type: object
* required:

View File

@@ -0,0 +1,59 @@
import { z } from "zod";
const PluginInstanceStatusSchema = z.enum([
"configured",
"needs_auth",
"needs_reauth",
"syncing",
"rate_limited",
"unavailable",
"unsupported_protocol",
"error",
"disabled",
]);
const OptionalJsonRecordSchema = z.preprocess(
(value) => (value === null ? undefined : value),
z.record(z.string(), z.unknown()).optional(),
);
const OptionalAuthSchema = z.preprocess(
(value) => {
if (value === null) {
return undefined;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value;
}
return Object.fromEntries(
Object.entries(value).map(([key, fieldValue]) => [
key,
fieldValue == null ? "" : String(fieldValue),
]),
);
},
z.record(z.string(), z.string()).optional(),
);
const PluginInstanceCreateSchema = z.object({
user: z.string().length(15),
plugin_id: z.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9_-]*$/),
enabled: z.boolean().default(false),
auth: OptionalAuthSchema,
config: OptionalJsonRecordSchema,
state: OptionalJsonRecordSchema,
status: PluginInstanceStatusSchema.optional(),
});
const PluginInstanceUpdateSchema = z.object({
enabled: z.boolean().optional(),
auth: OptionalAuthSchema,
config: OptionalJsonRecordSchema,
state: OptionalJsonRecordSchema,
status: PluginInstanceStatusSchema.optional(),
});
export {
PluginInstanceCreateSchema,
PluginInstanceUpdateSchema,
PluginInstanceStatusSchema,
};

View File

@@ -1,55 +0,0 @@
export interface BaseIntegration {
active: boolean
}
export interface IntegrationMergeSettings {
enabled: boolean;
}
export interface StravaIntegration extends BaseIntegration {
clientId: string | number;
clientSecret?: string;
routes: boolean;
activities: boolean;
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
after?: string
privacy: "original" | "settings"
merge: IntegrationMergeSettings;
}
export interface KomootIntegration extends BaseIntegration {
email: string,
password: string,
completed: boolean,
planned: boolean
privacy: "original" | "settings"
merge: IntegrationMergeSettings;
}
export interface HammerheadIntegration extends BaseIntegration {
email: string,
password: string,
completed: boolean,
planned: boolean,
after?: string
merge: IntegrationMergeSettings;
}
export class Integration {
id?: string;
user: string;
strava?: StravaIntegration | null;
komoot?: KomootIntegration | null;
hammerhead?: HammerheadIntegration | null;
constructor(user: string, strava?: StravaIntegration, komoot?: KomootIntegration, hammerhead?: HammerheadIntegration) {
this.user = user;
this.strava = strava;
this.komoot = komoot;
this.hammerhead = hammerhead;
}
}

View File

@@ -0,0 +1,25 @@
export interface PluginInstance {
id?: string;
user: string;
plugin_id: string;
enabled: boolean;
auth?: Record<string, string>;
config?: Record<string, unknown>;
state?: Record<string, unknown>;
status:
| "configured"
| "needs_auth"
| "needs_reauth"
| "syncing"
| "rate_limited"
| "unavailable"
| "unsupported_protocol"
| "error"
| "disabled";
last_error?: {
code?: string;
message?: string;
};
last_sync_at?: string;
retry_not_before?: string;
}

View File

@@ -0,0 +1,57 @@
export type LocalizedTextMap = Record<string, string>;
export interface ConfigFieldOption {
value: string;
label?: string;
labels?: LocalizedTextMap;
}
export interface ConfigField {
key: string;
type: "boolean" | "date" | "select" | "text" | "url";
label?: string;
labels?: LocalizedTextMap;
description?: string;
descriptions?: LocalizedTextMap;
options?: ConfigFieldOption[];
default?: unknown;
required?: boolean;
hidden?: boolean;
}
export interface PluginProvider {
id: string;
type: "trails";
name: string;
displayName?: string;
displayNames?: LocalizedTextMap;
description?: string;
descriptions?: LocalizedTextMap;
icon?: string;
iconDark?: string;
version?: string;
protocolVersion?: string;
risk?: string;
auth: {
type: string;
fields?: string[];
secretFields?: string[];
authorizationUrl?: string;
tokenUrl?: string;
tokenRequestFormat?: "json" | "form";
scopes?: string[];
scopeSeparator?: string;
authorizationParams?: Record<string, string>;
pkce?: boolean;
tokenAuth?: string;
};
configSchema?: ConfigField[];
hostConfig?: Record<string, unknown>;
metadata?: Record<string, unknown>;
capabilities?: string[];
limits?: {
recommendedBatchSize?: number;
};
status: "available" | "disabled" | "error";
error?: string;
}

View File

@@ -0,0 +1,45 @@
export interface PluginSystemCapability {
name: string;
version: string;
export: string;
requiredHostFunctions?: string[];
job?: string;
}
export interface PluginSystemManifest {
manifestVersion: string;
id: string;
type: "trails";
name: string;
displayName?: string;
description?: string;
icon?: string;
iconDark?: string;
version: string;
runtime: {
type: string;
entrypoint: string;
};
capabilities: PluginSystemCapability[];
auth?: Record<string, unknown>;
permissions?: Record<string, unknown>;
configSchema?: unknown[];
hostConfig?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
export interface PluginSystemPlugin {
id: string;
type: "trails";
name: string;
displayName?: string;
description?: string;
icon?: string;
iconDark?: string;
version: string;
runtime: string;
capabilities: string[];
status: "available" | "disabled" | "error";
error?: string;
manifest: PluginSystemManifest;
}

View File

@@ -256,4 +256,3 @@ export const defaultTrailSearchAttributes = [
export { Trail };
export type { TrailBoundingBox, TrailFilter, TrailFilterValues, TrailSearchResult };

View File

@@ -6,7 +6,7 @@ import { writable, type Writable } from "svelte/store";
export const categories: Writable<Category[]> = writable([])
export async function categories_index(f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f('/api/v1/category', {
const r = await f('/api/v1/category?perPage=-1&sort=name', {
method: 'GET',
})
if (!r.ok) {
@@ -19,4 +19,4 @@ export async function categories_index(f: (url: RequestInfo | URL, config?: Requ
categories.set(response.items);
return response.items as Category[];
}
}

View File

@@ -1,90 +0,0 @@
import { Integration } from "$lib/models/integration";
import { APIError } from "$lib/util/api_util";
import { type ListResult } from "pocketbase";
import { get, writable, type Writable } from "svelte/store";
import { currentUser } from "./user_store";
export const integrations: Writable<Integration[]> = writable([])
export async function integrations_index(f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let r = await f('/api/v1/integration' + new URLSearchParams({
}), {
method: 'GET',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const fetchedIntegrations: ListResult<Integration> = await r.json();
integrations.set(fetchedIntegrations.items);
return fetchedIntegrations.items;
}
export async function integrations_create(integration: Integration) {
const user = get(currentUser)
if (!user) {
throw Error("Unauthenticated")
}
integration.user = user.id;
let r = await fetch('/api/v1/integration', {
method: 'PUT',
body: JSON.stringify(integration),
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const model: Integration = await r.json();
return model;
}
export async function uploadGpx(integrationName: string, file: File) {
const formData = new FormData();
formData.append('file', file);
let r = await fetch(`/api/v1/integration/${integrationName}/upload`, {
method: 'POST',
body: formData,
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
}
export async function integrations_update(integration: Integration, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let r = await f('/api/v1/integration/' + integration.id, {
method: 'POST',
body: JSON.stringify(integration),
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const model: Integration = await r.json();
return model;
}
export async function integrations_delete(integration: Integration) {
const r = await fetch('/api/v1/integration/' + integration.id, {
method: 'DELETE',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
}

View File

@@ -0,0 +1,183 @@
import type { PluginInstance } from "$lib/models/plugin_instance";
import { APIError } from "$lib/util/api_util";
import type { ListResult } from "pocketbase";
import { get, writable, type Writable } from "svelte/store";
import { currentUser } from "./user_store";
export const pluginInstances: Writable<PluginInstance[]> = writable([]);
export async function plugin_instances_index(
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
) {
const r = await f("/api/v1/plugin-instance?perPage=-1", {
method: "GET",
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
const fetchedInstances: ListResult<PluginInstance> = await r.json();
pluginInstances.set(fetchedInstances.items);
return fetchedInstances.items;
}
export async function plugin_instances_create(
instance: Partial<PluginInstance>,
) {
const user = get(currentUser);
if (!user) {
throw Error("Unauthenticated");
}
const r = await fetch("/api/v1/plugin-instance", {
method: "PUT",
body: JSON.stringify({
...instance,
user: user.id,
}),
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
return (await r.json()) as PluginInstance;
}
export async function plugin_instances_update(
instance: PluginInstance,
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
) {
if (!instance.id) {
throw Error("Plugin instance has no id");
}
const r = await f("/api/v1/plugin-instance/" + instance.id, {
method: "POST",
body: JSON.stringify(instance),
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
return (await r.json()) as PluginInstance;
}
export async function plugin_oauth_start(
data: {
pluginId: string;
instanceId?: string;
authContext?: string;
redirectUri: string;
},
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
) {
const r = await f("/api/v1/plugin-system/oauth/start", {
method: "POST",
body: JSON.stringify(data),
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
return (await r.json()) as { url: string; state: string; instanceId: string };
}
export async function plugin_auth_validate(
data: {
pluginId: string;
instanceId?: string;
authContext?: string;
auth: Record<string, string>;
},
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
) {
const r = await f("/api/v1/plugin-system/auth/validate", {
method: "POST",
body: JSON.stringify(data),
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
return (await r.json()) as { ok: boolean; authContext?: string };
}
export async function plugin_oauth_callback(
data: { instanceId: string; code: string; state: string },
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
) {
const r = await f("/api/v1/plugin-system/oauth/callback", {
method: "POST",
body: JSON.stringify(data),
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
return (await r.json()) as { ok: boolean };
}
export async function plugin_oauth_revoke(
instanceId: string,
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
) {
const r = await f("/api/v1/plugin-system/oauth/revoke", {
method: "POST",
body: JSON.stringify({ instanceId }),
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
return (await r.json()) as { ok: boolean };
}
export async function plugin_category_remap_preview(
instanceId: string,
config?: Record<string, unknown>,
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
) {
const r = await f("/api/v1/plugin-system/category-remap/preview", {
method: "POST",
body: JSON.stringify({ instanceId, config }),
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
return (await r.json()) as { count: number; backfilledSinceMapping?: number };
}
export async function plugin_category_remap_apply(
instanceId: string,
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
) {
const r = await f("/api/v1/plugin-system/category-remap/apply", {
method: "POST",
body: JSON.stringify({ instanceId }),
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
return (await r.json()) as { count: number; remapped?: number };
}

View File

@@ -0,0 +1,125 @@
import type { LocalizedTextMap, PluginProvider } from "$lib/models/plugin_provider";
import type { PluginSystemPlugin } from "$lib/models/plugin_system";
import { APIError } from "$lib/util/api_util";
import { derived, writable, type Readable, type Writable } from "svelte/store";
import {
pluginInstances,
plugin_instances_index,
} from "./plugin_instance_store";
export const pluginProviders: Writable<PluginProvider[]> = writable([]);
// True when the user has at least one enabled plugin instance whose plugin
// advertises the trail send capability. Used to gate the trail "send to" action.
export const hasSendCapablePlugin: Readable<boolean> = derived(
[pluginProviders, pluginInstances],
([$plugins, $instances]) => {
const enabledProviders = new Set(
$instances.filter((a) => a.enabled).map((a) => a.plugin_id),
);
return $plugins.some(
(p) =>
p.status === "available" &&
(p.capabilities ?? []).includes("prepare_trail_send.v1") &&
enabledProviders.has(p.id),
);
},
);
let pluginDataLoaded = false;
// Loads plugins and instances once per session so the derived gating
// stores are populated (e.g. for the trail "send to" action). Safe to call
// repeatedly; only the first call performs the requests.
export async function load_plugin_data_once() {
if (pluginDataLoaded) {
return;
}
pluginDataLoaded = true;
try {
await Promise.all([
plugins_index(),
plugin_instances_index(),
]);
} catch (e) {
pluginDataLoaded = false;
console.error(e);
}
}
export async function plugins_index(
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
) {
const r = await f("/api/v1/plugin-system/plugins", {
method: "GET",
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail);
}
const data: { items: PluginSystemPlugin[] } = await r.json();
const items = data.items.map(pluginSystemToPluginProvider);
pluginProviders.set(items);
return items;
}
function pluginSystemToPluginProvider(plugin: PluginSystemPlugin): PluginProvider {
const contexts = Object.entries(
(plugin.manifest.auth?.contexts ?? {}) as Record<string, any>,
);
const primaryAuth = contexts[0]?.[1] ?? {};
const fields =
primaryAuth.fields ??
primaryAuth.secretFields ??
(primaryAuth.secretField ? [primaryAuth.secretField] : []);
const metadata = plugin.manifest.metadata ?? {};
return {
id: plugin.id,
type: plugin.type ?? plugin.manifest.type ?? "trails",
name: plugin.name,
displayName: plugin.displayName,
displayNames: localizedMetadata(metadata, "displayNames"),
description: plugin.description,
descriptions: localizedMetadata(metadata, "descriptions"),
icon: plugin.icon,
iconDark: plugin.iconDark,
version: plugin.version,
auth: {
type: primaryAuth.type ?? "none",
fields,
secretFields: primaryAuth.secretFields ?? fields,
authorizationUrl: primaryAuth.authorizationUrl,
tokenUrl: primaryAuth.tokenUrl,
tokenRequestFormat: primaryAuth.tokenRequestFormat,
scopes: primaryAuth.scopes,
scopeSeparator: primaryAuth.scopeSeparator,
authorizationParams: primaryAuth.authorizationParams,
pkce: primaryAuth.pkce,
tokenAuth: primaryAuth.tokenAuth,
},
configSchema: plugin.manifest.configSchema as PluginProvider["configSchema"],
hostConfig: plugin.manifest.hostConfig,
metadata,
capabilities: plugin.capabilities,
status: plugin.status,
error: plugin.error,
};
}
function localizedMetadata(
metadata: Record<string, unknown>,
key: string,
): LocalizedTextMap | undefined {
const value = metadata[key];
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const entries = Object.entries(value as Record<string, unknown>).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
);
return entries.length ? Object.fromEntries(entries) : undefined;
}

View File

@@ -1,31 +1,34 @@
import { browser } from "$app/environment";
import { get, writable, type Writable } from "svelte/store";
type Theme = "dark" | "light"
type Theme = "dark" | "light";
export const theme: Writable<Theme> = writable(getDefaultTheme());
function getDefaultTheme(): Theme {
if (browser) {
if (localStorage.getItem("theme")) {
return localStorage.getItem("theme") as Theme;
} else if (document.documentElement.classList.contains("light")) {
return "light"
} else if (document.documentElement.classList.contains("dark")) {
return "dark";
}
if (!browser) {
return "light";
}
return "light";
const stored = localStorage.getItem("theme");
if (stored === "dark" || stored === "light") {
return stored;
}
if (document.documentElement.classList.contains("dark")) {
return "dark";
}
if (document.documentElement.classList.contains("light")) {
return "light";
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function toggleTheme() {
const currentTheme = get(theme);
const newTheme = currentTheme === "light" ? "dark" : "light";
document.documentElement.classList.remove(currentTheme)
document.documentElement.classList.add(newTheme)
document.querySelector("meta[name='color-scheme']" )?.setAttribute("content", newTheme)
theme.set(newTheme)
document.documentElement.classList.remove(currentTheme);
document.documentElement.classList.add(newTheme);
document.querySelector("meta[name='color-scheme']")?.setAttribute("content", newTheme);
theme.set(newTheme);
localStorage.setItem("theme", newTheme);
}

View File

@@ -25,7 +25,7 @@ export enum Collection {
comments = "comments",
feed = "feed",
follows = "follows",
integrations = "integrations",
plugin_instances = "plugin_instances",
list_share = "list_share",
lists = "lists",
notifications = "notifications",

View File

@@ -0,0 +1,112 @@
import { get } from "svelte/store";
import { _ } from "svelte-i18n";
import { APIError } from "$lib/util/api_util";
type PluginErrorLike = {
code?: unknown;
message?: unknown;
};
const credentialErrorCodes = new Set(["auth_failed"]);
const reconnectErrorCodes = new Set(["invalid_grant", "unauthorized"]);
const unavailableErrorCodes = new Set(["provider_unavailable", "temporary_unavailable"]);
const internalErrorCodes = new Set(["internal_error", "plugin_error"]);
const authErrorHints = [
"auth_failed",
"login failed",
"email and password are required",
];
export function translatePluginError(code?: string, message?: string): string {
const normalizedCode = code?.trim();
if (normalizedCode && credentialErrorCodes.has(normalizedCode)) {
return get(_)("wrong-username-or-password");
}
if (normalizedCode && reconnectErrorCodes.has(normalizedCode)) {
return get(_)("plugin-error-reconnect-required");
}
if (normalizedCode === "rate_limited") {
return get(_)("plugin-error-rate-limited");
}
if (normalizedCode && unavailableErrorCodes.has(normalizedCode)) {
return get(_)("plugin-error-provider-unavailable");
}
if (normalizedCode === "invalid_request") {
return get(_)("plugin-error-invalid-request");
}
if (normalizedCode && internalErrorCodes.has(normalizedCode)) {
return get(_)("plugin-error-internal");
}
return message?.trim() || get(_)("plugin-setup-error");
}
export function translatePluginAPIError(error: unknown, fallback: string): string {
if (error instanceof APIError) {
const pluginError = extractPluginError(error.detail);
if (pluginError?.code) {
return translatePluginError(String(pluginError.code), stringValue(pluginError.message));
}
const raw = `${error.message} ${stringValue(error.detail)}`.toLowerCase();
if (authErrorHints.some((hint) => raw.includes(hint))) {
return get(_)("wrong-username-or-password");
}
}
if (error instanceof Error && error.message) {
return error.message;
}
return fallback;
}
function extractPluginError(value: unknown): PluginErrorLike | undefined {
if (!value) {
return undefined;
}
if (typeof value === "string") {
return extractPluginErrorFromString(value);
}
if (typeof value !== "object") {
return undefined;
}
const record = value as Record<string, unknown>;
if (typeof record.code === "string") {
return record;
}
for (const nested of Object.values(record)) {
const parsed = extractPluginError(nested);
if (parsed) {
return parsed;
}
}
return undefined;
}
function extractPluginErrorFromString(value: string): PluginErrorLike | undefined {
try {
const parsed = JSON.parse(value);
return extractPluginError(parsed);
} catch {
const match = value.match(/"code"\s*:\s*"([^"]+)"/);
return match ? { code: match[1] } : undefined;
}
}
function stringValue(value: unknown): string | undefined {
if (typeof value === "string") {
return value;
}
if (value == null) {
return undefined;
}
return JSON.stringify(value);
}

View File

@@ -0,0 +1,86 @@
import type { ConfigField, ConfigFieldOption, LocalizedTextMap, PluginProvider } from "$lib/models/plugin_provider";
export function localizedText(
texts: LocalizedTextMap | undefined,
currentLocale: string | null | undefined,
fallback = "",
): string {
if (!texts) {
return fallback;
}
const locale = normalizeLocale(currentLocale);
const language = locale.split("-")[0];
const candidates = [locale, language, "en"];
for (const candidate of candidates) {
const value = texts[candidate]?.trim();
if (value) {
return value;
}
}
const trimmedFallback = fallback.trim();
if (trimmedFallback) {
return trimmedFallback;
}
return "";
}
export function pluginTitle(plugin: PluginProvider, currentLocale: string | null | undefined): string {
return localizedText(plugin.displayNames, currentLocale, plugin.displayName || plugin.name);
}
export function pluginDescription(plugin: PluginProvider, currentLocale: string | null | undefined): string {
return localizedText(plugin.descriptions, currentLocale, plugin.description ?? "");
}
export function configFieldLabel(
field: ConfigField,
currentLocale: string | null | undefined,
fallback: string,
): string {
return localizedText(field.labels, currentLocale, field.label || fallback);
}
export function configFieldDescription(
field: ConfigField,
currentLocale: string | null | undefined,
): string | undefined {
return localizedText(field.descriptions, currentLocale, field.description ?? "") || undefined;
}
export function configFieldOptionLabel(
option: ConfigFieldOption,
currentLocale: string | null | undefined,
fallback: string,
): string {
return localizedText(option.labels, currentLocale, option.label || fallback);
}
export function providerCategoryLabel(
plugin: PluginProvider,
providerCategory: string,
currentLocale: string | null | undefined,
): string {
const providerCategories = plugin.metadata?.providerCategories;
if (!providerCategories || typeof providerCategories !== "object" || Array.isArray(providerCategories)) {
return providerCategory;
}
const category = (providerCategories as Record<string, unknown>)[providerCategory];
if (!category || typeof category !== "object" || Array.isArray(category)) {
return providerCategory;
}
const labels = (category as Record<string, unknown>).labels;
if (!labels || typeof labels !== "object" || Array.isArray(labels)) {
return providerCategory;
}
return localizedText(labels as LocalizedTextMap, currentLocale, providerCategory);
}
function normalizeLocale(value: string | null | undefined): string {
return (value || "en").trim().toLowerCase().replace("_", "-");
}

View File

@@ -14,6 +14,7 @@
import PageLoadingBar from "$lib/components/page_loading_bar.svelte";
import UploadDialog from "$lib/components/settings/upload_dialog.svelte";
import { currentUser } from "$lib/stores/user_store";
import { load_plugin_data_once } from "$lib/stores/plugin_store";
import { isRouteProtected } from "$lib/util/authorization_util";
import { onMount, type Snippet } from "svelte";
import { slide } from "svelte/transition";
@@ -42,6 +43,9 @@
if (page.data.origin != location.origin) {
showWarning = true;
}
if (data.user) {
load_plugin_data_once();
}
});
let hideDemoHint = $state(false);

View File

@@ -1,88 +0,0 @@
import { IntegrationCreateSchema } from "$lib/models/api/integration_schema";
import type { Integration } from "$lib/models/integration";
import { Collection, create, handleError, list } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
/**
* @swagger
* /api/v1/integration:
* get:
* summary: List integrations
* tags:
* - Integrations
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* - in: query
* name: perPage
* schema:
* type: integer
* - in: query
* name: sort
* schema:
* type: string
* - in: query
* name: filter
* schema:
* type: string
* - in: query
* name: expand
* schema:
* type: string
* responses:
* 200:
* description: List of integrations
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ListResult'
* 400:
* description: Bad Request
* 500:
* description: Internal Server Error
*/
export async function GET(event: RequestEvent) {
try {
const r = await list<Integration>(event, Collection.integrations);
return json(r)
} catch (e) {
return handleError(e)
}
}
/**
* @swagger
* /api/v1/integration:
* put:
* summary: Create integration
* tags:
* - Integrations
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/IntegrationInput'
* responses:
* 201:
* description: Integration created
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Integration'
* 400:
* description: Bad Request
* 500:
* description: Internal Server Error
*/
export async function PUT(event: RequestEvent) {
try {
const r = await create<Integration>(event, IntegrationCreateSchema, Collection.integrations)
return json(r);
} catch (e) {
return handleError(e)
}
}

View File

@@ -1,114 +0,0 @@
import { IntegrationUpdateSchema } from "$lib/models/api/integration_schema";
import type { Integration } from "$lib/models/integration";
import { Collection, handleError, remove, show, update } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
/**
* @swagger
* /api/v1/integration/{id}:
* get:
* summary: Get integration
* tags:
* - Integrations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* - in: query
* name: expand
* schema:
* type: string
* responses:
* 200:
* description: Integration details
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Integration'
* 404:
* description: Not Found
* 500:
* description: Internal Server Error
*/
export async function GET(event: RequestEvent) {
try {
const r = await show<Integration>(event, Collection.integrations)
return json(r)
} catch (e: any) {
return handleError(e)
}
}
/**
* @swagger
* /api/v1/integration/{id}:
* post:
* summary: Update integration
* tags:
* - Integrations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/IntegrationUpdateInput'
* responses:
* 200:
* description: Integration updated
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Integration'
* 400:
* description: Bad Request
* 404:
* description: Not Found
* 500:
* description: Internal Server Error
*/
export async function POST(event: RequestEvent) {
try {
const r = await update<Integration>(event, IntegrationUpdateSchema, Collection.integrations)
return json(r);
} catch (e: any) {
return handleError(e)
}
}
/**
* @swagger
* /api/v1/integration/{id}:
* delete:
* summary: Delete integration
* tags:
* - Integrations
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Integration deleted
* 404:
* description: Not Found
* 500:
* description: Internal Server Error
*/
export async function DELETE(event: RequestEvent) {
try {
const r = await remove(event, Collection.integrations)
return json(r);
} catch (e: any) {
return handleError(e)
}
}

View File

@@ -1,31 +0,0 @@
import { handleError } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
/**
* @swagger
* /api/v1/integration/hammerhead/login:
* get:
* summary: Get Hammerhead login endpoint
* description: Proxies to backend to get Hammerhead login configuration
* tags:
* - Integrations
* responses:
* 200:
* description: Hammerhead login endpoint
* content:
* application/json:
* schema:
* type: object
* 500:
* description: Internal Server Error
*/
export async function GET(event: RequestEvent) {
try {
const r = await event.locals.pb.send("/integration/hammerhead/login", {
method: "GET",
});
return json(r);
} catch (e: any) {
return handleError(e)
}
}

View File

@@ -1,31 +0,0 @@
import { handleError } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
/**
* @swagger
* /api/v1/integration/komoot/login:
* get:
* summary: Get Komoot login endpoint
* description: Proxies to backend to get Komoot login configuration
* tags:
* - Integrations
* responses:
* 200:
* description: Komoot login endpoint
* content:
* application/json:
* schema:
* type: object
* 500:
* description: Internal Server Error
*/
export async function GET(event: RequestEvent) {
try {
const r = await event.locals.pb.send("/integration/komoot/login", {
method: "GET",
});
return json(r);
} catch (e: any) {
return handleError(e)
}
}

View File

@@ -0,0 +1,111 @@
import {
PluginInstanceCreateSchema,
} from "$lib/models/api/plugin_instance_schema";
import type { PluginInstance } from "$lib/models/plugin_instance";
import { Collection, create, handleError, list } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
/**
* @swagger
* /api/v1/plugin-instance:
* get:
* summary: List plugin instances
* description: Retrieves the authenticated user's configured plugin instances.
* tags:
* - Plugins
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* - in: query
* name: perPage
* schema:
* type: integer
* - in: query
* name: sort
* schema:
* type: string
* - in: query
* name: filter
* schema:
* type: string
* responses:
* 200:
* description: ListResult<PluginInstance>
* 400:
* description: Bad Request
* 401:
* description: Unauthorized
* 500:
* description: Internal Server Error
*/
export async function GET(event: RequestEvent) {
try {
const r = await list<PluginInstance>(event, Collection.plugin_instances);
return json(r);
} catch (e) {
return handleError(e);
}
}
/**
* @swagger
* /api/v1/plugin-instance:
* put:
* summary: Create plugin instance
* description: Creates a plugin instance for the authenticated user.
* tags:
* - Plugins
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - user
* - plugin_id
* properties:
* user:
* type: string
* description: User record ID
* plugin_id:
* type: string
* enabled:
* type: boolean
* auth:
* type: object
* additionalProperties:
* type: string
* config:
* type: object
* additionalProperties: true
* state:
* type: object
* additionalProperties: true
* status:
* type: string
* enum: [configured, needs_auth, needs_reauth, syncing, rate_limited, unavailable, unsupported_protocol, error, disabled]
* responses:
* 200:
* description: Plugin instance created
* 400:
* description: Bad Request
* 401:
* description: Unauthorized
* 500:
* description: Internal Server Error
*/
export async function PUT(event: RequestEvent) {
try {
const r = await create<PluginInstance>(
event,
PluginInstanceCreateSchema,
Collection.plugin_instances,
);
return json(r);
} catch (e) {
return handleError(e);
}
}

View File

@@ -0,0 +1,138 @@
import {
PluginInstanceUpdateSchema,
} from "$lib/models/api/plugin_instance_schema";
import type { PluginInstance } from "$lib/models/plugin_instance";
import { Collection, handleError, remove, show, update } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
/**
* @swagger
* /api/v1/plugin-instance/{id}:
* get:
* summary: Get plugin instance
* description: Retrieves a plugin instance by ID.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: Plugin instance record ID
* responses:
* 200:
* description: Plugin instance details
* 400:
* description: Bad Request
* 401:
* description: Unauthorized
* 404:
* description: Not Found
* 500:
* description: Internal Server Error
*/
export async function GET(event: RequestEvent) {
try {
const r = await show<PluginInstance>(event, Collection.plugin_instances);
return json(r);
} catch (e: any) {
return handleError(e);
}
}
/**
* @swagger
* /api/v1/plugin-instance/{id}:
* post:
* summary: Update plugin instance
* description: Updates plugin instance settings, auth, state, or status.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: Plugin instance record ID
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* auth:
* type: object
* additionalProperties:
* type: string
* config:
* type: object
* additionalProperties: true
* state:
* type: object
* additionalProperties: true
* status:
* type: string
* enum: [configured, needs_auth, needs_reauth, syncing, rate_limited, unavailable, unsupported_protocol, error, disabled]
* responses:
* 200:
* description: Plugin instance updated
* 400:
* description: Bad Request
* 401:
* description: Unauthorized
* 404:
* description: Not Found
* 500:
* description: Internal Server Error
*/
export async function POST(event: RequestEvent) {
try {
const r = await update<PluginInstance>(
event,
PluginInstanceUpdateSchema,
Collection.plugin_instances,
);
return json(r);
} catch (e: any) {
return handleError(e);
}
}
/**
* @swagger
* /api/v1/plugin-instance/{id}:
* delete:
* summary: Delete plugin instance
* description: Deletes a configured plugin instance.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: Plugin instance record ID
* responses:
* 200:
* description: Success
* 401:
* description: Unauthorized
* 404:
* description: Not Found
* 500:
* description: Internal Server Error
*/
export async function DELETE(event: RequestEvent) {
try {
const r = await remove(event, Collection.plugin_instances);
return json(r);
} catch (e: any) {
return handleError(e);
}
}

View File

@@ -0,0 +1,63 @@
import { handleError } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
/**
* @swagger
* /api/v1/plugin-system/auth/validate:
* post:
* summary: Validate plugin session auth
* description: Validates session-style plugin credentials before saving them.
* tags:
* - Plugins
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - pluginId
* - auth
* properties:
* pluginId:
* type: string
* instanceId:
* type: string
* authContext:
* type: string
* auth:
* type: object
* additionalProperties:
* type: string
* responses:
* 200:
* description: Auth validation result
* content:
* application/json:
* schema:
* type: object
* properties:
* ok:
* type: boolean
* authContext:
* type: string
* 400:
* description: Bad Request
* 401:
* description: Unauthorized
* 500:
* description: Internal Server Error
*/
export async function POST(event: RequestEvent) {
try {
const body = await event.request.json();
const r = await event.locals.pb.send("/plugins/auth/validate", {
method: "POST",
body,
fetch: event.fetch,
});
return json(r);
} catch (e) {
return handleError(e);
}
}

View File

@@ -0,0 +1,48 @@
import { handleError } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
/**
* @swagger
* /api/v1/plugin-system/category-remap/apply:
* post:
* summary: Apply plugin category remap
* description: Updates imported trails to the category configured for their stored provider category in the current plugin instance mapping.
* tags:
* - Plugins
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - instanceId
* properties:
* instanceId:
* type: string
* description: Plugin instance record ID
* responses:
* 200:
* description: Remap result
* 400:
* description: Bad Request
* 401:
* description: Unauthorized
* 404:
* description: Not Found
* 500:
* description: Internal Server Error
*/
export async function POST(event: RequestEvent) {
try {
const body = await event.request.json();
const r = await event.locals.pb.send("/plugins/category-remap/apply", {
method: "POST",
body,
fetch: event.fetch,
});
return json(r);
} catch (e: any) {
return handleError(e);
}
}

View File

@@ -0,0 +1,48 @@
import { handleError } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
/**
* @swagger
* /api/v1/plugin-system/category-remap/preview:
* post:
* summary: Preview plugin category remap
* description: Counts imported trails whose stored provider category can be mapped by the current plugin instance category mapping.
* tags:
* - Plugins
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - instanceId
* properties:
* instanceId:
* type: string
* description: Plugin instance record ID
* responses:
* 200:
* description: Remap preview
* 400:
* description: Bad Request
* 401:
* description: Unauthorized
* 404:
* description: Not Found
* 500:
* description: Internal Server Error
*/
export async function POST(event: RequestEvent) {
try {
const body = await event.request.json();
const r = await event.locals.pb.send("/plugins/category-remap/preview", {
method: "POST",
body,
fetch: event.fetch,
});
return json(r);
} catch (e: any) {
return handleError(e);
}
}

View File

@@ -0,0 +1,58 @@
import { handleError } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
/**
* @swagger
* /api/v1/plugin-system/oauth/callback:
* post:
* summary: Complete plugin OAuth flow
* description: Exchanges an OAuth authorization code and stores the resulting plugin credentials.
* tags:
* - Plugins
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - instanceId
* - code
* - state
* properties:
* instanceId:
* type: string
* code:
* type: string
* state:
* type: string
* responses:
* 200:
* description: OAuth callback handled
* content:
* application/json:
* schema:
* type: object
* properties:
* ok:
* type: boolean
* 400:
* description: Bad Request
* 401:
* description: Unauthorized
* 500:
* description: Internal Server Error
*/
export async function POST(event: RequestEvent) {
try {
const body = await event.request.json();
const r = await event.locals.pb.send("/plugins/oauth/callback", {
method: "POST",
body,
fetch: event.fetch,
});
return json(r);
} catch (e) {
return handleError(e);
}
}

View File

@@ -3,52 +3,52 @@ import { json, type RequestEvent } from "@sveltejs/kit";
/**
* @swagger
* /api/v1/integration/hammerhead/upload:
* /api/v1/plugin-system/oauth/revoke:
* post:
* summary: Upload via Hammerhead integration
* description: Proxies file upload to backend Hammerhead integration
* summary: Revoke plugin OAuth credentials
* description: Revokes OAuth credentials stored for a plugin instance.
* tags:
* - Integrations
* - Plugins
* requestBody:
* required: true
* content:
* multipart/form-data:
* application/json:
* schema:
* type: object
* required:
* - file
* - instanceId
* properties:
* file:
* instanceId:
* type: string
* authContext:
* type: string
* format: binary
* responses:
* 200:
* description: Upload result
* description: OAuth credentials revoked
* content:
* application/json:
* schema:
* type: object
* properties:
* ok:
* type: boolean
* 400:
* description: Bad Request
* 401:
* description: Unauthorized
* 500:
* description: Internal Server Error
*/
export async function POST(event: RequestEvent) {
try {
const formData = await event.request.formData();
const file = formData.get("file");
if (!(file instanceof Blob)) {
return json({ message: "missing_file" }, { status: 400 });
}
const r = await event.locals.pb.send("/integration/hammerhead/upload", {
const body = await event.request.json();
const r = await event.locals.pb.send("/plugins/oauth/revoke", {
method: "POST",
body: formData,
body,
fetch: event.fetch,
});
return json(r);
} catch (e: any) {
return handleError(e)
} catch (e) {
return handleError(e);
}
}

View File

@@ -0,0 +1,66 @@
import { handleError } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
/**
* @swagger
* /api/v1/plugin-system/oauth/start:
* post:
* summary: Start plugin OAuth flow
* description: Creates an OAuth authorization URL for a plugin instance.
* tags:
* - Plugins
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - pluginId
* - instanceId
* - redirectUri
* properties:
* pluginId:
* type: string
* instanceId:
* type: string
* authContext:
* type: string
* redirectUri:
* type: string
* format: uri
* responses:
* 200:
* description: OAuth authorization URL
* content:
* application/json:
* schema:
* type: object
* properties:
* url:
* type: string
* format: uri
* state:
* type: string
* instanceId:
* type: string
* 400:
* description: Bad Request
* 401:
* description: Unauthorized
* 500:
* description: Internal Server Error
*/
export async function POST(event: RequestEvent) {
try {
const body = await event.request.json();
const r = await event.locals.pb.send("/plugins/oauth/start", {
method: "POST",
body,
fetch: event.fetch,
});
return json(r);
} catch (e) {
return handleError(e);
}
}

View File

@@ -0,0 +1,60 @@
import { handleError } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
/**
* @swagger
* /api/v1/plugin-system/plugins:
* get:
* summary: List installed plugins
* description: Refreshes the plugin cache and lists locally installed plugin providers with runtime status and manifest metadata.
* tags:
* - Plugins
* responses:
* 200:
* description: Installed plugin providers
* content:
* application/json:
* schema:
* type: object
* properties:
* items:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* type:
* type: string
* name:
* type: string
* version:
* type: string
* runtime:
* type: string
* capabilities:
* type: array
* items:
* type: string
* status:
* type: string
* enum: [available, disabled, error]
* error:
* type: string
* manifest:
* type: object
* 401:
* description: Unauthorized
* 500:
* description: Internal Server Error
*/
export async function GET(event: RequestEvent) {
try {
const r = await event.locals.pb.send("/plugins", {
method: "GET",
});
return json(r);
} catch (e: any) {
return handleError(e);
}
}

View File

@@ -0,0 +1,62 @@
import { handleError } from "$lib/util/api_util";
import { json, type RequestEvent } from "@sveltejs/kit";
/**
* @swagger
* /api/v1/plugin-system/trail-send:
* post:
* summary: Send trail through plugin
* description: Sends an existing trail to an enabled plugin provider that supports trail transfer.
* tags:
* - Plugins
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - pluginId
* - trailId
* properties:
* pluginId:
* type: string
* trailId:
* type: string
* share:
* type: string
* description: Optional share token used to authorize sending a shared trail.
* responses:
* 200:
* description: Trail sent
* content:
* application/json:
* schema:
* type: object
* properties:
* ok:
* type: boolean
* 400:
* description: Bad Request
* 401:
* description: Unauthorized
* 403:
* description: Forbidden
* 404:
* description: Not Found
* 500:
* description: Internal Server Error
*/
export async function POST(event: RequestEvent) {
try {
const body = await event.request.json();
const r = await event.locals.pb.send("/plugins/trail-send", {
method: "POST",
body,
fetch: event.fetch,
});
return json(r);
} catch (e: any) {
return handleError(e);
}
}

View File

@@ -21,7 +21,7 @@
},
{ text: $_("notifications"), value: "/settings/notifications" },
{ text: $_("map"), value: "/settings/map" },
{ text: $_("integrations"), value: "/settings/integrations" },
{ text: $_("plugins"), value: "/settings/plugins" },
{ text: $_("similar-trails"), value: "/settings/maintenance/similar-trails" },
{ text: `${$_("import")}/${$_("export")}`, value: "/settings/export" },
{

View File

@@ -1,278 +0,0 @@
<script lang="ts">
import { page } from "$app/state";
import HammerheadSettingsModal from "$lib/components/settings/integrations/hammerhead_settings_modal.svelte";
import IntegrationCard from "$lib/components/settings/integrations/integration_card.svelte";
import KomootSettingsModal from "$lib/components/settings/integrations/komoot_settings_modal.svelte";
import StravaSettingsModal from "$lib/components/settings/integrations/strava_settings_modal.svelte";
import {
Integration,
type HammerheadIntegration,
type KomootIntegration,
type StravaIntegration,
} from "$lib/models/integration.js";
import {
integrations_create,
integrations_update,
} from "$lib/stores/integration_store.js";
import { show_toast } from "$lib/stores/toast_store.svelte.js";
import { untrack } from "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 { theme } from "$lib/stores/theme_store";
let { data } = $props();
let integration = $state(untrack(() => data.integration));
const scope = "read_all,activity:read_all";
const redirectUri = page.url.href + "/callback/strava";
let stravaSettingsModal: StravaSettingsModal;
let stravaToggleValue: boolean = $derived(
integration?.strava?.active || false,
);
let komootSettingsModal: KomootSettingsModal;
let komootToggleValue: boolean = $state(
untrack(() => data.integration?.komoot?.active ?? false),
);
let hammerheadSettingsModal: HammerheadSettingsModal;
let hammerheadToggleValue: boolean = $state(
untrack(() => data.integration?.hammerhead?.active ?? false),
);
async function onSettingsSave(
form: StravaIntegration | KomootIntegration | HammerheadIntegration,
key: "strava" | "komoot" | "hammerhead",
) {
try {
if (integration) {
integration[key] = form as any;
integration = await integrations_update(integration);
} else {
const newIntegration: Integration = {
user: "",
[key]: form,
};
integration = await integrations_create(newIntegration);
}
if (key == "komoot" || key == "hammerhead") {
let verified = await verifyLogin(key);
if (!verified) {
return;
}
}
show_toast({
text: $_("settings-saved"),
icon: "check",
type: "success",
});
} catch (e) {
show_toast({
text: $_("error-setting-up-integration", {
values: { provider: key },
}),
icon: "close",
type: "error",
});
}
}
async function onStravaToggle(value: boolean) {
if (!integration?.strava) {
return;
}
if (value) {
const authUrl = `https://www.strava.com/oauth/authorize?client_id=${integration.strava.clientId}&response_type=code&redirect_uri=${redirectUri}&scope=${scope}&approval_prompt=auto`;
window.location.href = authUrl;
} else {
const deauthUrl = `https://www.strava.com/oauth/deauthorize`;
await fetch(deauthUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${integration.strava.accessToken}`,
},
});
integration.strava = {
clientId: integration.strava.clientId,
clientSecret: integration.strava.clientSecret,
routes: integration.strava.routes,
activities: integration.strava.activities,
privacy: integration.strava.privacy,
merge: integration.strava.merge,
accessToken: undefined,
refreshToken: undefined,
expiresAt: undefined,
active: false,
};
try {
integration = await integrations_update(integration);
} catch (e) {
show_toast({
text: $_("error-disabling-strava-integration"),
icon: "close",
type: "error",
});
}
show_toast({
text: "Strava " + $_("integration-disabled"),
icon: "check",
type: "success",
});
}
}
async function onKomootToggle(value: boolean) {
if (!integration?.komoot) {
return;
}
if (value) {
let verified = await verifyLogin("komoot");
if (!verified) {
return;
}
integration.komoot.active = true;
} else {
integration.komoot.active = false;
}
try {
integration = await integrations_update(integration);
} catch (e) {
show_toast({
text: $_("error-updating-komoot-integration"),
icon: "close",
type: "error",
});
return;
}
show_toast({
text:
"komoot " + $_(`integration-${value ? "enabled" : "disabled"}`),
icon: "check",
type: "success",
});
}
async function verifyLogin(integrationName: string): Promise<boolean> {
try {
const r = await fetch(
`/api/v1/integration/${integrationName}/login`,
{
method: "GET",
},
);
if (!r.ok) {
throw Error();
}
} catch (e) {
hammerheadToggleValue = false;
show_toast({
text: $_(`error-logging-in-to-${integrationName}`),
icon: "close",
type: "error",
});
return false;
}
return true;
}
async function onHammerheadToggle(value: boolean) {
if (!integration?.hammerhead) {
return;
}
if (value) {
let verified = await verifyLogin("hammerhead");
if (!verified) {
return;
}
integration.hammerhead.active = true;
} else {
integration.hammerhead.active = false;
}
try {
integration = await integrations_update(integration);
} catch (e) {
show_toast({
text: $_("error-updating-hammerhead-integration"),
icon: "close",
type: "error",
});
return;
}
show_toast({
text:
"Hammerhead " +
$_(`integration-${value ? "enabled" : "disabled"}`),
icon: "check",
type: "success",
});
}
</script>
<svelte:head>
<title>{$_("settings")} | wanderer</title>
</svelte:head>
<h3 class="text-2xl font-semibold">{$_("integrations")}</h3>
<hr class="mt-4 mb-6 border-input-border" />
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<IntegrationCard
img="https://upload.wikimedia.org/wikipedia/commons/c/cb/Strava_Logo.svg"
title="Strava"
description={$_("integration-description-strava")}
disabled={!integration?.strava}
active={stravaToggleValue}
onclick={() => stravaSettingsModal.openModal()}
ontoggle={onStravaToggle}
></IntegrationCard>
<IntegrationCard
img="https://upload.wikimedia.org/wikipedia/commons/8/82/Komoot-logo-type.svg"
title="komoot"
description={$_("integration-description-komoot")}
disabled={!integration?.komoot}
bind:active={komootToggleValue}
onclick={() => komootSettingsModal.openModal()}
ontoggle={onKomootToggle}
></IntegrationCard>
<IntegrationCard
img={$theme == "light" ? hammerheadLogoDark : hammerheadLogoWhite}
title="Hammerhead"
description={$_("integration-description-hammerhead")}
disabled={!integration?.hammerhead}
bind:active={hammerheadToggleValue}
onclick={() => hammerheadSettingsModal.openModal()}
ontoggle={onHammerheadToggle}
></IntegrationCard>
</div>
<StravaSettingsModal
bind:this={stravaSettingsModal}
{integration}
onsave={(form) => onSettingsSave(form, "strava")}
></StravaSettingsModal>
<KomootSettingsModal
bind:this={komootSettingsModal}
{integration}
onsave={(form) => onSettingsSave(form, "komoot")}
></KomootSettingsModal>
<HammerheadSettingsModal
bind:this={hammerheadSettingsModal}
{integration}
onsave={(form) => onSettingsSave(form, "hammerhead")}
></HammerheadSettingsModal>

View File

@@ -1,7 +0,0 @@
import { integrations_index } from "$lib/stores/integration_store";
import { type Load } from "@sveltejs/kit";
export const load: Load = async ({ params, fetch }) => {
const integrations = await integrations_index(fetch)
return { integration: integrations.at(0) }
};

View File

@@ -1,41 +0,0 @@
import { error, redirect, type ServerLoad } from "@sveltejs/kit";
import { ClientResponseError } from "pocketbase";
export const load: ServerLoad = async ({ url, fetch, locals }) => {
const oauthError = url.searchParams.get('error');
if (oauthError) {
// user cancelled
if (oauthError == "access_denied") {
return redirect(302, '/settings/integrations')
}
return error(400, {
message: oauthError
});
}
const code = url.searchParams.get('code');
if (!code) {
return error(400, {
message: "No code provided"
});
}
try {
await locals.pb.send("/integration/strava/token", {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code,
grant_type: 'authorization_code'
})
});
} catch (e) {
console.error(e)
if (e instanceof ClientResponseError) {
return error(e.status, e.message);
}
throw e
}
return redirect(302, '/settings/integrations')
}

View File

@@ -0,0 +1,561 @@
<script lang="ts">
import ConfirmModal from "$lib/components/confirm_modal.svelte";
import PluginCard from "$lib/components/settings/plugins/plugin_card.svelte";
import PluginInstanceSettingsModal from "$lib/components/settings/plugins/plugin_instance_settings_modal.svelte";
import type { Category } from "$lib/models/category.js";
import type { PluginInstance } from "$lib/models/plugin_instance.js";
import type { PluginProvider } from "$lib/models/plugin_provider.js";
import {
plugin_category_remap_apply,
plugin_category_remap_preview,
plugin_instances_create,
plugin_instances_update,
} from "$lib/stores/plugin_instance_store.js";
import { show_toast } from "$lib/stores/toast_store.svelte.js";
import {
pluginDescription as localizedPluginDescription,
pluginTitle as localizedPluginTitle,
} from "$lib/util/plugin_i18n";
import { translatePluginError } from "$lib/util/plugin_error_i18n";
import { onMount, tick, untrack } from "svelte";
import { _, locale } from "svelte-i18n";
import { theme } from "$lib/stores/theme_store";
let { data } = $props();
let instances: PluginInstance[] = $state(
untrack(() => data.pluginInstances ?? []),
);
let plugins: PluginProvider[] = $state(
untrack(() => data.pluginProviders ?? []),
);
let categories: Category[] = $state(untrack(() => data.categories ?? []));
let pluginSettingsModal: PluginInstanceSettingsModal | undefined = $state();
let categoryRemapConfirmModal: ConfirmModal | undefined = $state();
let selectedPlugin: PluginProvider | undefined = $state();
let pendingCategoryRemap:
| {
instanceId: string;
pluginTitle: string;
count: number;
backfilledSinceMapping: number;
backfilledOnly: boolean;
beforeSave?: boolean;
onresolve?: (confirmed: boolean) => void;
}
| undefined = $state();
let currentTheme: "dark" | "light" = $state("light");
const applyRemapAfterSave = new Set<string>();
const suppressRemapPromptAfterSave = new Set<string>();
let pluginGroups = $derived.by(() => {
const groups: { type: PluginProvider["type"]; plugins: PluginProvider[] }[] = [];
for (const plugin of plugins) {
let group = groups.find((candidate) => candidate.type === plugin.type);
if (!group) {
group = { type: plugin.type, plugins: [] };
groups.push(group);
}
group.plugins.push(plugin);
}
return groups;
});
onMount(() => {
currentTheme = document.documentElement.classList.contains("dark") ? "dark" : "light";
queueMicrotask(() => {
void maybePromptBackfilledCategoryRemap();
});
return theme.subscribe((value) => {
currentTheme = value;
});
});
async function savePluginInstance(instance: Partial<PluginInstance>) {
let saved: PluginInstance;
try {
if (instance.id) {
saved = await plugin_instances_update(
instance as PluginInstance,
);
} else {
saved = await plugin_instances_create(instance);
}
instances = [
...instances.filter((existing) => existing.id != saved.id),
saved,
];
show_toast({
text: $_("settings-saved"),
icon: "check",
type: "success",
});
} catch (e) {
show_toast({
text: $_("error-setting-up-plugin", {
values: { provider: instance.plugin_id },
}),
icon: "close",
type: "error",
});
throw e;
}
if (saved.id && applyRemapAfterSave.has(saved.id)) {
applyRemapAfterSave.delete(saved.id);
try {
await applyCategoryRemap(saved.id);
} catch (e) {
show_toast({
text: $_("plugin-category-remap-error"),
icon: "close",
type: "error",
});
}
} else if (saved.id && suppressRemapPromptAfterSave.has(saved.id)) {
suppressRemapPromptAfterSave.delete(saved.id);
}
return saved;
}
async function onPluginToggle(
plugin: PluginProvider,
instance: PluginInstance | undefined,
value: boolean,
) {
if (!instance) {
return;
}
if (value && pluginRequiresConnection(plugin, instance)) {
show_toast({
text: $_("plugin-connect-before-enabling"),
icon: "close",
type: "error",
});
return;
}
try {
const saved = await plugin_instances_update({
...instance,
enabled: value,
status: plugin.auth.type === "oauth2" || value ? "configured" : "disabled",
});
instances = [
...instances.filter((existing) => existing.id != saved.id),
saved,
];
if (value) {
await maybePromptCategoryRemap(saved, plugin);
}
} catch (e) {
show_toast({
text: $_("error-setting-up-plugin", { values: { provider: pluginTitle(plugin) } }),
icon: "close",
type: "error",
});
return;
}
show_toast({
text: pluginTitle(plugin) + " " + $_(`plugin-${value ? "enabled" : "disabled"}`),
icon: "check",
type: "success",
});
}
async function maybePromptCategoryRemap(
instance: PluginInstance,
plugin: PluginProvider | undefined,
options: { backfilledOnly?: boolean } = {},
) {
if (!instance.id || !plugin) {
return;
}
try {
const preview = await plugin_category_remap_preview(instance.id);
const backfilledSinceMapping = preview.backfilledSinceMapping ?? 0;
if (preview.count <= 0 || (options.backfilledOnly && backfilledSinceMapping <= 0)) {
return;
}
pendingCategoryRemap = {
instanceId: instance.id,
pluginTitle: pluginTitle(plugin),
count: preview.count,
backfilledSinceMapping,
backfilledOnly: options.backfilledOnly ?? false,
};
await tick();
categoryRemapConfirmModal?.openModal();
} catch (e) {
show_toast({
text: $_("plugin-category-remap-preview-error"),
icon: "close",
type: "error",
});
}
}
async function applyCategoryRemap(instanceId: string) {
const result = await plugin_category_remap_apply(instanceId);
show_toast({
text: $_("plugin-category-remap-success", {
values: { count: result.remapped ?? 0 },
}),
icon: "check",
type: "success",
});
}
async function confirmCategoryRemap() {
if (!pendingCategoryRemap) {
return;
}
if (pendingCategoryRemap.beforeSave) {
if (pendingCategoryRemap.count > 0) {
applyRemapAfterSave.add(pendingCategoryRemap.instanceId);
} else {
suppressRemapPromptAfterSave.add(pendingCategoryRemap.instanceId);
}
pendingCategoryRemap.onresolve?.(true);
pendingCategoryRemap = undefined;
return;
}
try {
await applyCategoryRemap(pendingCategoryRemap.instanceId);
} catch (e) {
show_toast({
text: $_("plugin-category-remap-error"),
icon: "close",
type: "error",
});
} finally {
pendingCategoryRemap = undefined;
}
}
function continueWithoutCategoryRemap() {
if (!pendingCategoryRemap?.beforeSave) {
return;
}
suppressRemapPromptAfterSave.add(pendingCategoryRemap.instanceId);
pendingCategoryRemap.onresolve?.(true);
pendingCategoryRemap = undefined;
}
async function dismissCategoryRemap() {
if (!pendingCategoryRemap) {
return;
}
if (pendingCategoryRemap.beforeSave) {
pendingCategoryRemap.onresolve?.(false);
pendingCategoryRemap = undefined;
return;
}
const instance = instances.find(
(candidate) => candidate.id === pendingCategoryRemap?.instanceId,
);
if (!instance || !pendingCategoryRemap.backfilledOnly) {
pendingCategoryRemap = undefined;
return;
}
try {
const saved = await plugin_instances_update({
...instance,
config: {
...(instance.config ?? {}),
host: {
...((instance.config?.host as Record<string, unknown> | undefined) ?? {}),
categoryRemapDismissedAt: new Date().toISOString(),
},
},
});
instances = [
...instances.filter((existing) => existing.id != saved.id),
saved,
];
} catch (e) {
show_toast({
text: $_("plugin-category-remap-dismiss-error"),
icon: "close",
type: "error",
});
} finally {
pendingCategoryRemap = undefined;
}
}
function instanceError(instance: PluginInstance | undefined) {
if (!instance?.last_error) {
return "";
}
const code = instance.last_error.code?.trim();
const message = instance.last_error.message?.trim();
if (!code && !message) {
return "";
}
return translatePluginError(code, message);
}
function instanceForPlugin(plugin: PluginProvider) {
return instances.find((instance) => instance.plugin_id == plugin.id);
}
function pluginForInstance(instance: PluginInstance) {
return plugins.find((plugin) => plugin.id === instance.plugin_id) ?? selectedPlugin;
}
async function maybePromptBackfilledCategoryRemap() {
for (const instance of instances) {
if (pendingCategoryRemap) {
return;
}
const hostConfig = (instance.config?.host as Record<string, unknown> | undefined) ?? {};
const mappingUpdatedAt = typeof hostConfig.categoryMappingUpdatedAt === "string"
? Date.parse(hostConfig.categoryMappingUpdatedAt)
: 0;
const dismissedAt = typeof hostConfig.categoryRemapDismissedAt === "string"
? Date.parse(hostConfig.categoryRemapDismissedAt)
: 0;
if (dismissedAt > mappingUpdatedAt) {
continue;
}
const plugin = pluginForInstance(instance);
if (!plugin || plugin.status !== "available") {
continue;
}
await maybePromptCategoryRemap(instance, plugin, { backfilledOnly: true });
}
}
async function confirmCategoryMappingSave(candidate: Partial<PluginInstance>) {
if (!candidate.id) {
return true;
}
const plugin = plugins.find((item) => item.id === candidate.plugin_id) ?? selectedPlugin;
if (!plugin) {
return true;
}
try {
const preview = await plugin_category_remap_preview(candidate.id, candidate.config);
let resolvePrompt: (confirmed: boolean) => void = () => {};
const result = new Promise<boolean>((resolve) => {
resolvePrompt = resolve;
});
pendingCategoryRemap = {
instanceId: candidate.id,
pluginTitle: pluginTitle(plugin),
count: preview.count,
backfilledSinceMapping: 0,
backfilledOnly: false,
beforeSave: true,
onresolve: resolvePrompt,
};
await tick();
categoryRemapConfirmModal?.openModal();
return await result;
} catch (e) {
show_toast({
text: $_("plugin-category-remap-preview-error"),
icon: "close",
type: "error",
});
return false;
}
}
function categoryRemapConfirmText() {
if (!pendingCategoryRemap) {
return "";
}
if (pendingCategoryRemap.backfilledOnly) {
return $_("plugin-category-remap-backfilled-confirm", {
values: {
count: pendingCategoryRemap.count,
},
});
}
if (pendingCategoryRemap.beforeSave && pendingCategoryRemap.count <= 0) {
return $_("plugin-category-remap-confirm-unspecified");
}
const confirmText = $_("plugin-category-remap-confirm", {
values: {
count: pendingCategoryRemap.count,
},
});
if (pendingCategoryRemap.backfilledSinceMapping <= 0) {
return confirmText;
}
return `${confirmText} ${$_("plugin-category-remap-sync-hint")}`;
}
function categoryRemapAction() {
if (pendingCategoryRemap?.beforeSave && pendingCategoryRemap.count <= 0) {
return "save";
}
return "plugin-category-remap-action";
}
function categoryRemapAlternative() {
if (!pendingCategoryRemap?.beforeSave || pendingCategoryRemap.count <= 0) {
return undefined;
}
return "plugin-category-remap-continue-without-remap";
}
async function openPluginSettings(plugin: PluginProvider) {
selectedPlugin = plugin;
await tick();
pluginSettingsModal?.openModal();
}
function pluginLogo(plugin: PluginProvider) {
if (currentTheme === "dark" && plugin.iconDark) {
return plugin.iconDark;
}
return plugin.icon || undefined;
}
function pluginTitle(plugin: PluginProvider) {
return localizedPluginTitle(plugin, $locale);
}
function pluginDescription(plugin: PluginProvider) {
return localizedPluginDescription(plugin, $locale);
}
function pluginTypeTitle(type: PluginProvider["type"]) {
return $_(`plugin-type-${type}`);
}
function pluginTypeDescription(type: PluginProvider["type"]) {
return $_(`plugin-type-${type}-description`);
}
function pluginCardError(
plugin: PluginProvider,
instance: PluginInstance | undefined,
) {
if (plugin.status != "available") {
return plugin.error ?? "";
}
return instanceError(instance);
}
function pluginRequiresConnection(
plugin: PluginProvider,
instance: PluginInstance | undefined,
) {
return plugin.auth.type === "oauth2" && instance?.status !== "configured";
}
function pluginSettingsModalKey(plugin: PluginProvider) {
const instance = instanceForPlugin(plugin);
return `${plugin.id}:${instance?.id ?? "new"}:${JSON.stringify(instance?.config ?? {})}`;
}
</script>
<svelte:head>
<title>{$_("settings")} | wanderer</title>
</svelte:head>
<h3 class="text-2xl font-semibold">{$_("plugins")}</h3>
<hr class="mt-4 mb-6 border-input-border" />
{#if pluginGroups.length === 0}
<div
class="rounded-xl border border-dashed border-input-border bg-input-background px-6 py-10 text-center"
>
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full border border-input-border text-xl text-gray-500"
aria-hidden="true"
>
<i class="fa fa-plug"></i>
</div>
<h4 class="text-lg font-semibold">{$_("plugins-empty-title")}</h4>
<p class="mx-auto mt-2 max-w-xl text-sm text-gray-500">
{$_("plugins-empty-description")}
</p>
<a
class="btn-secondary mt-6 inline-flex items-center justify-center gap-2"
href="https://wanderer.to/run/installation/plugins"
target="_blank"
rel="noreferrer"
>
<i class="fa fa-book" aria-hidden="true"></i>
{$_("plugins-empty-docs-link")}
</a>
</div>
{:else}
<div class="space-y-8">
{#each pluginGroups as group (group.type)}
<section>
<div class="mb-4 space-y-2">
<h4 class="text-xl font-medium">{pluginTypeTitle(group.type)}</h4>
<p class="text-sm text-gray-500 max-w-3xl">
{pluginTypeDescription(group.type)}
</p>
</div>
<div class="space-y-3">
{#each group.plugins as plugin (plugin.id)}
{@const instance = instanceForPlugin(plugin)}
{@const settingsDisabled = plugin.status != "available"}
<PluginCard
img={pluginLogo(plugin)}
title={pluginTitle(plugin)}
description={pluginDescription(plugin)}
{settingsDisabled}
toggleDisabled={!instance || settingsDisabled || pluginRequiresConnection(plugin, instance)}
active={instance?.enabled ?? false}
lastSyncAt={instance?.last_sync_at}
error={pluginCardError(plugin, instance)}
onclick={() => openPluginSettings(plugin)}
ontoggle={(value) => onPluginToggle(plugin, instance, value)}
></PluginCard>
{/each}
</div>
</section>
{/each}
</div>
{/if}
{#if pendingCategoryRemap}
<ConfirmModal
bind:this={categoryRemapConfirmModal}
id="plugin-category-remap-confirm"
title={$_("plugin-category-remap-title")}
text={categoryRemapConfirmText()}
action={categoryRemapAction()}
deny={pendingCategoryRemap.backfilledOnly
? "plugin-category-remap-ignore"
: pendingCategoryRemap.beforeSave
? "plugin-category-remap-back-to-settings"
: "cancel"}
alternative={categoryRemapAlternative()}
onconfirm={confirmCategoryRemap}
oncancel={dismissCategoryRemap}
onalternative={continueWithoutCategoryRemap}
></ConfirmModal>
{/if}
{#if selectedPlugin}
{#key pluginSettingsModalKey(selectedPlugin)}
<PluginInstanceSettingsModal
bind:this={pluginSettingsModal}
plugin={selectedPlugin}
instance={instanceForPlugin(selectedPlugin)}
categories={categories}
onbeforecategorymappingsave={confirmCategoryMappingSave}
onsave={savePluginInstance}
></PluginInstanceSettingsModal>
{/key}
{/if}

View File

@@ -0,0 +1,13 @@
import { plugin_instances_index } from "$lib/stores/plugin_instance_store";
import { plugins_index } from "$lib/stores/plugin_store";
import { categories_index } from "$lib/stores/category_store";
import { type Load } from "@sveltejs/kit";
export const load: Load = async ({ fetch }) => {
const [pluginInstances, pluginProviders, categories] = await Promise.all([
plugin_instances_index(fetch),
plugins_index(fetch),
categories_index(fetch),
]);
return { pluginInstances, pluginProviders, categories };
};

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import { plugin_oauth_callback } from "$lib/stores/plugin_instance_store";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { onMount } from "svelte";
import { _ } from "svelte-i18n";
let error = $state("");
onMount(async () => {
const code = page.url.searchParams.get("code") ?? "";
const state = page.url.searchParams.get("state") ?? "";
const providerError = page.url.searchParams.get("error");
const instanceId = sessionStorage.getItem(`wanderer_plugin_oauth_${state}`) ?? "";
sessionStorage.removeItem(`wanderer_plugin_oauth_${state}`);
if (providerError) {
error = providerError;
return;
}
if (!code || !state || !instanceId) {
error = "invalid_oauth_callback";
return;
}
try {
await plugin_oauth_callback({ instanceId, code, state });
await goto("/settings/plugins");
} catch (e) {
error = e instanceof Error ? e.message : "oauth_error";
}
});
</script>
<svelte:head>
<title>{$_("plugins")} | wanderer</title>
</svelte:head>
<div class="mx-auto max-w-md py-12">
{#if error}
<h3 class="text-xl font-semibold">{$_("error")}</h3>
<p class="mt-3 text-sm text-gray-500">{error}</p>
<a class="btn-secondary mt-6 inline-block" href="/settings/plugins">{$_("plugins")}</a>
{:else}
<h3 class="text-xl font-semibold">{$_("plugins")}</h3>
{/if}
</div>