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

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

@@ -0,0 +1,22 @@
<script lang="ts">
import Toggle from "$lib/components/base/toggle.svelte";
import { _ } from "svelte-i18n";
interface Props {
prefix: string;
value?: boolean;
}
let { prefix, value = $bindable(false) }: Props = $props();
</script>
<div class="pt-4 border-t border-input-border space-y-2">
<Toggle
name={`${prefix}.enabled`}
bind:value
label={$_("plugin-auto-merge-label")}
/>
<p class="text-xs text-gray-500 max-w-lg">
{$_("plugin-auto-merge-hint")}
</p>
</div>