From 8cb836e16c688027767581c638d7d335aff60bbc Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 24 Mar 2026 12:40:33 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20provider=20descriptor=20registry=20?= =?UTF-8?q?=E2=80=94=20eliminate=20provider-specific=20hardcoding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all if/else chains keyed on provider type strings with a descriptor-driven architecture. Each provider type (immich, gitea, planka, scheduler, nut, google_photos) has a descriptor in frontend/src/lib/providers/ that declares config fields, event tracking fields, collection metadata, validation, and hooks. Components now use getDescriptor(type) and render dynamically. Dashboard provider card shows provider name + type when global filter is active. Grid-items derived from registry. --- CLAUDE.md | 11 +- frontend/src/lib/grid-items.ts | 49 ++-- frontend/src/lib/i18n/en.json | 2 + frontend/src/lib/i18n/ru.json | 2 + frontend/src/lib/providers/gitea.ts | 60 +++++ frontend/src/lib/providers/google-photos.ts | 58 +++++ frontend/src/lib/providers/immich.ts | 133 +++++++++++ frontend/src/lib/providers/index.ts | 84 +++++++ frontend/src/lib/providers/nut.ts | 65 ++++++ frontend/src/lib/providers/planka.ts | 66 ++++++ frontend/src/lib/providers/scheduler.ts | 24 ++ frontend/src/lib/providers/types.ts | 154 ++++++++++++ frontend/src/routes/+page.svelte | 19 +- .../routes/notification-trackers/+page.svelte | 41 ++-- .../notification-trackers/TrackerForm.svelte | 20 +- frontend/src/routes/providers/+page.svelte | 176 ++++++-------- .../src/routes/providers/new/+page.svelte | 70 ++++-- .../src/routes/tracking-configs/+page.svelte | 220 ++++++------------ scripts/restart-backend.sh | 3 +- 19 files changed, 904 insertions(+), 353 deletions(-) create mode 100644 frontend/src/lib/providers/gitea.ts create mode 100644 frontend/src/lib/providers/google-photos.ts create mode 100644 frontend/src/lib/providers/immich.ts create mode 100644 frontend/src/lib/providers/index.ts create mode 100644 frontend/src/lib/providers/nut.ts create mode 100644 frontend/src/lib/providers/planka.ts create mode 100644 frontend/src/lib/providers/scheduler.ts create mode 100644 frontend/src/lib/providers/types.ts diff --git a/CLAUDE.md b/CLAUDE.md index a64d7ce..e826775 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,9 +26,12 @@ Detailed context is split into focused documents under `.claude/docs/`. Read the - Provider capabilities in `packages/core/src/notify_bridge_core/providers/capabilities.py` - Seed functions in `packages/server/src/notify_bridge_server/database/seeds.py` (notification templates, command templates, tracking configs, command configs) - Template variable definitions in `packages/server/src/notify_bridge_server/api/template_configs.py` (`get_template_variables()`) -8. **No provider-specific hardcoding** — UI labels, icons, form defaults, and feature checks MUST be provider-agnostic. NEVER hardcode a specific provider type (e.g. `'immich'`) where multiple providers could appear: - - Form defaults: use `provider_type: ''` (empty), not `'immich'` - - Collection labels: use the `collectionMeta` lookup in `TrackerForm.svelte`, not hardcoded "Albums" +8. **No provider-specific hardcoding** — ALL provider-specific UI logic lives in **provider descriptors** (`frontend/src/lib/providers/`). NEVER add `if (type === 'xyz')` in components. + - Form fields, validation, config building → defined in the descriptor's `configFields` / `buildConfig` / `hasConfigChanged` + - Event tracking checkboxes → `eventFields`; extra controls → `extraTrackingFields`; feature sections (periodic/scheduled/memory) → `featureSections` + - Collection labels/icons → `collectionMeta`; webhook URL display → `webhookUrlPattern` + - Pre-save hooks (e.g. shared-link validation) → `onBeforeSave` + - Components use `getDescriptor(type)` and render dynamically from the descriptor - Feature gating: check `capabilities.notification_slots` or `capabilities.commands`, not `provider.type === 'immich'` - - Provider-specific API calls (e.g. `/albums/.../shared-links`): guard with a provider type check - Template variable helpers: ALL provider types must have entries in `get_template_variables()` +9. **New provider descriptor checklist** — when adding a new service provider, create a descriptor file in `frontend/src/lib/providers/{name}.ts` and register it in `index.ts`. The descriptor must define: `type`, `defaultName`, `icon`, `hasUrl`, `configFields`, `buildConfig()`, `hasConfigChanged()`, `eventFields`, `collectionMeta` (or `null`). Optional: `extraTrackingFields`, `featureSections`, `webhookUrlPattern`, `webhookBased`, `onBeforeSave`. Also add i18n keys: `providers.type{PascalName}` and `gridDesc.provider{PascalName}` in both `en.json` and `ru.json`. diff --git a/frontend/src/lib/grid-items.ts b/frontend/src/lib/grid-items.ts index 659349d..1a385e3 100644 --- a/frontend/src/lib/grid-items.ts +++ b/frontend/src/lib/grid-items.ts @@ -1,24 +1,22 @@ /** * Shared IconGridSelect item definitions used across multiple pages. * Keeps grid item arrays DRY and consistent. + * + * Provider-specific items (type selector, filter, icons) are derived + * from the provider descriptor registry — see lib/providers/. */ import { t } from '$lib/i18n'; import type { GridItem } from '$lib/components/IconGridSelect.svelte'; - -/** Default icon for each provider type. Use instead of hardcoded 'mdiServer'. */ -const PROVIDER_TYPE_ICONS: Record = { - immich: 'mdiImageMultiple', - gitea: 'mdiGit', - planka: 'mdiViewDashboard', - scheduler: 'mdiClockOutline', - nut: 'mdiBatteryCharging80', -}; +import { allDescriptors, getDescriptor } from '$lib/providers'; /** Get the default icon for a provider, falling back by type then generic. */ export function providerDefaultIcon(provider: { icon?: string; type?: string }): string { if (provider.icon) return provider.icon; - if (provider.type && PROVIDER_TYPE_ICONS[provider.type]) return PROVIDER_TYPE_ICONS[provider.type]; + if (provider.type) { + const desc = getDescriptor(provider.type); + if (desc) return desc.icon; + } return 'mdiServer'; } @@ -111,23 +109,24 @@ export const previewTargetTypeItems = (): GridItem[] => [ { value: 'webhook', icon: 'mdiWebhook', label: t('targets.typeWebhook'), desc: t('gridDesc.previewWebhook') }, ]; -// --- Provider type filter (with "All" option) --- +// --- Provider type items (derived from descriptor registry) --- +/** Convert snake_case type to PascalCase i18n suffix: "google_photos" → "GooglePhotos" */ +function typeToKey(type: string): string { + return type.replace(/(^|_)([a-z])/g, (_, __, c) => c.toUpperCase()); +} + +function descriptorToGridItem(d: { type: string; icon: string }): GridItem { + const key = typeToKey(d.type); + return { value: d.type, icon: d.icon, label: t(`providers.type${key}`), desc: t(`gridDesc.provider${key}`) }; +} + +/** Provider type filter with "All types" option. */ export const providerTypeFilterItems = (): GridItem[] => [ { value: '', icon: 'mdiFilterOff', label: t('common.allTypes'), desc: t('gridDesc.allEvents') }, - { value: 'immich', icon: 'mdiCamera', label: t('providers.typeImmich'), desc: t('gridDesc.providerImmich') }, - { value: 'gitea', icon: 'mdiGit', label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') }, - { value: 'planka', icon: 'mdiViewDashboard', label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') }, - { value: 'scheduler', icon: 'mdiClockOutline', label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') }, - { value: 'nut', icon: PROVIDER_TYPE_ICONS.nut, label: t('providers.typeNut'), desc: t('gridDesc.providerNut') }, + ...allDescriptors().map(descriptorToGridItem), ]; -// --- Provider type --- - -export const providerTypeItems = (): GridItem[] => [ - { value: 'immich', icon: PROVIDER_TYPE_ICONS.immich, label: t('providers.typeImmich'), desc: t('gridDesc.providerImmich') }, - { value: 'gitea', icon: PROVIDER_TYPE_ICONS.gitea, label: t('providers.typeGitea'), desc: t('gridDesc.providerGitea') }, - { value: 'planka', icon: PROVIDER_TYPE_ICONS.planka, label: t('providers.typePlanka'), desc: t('gridDesc.providerPlanka') }, - { value: 'scheduler', icon: PROVIDER_TYPE_ICONS.scheduler, label: t('providers.typeScheduler'), desc: t('gridDesc.providerScheduler') }, - { value: 'nut', icon: PROVIDER_TYPE_ICONS.nut, label: t('providers.typeNut'), desc: t('gridDesc.providerNut') }, -]; +/** Provider type selector (no "All" option). */ +export const providerTypeItems = (): GridItem[] => + allDescriptors().map(descriptorToGridItem); diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index b1c5f9b..c6cb683 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -186,6 +186,7 @@ "repos_count": "repo(s)", "boards_count": "board(s)", "devices_count": "device(s)", + "collections_count": "collection(s)", "every": "every", "trackImages": "Track images", "trackVideos": "Track videos", @@ -403,6 +404,7 @@ "name": "Name", "namePlaceholder": "Default tracking", "noConfigs": "No tracking configs yet.", + "unknownProviderType": "Unknown provider type", "eventTracking": "Event Tracking", "assetsAdded": "Assets added", "assetsRemoved": "Assets removed", diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index 7dfe6ee..0a6c9b0 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -186,6 +186,7 @@ "repos_count": "репозиторий(ев)", "boards_count": "доска(ок)", "devices_count": "устройство(в)", + "collections_count": "коллекция(й)", "every": "каждые", "trackImages": "Отслеживать фото", "trackVideos": "Отслеживать видео", @@ -403,6 +404,7 @@ "name": "Название", "namePlaceholder": "Основное отслеживание", "noConfigs": "Конфигураций отслеживания пока нет.", + "unknownProviderType": "Неизвестный тип провайдера", "eventTracking": "Отслеживание событий", "assetsAdded": "Добавлены файлы", "assetsRemoved": "Удалены файлы", diff --git a/frontend/src/lib/providers/gitea.ts b/frontend/src/lib/providers/gitea.ts new file mode 100644 index 0000000..7ec1d52 --- /dev/null +++ b/frontend/src/lib/providers/gitea.ts @@ -0,0 +1,60 @@ +import type { ProviderDescriptor } from './types'; + +export const giteaDescriptor: ProviderDescriptor = { + type: 'gitea', + defaultName: 'Gitea', + icon: 'mdiGit', + hasUrl: true, + urlPlaceholder: 'https://gitea.example.com', + webhookBased: true, + + configFields: [ + { + key: 'webhook_secret', configKey: 'webhook_secret', + label: 'providers.webhookSecret', editLabel: 'providers.webhookSecretKeep', + type: 'password', required: 'create-only', hint: 'providers.webhookSecretHint', + }, + { + key: 'api_token', configKey: 'api_token', + label: 'providers.apiToken', + type: 'password', optional: true, hint: 'providers.apiTokenHint', + }, + ], + + buildConfig(form, editing) { + const config: Record = { url: form.url }; + if (form.api_token) config.api_token = form.api_token; + if (form.webhook_secret) config.webhook_secret = form.webhook_secret; + if (!editing && !form.webhook_secret) { + return { config, error: 'providers.webhookSecretRequired' }; + } + return { config }; + }, + + hasConfigChanged(form, existing) { + return form.url !== (existing.url || '') || + !!form.api_token || !!form.webhook_secret; + }, + + eventFields: [ + { key: 'track_push', label: 'trackingConfig.push', default: true }, + { key: 'track_issue_opened', label: 'trackingConfig.issueOpened', default: true }, + { key: 'track_issue_closed', label: 'trackingConfig.issueClosed', default: true }, + { key: 'track_issue_commented', label: 'trackingConfig.issueCommented', default: false }, + { key: 'track_pr_opened', label: 'trackingConfig.prOpened', default: true }, + { key: 'track_pr_closed', label: 'trackingConfig.prClosed', default: true }, + { key: 'track_pr_merged', label: 'trackingConfig.prMerged', default: true }, + { key: 'track_pr_commented', label: 'trackingConfig.prCommented', default: false }, + { key: 'track_release_published', label: 'trackingConfig.releasePublished', default: true }, + ], + + collectionMeta: { + label: 'notificationTracker.repositories', + icon: 'mdiGit', + placeholder: 'notificationTracker.selectRepositories', + countLabel: 'notificationTracker.repos_count', + desc: () => '', + }, + + webhookUrlPattern: '/api/webhooks/gitea/{id}', +}; diff --git a/frontend/src/lib/providers/google-photos.ts b/frontend/src/lib/providers/google-photos.ts new file mode 100644 index 0000000..f438953 --- /dev/null +++ b/frontend/src/lib/providers/google-photos.ts @@ -0,0 +1,58 @@ +import type { ProviderDescriptor } from './types'; + +export const googlePhotosDescriptor: ProviderDescriptor = { + type: 'google_photos', + defaultName: 'Google Photos', + icon: 'mdiGoogle', + hasUrl: false, + + configFields: [ + { + key: 'gp_client_id', configKey: 'client_id', + label: 'providers.gpClientId', type: 'text', + required: 'create-only', placeholder: '123456789.apps.googleusercontent.com', + }, + { + key: 'gp_client_secret', configKey: 'client_secret', + label: 'providers.gpClientSecret', editLabel: 'providers.gpClientSecretKeep', + type: 'password', required: 'create-only', + }, + { + key: 'gp_refresh_token', configKey: 'refresh_token', + label: 'providers.gpRefreshToken', editLabel: 'providers.gpRefreshTokenKeep', + type: 'password', required: 'create-only', hint: 'providers.gpRefreshTokenHint', + }, + ], + + buildConfig(form, editing) { + const config: Record = {}; + if (form.gp_client_id) config.client_id = form.gp_client_id; + if (form.gp_client_secret) config.client_secret = form.gp_client_secret; + if (form.gp_refresh_token) config.refresh_token = form.gp_refresh_token; + if (!editing && (!form.gp_client_id || !form.gp_client_secret || !form.gp_refresh_token)) { + return { config, error: 'providers.gpAllFieldsRequired' }; + } + return { config }; + }, + + hasConfigChanged(form, existing) { + return form.gp_client_id !== (existing.client_id || '') || + !!form.gp_client_secret || !!form.gp_refresh_token; + }, + + eventFields: [ + { key: 'track_assets_added', label: 'trackingConfig.assetsAdded', default: true }, + { key: 'track_assets_removed', label: 'trackingConfig.assetsRemoved', default: false }, + { key: 'track_collection_renamed', label: 'trackingConfig.albumRenamed', default: true }, + { key: 'track_collection_deleted', label: 'trackingConfig.albumDeleted', default: true }, + { key: 'track_sharing_changed', label: 'trackingConfig.sharingChanged', default: false }, + ], + + collectionMeta: { + label: 'notificationTracker.albums', + icon: 'mdiGoogle', + placeholder: 'notificationTracker.selectAlbums', + countLabel: 'notificationTracker.albums_count', + desc: (col) => `${col.assetCount ?? col.asset_count ?? col.mediaItemsCount ?? 0} items`, + }, +}; diff --git a/frontend/src/lib/providers/immich.ts b/frontend/src/lib/providers/immich.ts new file mode 100644 index 0000000..418ad31 --- /dev/null +++ b/frontend/src/lib/providers/immich.ts @@ -0,0 +1,133 @@ +import type { ProviderDescriptor } from './types'; + +export const immichDescriptor: ProviderDescriptor = { + type: 'immich', + defaultName: 'Immich', + icon: 'mdiImageMultiple', + hasUrl: true, + urlPlaceholder: undefined, // uses generic i18n placeholder + + configFields: [ + { + key: 'api_key', configKey: 'api_key', + label: 'providers.apiKey', editLabel: 'providers.apiKeyKeep', + type: 'password', required: 'create-only', + }, + { + key: 'external_domain', configKey: 'external_domain', + label: 'providers.externalDomain', + type: 'text', optional: true, placeholder: 'https://photos.example.com', + }, + ], + + buildConfig(form, editing) { + const config: Record = { url: form.url }; + if (form.api_key) config.api_key = form.api_key; + if (form.external_domain) config.external_domain = form.external_domain; + if (!editing) config.api_key = form.api_key; + return { config }; + }, + + hasConfigChanged(form, existing) { + return form.url !== (existing.url || '') || + !!form.api_key || + form.external_domain !== (existing.external_domain || ''); + }, + + eventFields: [ + { key: 'track_assets_added', label: 'trackingConfig.assetsAdded', default: true }, + { key: 'track_assets_removed', label: 'trackingConfig.assetsRemoved', default: false }, + { key: 'track_collection_renamed', label: 'trackingConfig.albumRenamed', default: true }, + { key: 'track_collection_deleted', label: 'trackingConfig.albumDeleted', default: true }, + { key: 'track_sharing_changed', label: 'trackingConfig.sharingChanged', default: false }, + { key: 'track_images', label: 'trackingConfig.trackImages', default: true }, + { key: 'track_videos', label: 'trackingConfig.trackVideos', default: true }, + { key: 'notify_favorites_only', label: 'trackingConfig.favoritesOnly', default: false, hint: 'hints.favoritesOnly' }, + { key: 'include_tags', label: 'trackingConfig.includePeople', default: true }, + { key: 'include_asset_details', label: 'trackingConfig.includeDetails', default: false }, + ], + + extraTrackingFields: [ + { key: 'max_assets_to_show', label: 'trackingConfig.maxAssets', type: 'number', min: 0, max: 50, defaultValue: 5, hint: 'hints.maxAssets' }, + { key: 'assets_order_by', label: 'trackingConfig.sortBy', type: 'grid-select', gridItems: 'sortByItems', gridColumns: 2, defaultValue: 'none' }, + { key: 'assets_order', label: 'trackingConfig.sortOrder', type: 'grid-select', gridItems: 'sortOrderItems', gridColumns: 2, defaultValue: 'descending' }, + ], + + featureSections: [ + { + key: 'periodic', legend: 'trackingConfig.periodicSummary', legendHint: 'hints.periodicSummary', + enabledField: 'periodic_enabled', enabledDefault: false, + fields: [ + { key: 'periodic_interval_days', label: 'trackingConfig.intervalDays', type: 'number', min: 1, defaultValue: 1 }, + { key: 'periodic_start_date', label: 'trackingConfig.startDate', type: 'number', defaultValue: '2025-01-01' }, // rendered as date input + { key: 'periodic_times', label: 'trackingConfig.times', type: 'number', defaultValue: '12:00' }, // rendered as text input + ], + }, + { + key: 'scheduled', legend: 'trackingConfig.scheduledAssets', legendHint: 'hints.scheduledAssets', + enabledField: 'scheduled_enabled', enabledDefault: false, + fields: [ + { key: 'scheduled_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' }, + { key: 'scheduled_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'per_collection' }, + { key: 'scheduled_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10, hint: 'hints.maxAssets' }, + { key: 'scheduled_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' }, + { key: 'scheduled_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0, hint: 'hints.minRating' }, + ], + checkboxes: [ + { key: 'scheduled_favorite_only', label: 'trackingConfig.favoritesOnly', default: false, hint: 'hints.favoritesOnly' }, + ], + }, + { + key: 'memory', legend: 'trackingConfig.memoryMode', legendHint: 'hints.memoryMode', + enabledField: 'memory_enabled', enabledDefault: false, + fields: [ + { key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums' }, + { key: 'memory_times', label: 'trackingConfig.times', type: 'number', defaultValue: '09:00' }, + { key: 'memory_collection_mode', label: 'trackingConfig.albumMode', type: 'grid-select', gridItems: 'albumModeItems', gridColumns: 3, defaultValue: 'combined' }, + { key: 'memory_limit', label: 'trackingConfig.maxAssets', type: 'number', min: 1, max: 100, defaultValue: 10 }, + { key: 'memory_asset_type', label: 'trackingConfig.assetType', type: 'grid-select', gridItems: 'assetTypeItems', gridColumns: 3, defaultValue: 'all' }, + { key: 'memory_min_rating', label: 'trackingConfig.minRating', type: 'number', min: 0, max: 5, defaultValue: 0 }, + ], + checkboxes: [ + { key: 'memory_favorite_only', label: 'trackingConfig.favoritesOnly', default: false, hint: 'hints.favoritesOnly' }, + ], + }, + ], + + collectionMeta: { + label: 'notificationTracker.albums', + icon: 'mdiImageMultiple', + placeholder: 'notificationTracker.selectAlbums', + countLabel: 'notificationTracker.albums_count', + desc: (col) => `${col.assetCount ?? col.asset_count ?? 0} assets`, + }, + + async onBeforeSave({ form, previousCollectionIds, collections, api: apiFn }) { + const newIds = (form.collection_ids as string[]).filter(id => !previousCollectionIds.includes(id)); + if (newIds.length === 0) return { proceed: true }; + + interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean } + const warnings: { id: string; name: string; issue: string }[] = []; + + for (const albumId of newIds) { + try { + const links = await apiFn(`/providers/${form.provider_id}/albums/${albumId}/shared-links`); + const validLink = links.find((l) => l.is_accessible && !l.is_expired); + if (!validLink) { + const album = collections.find(c => c.id === albumId); + const problematic = links.find((l) => l.is_expired || l.has_password); + warnings.push({ + id: albumId, + name: album?.albumName || album?.name || albumId, + issue: problematic + ? (problematic.is_expired ? 'expired' : 'password-protected') + : 'missing', + }); + } + } catch { /* shared-link check failed, proceed */ } + } + + if (warnings.length > 0) return { warnings, proceed: false }; + return { proceed: true }; + }, +}; diff --git a/frontend/src/lib/providers/index.ts b/frontend/src/lib/providers/index.ts new file mode 100644 index 0000000..5f691cc --- /dev/null +++ b/frontend/src/lib/providers/index.ts @@ -0,0 +1,84 @@ +/** + * Provider descriptor registry. + * + * Single entry point for all provider-specific UI logic. + * Components call `getDescriptor(type)` instead of if/else chains. + */ + +import type { ProviderDescriptor } from './types'; +import { immichDescriptor } from './immich'; +import { giteaDescriptor } from './gitea'; +import { plankaDescriptor } from './planka'; +import { schedulerDescriptor } from './scheduler'; +import { nutDescriptor } from './nut'; +import { googlePhotosDescriptor } from './google-photos'; + +const REGISTRY: ReadonlyMap = new Map([ + ['immich', immichDescriptor], + ['gitea', giteaDescriptor], + ['planka', plankaDescriptor], + ['scheduler', schedulerDescriptor], + ['nut', nutDescriptor], + ['google_photos', googlePhotosDescriptor], +]); + +/** Look up a provider descriptor by type. Returns null for unknown types. */ +export function getDescriptor(type: string): ProviderDescriptor | null { + return REGISTRY.get(type) ?? null; +} + +/** All registered descriptors (stable order). */ +export function allDescriptors(): ProviderDescriptor[] { + return [...REGISTRY.values()]; +} + +/** All registered provider type strings. */ +export function allProviderTypes(): string[] { + return [...REGISTRY.keys()]; +} + +/** + * Build a default form state for the tracking config "Event tracking" fieldset. + * Merges event field defaults from ALL providers so the form can load any + * existing config without missing keys, but only the active provider's fields + * are rendered. + */ +export function buildTrackingFormDefaults(): Record { + const defaults: Record = {}; + for (const desc of REGISTRY.values()) { + for (const field of desc.eventFields) { + defaults[field.key] = field.default; + } + for (const extra of desc.extraTrackingFields ?? []) { + defaults[extra.key] = extra.defaultValue ?? ''; + } + for (const section of desc.featureSections ?? []) { + defaults[section.enabledField] = section.enabledDefault; + for (const f of section.fields) { + defaults[f.key] = f.defaultValue ?? ''; + } + for (const cb of section.checkboxes ?? []) { + defaults[cb.key] = cb.default; + } + } + } + return defaults; +} + +/** + * Build a default flat form state for the provider config form. + * All config fields from all providers are included with empty/default values. + */ +export function buildProviderFormDefaults(): Record { + const defaults: Record = { + name: '', type: '', url: '', icon: '', + }; + for (const desc of REGISTRY.values()) { + for (const field of desc.configFields) { + defaults[field.key] = field.defaultValue ?? (field.type === 'number' ? 0 : ''); + } + } + return defaults; +} + +export type { ProviderDescriptor, ConfigField, EventTrackingField, ExtraTrackingField, FeatureSection, CollectionMeta } from './types'; diff --git a/frontend/src/lib/providers/nut.ts b/frontend/src/lib/providers/nut.ts new file mode 100644 index 0000000..bcc1eb8 --- /dev/null +++ b/frontend/src/lib/providers/nut.ts @@ -0,0 +1,65 @@ +import type { ProviderDescriptor } from './types'; + +export const nutDescriptor: ProviderDescriptor = { + type: 'nut', + defaultName: 'NUT', + icon: 'mdiBatteryCharging80', + hasUrl: false, + + configFields: [ + { + key: 'nut_host', configKey: 'host', + label: 'providers.nutHost', type: 'text', + required: true, placeholder: '192.168.1.100 or ups.local', + }, + { + key: 'nut_port', configKey: 'port', + label: 'providers.nutPort', type: 'number', + min: 1, max: 65535, defaultValue: 3493, + }, + { + key: 'nut_username', configKey: 'username', + label: 'providers.nutUsername', type: 'text', + optional: true, hint: 'providers.nutUsernameHint', + }, + { + key: 'nut_password', configKey: 'password', + label: 'providers.nutPassword', type: 'password', + optional: true, hint: 'providers.nutPasswordHint', + }, + ], + + buildConfig(form) { + const config: Record = { + host: form.nut_host, + port: form.nut_port || 3493, + }; + if (form.nut_username) config.username = form.nut_username; + if (form.nut_password) config.password = form.nut_password; + return { config }; + }, + + hasConfigChanged() { + // NUT fields are always sent (no secret masking) + return true; + }, + + eventFields: [ + { key: 'track_ups_online', label: 'trackingConfig.upsOnline', default: true }, + { key: 'track_ups_on_battery', label: 'trackingConfig.upsOnBattery', default: true }, + { key: 'track_ups_low_battery', label: 'trackingConfig.upsLowBattery', default: true }, + { key: 'track_ups_battery_restored', label: 'trackingConfig.upsBatteryRestored', default: true }, + { key: 'track_ups_comms_lost', label: 'trackingConfig.upsCommsLost', default: true }, + { key: 'track_ups_comms_restored', label: 'trackingConfig.upsCommsRestored', default: true }, + { key: 'track_ups_replace_battery', label: 'trackingConfig.upsReplaceBattery', default: true }, + { key: 'track_ups_overload', label: 'trackingConfig.upsOverload', default: true }, + ], + + collectionMeta: { + label: 'notificationTracker.upsDevices', + icon: 'mdiBatteryCharging80', + placeholder: 'notificationTracker.selectUpsDevices', + countLabel: 'notificationTracker.devices_count', + desc: (col) => col.description || '', + }, +}; diff --git a/frontend/src/lib/providers/planka.ts b/frontend/src/lib/providers/planka.ts new file mode 100644 index 0000000..a0521d8 --- /dev/null +++ b/frontend/src/lib/providers/planka.ts @@ -0,0 +1,66 @@ +import type { ProviderDescriptor } from './types'; + +export const plankaDescriptor: ProviderDescriptor = { + type: 'planka', + defaultName: 'Planka', + icon: 'mdiViewDashboard', + hasUrl: true, + urlPlaceholder: 'https://planka.example.com', + webhookBased: true, + + configFields: [ + { + key: 'webhook_secret', configKey: 'webhook_secret', + label: 'providers.webhookSecret', editLabel: 'providers.webhookSecretKeep', + type: 'password', required: 'create-only', hint: 'providers.plankaWebhookSecretHint', + }, + { + key: 'api_key', configKey: 'api_key', + label: 'providers.apiKey', + type: 'password', optional: true, hint: 'providers.plankaApiKeyHint', + }, + ], + + buildConfig(form, editing) { + const config: Record = { url: form.url }; + if (form.api_key) config.api_key = form.api_key; + if (form.webhook_secret) config.webhook_secret = form.webhook_secret; + if (!editing && !form.webhook_secret) { + return { config, error: 'providers.webhookSecretRequired' }; + } + return { config }; + }, + + hasConfigChanged(form, existing) { + return form.url !== (existing.url || '') || + !!form.api_key || !!form.webhook_secret; + }, + + eventFields: [ + { key: 'track_card_created', label: 'trackingConfig.cardCreated', default: true }, + { key: 'track_card_updated', label: 'trackingConfig.cardUpdated', default: false }, + { key: 'track_card_moved', label: 'trackingConfig.cardMoved', default: true }, + { key: 'track_card_deleted', label: 'trackingConfig.cardDeleted', default: false }, + { key: 'track_card_commented', label: 'trackingConfig.cardCommented', default: true }, + { key: 'track_comment_updated', label: 'trackingConfig.commentUpdated', default: false }, + { key: 'track_board_created', label: 'trackingConfig.boardCreated', default: true }, + { key: 'track_board_updated', label: 'trackingConfig.boardUpdated', default: false }, + { key: 'track_board_deleted', label: 'trackingConfig.boardDeleted', default: true }, + { key: 'track_list_created', label: 'trackingConfig.listCreated', default: false }, + { key: 'track_list_updated', label: 'trackingConfig.listUpdated', default: false }, + { key: 'track_list_deleted', label: 'trackingConfig.listDeleted', default: false }, + { key: 'track_attachment_created', label: 'trackingConfig.attachmentCreated', default: true }, + { key: 'track_card_label_added', label: 'trackingConfig.cardLabelAdded', default: false }, + { key: 'track_task_completed', label: 'trackingConfig.taskCompleted', default: true }, + ], + + collectionMeta: { + label: 'notificationTracker.boards', + icon: 'mdiViewDashboard', + placeholder: 'notificationTracker.selectBoards', + countLabel: 'notificationTracker.boards_count', + desc: () => '', + }, + + webhookUrlPattern: '/api/webhooks/planka/{id}', +}; diff --git a/frontend/src/lib/providers/scheduler.ts b/frontend/src/lib/providers/scheduler.ts new file mode 100644 index 0000000..e6bd5a7 --- /dev/null +++ b/frontend/src/lib/providers/scheduler.ts @@ -0,0 +1,24 @@ +import type { ProviderDescriptor } from './types'; + +export const schedulerDescriptor: ProviderDescriptor = { + type: 'scheduler', + defaultName: 'Scheduler', + icon: 'mdiClockOutline', + hasUrl: false, + + configFields: [], + + buildConfig() { + return { config: {} }; + }, + + hasConfigChanged() { + return false; + }, + + eventFields: [ + { key: 'track_scheduled_message', label: 'trackingConfig.scheduledMessage', default: true }, + ], + + collectionMeta: null, +}; diff --git a/frontend/src/lib/providers/types.ts b/frontend/src/lib/providers/types.ts new file mode 100644 index 0000000..01408b1 --- /dev/null +++ b/frontend/src/lib/providers/types.ts @@ -0,0 +1,154 @@ +/** + * Provider descriptor interface. + * + * Each service provider type has exactly one descriptor that declares + * everything the UI needs: config form fields, event tracking fields, + * collection metadata, validation, and provider-specific hooks. + * + * Components consume descriptors via the registry (see ./index.ts) + * instead of if/else chains keyed on provider type strings. + */ + +import type { api } from '$lib/api'; + +// ── Config form ────────────────────────────────────────────────────── + +export interface ConfigField { + /** Form field key (flat namespace, e.g. "api_key", "nut_host"). */ + key: string; + /** Key in the config payload sent to the API (defaults to `key`). */ + configKey?: string; + /** i18n key for the field label. */ + label: string; + type: 'text' | 'password' | 'number'; + placeholder?: string; + /** + * - `true` → always required + * - `'create-only'` → required only when creating, blank = "keep existing" on edit + * - `false` / omitted → optional + */ + required?: boolean | 'create-only'; + /** Show "(optional)" suffix next to label. */ + optional?: boolean; + /** i18n key for hint text below the field. */ + hint?: string; + /** Edit-mode label override (e.g. "API key (leave blank to keep)"). */ + editLabel?: string; + min?: number; + max?: number; + /** Default value for this field. */ + defaultValue?: string | number; +} + +// ── Event tracking (TrackingConfig form) ───────────────────────────── + +export interface EventTrackingField { + /** Form field key, e.g. "track_assets_added". */ + key: string; + /** i18n key for checkbox label. */ + label: string; + /** Default value when creating a new tracking config. */ + default: boolean; + /** Optional i18n key for a Hint tooltip. */ + hint?: string; +} + +/** Extra non-boolean fields shown after event checkboxes (e.g. max_assets). */ +export interface ExtraTrackingField { + key: string; + label: string; + type: 'number' | 'grid-select'; + /** Grid-select item source function name from grid-items.ts. */ + gridItems?: string; + gridColumns?: number; + hint?: string; + min?: number; + max?: number; + defaultValue?: string | number; +} + +/** A feature section like periodic summary, scheduled assets, memory mode. */ +export interface FeatureSection { + /** Unique key, e.g. "periodic", "scheduled", "memory". */ + key: string; + /** i18n key for fieldset legend. */ + legend: string; + /** Hint tooltip for legend. */ + legendHint?: string; + /** Form field that toggles this section on/off. */ + enabledField: string; + /** Default value for the enabled field. */ + enabledDefault: boolean; + /** Fields shown when section is enabled. */ + fields: ExtraTrackingField[]; + /** Boolean fields (checkboxes) inside the section. */ + checkboxes?: EventTrackingField[]; +} + +// ── Collection metadata (TrackerForm) ──────────────────────────────── + +export interface CollectionMeta { + /** i18n key for collection noun ("Albums", "Repositories", etc.). */ + label: string; + /** MDI icon name. */ + icon: string; + /** i18n key for multi-select placeholder. */ + placeholder: string; + /** i18n key for count badge in tracker list (e.g. "3 albums"). */ + countLabel: string; + /** Per-collection description line. */ + desc: (col: any) => string; +} + +// ── Main descriptor ────────────────────────────────────────────────── + +export interface ProviderDescriptor { + /** Provider type string (must match backend). */ + type: string; + /** Default display name for new providers. */ + defaultName: string; + /** MDI icon name. */ + icon: string; + + // ── Provider config form ── + /** Whether this provider shows a URL field. */ + hasUrl: boolean; + /** Placeholder for the URL field. */ + urlPlaceholder?: string; + /** Provider-specific config fields (API key, webhook secret, etc.). */ + configFields: ConfigField[]; + /** Build the config payload from flat form state. Return error i18n key to abort. */ + buildConfig(form: Record, editing: boolean): { config: Record; error?: string }; + /** Detect whether any config field changed during edit (secrets are blank on load). */ + hasConfigChanged(form: Record, existing: Record): boolean; + + // ── Event tracking ── + /** Checkbox fields for the "Event tracking" fieldset. */ + eventFields: EventTrackingField[]; + /** Extra non-boolean controls shown after the event checkboxes. */ + extraTrackingFields?: ExtraTrackingField[]; + /** Feature sections (periodic, scheduled, memory) — only for providers that support them. */ + featureSections?: FeatureSection[]; + + // ── Collections / Trackers ── + /** Null means this provider has no collections (e.g. scheduler). */ + collectionMeta: CollectionMeta | null; + /** Whether this provider is webhook-based (hides scan_interval). */ + webhookBased?: boolean; + + // ── Webhook URL display ── + /** Pattern shown in edit mode, e.g. "/api/webhooks/gitea/{id}". */ + webhookUrlPattern?: string; + + // ── Provider-specific hooks ── + /** + * Called after collection selection changes (before save). + * E.g. Immich shared-link validation. + */ + onBeforeSave?(ctx: { + form: Record; + previousCollectionIds: string[]; + collections: any[]; + api: typeof api; + }): Promise<{ warnings?: { id: string; name: string; issue: string }[]; proceed: boolean }>; +} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index ac6b05d..2bb4493 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -12,6 +12,7 @@ import EntitySelect from '$lib/components/EntitySelect.svelte'; import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; + import { getDescriptor } from '$lib/providers'; import type { DashboardStatus } from '$lib/types'; let status = $state(null); @@ -181,8 +182,18 @@ ? providers.filter(p => p.type === globalProviderFilter.providerType).length : displayProviders); - const statCards = $derived(status ? [ - { icon: 'mdiServer', label: 'dashboard.providers', value: filteredProviderCount, color: '#0d9488' }, + const providerCard = $derived.by(() => { + const gp = globalProviderFilter.provider; + if (gp) { + const desc = getDescriptor(gp.type); + return { icon: providerDefaultIcon(gp), label: '', literalLabel: desc?.defaultName ?? gp.type, value: 0, literalValue: gp.name, color: '#0d9488' }; + } + return { icon: 'mdiServer', label: 'dashboard.providers', value: filteredProviderCount, color: '#0d9488' }; + }); + + interface StatCard { icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; color: string } + const statCards = $derived(status ? [ + providerCard, { icon: 'mdiRadar', label: 'dashboard.activeTrackers', value: displayActive, suffix: ` / ${displayTotal}`, color: '#6366f1' }, { icon: 'mdiTarget', label: 'dashboard.targets', value: displayTargets, color: '#f59e0b' }, ...(status.command_trackers !== undefined ? [{ icon: 'mdiConsoleLine', label: 'nav.commandTrackers', value: displayCommandTrackers, color: '#8b5cf6' }] : []), @@ -238,9 +249,9 @@
-

