feat: workflows & plugins (#26727)

feat: plugins

chore: better types

feat: plugins
This commit is contained in:
Jason Rasmussen
2026-05-18 11:09:33 -04:00
committed by GitHub
parent 7384799f19
commit 3d075f2bf8
144 changed files with 6099 additions and 7419 deletions
@@ -1,18 +1,19 @@
<script lang="ts">
import type { HeaderButtonActionItem } from '$lib/types';
import { Button } from '@immich/ui';
import { Button, type Variants } from '@immich/ui';
type Props = {
action: HeaderButtonActionItem;
variant?: Variants;
};
const { action }: Props = $props();
const { action, variant }: Props = $props();
const { title, icon, color = 'secondary', onAction } = $derived(action);
</script>
{#if action.$if?.() ?? true}
<Button
variant="ghost"
variant={variant ?? 'ghost'}
size="small"
{color}
leadingIcon={icon}
@@ -0,0 +1,59 @@
<script lang="ts">
import AlbumThumbnail from '$lib/components/album-page/AlbumThumbnail.svelte';
import { Button, Text, Label, modalManager } from '@immich/ui';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import { t } from 'svelte-i18n';
type Props = {
label: string;
description?: string;
albumIds: string[];
array?: boolean;
};
let { array, label, description, albumIds = $bindable([]) }: Props = $props();
const onAlbums = async () => {
const albums = await modalManager.show(AlbumPickerModal);
if (!albums || albums.length === 0) {
return;
}
albumIds = array ? [...albumIds, ...albums.map((album) => album.id)] : [albums[0].id];
};
</script>
{#snippet button()}
<Button size="small" shape="round" color="secondary" onclick={() => onAlbums()}>{$t('choose')}</Button>
{/snippet}
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-0.5">
<Label for="album-picker" size="small" class="font-medium" {label} />
{#if description}
<Text color="muted" size="small">{description}</Text>
{/if}
</div>
{#if array}
<div class="flex flex-col gap-2">
{#each albumIds as albumId, i (i)}
<AlbumThumbnail
{albumId}
onDelete={() => {
albumIds.splice(i, 1);
albumIds = [...albumIds];
}}
/>
{/each}
{@render button()}
</div>
{:else}
{@const albumId = albumIds[0]}
{#if albumId}
<AlbumThumbnail {albumId} onDelete={() => (albumIds = [])} />
{:else}
{@render button()}
{/if}
{/if}
</div>
@@ -0,0 +1,99 @@
<script lang="ts">
import SchemaAlbumPicker from '$lib/components/SchemaAlbumPicker.svelte';
import Self from '$lib/components/SchemaConfiguration.svelte';
import type { JSONSchemaProperty, SchemaConfig } from '$lib/types';
import { CodeBlock, Field, Input, Label, MultiSelect, NumberInput, Select, Switch, Text } from '@immich/ui';
type Props = {
schema: JSONSchemaProperty;
root?: boolean;
key?: string;
config: SchemaConfig;
};
let { schema, key = '', root = false, config = $bindable() }: Props = $props();
const label = $derived(schema.title ?? key);
const description = $derived(schema.description);
const getValue = <T,>(defaultValue?: T) => (root === true ? config : (config?.[key] ?? defaultValue)) as T;
const setValue = <T,>(value: T) => {
if (root === true) {
config = value;
} else {
if (config === undefined) {
config = {};
}
config[key] = value;
}
};
const getUiHintValue = () => {
if (schema.array) {
return getValue<string[]>([]);
}
const values = getValue<string>();
return values ? [values] : [];
};
const setUiHintValue = (values: string[]) => setValue(schema.array ? values : values[0]);
const getBoolean = (defaultValue = false) => getValue<boolean>(defaultValue);
const getString = () => getValue<string>();
const getEnum = () => getValue<string[]>([]);
const getNumber = () => getValue<number>();
</script>
<!-- Empty schema object -->
{#if Object.keys(schema).length === 0}
<!-- noop -->
<!-- nested configuration (also top level objects) -->
{:else if schema.type === 'object'}
{#if !root}
<div class="flex flex-col gap-2">
<Label size="small" class="font-medium" {label}></Label>
{#if description}
<Text color="muted" size="small">{description}</Text>
{/if}
</div>
{/if}
<div class="flex flex-col gap-4 {root ? '' : 'border-l-4 border-gray-200 ps-2'}">
{#each Object.entries(schema.properties ?? {}) as [childKey, childSchema] (childKey)}
<Self schema={childSchema} key={childKey} bind:config={getValue, setValue} />
{/each}
</div>
{:else if schema.uiHint === 'albumId'}
<SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} />
{:else if schema.enum && schema.array}
<Field {label} {description}>
<MultiSelect options={schema.enum} bind:values={getEnum, setValue} />
</Field>
{:else if schema.enum}
<Field {label} {description}>
<Select options={schema.enum} bind:value={getString, setValue} />
</Field>
{:else if schema.array}
<!-- {@const values = getValue<string[]>([])}
<Field {label} {description}>
{#each values as value, i (i)}
<Input bind:value={() => getValue<string>(), setValue} />
{/each}
</Field> -->
{:else if schema.type === 'boolean'}
<Field {label} {description}>
<Switch bind:checked={() => getBoolean(schema.default ?? false), setValue} />
</Field>
{:else if schema.type === 'number'}
<Field {label} {description}>
<NumberInput bind:value={getNumber, setValue} />
</Field>
{:else if schema.type === 'string'}
<Field {label} {description}>
<Input bind:value={() => getValue<string>(), setValue} />
</Field>
{:else}
<Text>Unknown schema</Text>
<CodeBlock code={JSON.stringify(schema, null, 2)} />
{/if}
@@ -23,7 +23,7 @@
showDateRange = false,
showItemCount = false,
preload = false,
onShowContextMenu = undefined,
onShowContextMenu,
}: Props = $props();
const showAlbumContextMenu = (e: MouseEvent) => {
@@ -0,0 +1,50 @@
<script lang="ts">
import AlbumCover from '$lib/components/album-page/AlbumCover.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAlbumInfo } from '@immich/sdk';
import { IconButton, LoadingSpinner } from '@immich/ui';
import { mdiTrashCanOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
albumId: string;
onDelete: () => unknown;
}
let { albumId, onDelete }: Props = $props();
</script>
<div>
{#await getAlbumInfo({ ...authManager.params, id: albumId })}
<LoadingSpinner />
{:then album}
<div class="flex gap-2">
<AlbumCover {album} class="size-24" />
<p
class="line-clamp-2 grow text-lg/6 font-semibold text-black group-hover:text-primary dark:text-white"
data-testid="album-name"
title={album.albumName}
>
{album.albumName}
</p>
{#if album.description}
<p
class="line-clamp-2 grow text-lg/6 font-semibold text-black group-hover:text-primary dark:text-white"
data-testid="album-name"
>
{album.description}
</p>
{/if}
<div class="">
<IconButton
icon={mdiTrashCanOutline}
shape="round"
color="danger"
variant="ghost"
onclick={onDelete}
aria-label={$t('remove')}
/>
</div>
</div>
{/await}
</div>
@@ -0,0 +1,84 @@
import {
getWorkflowTriggers,
searchPluginMethods,
WorkflowTrigger,
type PluginMethodResponseDto,
type WorkflowTriggerResponseDto,
} from '@immich/sdk';
import { t } from 'svelte-i18n';
import { SvelteMap } from 'svelte/reactivity';
import { get } from 'svelte/store';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
class PluginManager {
#loading: Promise<void> | undefined;
#methodMap = new SvelteMap<string, PluginMethodResponseDto>();
#methods = $state<PluginMethodResponseDto[]>([]);
#triggers = $state<WorkflowTriggerResponseDto[]>([]);
constructor() {
eventManager.on({
AuthLogout: () => this.clearCache(),
AuthUserLoaded: () => this.initialize(),
});
// loaded event might have already happened
if (authManager.authenticated) {
void this.initialize();
}
}
get triggers() {
return this.#triggers;
}
ready() {
return this.initialize();
}
getMethod(key: string) {
return this.#methodMap.get(key);
}
getMethodLabel(key: string) {
const method = this.getMethod(key);
return method?.title ?? get(t)('unknown');
}
getTrigger(trigger: WorkflowTrigger) {
const result = this.#triggers.find((t) => t.trigger === trigger);
if (!result) {
throw new Error(`Unknown trigger type: ${trigger}`);
}
return result;
}
private clearCache() {
this.#loading = undefined;
this.#methodMap = new SvelteMap();
}
private initialize() {
if (!this.#loading) {
this.#loading = this.load();
}
return this.#loading;
}
private async load() {
const [methods, triggers] = await Promise.all([searchPluginMethods({}), getWorkflowTriggers()]);
this.#methods = methods;
for (const method of this.#methods) {
this.#methodMap.set(method.key, method);
}
this.#triggers = triggers;
}
}
export const pluginManager = new PluginManager();
@@ -1,80 +0,0 @@
<script lang="ts">
import type { PluginActionResponseDto, PluginFilterResponseDto } from '@immich/sdk';
import { Modal, ModalBody, Text } from '@immich/ui';
import { mdiFilterOutline, mdiPlayCircleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
filters: PluginFilterResponseDto[];
actions: PluginActionResponseDto[];
onClose: (result?: { type: 'filter' | 'action'; item: PluginFilterResponseDto | PluginActionResponseDto }) => void;
type?: 'filter' | 'action';
};
let { filters, actions, onClose, type }: Props = $props();
type StepType = 'filter' | 'action';
const handleSelect = (type: StepType, item: PluginFilterResponseDto | PluginActionResponseDto) => {
onClose({ type, item });
};
const getModalTitle = () => {
if (type === 'filter') {
return $t('add_filter');
} else if (type === 'action') {
return $t('add_action');
} else {
return $t('add_workflow_step');
}
};
const getModalIcon = () => {
if (type === 'filter') {
return mdiFilterOutline;
} else if (type === 'action') {
return mdiPlayCircleOutline;
} else {
return false;
}
};
</script>
{#snippet stepButton(title: string, description?: string, onclick?: () => void)}
<button
type="button"
{onclick}
class="flex items-start gap-3 rounded-lg border bg-light-100 p-3 text-left text-dark hover:border-primary"
>
<div class="flex-1">
<Text color="primary" fontWeight="medium">{title}</Text>
{#if description}
<Text size="small" class="mt-1">{description}</Text>
{/if}
</div>
</button>
{/snippet}
<Modal title={getModalTitle()} icon={getModalIcon()} onClose={() => onClose()}>
<ModalBody>
<div class="space-y-6">
<!-- Filters Section -->
{#if filters.length > 0 && (!type || type === 'filter')}
<div class="grid grid-cols-1 gap-2">
{#each filters as filter (filter.id)}
{@render stepButton(filter.title, filter.description, () => handleSelect('filter', filter))}
{/each}
</div>
{/if}
<!-- Actions Section -->
{#if actions.length > 0 && (!type || type === 'action')}
<div class="grid grid-cols-1 gap-2">
{#each actions as action (action.id)}
{@render stepButton(action.title, action.description, () => handleSelect('action', action))}
{/each}
</div>
{/if}
</div>
</ModalBody>
</Modal>
+2 -2
View File
@@ -21,9 +21,9 @@
let search = $state('');
let selectedRowIndex: number = $state(-1);
interface Props {
type Props = {
onClose: (albums?: AlbumResponseDto[]) => void;
}
};
let { onClose }: Props = $props();
@@ -0,0 +1,41 @@
<script lang="ts">
import { searchPluginMethods, WorkflowTrigger, type PluginMethodResponseDto } from '@immich/sdk';
import { Badge, BasicModal, ListButton, LoadingSpinner, Stack, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
trigger: WorkflowTrigger;
selectedKey?: string;
onClose: (method?: PluginMethodResponseDto) => void;
};
const { trigger, selectedKey, onClose }: Props = $props();
</script>
<BasicModal title={$t('add_step')} {onClose}>
{#await searchPluginMethods({ trigger })}
<div class="flex w-full place-content-center place-items-center">
<LoadingSpinner />
</div>
{:then methods}
<Stack>
{#each methods as method (method.key)}
<ListButton selected={method.key === selectedKey} onclick={() => onClose(method)}>
<div class="grow text-start">
<Text fontWeight="medium" class="flex items-center gap-1"
>{method.title}
{#if method.uiHints.includes('filter')}
<Badge size="tiny" color="info" title={$t('plugin_method_filter_type_description')}
>{$t('plugin_method_filter_type')}</Badge
>
{/if}
</Text>
{#if method.description}
<Text size="tiny" color="muted">{method.description}</Text>
{/if}
</div>
</ListButton>
{/each}
</Stack>
{/await}
</BasicModal>
@@ -0,0 +1,75 @@
<script lang="ts">
import SchemaConfiguration from '$lib/components/SchemaConfiguration.svelte';
import PluginMethodPicker from '$lib/modals/PluginMethodPicker.svelte';
import { type JSONSchemaProperty, type SchemaConfig } from '$lib/types';
import { WorkflowTrigger, type PluginMethodResponseDto, type WorkflowStepDto } from '@immich/sdk';
import { FormModal, IconButton, modalManager, Stack, Text, Textarea } from '@immich/ui';
import { mdiPencilOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
trigger: WorkflowTrigger;
onClose: (step?: WorkflowStepDto) => void;
};
const { trigger, onClose }: Props = $props();
const onSubmit = () => {
if (method) {
onClose({ method: method.key, config, enabled: true });
}
};
let method = $state<PluginMethodResponseDto>();
let config = $state<SchemaConfig>({});
let debug = $state(false);
const onPickMethod = async () => {
const selected = await modalManager.show(PluginMethodPicker, { trigger, selectedKey: method?.key });
if (!selected) {
return;
}
method = selected;
config = selected.schema ? {} : null;
};
void onPickMethod();
</script>
{#if method}
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="small">
<div class="flex items-center justify-between gap-2">
<div class="grow text-start">
<Text fontWeight="medium">{method.title}</Text>
{#if method.description}
<Text size="tiny" color="muted">{method.description}</Text>
{/if}
</div>
<IconButton
icon={mdiPencilOutline}
onclick={onPickMethod}
variant="ghost"
shape="round"
color="secondary"
aria-label={$t('edit')}
/>
</div>
{#if method.schema}
<hr class="my-4" />
<div class="mt-4 grow text-start">
<Text fontWeight="medium">{$t('configuration')}</Text>
<Stack gap={4}>
<SchemaConfiguration schema={method.schema as JSONSchemaProperty} bind:config root />
</Stack>
</div>
{/if}
{#if debug}
<hr class="my-4" />
<Text fontWeight="medium">{$t('preview')}</Text>
<Textarea class="mt-2" readonly grow value={JSON.stringify({ method: method.key, config }, null, 2)} />
{/if}
</FormModal>
{/if}
@@ -0,0 +1,67 @@
<script lang="ts">
import SchemaConfiguration from '$lib/components/SchemaConfiguration.svelte';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import PluginMethodPicker from '$lib/modals/PluginMethodPicker.svelte';
import { type JSONSchemaProperty, type SchemaConfig } from '$lib/types';
import { WorkflowTrigger, type WorkflowStepDto } from '@immich/sdk';
import { Button, FormModal, modalManager, Stack, Text, Textarea } from '@immich/ui';
import { mdiPencilOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
trigger: WorkflowTrigger;
step: WorkflowStepDto;
onClose: (step?: WorkflowStepDto) => void;
};
const { trigger, step, onClose }: Props = $props();
const onSubmit = () => onClose(method ? { method: method.key, config, enabled } : undefined);
let enabled = $state<boolean>(true);
let method = $state(pluginManager.getMethod(step.method));
let config = $state<SchemaConfig>(step.config);
let debug = $state(false);
const onPickMethod = async () => {
const selected = await modalManager.show(PluginMethodPicker, { trigger, selectedKey: method?.key });
if (!selected) {
return;
}
method = selected;
config = selected.schema ? {} : null;
};
</script>
{#if method}
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="small">
<div class="flex items-center justify-between gap-2">
<div class="grow text-start">
<Text fontWeight="medium">{method.title}</Text>
{#if method.description}
<Text size="small" color="muted">{method.description}</Text>
{/if}
</div>
<Button size="small" color="secondary" shape="round" onclick={onPickMethod} leadingIcon={mdiPencilOutline}
>{$t('change')}</Button
>
</div>
{#if method.schema}
<hr class="my-4" />
<div class="mt-4 grow text-start">
<Text fontWeight="medium">{$t('configuration')}</Text>
<Stack gap={4}>
<SchemaConfiguration schema={method.schema as JSONSchemaProperty} bind:config root />
</Stack>
</div>
{/if}
{#if debug}
<hr class="my-4" />
<Text fontWeight="medium">{$t('preview')}</Text>
<Textarea class="mt-2" readonly grow value={JSON.stringify({ method: method.key, config }, null, 2)} />
{/if}
</FormModal>
{/if}
@@ -0,0 +1,37 @@
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import { type WorkflowResponseDto } from '@immich/sdk';
import { FormModal, ListButton, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
workflow: WorkflowResponseDto;
onClose: () => void;
};
const { workflow, onClose }: Props = $props();
let selected = $state(pluginManager.getTrigger(workflow.trigger));
const onSubmit = async () => {
const success = await handleUpdateWorkflow(workflow.id, { trigger: selected.trigger });
if (success) {
onClose();
}
};
</script>
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
<div class="flex flex-col gap-2">
{#each pluginManager.triggers as item (item.trigger)}
<ListButton selected={item.trigger === selected.trigger} onclick={() => (selected = item)}>
<div class="grow text-start">
<Text fontWeight="medium">{getTriggerName($t, item.trigger)}</Text>
<Text size="tiny" color="muted">{getTriggerDescription($t, item.trigger)}</Text>
</div>
</ListButton>
{/each}
</div>
</FormModal>
@@ -0,0 +1,31 @@
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import { WorkflowTrigger } from '@immich/sdk';
import { FormModal, ListButton, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
selected?: WorkflowTrigger;
onClose: (trigger?: WorkflowTrigger) => void;
};
const { selected: initialSelected, onClose }: Props = $props();
let selected = $state(initialSelected);
const onSubmit = () => onClose(selected);
</script>
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
<div class="flex flex-col gap-2">
{#each pluginManager.triggers as item (item.trigger)}
<ListButton selected={selected === item.trigger} onclick={() => (selected = item.trigger)}>
<div class="grow text-start">
<Text fontWeight="medium">{getTriggerName($t, item.trigger)}</Text>
<Text size="tiny" color="muted">{getTriggerDescription($t, item.trigger)}</Text>
</div>
</ListButton>
{/each}
</div>
</FormModal>
+2 -2
View File
@@ -146,8 +146,8 @@ export const Route = {
geolocationUtility: () => '/utilities/geolocation',
// workflows
workflows: () => '/utilities/workflows',
viewWorkflow: ({ id }: { id: string }) => `/utilities/workflows/${id}`,
workflows: () => '/workflows',
viewWorkflow: ({ id }: { id: string }) => `/workflows/${id}`,
// queues
queues: () => '/admin/queues',
+10 -344
View File
@@ -1,19 +1,11 @@
import {
createWorkflow,
deleteWorkflow,
getAlbumInfo,
getPerson,
PluginTriggerType,
updateWorkflow,
WorkflowTrigger,
type AlbumResponseDto,
type PersonResponseDto,
type PluginActionResponseDto,
type PluginContextType,
type PluginFilterResponseDto,
type PluginTriggerResponseDto,
type WorkflowActionItemDto,
type WorkflowCreateDto,
type WorkflowFilterItemDto,
type WorkflowResponseDto,
type WorkflowUpdateDto,
} from '@immich/sdk';
@@ -29,306 +21,14 @@ import { getFormatter } from '$lib/utils/i18n';
export type PickerSubType = 'album-picker' | 'people-picker';
export type PickerMetadata = AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[];
export interface WorkflowPayload {
name: string;
description: string;
enabled: boolean;
triggerType: string;
filters: Record<string, unknown>[];
actions: Record<string, unknown>[];
}
/**
* Get filters that support the given context
*/
export const getFiltersByContext = (
availableFilters: PluginFilterResponseDto[],
context: PluginContextType,
): PluginFilterResponseDto[] => {
return availableFilters.filter((filter) => filter.supportedContexts.includes(context));
};
/**
* Get actions that support the given context
*/
export const getActionsByContext = (
availableActions: PluginActionResponseDto[],
context: PluginContextType,
): PluginActionResponseDto[] => {
return availableActions.filter((action) => action.supportedContexts.includes(context));
};
export const remapConfigsOnReorder = (
configs: Record<string, unknown>,
prefix: 'filter' | 'action',
fromIndex: number,
toIndex: number,
totalCount: number,
): Record<string, unknown> => {
const newConfigs: Record<string, unknown> = {};
// Create an array of configs in order
const configArray: unknown[] = [];
for (let i = 0; i < totalCount; i++) {
configArray.push(configs[`${prefix}_${i}`] ?? {});
}
// Move the item from fromIndex to toIndex
const [movedItem] = configArray.splice(fromIndex, 1);
configArray.splice(toIndex, 0, movedItem);
// Rebuild the configs object with new indices
for (let i = 0; i < configArray.length; i++) {
newConfigs[`${prefix}_${i}`] = configArray[i];
}
return newConfigs;
};
/**
* Remap configs when an item is removed
* Shifts all configs after the removed index down by one
*/
export const remapConfigsOnRemove = (
configs: Record<string, unknown>,
prefix: 'filter' | 'action',
removedIndex: number,
totalCount: number,
): Record<string, unknown> => {
const newConfigs: Record<string, unknown> = {};
let newIndex = 0;
for (let i = 0; i < totalCount; i++) {
if (i !== removedIndex) {
newConfigs[`${prefix}_${newIndex}`] = configs[`${prefix}_${i}`] ?? {};
newIndex++;
}
}
return newConfigs;
};
export const initializeConfigs = (
type: 'action' | 'filter',
workflow: WorkflowResponseDto,
): Record<string, unknown> => {
const configs: Record<string, unknown> = {};
if (workflow.filters && type == 'filter') {
for (const [index, workflowFilter] of workflow.filters.entries()) {
configs[`filter_${index}`] = workflowFilter.filterConfig ?? {};
}
}
if (workflow.actions && type == 'action') {
for (const [index, workflowAction] of workflow.actions.entries()) {
configs[`action_${index}`] = workflowAction.actionConfig ?? {};
}
}
return configs;
};
/**
* Build workflow payload from current state
* Uses index-based keys to support multiple filters/actions of the same type
*/
export const buildWorkflowPayload = (
name: string,
description: string,
enabled: boolean,
triggerType: string,
orderedFilters: PluginFilterResponseDto[],
orderedActions: PluginActionResponseDto[],
filterConfigs: Record<string, unknown>,
actionConfigs: Record<string, unknown>,
): WorkflowPayload => {
const filters = orderedFilters.map((filter, index) => ({
[filter.methodName]: filterConfigs[`filter_${index}`] ?? {},
}));
const actions = orderedActions.map((action, index) => ({
[action.methodName]: actionConfigs[`action_${index}`] ?? {},
}));
return {
name,
description,
enabled,
triggerType,
filters,
actions,
};
};
export const parseWorkflowJson = (
jsonString: string,
availableTriggers: PluginTriggerResponseDto[],
availableFilters: PluginFilterResponseDto[],
availableActions: PluginActionResponseDto[],
): {
success: boolean;
error?: string;
data?: {
name: string;
description: string;
enabled: boolean;
trigger?: PluginTriggerResponseDto;
filters: PluginFilterResponseDto[];
actions: PluginActionResponseDto[];
filterConfigs: Record<string, unknown>;
actionConfigs: Record<string, unknown>;
};
} => {
try {
const parsed = JSON.parse(jsonString);
const trigger = availableTriggers.find((t) => t.type === parsed.triggerType);
const filters: PluginFilterResponseDto[] = [];
const filterConfigs: Record<string, unknown> = {};
if (Array.isArray(parsed.filters)) {
for (const [index, filterObj] of parsed.filters.entries()) {
const methodName = Object.keys(filterObj)[0];
const filter = availableFilters.find((f) => f.methodName === methodName);
if (filter) {
filters.push(filter);
filterConfigs[`filter_${index}`] = (filterObj as Record<string, unknown>)[methodName];
}
}
}
const actions: PluginActionResponseDto[] = [];
const actionConfigs: Record<string, unknown> = {};
if (Array.isArray(parsed.actions)) {
for (const [index, actionObj] of parsed.actions.entries()) {
const methodName = Object.keys(actionObj)[0];
const action = availableActions.find((a) => a.methodName === methodName);
if (action) {
actions.push(action);
actionConfigs[`action_${index}`] = (actionObj as Record<string, unknown>)[methodName];
}
}
}
return {
success: true,
data: {
name: parsed.name ?? '',
description: parsed.description ?? '',
enabled: parsed.enabled ?? false,
trigger,
filters,
actions,
filterConfigs,
actionConfigs,
},
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Invalid JSON',
};
}
};
export const hasWorkflowChanged = (
previousWorkflow: WorkflowResponseDto,
enabled: boolean,
name: string,
description: string,
triggerType: string,
orderedFilters: PluginFilterResponseDto[],
orderedActions: PluginActionResponseDto[],
filterConfigs: Record<string, unknown>,
actionConfigs: Record<string, unknown>,
): boolean => {
if (enabled !== previousWorkflow.enabled) {
return true;
}
if (name !== (previousWorkflow.name ?? '') || description !== (previousWorkflow.description ?? '')) {
return true;
}
if (triggerType !== previousWorkflow.triggerType) {
return true;
}
const previousFilterIds = previousWorkflow.filters?.map((f) => f.pluginFilterId) ?? [];
const currentFilterIds = orderedFilters.map((f) => f.id);
if (JSON.stringify(previousFilterIds) !== JSON.stringify(currentFilterIds)) {
return true;
}
const previousActionIds = previousWorkflow.actions?.map((a) => a.pluginActionId) ?? [];
const currentActionIds = orderedActions.map((a) => a.id);
if (JSON.stringify(previousActionIds) !== JSON.stringify(currentActionIds)) {
return true;
}
const previousFilterConfigs: Record<string, unknown> = {};
for (const [index, wf] of (previousWorkflow.filters ?? []).entries()) {
previousFilterConfigs[`filter_${index}`] = wf.filterConfig ?? {};
}
if (JSON.stringify(previousFilterConfigs) !== JSON.stringify(filterConfigs)) {
return true;
}
const previousActionConfigs: Record<string, unknown> = {};
for (const [index, wa] of (previousWorkflow.actions ?? []).entries()) {
previousActionConfigs[`action_${index}`] = wa.actionConfig ?? {};
}
if (JSON.stringify(previousActionConfigs) !== JSON.stringify(actionConfigs)) {
return true;
}
return false;
};
export const handleUpdateWorkflow = async (
workflowId: string,
name: string,
description: string,
enabled: boolean,
triggerType: PluginTriggerType,
orderedFilters: PluginFilterResponseDto[],
orderedActions: PluginActionResponseDto[],
filterConfigs: Record<string, unknown>,
actionConfigs: Record<string, unknown>,
): Promise<WorkflowResponseDto> => {
const filters = orderedFilters.map((filter, index) => ({
pluginFilterId: filter.id,
filterConfig: filterConfigs[`filter_${index}`] ?? {},
})) as WorkflowFilterItemDto[];
const actions = orderedActions.map((action, index) => ({
pluginActionId: action.id,
actionConfig: actionConfigs[`action_${index}`] ?? {},
})) as WorkflowActionItemDto[];
const updateDto: WorkflowUpdateDto = {
name,
description,
enabled,
filters,
actions,
triggerType,
};
return updateWorkflow({ id: workflowId, workflowUpdateDto: updateDto });
};
export const getWorkflowsActions = ($t: MessageFormatter) => {
const Create: ActionItem = {
title: $t('create_workflow'),
icon: mdiPlus,
onAction: () =>
handleCreateWorkflow({
name: $t('untitled_workflow'),
triggerType: PluginTriggerType.AssetCreate,
filters: [],
actions: [],
trigger: WorkflowTrigger.AssetCreate,
steps: [],
enabled: false,
}),
};
@@ -341,9 +41,7 @@ export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowRespo
title: workflow.enabled ? $t('disable') : $t('enable'),
icon: workflow.enabled ? mdiPause : mdiPlay,
color: workflow.enabled ? 'danger' : 'primary',
onAction: async () => {
await handleToggleWorkflowEnabled(workflow);
},
onAction: () => handleUpdateWorkflow(workflow.id, { enabled: !workflow.enabled }),
};
const Edit: ActionItem = {
@@ -385,22 +83,17 @@ const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
}
};
export const handleToggleWorkflowEnabled = async (
workflow: WorkflowResponseDto,
): Promise<WorkflowResponseDto | undefined> => {
export const handleUpdateWorkflow = async (id: string, dto: WorkflowUpdateDto) => {
const $t = await getFormatter();
try {
const updated = await updateWorkflow({
id: workflow.id,
workflowUpdateDto: { enabled: !workflow.enabled },
});
eventManager.emit('WorkflowUpdate', updated);
toastManager.primary($t('workflow_updated'));
return updated;
const response = await updateWorkflow({ id, workflowUpdateDto: dto });
eventManager.emit('WorkflowUpdate', response);
toastManager.primary($t('workflow_update_success'), { closable: true });
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_update_workflow'));
return false;
}
};
@@ -426,30 +119,3 @@ export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promi
return false;
}
};
export const fetchPickerMetadata = async (
value: string | string[] | undefined,
subType: PickerSubType,
): Promise<PickerMetadata | undefined> => {
if (!value) {
return undefined;
}
const isAlbum = subType === 'album-picker';
try {
if (Array.isArray(value) && value.length > 0) {
// Multiple selection
return isAlbum
? await Promise.all(value.map((id) => getAlbumInfo({ id })))
: await Promise.all(value.map((id) => getPerson({ id })));
} else if (typeof value === 'string' && value) {
// Single selection
return isAlbum ? await getAlbumInfo({ id: value }) : await getPerson({ id: value });
}
} catch (error) {
console.error(`Failed to fetch picker metadata:`, error);
}
return undefined;
};
+18
View File
@@ -91,3 +91,21 @@ export type SearchFilter = {
mediaType: MediaType;
rating?: number | null;
};
export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object';
export type JSONSchemaProperty = {
type: JSONSchemaType;
title?: string;
description?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default?: any;
enum?: string[];
array?: boolean;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
uiHint?: 'albumId' | 'assetId' | 'personId';
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type SchemaConfig = any;
+24 -122
View File
@@ -1,128 +1,30 @@
export type ComponentType = 'select' | 'multiselect' | 'text' | 'switch' | 'checkbox';
import { WorkflowTrigger } from '@immich/sdk';
import type { MessageFormatter } from 'svelte-i18n';
export interface ComponentConfig {
type: ComponentType;
label?: string;
description?: string;
defaultValue?: unknown;
required?: boolean;
options?: Array<{ label: string; value: string | number | boolean }>;
placeholder?: string;
subType?: string;
title?: string;
}
interface JSONSchemaProperty {
type?: string;
description?: string;
default?: unknown;
enum?: unknown[];
items?: JSONSchemaProperty;
subType?: string;
title?: string;
}
interface JSONSchema {
type?: string;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
}
export const getComponentDefaultValue = (component: ComponentConfig): unknown => {
if (component.defaultValue !== undefined) {
return component.defaultValue;
}
if (component.type === 'multiselect' || (component.type === 'text' && component.subType === 'people-picker')) {
return [];
}
if (component.type === 'switch') {
return false;
}
return '';
};
export const getComponentFromSchema = (schema: object | null): Record<string, ComponentConfig> | null => {
if (!schema || !isJSONSchema(schema) || !schema.properties) {
return null;
}
const components: Record<string, ComponentConfig> = {};
const requiredFields = schema.required || [];
for (const [propertyName, property] of Object.entries(schema.properties)) {
const config = getComponentForProperty(property, propertyName);
if (config) {
config.required = requiredFields.includes(propertyName);
components[propertyName] = config;
export const getTriggerName = ($t: MessageFormatter, type: WorkflowTrigger) => {
switch (type) {
case WorkflowTrigger.AssetCreate: {
return $t('trigger_asset_uploaded');
}
case WorkflowTrigger.PersonRecognized: {
return $t('trigger_person_recognized');
}
default: {
return type;
}
}
return Object.keys(components).length > 0 ? components : null;
};
function isJSONSchema(obj: object): obj is JSONSchema {
return 'properties' in obj || 'type' in obj;
}
function getComponentForProperty(property: JSONSchemaProperty, propertyName: string): ComponentConfig | null {
const { type, title, enum: enumValues, description, default: defaultValue, items } = property;
const config: ComponentConfig = {
type: 'text',
label: formatLabel(propertyName),
description,
defaultValue,
title,
};
if (enumValues && enumValues.length > 0) {
config.type = 'select';
config.options = enumValues.map((value: unknown) => ({
label: formatLabel(String(value)),
value: value as string | number | boolean,
}));
return config;
export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigger) => {
switch (type) {
case WorkflowTrigger.AssetCreate: {
return $t('trigger_asset_uploaded_description');
}
case WorkflowTrigger.PersonRecognized: {
return $t('trigger_person_recognized_description');
}
default: {
return type;
}
}
if (type === 'array' && items?.enum && items.enum.length > 0) {
config.type = 'multiselect';
config.subType = items.subType;
config.options = items.enum.map((value: unknown) => ({
label: formatLabel(String(value)),
value: value as string | number | boolean,
}));
return config;
}
if (type === 'boolean') {
config.type = 'switch';
return config;
}
if (type === 'string') {
config.type = 'text';
config.subType = property.subType;
config.placeholder = description;
return config;
}
if (type === 'array') {
config.type = 'multiselect';
config.subType = property.subType;
return config;
}
return config;
}
export function formatLabel(propertyName: string): string {
return propertyName
.replaceAll(/([A-Z])/g, ' $1')
.replaceAll('_', ' ')
.replace(/^./, (str) => str.toUpperCase())
.trim();
}
};