refactor: provider descriptor registry — eliminate provider-specific hardcoding
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.
This commit is contained in:
@@ -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<string, string> = {
|
||||
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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Удалены файлы",
|
||||
|
||||
@@ -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<string, any> = { 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}',
|
||||
};
|
||||
@@ -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<string, any> = {};
|
||||
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`,
|
||||
},
|
||||
};
|
||||
@@ -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<string, any> = { 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<SharedLink[]>(`/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 };
|
||||
},
|
||||
};
|
||||
@@ -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<string, ProviderDescriptor> = 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<string, any> {
|
||||
const defaults: Record<string, any> = {};
|
||||
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<string, any> {
|
||||
const defaults: Record<string, any> = {
|
||||
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';
|
||||
@@ -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<string, any> = {
|
||||
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 || '',
|
||||
},
|
||||
};
|
||||
@@ -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<string, any> = { 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}',
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<string, any>, editing: boolean): { config: Record<string, any>; error?: string };
|
||||
/** Detect whether any config field changed during edit (secrets are blank on load). */
|
||||
hasConfigChanged(form: Record<string, any>, existing: Record<string, any>): 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<string, any>;
|
||||
previousCollectionIds: string[];
|
||||
collections: any[];
|
||||
api: typeof api;
|
||||
}): Promise<{ warnings?: { id: string; name: string; issue: string }[]; proceed: boolean }>;
|
||||
}
|
||||
Reference in New Issue
Block a user