{t(card.label)}

+

{card.literalLabel || t(card.label)}

- {card.value}{#if card.suffix}{card.suffix}{/if} + {#if card.literalValue}{card.literalValue}{:else}{card.value}{/if}{#if card.suffix}{card.suffix}{/if}

diff --git a/frontend/src/routes/notification-trackers/+page.svelte b/frontend/src/routes/notification-trackers/+page.svelte index e612333..e5c07e3 100644 --- a/frontend/src/routes/notification-trackers/+page.svelte +++ b/frontend/src/routes/notification-trackers/+page.svelte @@ -16,6 +16,7 @@ import { highlightFromUrl } from '$lib/highlight'; import { providerDefaultIcon } from '$lib/grid-items'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; + import { getDescriptor } from '$lib/providers'; import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types'; import TrackerForm from './TrackerForm.svelte'; @@ -153,33 +154,22 @@ e.preventDefault(); error = ''; if (submitting) return; - const newAlbumIds = form.collection_ids.filter(id => !previousCollectionIds.includes(id)); - if (newAlbumIds.length > 0 && form.provider_id && selectedProviderType === 'immich') { + // Delegate provider-specific pre-save checks to the descriptor + const desc = getDescriptor(selectedProviderType); + if (desc?.onBeforeSave && form.provider_id) { linkCheckLoading = true; try { - const missingAlbums: { id: string; name: string; issue: string }[] = []; - for (const albumId of newAlbumIds) { - interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean } - const links = await api(`/providers/${form.provider_id}/albums/${albumId}/shared-links`); - const validLink = links.find((l) => l.is_accessible && !l.is_expired); - if (!validLink) { - const album = collections.find(c => c.id === albumId); - const problematicLink = links.find((l) => l.is_expired || l.has_password); - missingAlbums.push({ - id: albumId, - name: album?.albumName || album?.name || albumId, - issue: problematicLink - ? (problematicLink.is_expired ? 'expired' : 'password-protected') - : 'missing', - }); + const result = await desc.onBeforeSave({ + form, previousCollectionIds, collections, api, + }); + if (!result.proceed) { + if (result.warnings?.length) { + linkWarning = { albums: result.warnings, providerId: form.provider_id }; } - } - if (missingAlbums.length > 0) { - linkWarning = { albums: missingAlbums, providerId: form.provider_id }; linkCheckLoading = false; return; } - } catch (e) { console.warn('Shared link check failed, proceeding:', e); } + } catch (err) { console.warn('Pre-save check failed, proceeding:', err); } linkCheckLoading = false; } @@ -277,15 +267,10 @@ return p?.name || `#${id}`; } - const collectionCountLabel: Record = { - immich: 'notificationTracker.albums_count', - gitea: 'notificationTracker.repos_count', - planka: 'notificationTracker.boards_count', - nut: 'notificationTracker.devices_count', - }; function getCollectionLabel(tracker: Tracker): string { const pt = getProviderType(tracker); - return t(collectionCountLabel[pt] || 'notificationTracker.albums_count'); + const desc = getDescriptor(pt); + return desc?.collectionMeta ? t(desc.collectionMeta.countLabel) : t('notificationTracker.collections_count'); } function configsForTracker(tracker: Tracker, configs: (TrackingConfig | TemplateConfig)[]): (TrackingConfig | TemplateConfig)[] { diff --git a/frontend/src/routes/notification-trackers/TrackerForm.svelte b/frontend/src/routes/notification-trackers/TrackerForm.svelte index 33f86d4..492a7e6 100644 --- a/frontend/src/routes/notification-trackers/TrackerForm.svelte +++ b/frontend/src/routes/notification-trackers/TrackerForm.svelte @@ -6,6 +6,7 @@ import Hint from '$lib/components/Hint.svelte'; import EntitySelect from '$lib/components/EntitySelect.svelte'; import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte'; + import { getDescriptor } from '$lib/providers'; interface Props { form: { @@ -45,17 +46,10 @@ formatDate, }: Props = $props(); + let descriptor = $derived(getDescriptor(providerType)); let isScheduler = $derived(providerType === 'scheduler'); - let isWebhook = $derived(providerType === 'gitea' || providerType === 'planka'); - - // Collection label/icon/desc per provider type - const collectionMeta: Record string }> = { - immich: { label: t('notificationTracker.albums'), icon: 'mdiImageMultiple', placeholder: t('notificationTracker.selectAlbums'), desc: (col) => `${col.assetCount ?? col.asset_count ?? 0} assets` }, - gitea: { label: t('notificationTracker.repositories'), icon: 'mdiGit', placeholder: t('notificationTracker.selectRepositories'), desc: () => '' }, - planka: { label: t('notificationTracker.boards'), icon: 'mdiViewDashboard', placeholder: t('notificationTracker.selectBoards'), desc: () => '' }, - nut: { label: t('notificationTracker.upsDevices'), icon: 'mdiBatteryCharging80', placeholder: t('notificationTracker.selectUpsDevices'), desc: (col) => col.description || '' }, - }; - let colMeta = $derived(collectionMeta[providerType] || { label: t('notificationTracker.albums'), icon: 'mdiServer', placeholder: t('notificationTracker.selectAlbums'), desc: () => '' }); + let isWebhook = $derived(descriptor?.webhookBased ?? false); + let colMeta = $derived(descriptor?.collectionMeta); // Custom variable management for scheduler function addVariable() { @@ -99,9 +93,9 @@ - {#if !isScheduler && collections.length > 0} + {#if !isScheduler && colMeta && collections.length > 0}
- + ({ value: col.id, @@ -110,7 +104,7 @@ desc: colMeta.desc(col), }))} bind:values={form.collection_ids} - placeholder={colMeta.placeholder} + placeholder={t(colMeta.placeholder)} />
{/if} diff --git a/frontend/src/routes/providers/+page.svelte b/frontend/src/routes/providers/+page.svelte index 621334a..168ea1b 100644 --- a/frontend/src/routes/providers/+page.svelte +++ b/frontend/src/routes/providers/+page.svelte @@ -17,6 +17,7 @@ import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { highlightFromUrl } from '$lib/highlight'; + import { getDescriptor, buildProviderFormDefaults } from '$lib/providers'; import type { ServiceProvider } from '$lib/types'; let allProviders = $derived(providersCache.items); @@ -27,7 +28,7 @@ )); let showForm = $state(false); let editing = $state(null); - let form = $state({ name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '', nut_host: '', nut_port: 3493, nut_username: '', nut_password: '' }); + let form = $state(buildProviderFormDefaults()); let nameManuallyEdited = $state(false); let error = $state(''); let loadError = $state(''); @@ -35,15 +36,13 @@ let loaded = $state(false); let confirmDelete = $state(null); - const providerDefaultNames: Record = { - immich: 'Immich', gitea: 'Gitea', planka: 'Planka', scheduler: 'Scheduler', nut: 'NUT', - }; + let descriptor = $derived(getDescriptor(form.type)); // Auto-update name when provider type changes (unless user manually edited) $effect(() => { - const type = form.type; - if (!nameManuallyEdited && !editing) { - form.name = providerDefaultNames[type] || type; + const desc = getDescriptor(form.type); + if (!nameManuallyEdited && !editing && desc) { + form.name = desc.defaultName; } }); @@ -67,19 +66,33 @@ } function openNew() { - form = { name: 'Immich', type: 'immich', url: '', api_key: '', api_token: '', webhook_secret: '', external_domain: '', icon: '', nut_host: '', nut_port: 3493, nut_username: '', nut_password: '' }; + form = buildProviderFormDefaults(); nameManuallyEdited = false; editing = null; showForm = true; } + function edit(p: any) { const cfg = p.config || {}; - form = { - name: p.name, type: p.type, url: cfg.url || '', - api_key: '', api_token: '', webhook_secret: '', - external_domain: cfg.external_domain || '', icon: p.icon || '', - nut_host: cfg.host || '', nut_port: cfg.port || 3493, - nut_username: '', nut_password: '', - }; + const base = buildProviderFormDefaults(); + const desc = getDescriptor(p.type); + // Populate common fields + base.name = p.name; + base.type = p.type; + base.icon = p.icon || ''; + base.url = cfg.url || ''; + // Populate provider-specific fields from config using configKey mapping + if (desc) { + for (const field of desc.configFields) { + const cfgKey = field.configKey || field.key; + // Secrets (password fields) are blank on edit; non-secret fields load from config + if (field.type === 'password') { + base[field.key] = ''; + } else { + base[field.key] = cfg[cfgKey] ?? field.defaultValue ?? ''; + } + } + } + form = base; nameManuallyEdited = true; editing = p.id; showForm = true; } @@ -87,40 +100,19 @@ async function save(e: SubmitEvent) { e.preventDefault(); error = ''; submitting = true; try { - let config: any; - if (form.type === 'nut') { - config = { host: form.nut_host, port: form.nut_port || 3493 }; - if (form.nut_username) config.username = form.nut_username; - if (form.nut_password) config.password = form.nut_password; - } else { - config = { url: form.url }; - } - if (form.type === 'immich') { - if (form.api_key) config.api_key = form.api_key; - if (form.external_domain) config.external_domain = form.external_domain; - if (!editing) config.api_key = form.api_key; - } else if (form.type === 'gitea') { - if (form.api_token) config.api_token = form.api_token; - if (form.webhook_secret) config.webhook_secret = form.webhook_secret; - if (!editing && !form.webhook_secret) { - error = t('providers.webhookSecretRequired'); - snackError(error); submitting = false; return; - } - } else if (form.type === 'planka') { - if (form.api_key) config.api_key = form.api_key; - if (form.webhook_secret) config.webhook_secret = form.webhook_secret; - if (!editing && !form.webhook_secret) { - error = t('providers.webhookSecretRequired'); - snackError(error); submitting = false; return; - } + const desc = getDescriptor(form.type); + if (!desc) { + error = `Unknown provider type: ${form.type}`; + snackError(error); submitting = false; return; + } + const { config, error: buildError } = desc.buildConfig(form, !!editing); + if (buildError) { + error = t(buildError); + snackError(error); submitting = false; return; } if (editing) { - // Only send config if user changed a config field (secrets are blank on edit) - const hasConfigChange = form.url !== (providers.find(p => p.id === editing)?.config?.url || '') || - (form.type === 'immich' && (form.api_key || form.external_domain !== (providers.find(p => p.id === editing)?.config?.external_domain || ''))) || - (form.type === 'gitea' && (form.api_token || form.webhook_secret)) || - (form.type === 'planka' && (form.api_key || form.webhook_secret)) || - (form.type === 'nut'); + const existing = providers.find(p => p.id === editing)?.config || {}; + const hasConfigChange = desc.hasConfigChanged(form, existing); const body: any = { name: form.name, icon: form.icon }; if (hasConfigChange) body.config = config; await api(`/providers/${editing}`, { method: 'PUT', body: JSON.stringify(body) }); @@ -185,77 +177,40 @@ nameManuallyEdited = true} required class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" /> - {#if form.type !== 'scheduler' && form.type !== 'nut'} + {#if descriptor?.hasUrl}
- +
{/if} - {#if form.type === 'immich'} + {#each descriptor?.configFields ?? [] as field (field.key)}
- - + + {#if field.type === 'number'} + + {:else} + + {/if} + {#if field.hint} +

{t(field.hint)}

+ {/if}
-
- - -
- {:else if form.type === 'gitea'} -
- - -

{t('providers.webhookSecretHint')}

-
-
- - -

{t('providers.apiTokenHint')}

-
- {#if editing} + {/each} + {#if descriptor?.webhookUrlPattern && editing}
- /api/webhooks/gitea/{editing} + {descriptor.webhookUrlPattern.replace('{id}', String(editing))}

{t('providers.webhookUrlHint')}

{/if} - {:else if form.type === 'planka'} -
- - -

{t('providers.plankaWebhookSecretHint')}

-
-
- - -

{t('providers.plankaApiKeyHint')}

-
- {#if editing} -
- - /api/webhooks/planka/{editing} -

{t('providers.plankaWebhookUrlHint')}

-
- {/if} - {:else if form.type === 'nut'} -
- - -
-
- - -
-
- - -

{t('providers.nutUsernameHint')}

-
-
- - -

{t('providers.nutPasswordHint')}

-
- {/if}