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:
2026-03-24 12:40:33 +03:00
parent c6bb2b5b51
commit 8cb836e16c
19 changed files with 904 additions and 353 deletions
+24 -25
View File
@@ -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);
+2
View File
@@ -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",
+2
View File
@@ -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": "Удалены файлы",
+60
View File
@@ -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`,
},
};
+133
View File
@@ -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 };
},
};
+84
View File
@@ -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';
+65
View File
@@ -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 || '',
},
};
+66
View File
@@ -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}',
};
+24
View File
@@ -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,
};
+154
View File
@@ -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 }>;
}