feat: add plugin system (#1034)

* feat: add plugin system

* fix db docker build

* fix hammerhead readme, add strava subscription news to docs

* fixes and sdk improvements

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

* optimize meili trail index

* several fixes

---------

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

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

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

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

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

* Release v0.19.2 (#1035)

* chore: release v0.19.2

* add changelog

---------

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

* speed up plugin sync and several small fixes

* concepts for security improvements and process stability

* improve concept

* security concept implemented

* remove insecure TLS

* worker concept implemented

* fixes and cleanup

* fixes

* docu

* mermaid, namings

* WASM plugin host improvements, plugin logging

* fix db migration

* Improve plugin config and category mapping UI

* fixes

* further fixes

* remove manual test sync

* fix db migration and strava mapping

* type added, UI improvements

* fix plugin card toggle clickable area

* optimize synch status card layout

* plugin type 'trails' instead of 'integration'

* session auth validation in UI

* fix komoot date and waypoints

* improve category mapping

* fix send to hammerhead: trail name

* plugin setup error handling improved

* fix review findings

* re-mapping added

* rename remote_category

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
This commit is contained in:
slothful-vassal
2026-06-22 15:00:44 +02:00
committed by GitHub
parent 2e3d8537b8
commit 485ec53f6d
196 changed files with 20947 additions and 5454 deletions

View File

@@ -6,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);
}