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();
}
};
@@ -9,6 +9,7 @@
mdiCrosshairsGps,
mdiImageSizeSelectLarge,
mdiLinkEdit,
mdiStateMachine,
} from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -16,7 +17,7 @@
{ href: Route.duplicatesUtility(), icon: mdiContentDuplicate, label: $t('review_duplicates') },
{ href: Route.largeFileUtility(), icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
{ href: Route.geolocationUtility(), icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
// { href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') },
{ href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') },
];
</script>
@@ -1,619 +0,0 @@
<script lang="ts">
import { beforeNavigate, goto } from '$app/navigation';
import { dragAndDrop } from '$lib/attachments/drag-and-drop.svelte';
import ControlAppBar from '$lib/components/shared-components/ControlAppBar.svelte';
import SchemaFormFields from './SchemaFormFields.svelte';
import WorkflowCardConnector from './WorkflowCardConnector.svelte';
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
import WorkflowSummarySidebar from './WorkflowSummary.svelte';
import WorkflowTriggerCard from './WorkflowTriggerCard.svelte';
import AddWorkflowStepModal from '$lib/modals/AddWorkflowStepModal.svelte';
import { Route } from '$lib/route';
import {
buildWorkflowPayload,
getActionsByContext,
getFiltersByContext,
handleUpdateWorkflow,
hasWorkflowChanged,
initializeConfigs,
parseWorkflowJson,
remapConfigsOnRemove,
remapConfigsOnReorder,
type WorkflowPayload,
} from '$lib/services/workflow.service';
import { handleError } from '$lib/utils/handle-error';
import type { PluginActionResponseDto, PluginFilterResponseDto, PluginTriggerResponseDto } from '@immich/sdk';
import {
Button,
Card,
CardBody,
CardDescription,
CardHeader,
CardTitle,
Container,
Field,
HStack,
Icon,
Input,
Switch,
Text,
Textarea,
VStack,
modalManager,
toastManager,
} from '@immich/ui';
import {
mdiArrowLeft,
mdiCodeJson,
mdiContentSave,
mdiFilterOutline,
mdiFlashOutline,
mdiInformationOutline,
mdiPlayCircleOutline,
mdiPlus,
mdiTrashCanOutline,
mdiViewDashboard,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
let { data }: Props = $props();
const triggers = data.triggers;
const filters = data.plugins.flatMap((plugin) => plugin.filters);
const actions = data.plugins.flatMap((plugin) => plugin.actions);
let previousWorkflow = data.workflow;
let editWorkflow = $state(data.workflow);
let viewMode: 'visual' | 'json' = $state('visual');
let name: string = $derived(editWorkflow.name ?? '');
let description: string = $derived(editWorkflow.description ?? '');
let selectedTrigger = $state(triggers.find((t) => t.type === editWorkflow.triggerType) ?? triggers[0]);
let triggerType = $derived(selectedTrigger.type);
let supportFilters = $derived(getFiltersByContext(filters, selectedTrigger.contextType));
let supportActions = $derived(getActionsByContext(actions, selectedTrigger.contextType));
let selectedFilters: PluginFilterResponseDto[] = $derived(
(editWorkflow.filters ?? []).flatMap((workflowFilter) =>
supportFilters.filter((supportedFilter) => supportedFilter.id === workflowFilter.pluginFilterId),
),
);
let selectedActions: PluginActionResponseDto[] = $derived(
(editWorkflow.actions ?? []).flatMap((workflowAction) =>
supportActions.filter((supportedAction) => supportedAction.id === workflowAction.pluginActionId),
),
);
let filterConfigs: Record<string, unknown> = $derived(initializeConfigs('filter', editWorkflow));
let actionConfigs: Record<string, unknown> = $derived(initializeConfigs('action', editWorkflow));
$effect(() => {
editWorkflow.triggerType = triggerType;
});
// Clear filters and actions when trigger changes (context changes)
let previousContext = $state<string | undefined>(undefined);
$effect(() => {
const currentContext = selectedTrigger.contextType;
if (previousContext !== undefined && previousContext !== currentContext) {
selectedFilters = [];
selectedActions = [];
filterConfigs = {};
actionConfigs = {};
}
previousContext = currentContext;
});
const updateWorkflow = async () => {
try {
const updated = await handleUpdateWorkflow(
editWorkflow.id,
name,
description,
editWorkflow.enabled,
triggerType,
selectedFilters,
selectedActions,
filterConfigs,
actionConfigs,
);
previousWorkflow = updated;
editWorkflow = updated;
toastManager.primary($t('workflow_update_success'), {
closable: true,
});
} catch (error) {
handleError(error, 'Failed to update workflow');
}
};
const jsonContent = $derived(
buildWorkflowPayload(
name,
description,
editWorkflow.enabled,
triggerType,
selectedFilters,
selectedActions,
filterConfigs,
actionConfigs,
),
);
let jsonEditorContent: WorkflowPayload = $state({
name: '',
description: '',
enabled: false,
triggerType: '',
filters: [],
actions: [],
});
const syncFromJson = () => {
const result = parseWorkflowJson(JSON.stringify(jsonEditorContent), triggers, filters, actions);
if (!result.success) {
return;
}
if (result.data) {
name = result.data.name;
description = result.data.description;
editWorkflow.enabled = result.data.enabled;
if (result.data.trigger) {
selectedTrigger = result.data.trigger;
}
selectedFilters = result.data.filters;
selectedActions = result.data.actions;
filterConfigs = result.data.filterConfigs;
actionConfigs = result.data.actionConfigs;
}
};
let hasChanges: boolean = $derived(
hasWorkflowChanged(
previousWorkflow,
editWorkflow.enabled,
name,
description,
triggerType,
selectedFilters,
selectedActions,
filterConfigs,
actionConfigs,
),
);
let draggedFilterIndex: number | null = $state(null);
let draggedActionIndex: number | null = $state(null);
let dragOverFilterIndex: number | null = $state(null);
let dragOverActionIndex: number | null = $state(null);
const handleFilterDragStart = (index: number) => {
draggedFilterIndex = index;
};
const handleFilterDragEnter = (index: number) => {
if (draggedFilterIndex !== null && draggedFilterIndex !== index) {
dragOverFilterIndex = index;
}
};
const handleFilterDrop = (e: DragEvent, index: number) => {
e.preventDefault();
if (draggedFilterIndex === null || draggedFilterIndex === index) {
return;
}
// Remap configs to follow the new order
filterConfigs = remapConfigsOnReorder(filterConfigs, 'filter', draggedFilterIndex, index, selectedFilters.length);
const newFilters = [...selectedFilters];
const [draggedItem] = newFilters.splice(draggedFilterIndex, 1);
newFilters.splice(index, 0, draggedItem);
selectedFilters = newFilters;
};
const handleFilterDragEnd = () => {
draggedFilterIndex = null;
dragOverFilterIndex = null;
};
const handleActionDragStart = (index: number) => {
draggedActionIndex = index;
};
const handleActionDragEnter = (index: number) => {
if (draggedActionIndex !== null && draggedActionIndex !== index) {
dragOverActionIndex = index;
}
};
const handleActionDrop = (e: DragEvent, index: number) => {
e.preventDefault();
if (draggedActionIndex === null || draggedActionIndex === index) {
return;
}
actionConfigs = remapConfigsOnReorder(actionConfigs, 'action', draggedActionIndex, index, selectedActions.length);
const newActions = [...selectedActions];
const [draggedItem] = newActions.splice(draggedActionIndex, 1);
newActions.splice(index, 0, draggedItem);
selectedActions = newActions;
};
const handleActionDragEnd = () => {
draggedActionIndex = null;
dragOverActionIndex = null;
};
const handleAddStep = async (type: 'action' | 'filter') => {
const result = await modalManager.show(AddWorkflowStepModal, {
filters: supportFilters,
actions: supportActions,
type,
});
if (result) {
if (result.type === 'filter') {
selectedFilters = [...selectedFilters, result.item as PluginFilterResponseDto];
} else if (result.type === 'action') {
selectedActions = [...selectedActions, result.item as PluginActionResponseDto];
}
}
};
const handleRemoveFilter = (index: number) => {
filterConfigs = remapConfigsOnRemove(filterConfigs, 'filter', index, selectedFilters.length);
selectedFilters = selectedFilters.filter((_, i) => i !== index);
};
const handleRemoveAction = (index: number) => {
actionConfigs = remapConfigsOnRemove(actionConfigs, 'action', index, selectedActions.length);
selectedActions = selectedActions.filter((_, i) => i !== index);
};
const handleTriggerChange = async (newTrigger: PluginTriggerResponseDto) => {
const confirmed = await modalManager.showDialog({
prompt: $t('change_trigger_prompt'),
title: $t('change_trigger'),
confirmColor: 'primary',
});
if (!confirmed) {
return;
}
selectedTrigger = newTrigger;
};
let allowNavigation = $state(false);
beforeNavigate(({ cancel, to }) => {
if (hasChanges && !allowNavigation) {
cancel();
modalManager
.showDialog({
prompt: $t('workflow_navigation_prompt'),
confirmColor: 'primary',
})
.then((isConfirmed) => {
if (isConfirmed && to) {
allowNavigation = true;
void goto(to.url);
}
})
.catch(() => {});
}
});
</script>
{#snippet cardOrder(index: number)}
<div class="flex size-8 shrink-0 place-content-center place-items-center rounded-lg border bg-light-50">
<Text size="small" class="font-mono font-bold">
{index + 1}
</Text>
</div>
{/snippet}
{#snippet stepSeparator()}
<div class="relative flex justify-center py-4">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t-2 border-dashed border-light-200"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<Text class="bg-white px-2 dark:bg-black" fontWeight="semi-bold" size="tiny" color="muted">{$t('then')}</Text>
</div>
</div>
{/snippet}
{#snippet emptyCreateButton(title: string, description: string, onclick: () => Promise<void>)}
<button
type="button"
{onclick}
class="flex w-full flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-8 transition-all hover:border-light-400 hover:bg-light-50"
>
<Icon icon={mdiPlus} size="32" />
<Text size="small" fontWeight="medium">{title}</Text>
<Text size="tiny">{description}</Text>
</button>
{/snippet}
<svelte:head>
<title>{data.meta.title} - Immich</title>
</svelte:head>
<main class="pt-24 immich-scrollbar">
<Container size="medium" class="p-4" center>
{#if viewMode === 'json'}
<WorkflowJsonEditor
jsonContent={jsonEditorContent}
onApply={syncFromJson}
onContentChange={(content) => (jsonEditorContent = content)}
/>
{:else}
<VStack gap={0}>
<Card expandable>
<CardHeader>
<div class="flex place-items-start gap-3">
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
<div class="flex flex-col">
<CardTitle>
{$t('workflow_info')}
</CardTitle>
</div>
</div>
</CardHeader>
<CardBody>
<VStack gap={4}>
<div
class="relative w-full overflow-hidden rounded-xl border p-4"
class:bg-primary-50={editWorkflow.enabled}
>
<Field
label={editWorkflow.enabled ? $t('enabled') : $t('disabled')}
for="workflow-enabled"
color={editWorkflow.enabled ? 'primary' : 'secondary'}
>
<Switch id="workflow-enabled" bind:checked={editWorkflow.enabled} />
</Field>
</div>
<Field label={$t('name')} for="workflow-name" required>
<Input id="workflow-name" placeholder={$t('workflow_name')} bind:value={name} />
</Field>
<Field label={$t('description')} for="workflow-description">
<Textarea
id="workflow-description"
grow
placeholder={$t('workflow_description')}
bind:value={description}
/>
</Field>
</VStack>
</CardBody>
</Card>
<div class="my-10 h-px w-[98%] bg-light-200"></div>
<Card expandable>
<CardHeader class="bg-primary-50">
<div class="flex items-start gap-3">
<Icon icon={mdiFlashOutline} size="20" class="mt-1 text-primary" />
<div class="flex flex-col">
<CardTitle class="text-left text-primary">{$t('trigger')}</CardTitle>
<CardDescription>{$t('trigger_description')}</CardDescription>
</div>
</div>
</CardHeader>
<CardBody>
<div class="grid grid-cols-2 gap-4">
{#each triggers as trigger (trigger.type)}
<WorkflowTriggerCard
{trigger}
selected={selectedTrigger.type === trigger.type}
onclick={() => handleTriggerChange(trigger)}
/>
{/each}
</div>
</CardBody>
</Card>
<WorkflowCardConnector />
<Card expandable>
<CardHeader class="bg-warning-50">
<div class="flex items-start gap-3">
<Icon icon={mdiFilterOutline} size="20" class="mt-1 text-warning" />
<div class="flex flex-col">
<CardTitle class="text-left text-warning">{$t('filter')}</CardTitle>
<CardDescription>{$t('filter_description')}</CardDescription>
</div>
</div>
</CardHeader>
<CardBody>
{#if selectedFilters.length === 0}
{@render emptyCreateButton($t('add_filter'), $t('add_filter_description'), () => handleAddStep('filter'))}
{:else}
{#each selectedFilters as filter, index (index)}
{#if index > 0}
{@render stepSeparator()}
{/if}
<div
{@attach dragAndDrop({
index,
onDragStart: handleFilterDragStart,
onDragEnter: handleFilterDragEnter,
onDrop: handleFilterDrop,
onDragEnd: handleFilterDragEnd,
isDragging: draggedFilterIndex === index,
isDragOver: dragOverFilterIndex === index,
})}
class="mb-4 cursor-move rounded-2xl border-2 border-dashed bg-light-50 p-4 transition-all hover:border-light-300"
>
<div class="flex items-start gap-4">
{@render cardOrder(index)}
<div class="flex-1">
<h1 class="mb-3 text-lg font-bold">{filter.title}</h1>
<SchemaFormFields
schema={filter.schema}
bind:config={filterConfigs}
configKey={`filter_${index}`}
/>
</div>
<div class="flex flex-col gap-2">
<Button
size="medium"
variant="ghost"
color="danger"
onclick={() => handleRemoveFilter(index)}
leadingIcon={mdiTrashCanOutline}
/>
</div>
</div>
</div>
{/each}
<Button
size="small"
fullWidth
variant="ghost"
leadingIcon={mdiPlus}
onclick={() => handleAddStep('filter')}
>
{$t('add_filter')}
</Button>
{/if}
</CardBody>
</Card>
<WorkflowCardConnector />
<Card expandable expanded>
<CardHeader class="bg-success-50">
<div class="flex items-start gap-3">
<Icon icon={mdiPlayCircleOutline} size="20" class="mt-1 text-success" />
<div class="flex flex-col">
<CardTitle class="text-left text-success">{$t('action')}</CardTitle>
<CardDescription>{$t('action_description')}</CardDescription>
</div>
</div>
</CardHeader>
<CardBody>
{#if selectedActions.length === 0}
{@render emptyCreateButton($t('add_action'), $t('add_action_description'), () => handleAddStep('action'))}
{:else}
{#each selectedActions as action, index (index)}
{#if index > 0}
{@render stepSeparator()}
{/if}
<div
{@attach dragAndDrop({
index,
onDragStart: handleActionDragStart,
onDragEnter: handleActionDragEnter,
onDrop: handleActionDrop,
onDragEnd: handleActionDragEnd,
isDragging: draggedActionIndex === index,
isDragOver: dragOverActionIndex === index,
})}
class="mb-4 cursor-move rounded-2xl border-2 border-dashed bg-light-50 p-4 transition-all hover:border-light-300"
>
<div class="flex items-start gap-4">
{@render cardOrder(index)}
<div class="flex-1">
<h1 class="mb-3 text-lg font-bold">{action.title}</h1>
<SchemaFormFields
schema={action.schema}
bind:config={actionConfigs}
configKey={`action_${index}`}
/>
</div>
<div class="flex flex-col gap-2">
<Button
size="medium"
variant="ghost"
color="danger"
onclick={() => handleRemoveAction(index)}
leadingIcon={mdiTrashCanOutline}
/>
</div>
</div>
</div>
{/each}
<Button
size="small"
fullWidth
variant="ghost"
leadingIcon={mdiPlus}
onclick={() => handleAddStep('action')}
>
{$t('add_action')}
</Button>
{/if}
</CardBody>
</Card>
</VStack>
{/if}
</Container>
<WorkflowSummarySidebar trigger={selectedTrigger} filters={selectedFilters} actions={selectedActions} />
</main>
<ControlAppBar onClose={() => goto(Route.workflows())} backIcon={mdiArrowLeft} tailwindClasses="fixed! top-0! w-full">
{#snippet leading()}
<Text>{data.meta.title}</Text>
{/snippet}
{#snippet trailing()}
<HStack gap={4}>
<HStack gap={1} class="rounded-lg border border-light-300 p-1">
<Button
size="small"
variant={viewMode === 'visual' ? 'outline' : 'ghost'}
color={viewMode === 'visual' ? 'primary' : 'secondary'}
leadingIcon={mdiViewDashboard}
onclick={() => (viewMode = 'visual')}
>
{$t('visual')}
</Button>
<Button
size="small"
variant={viewMode === 'json' ? 'outline' : 'ghost'}
color={viewMode === 'json' ? 'primary' : 'secondary'}
leadingIcon={mdiCodeJson}
onclick={() => {
viewMode = 'json';
jsonEditorContent = jsonContent;
}}
>
JSON
</Button>
</HStack>
<Button leadingIcon={mdiContentSave} size="small" color="primary" onclick={updateWorkflow} disabled={!hasChanges}>
{$t('save')}
</Button>
</HStack>
{/snippet}
</ControlAppBar>
@@ -1,23 +0,0 @@
import { getPlugins, getPluginTriggers, getWorkflow } from '@immich/sdk';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url, params }) => {
await authenticate(url);
const [plugins, workflow, triggers] = await Promise.all([
getPlugins(),
getWorkflow({ id: params.workflowId }),
getPluginTriggers(),
]);
const $t = await getFormatter();
return {
plugins,
workflow,
triggers,
meta: {
title: $t('edit_workflow'),
},
};
}) satisfies PageLoad;
@@ -1,161 +0,0 @@
<script lang="ts">
import { getComponentDefaultValue, getComponentFromSchema } from '$lib/utils/workflow';
import { Field, Input, MultiSelect, Select, Switch, Text } from '@immich/ui';
import WorkflowPickerField from './WorkflowPickerField.svelte';
type Props = {
schema: object | null;
config: Record<string, unknown>;
configKey?: string;
};
let { schema = null, config = $bindable({}), configKey }: Props = $props();
const components = $derived(getComponentFromSchema(schema));
// Get the actual config object to work with
const actualConfig = $derived(configKey ? (config[configKey] as Record<string, unknown>) || {} : config);
// Update function that handles nested config
const updateConfig = (key: string, value: unknown) => {
config = configKey ? { ...config, [configKey]: { ...actualConfig, [key]: value } } : { ...config, [key]: value };
};
const updateConfigBatch = (updates: Record<string, unknown>) => {
config = configKey ? { ...config, [configKey]: { ...actualConfig, ...updates } } : { ...config, ...updates };
};
// Derive which keys need initialization (missing from actualConfig)
const uninitializedKeys = $derived.by(() => {
if (!components) {
return [];
}
return Object.entries(components)
.filter(([key]) => actualConfig[key] === undefined)
.map(([key, component]) => ({ key, component, defaultValue: getComponentDefaultValue(component) }));
});
// Derive the batch updates needed
const pendingUpdates = $derived.by(() => {
const updates: Record<string, unknown> = {};
for (const { key, defaultValue } of uninitializedKeys) {
updates[key] = defaultValue;
}
return updates;
});
// Initialize config namespace if needed
$effect(() => {
if (configKey && !config[configKey]) {
config = { ...config, [configKey]: {} };
}
});
// Apply pending config updates
$effect(() => {
if (Object.keys(pendingUpdates).length > 0) {
updateConfigBatch(pendingUpdates);
}
});
const isPickerField = (subType: string | undefined) => subType === 'album-picker' || subType === 'people-picker';
</script>
{#if components}
<div class="flex flex-col gap-2">
{#each Object.entries(components) as [key, component] (key)}
{@const label = component.title || component.label || key}
<div class="flex flex-col gap-1 rounded-xl border bg-light p-4">
<!-- Select component -->
{#if component.type === 'select'}
{#if isPickerField(component.subType)}
<WorkflowPickerField
{component}
configKey={key}
value={actualConfig[key] as string | string[]}
onchange={(value) => updateConfig(key, value)}
/>
{:else}
{@const options = component.options?.map((opt) => {
return { label: opt.label, value: String(opt.value) };
}) || [{ label: 'N/A', value: '' }]}
<Field
{label}
required={component.required}
description={component.description}
requiredIndicator={component.required}
>
<Select {options} onChange={(value) => updateConfig(key, value)} value={actualConfig[key] as string} />
</Field>
{/if}
<!-- MultiSelect component -->
{:else if component.type === 'multiselect'}
{#if isPickerField(component.subType)}
<WorkflowPickerField
{component}
configKey={key}
value={actualConfig[key] as string | string[]}
onchange={(value) => updateConfig(key, value)}
/>
{:else}
{@const options = component.options?.map((opt) => {
return { label: opt.label, value: String(opt.value) };
}) || [{ label: 'N/A', value: '' }]}
<Field
{label}
required={component.required}
description={component.description}
requiredIndicator={component.required}
>
<MultiSelect
{options}
values={(actualConfig[key] as string[]) ?? []}
onChange={(values) => updateConfig(key, values)}
/>
</Field>
{/if}
<!-- Switch component -->
{:else if component.type === 'switch'}
{@const checked = Boolean(actualConfig[key])}
<Field
{label}
description={component.description}
requiredIndicator={component.required}
required={component.required}
>
<Switch {checked} onCheckedChange={(check) => updateConfig(key, check)} />
</Field>
<!-- Text input -->
{:else if isPickerField(component.subType)}
<WorkflowPickerField
{component}
configKey={key}
value={actualConfig[key] as string | string[]}
onchange={(value) => updateConfig(key, value)}
/>
{:else}
<Field
{label}
description={component.description}
requiredIndicator={component.required}
required={component.required}
>
<Input
id={key}
value={actualConfig[key] as string}
oninput={(e) => updateConfig(key, e.currentTarget.value)}
required={component.required}
/>
</Field>
{/if}
</div>
{/each}
</div>
{:else}
<Text size="small" color="muted">No configuration required</Text>
{/if}
@@ -1,42 +0,0 @@
<script lang="ts">
type Props = {
animated?: boolean;
};
let { animated = true }: Props = $props();
</script>
<div class="flex justify-center py-2">
<div class="relative h-12 w-0.5">
<div class="absolute inset-0 bg-linear-to-b from-primary/30 via-primary/50 to-primary/30"></div>
{#if animated}
<div class="flow-pulse absolute inset-0 bg-linear-to-b from-transparent via-primary to-transparent"></div>
{/if}
<div class="absolute top-0 left-1/2 -translate-1/2">
<div class="size-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
</div>
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2">
<div class="size-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
</div>
</div>
</div>
<style>
@keyframes flow {
0% {
transform: translateY(-25%);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: translateY(25%);
opacity: 0;
}
}
.flow-pulse {
animation: flow 2s ease-in-out infinite;
}
</style>
@@ -1,104 +0,0 @@
<script lang="ts">
import WorkflowPickerItemCard from './WorkflowPickerItemCard.svelte';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
import { fetchPickerMetadata, type PickerMetadata } from '$lib/services/workflow.service';
import type { ComponentConfig } from '$lib/utils/workflow';
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
import { Button, Field, modalManager } from '@immich/ui';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
component: ComponentConfig;
configKey: string;
value: string | string[] | undefined;
onchange: (value: string | string[]) => void;
};
let { component, configKey, value = $bindable(), onchange }: Props = $props();
const label = $derived(component.title || component.label || configKey);
const subType = $derived(component.subType as 'album-picker' | 'people-picker');
const isAlbum = $derived(subType === 'album-picker');
const multiple = $derived(component.type === 'multiselect' || Array.isArray(value));
let pickerMetadata = $state<PickerMetadata | undefined>();
$effect(() => {
if (!value) {
pickerMetadata = undefined;
return;
}
if (!pickerMetadata) {
void loadMetadata();
}
});
const loadMetadata = async () => {
pickerMetadata = await fetchPickerMetadata(value, subType);
};
const handlePicker = async () => {
if (isAlbum) {
const albums = await modalManager.show(AlbumPickerModal);
if (albums && albums.length > 0) {
const newValue = multiple ? albums.map((album) => album.id) : albums[0].id;
onchange(newValue);
pickerMetadata = multiple ? albums : albums[0];
}
} else {
const currentIds = (Array.isArray(value) ? value : []) as string[];
const excludedIds = multiple ? currentIds : [];
const people = await modalManager.show(PeoplePickerModal, { multiple, excludedIds });
if (people && people.length > 0) {
const newValue = multiple ? people.map((person) => person.id) : people[0].id;
onchange(newValue);
pickerMetadata = multiple ? people : people[0];
}
}
};
const removeSelection = () => {
onchange(multiple ? [] : '');
pickerMetadata = undefined;
};
const removeItemFromSelection = (itemId: string) => {
if (!Array.isArray(value)) {
return;
}
const newValue = value.filter((id) => id !== itemId);
onchange(newValue);
if (Array.isArray(pickerMetadata)) {
pickerMetadata = pickerMetadata.filter((item) => item.id !== itemId) as AlbumResponseDto[] | PersonResponseDto[];
}
};
const getButtonText = () => {
if (isAlbum) {
return multiple ? $t('select_albums') : $t('select_album');
}
return multiple ? $t('select_people') : $t('select_person');
};
</script>
<Field {label} required={component.required} description={component.description} requiredIndicator={component.required}>
<div class="flex flex-col gap-3">
{#if pickerMetadata && !Array.isArray(pickerMetadata)}
<WorkflowPickerItemCard item={pickerMetadata} {isAlbum} onRemove={removeSelection} />
{:else if pickerMetadata && Array.isArray(pickerMetadata) && pickerMetadata.length > 0}
<div class="flex flex-col gap-2">
{#each pickerMetadata as item (item.id)}
<WorkflowPickerItemCard {item} {isAlbum} onRemove={() => removeItemFromSelection(item.id)} />
{/each}
</div>
{/if}
<Button size="small" variant="outline" leadingIcon={mdiPlus} onclick={handlePicker}>
{getButtonText()}
</Button>
</div>
</Field>
@@ -1,57 +0,0 @@
<script lang="ts">
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
import { Card, CardBody, IconButton, Text } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
item: AlbumResponseDto | PersonResponseDto;
isAlbum: boolean;
onRemove: () => void;
};
let { item, isAlbum, onRemove }: Props = $props();
</script>
<Card color="secondary">
<CardBody class="flex items-center gap-3">
<div class="shrink-0">
{#if isAlbum && 'albumThumbnailAssetId' in item}
{#if item.albumThumbnailAssetId}
<img
src={getAssetMediaUrl({ id: item.albumThumbnailAssetId })}
alt={item.albumName}
class="size-12 rounded-lg object-cover"
/>
{:else}
<div class="size-12 rounded-lg"></div>
{/if}
{:else if !isAlbum && 'name' in item}
<img src={getPeopleThumbnailUrl(item)} alt={item.name} class="size-12 rounded-full object-cover" />
{/if}
</div>
<div class="min-w-0 flex-1">
<Text class="truncate" fontWeight="semi-bold">
{isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''}
</Text>
{#if isAlbum && 'assetCount' in item}
<Text size="small" color="muted">
{$t('items_count', { values: { count: item.assetCount } })}
</Text>
{/if}
</div>
<IconButton
type="button"
onclick={onRemove}
class="shrink-0"
shape="round"
aria-label={$t('remove')}
icon={mdiClose}
size="small"
variant="ghost"
color="secondary"
/>
</CardBody>
</Card>
@@ -1,80 +0,0 @@
<script lang="ts">
import { PluginTriggerType, type PluginTriggerResponseDto } from '@immich/sdk';
import { Icon, Text } from '@immich/ui';
import { mdiFaceRecognition, mdiFileUploadOutline, mdiLightningBolt } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
trigger: PluginTriggerResponseDto;
selected: boolean;
onclick: () => void;
};
let { trigger, selected, onclick }: Props = $props();
const getTriggerIcon = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return mdiFileUploadOutline;
}
case PluginTriggerType.PersonRecognized: {
return mdiFaceRecognition;
}
default: {
return mdiLightningBolt;
}
}
};
const getTriggerName = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return $t('trigger_asset_uploaded');
}
case PluginTriggerType.PersonRecognized: {
return $t('trigger_person_recognized');
}
default: {
return triggerType;
}
}
};
const getTriggerDescription = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return $t('trigger_asset_uploaded_description');
}
case PluginTriggerType.PersonRecognized: {
return $t('trigger_person_recognized_description');
}
default: {
return '';
}
}
};
</script>
<button
type="button"
{onclick}
class="group w-full cursor-pointer rounded-xl border-2 p-4 text-left {selected
? 'border-primary text-primary'
: 'border-light-100 text-light-400 hover:border-light-200 hover:text-light-700'}"
>
<div class="flex items-center gap-3">
<div
class="rounded-xl p-2 {selected
? 'bg-primary text-light'
: 'bg-light-300 text-light-100 group-hover:bg-light-500'}"
>
<Icon icon={getTriggerIcon(trigger.type)} size="24" />
</div>
<div class="flex-1">
<Text fontWeight="semi-bold" class="mb-1">{getTriggerName(trigger.type)}</Text>
{#if getTriggerDescription(trigger.type)}
<Text size="small">{getTriggerDescription(trigger.type)}</Text>
{/if}
</div>
</div>
</button>
@@ -4,14 +4,10 @@
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { Route } from '$lib/route';
import {
getWorkflowActions,
getWorkflowsActions,
getWorkflowShowSchemaAction,
type WorkflowPayload,
} from '$lib/services/workflow.service';
import { type PluginFilterResponseDto, type WorkflowResponseDto } from '@immich/sdk';
import { getWorkflowActions, getWorkflowsActions, getWorkflowShowSchemaAction } from '$lib/services/workflow.service';
import { getWorkflowForShare, type WorkflowResponseDto } from '@immich/sdk';
import {
Button,
Card,
@@ -20,6 +16,7 @@
CardHeader,
CardTitle,
CodeBlock,
Container,
IconButton,
MenuItemType,
menuManager,
@@ -28,7 +25,7 @@
} from '@immich/ui';
import { mdiClose, mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { SvelteSet } from 'svelte/reactivity';
import type { PageData } from './$types';
type Props = {
@@ -39,66 +36,16 @@
let workflows = $state<WorkflowResponseDto[]>(data.workflows);
const expandedWorkflows = new SvelteSet<string>();
const pluginFilterLookup = new SvelteMap<string, PluginFilterResponseDto>();
const pluginActionLookup = new SvelteMap<string, PluginFilterResponseDto>();
const expandedIds = new SvelteSet<string>();
for (const plugin of data.plugins) {
for (const filter of plugin.filters ?? []) {
pluginFilterLookup.set(filter.id, { ...filter });
}
for (const action of plugin.actions ?? []) {
pluginActionLookup.set(action.id, { ...action });
}
}
const toggleShowingSchema = (id: string) => {
if (expandedWorkflows.has(id)) {
expandedWorkflows.delete(id);
const toggleExpanded = (id: string) => {
if (expandedIds.has(id)) {
expandedIds.delete(id);
} else {
expandedWorkflows.add(id);
expandedIds.add(id);
}
};
const constructPayload = (workflow: WorkflowResponseDto): WorkflowPayload => {
const orderedFilters = [...(workflow.filters ?? [])].sort((a, b) => a.order - b.order);
const orderedActions = [...(workflow.actions ?? [])].sort((a, b) => a.order - b.order);
return {
name: workflow.name ?? '',
description: workflow.description ?? '',
enabled: workflow.enabled,
triggerType: workflow.triggerType,
filters: orderedFilters.map((filter) => {
const meta = pluginFilterLookup.get(filter.pluginFilterId);
const key = meta?.methodName ?? filter.pluginFilterId;
return {
[key]: filter.filterConfig ?? {},
};
}),
actions: orderedActions.map((action) => {
const meta = pluginActionLookup.get(action.pluginActionId);
const key = meta?.methodName ?? action.pluginActionId;
return {
[key]: action.actionConfig ?? {},
};
}),
};
};
const getJson = (workflow: WorkflowResponseDto) => JSON.stringify(constructPayload(workflow), null, 2);
const getFilterLabel = (filterId: string) => {
const meta = pluginFilterLookup.get(filterId);
return meta?.title ?? $t('filter');
};
const getActionLabel = (actionId: string) => {
const meta = pluginActionLookup.get(actionId);
return meta?.title ?? $t('action');
};
const getTriggerLabel = (triggerType: string) => {
const labels: Record<string, string> = {
AssetCreate: $t('asset_created'),
@@ -121,7 +68,7 @@
items: [
ToggleEnabled,
Edit,
getWorkflowShowSchemaAction($t, expandedWorkflows.has(workflow.id), () => toggleShowingSchema(workflow.id)),
getWorkflowShowSchemaAction($t, expandedIds.has(workflow.id), () => toggleExpanded(workflow.id)),
MenuItemType.Divider,
Delete,
],
@@ -153,9 +100,10 @@
<UserPageLayout title={data.meta.title} actions={[Create]} scrollbar={false}>
<section class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 sm:w-5/6 md:w-4xl">
<Container center size="large" class="pb-28">
{#if workflows.length === 0}
<EmptyPlaceholder
fullWidth
title={$t('create_first_workflow')}
text={$t('workflows_help_text')}
onClick={() => Create.onAction(Create)}
@@ -177,11 +125,11 @@
<div class="flex items-center gap-3">
<span class="rounded-full {workflow.enabled ? 'size-3 bg-success' : 'size-3 rounded-full bg-muted'}"
></span>
<CardTitle>{workflow.name}</CardTitle>
<CardTitle>{workflow.name || $t('workflow')}</CardTitle>
</div>
<CardDescription class="mt-1 text-sm">
{workflow.description || $t('workflows_help_text')}
</CardDescription>
{#if workflow.description}
<CardDescription class="mt-1 text-sm">{workflow.description}</CardDescription>
{/if}
</div>
<div class="flex items-center gap-4">
@@ -209,42 +157,24 @@
<div class="mb-3">
<Text size="tiny" color="muted" fontWeight="medium">{$t('trigger')}</Text>
</div>
{@render chipItem(getTriggerLabel(workflow.triggerType))}
</div>
<!-- Filters Section -->
<div class="rounded-2xl border border-light-200 bg-light-50 p-4">
<div class="mb-3">
<Text size="tiny" color="muted" fontWeight="medium">{$t('filters')}</Text>
</div>
<div class="flex flex-wrap gap-2">
{#if workflow.filters.length === 0}
<span class="text-sm text-light-600">
{$t('no_filters_added')}
</span>
{:else}
{#each workflow.filters as workflowFilter (workflowFilter.id)}
{@render chipItem(getFilterLabel(workflowFilter.pluginFilterId))}
{/each}
{/if}
</div>
{@render chipItem(getTriggerLabel(workflow.trigger))}
</div>
<!-- Actions Section -->
<div class="rounded-2xl border border-light-200 bg-light-50 p-4">
<div class="mb-3">
<Text size="tiny" color="muted" fontWeight="medium">{$t('actions')}</Text>
<Text size="tiny" color="muted" fontWeight="medium">{$t('steps')}</Text>
</div>
<div>
{#if workflow.actions.length === 0}
{#if workflow.steps.length === 0}
<span class="text-sm text-light-600">
{$t('no_actions_added')}
{$t('no_steps')}
</span>
{:else}
<div class="flex flex-wrap gap-2">
{#each workflow.actions as workflowAction (workflowAction.id)}
{@render chipItem(getActionLabel(workflowAction.pluginActionId))}
{#each workflow.steps as step, i (i)}
{@render chipItem(pluginManager.getMethodLabel(step.method))}
{/each}
</div>
{/if}
@@ -252,23 +182,25 @@
</div>
</div>
{#if expandedWorkflows.has(workflow.id)}
<VStack gap={2} class="w-full rounded-2xl border border-light-200 bg-light-50 p-4">
<CodeBlock code={getJson(workflow)} lineNumbers />
<Button
leadingIcon={mdiClose}
fullWidth
variant="ghost"
color="secondary"
onclick={() => toggleShowingSchema(workflow.id)}>{$t('close')}</Button
>
</VStack>
{#if expandedIds.has(workflow.id)}
{#await getWorkflowForShare({ id: workflow.id }) then result}
<VStack gap={2} class="w-full rounded-2xl border border-light-200 bg-light-50 p-4">
<CodeBlock code={JSON.stringify(result, null, 2)} lineNumbers />
<Button
leadingIcon={mdiClose}
fullWidth
variant="ghost"
color="secondary"
onclick={() => toggleExpanded(workflow.id)}>{$t('close')}</Button
>
</VStack>
{/await}
{/if}
</CardBody>
</Card>
{/each}
</div>
{/if}
</section>
</Container>
</section>
</UserPageLayout>
@@ -1,5 +1,5 @@
import { getPlugins, getWorkflows } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import { searchWorkflows } from '@immich/sdk';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
@@ -7,17 +7,11 @@ import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const isReady = false;
if (!isReady) {
redirect(307, '/utilities');
}
const [workflows, plugins] = await Promise.all([getWorkflows(), getPlugins()]);
const [workflows] = await Promise.all([searchWorkflows({}), pluginManager.ready()]);
const $t = await getFormatter();
return {
workflows,
plugins,
meta: {
title: $t('workflows'),
},
@@ -0,0 +1,257 @@
<script lang="ts">
import { goto, invalidate } from '$app/navigation';
import OnEvents from '$lib/components/OnEvents.svelte';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
import WorkflowEditStepModal from '$lib/modals/WorkflowEditStepModal.svelte';
import WorkflowTriggerPicker from '$lib/modals/WorkflowTriggerPicker.svelte';
import { Route } from '$lib/route';
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import type { WorkflowResponseDto, WorkflowStepDto } from '@immich/sdk';
import {
ActionBar,
AppShell,
AppShellBar,
Button,
Card,
CardBody,
CardDescription,
CardHeader,
CardTitle,
Container,
ControlBarContent,
ControlBarDescription,
ControlBarHeader,
ControlBarTitle,
Field,
Icon,
IconButton,
Input,
modalManager,
Stack,
Switch,
Text,
Textarea,
VStack,
type ActionItem,
} from '@immich/ui';
import {
mdiArrowLeft,
mdiContentSave,
mdiFlashOutline,
mdiFormatListBulletedSquare,
mdiInformationOutline,
mdiPencilOutline,
mdiPlus,
mdiTrashCanOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
type Props = {
data: PageData;
};
let { data }: Props = $props();
let { id, enabled, name, description, trigger, steps } = $derived(data.workflow);
const handleAddStep = async () => {
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
if (step) {
steps = [...steps, step];
}
};
const handleEditStep = async (step: WorkflowStepDto) => {
const result = await modalManager.show(WorkflowEditStepModal, { trigger, step });
if (result) {
Object.assign(step, result);
}
};
const handleDeleteStep = async (index: number) => {
const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') });
if (confirmed) {
steps.splice(index, 1);
steps = [...steps];
}
};
const onClose = async () => {
// check for pending changes
await goto(Route.workflows());
};
const onChangeTrigger = async () => {
const newTrigger = await modalManager.show(WorkflowTriggerPicker, { selected: trigger });
if (newTrigger) {
trigger = newTrigger;
}
};
const onWorkflowUpdate = async (response: WorkflowResponseDto) => {
if (id === response.id) {
data.workflow = response;
await invalidate('workflow:data');
}
};
const Done: ActionItem = {
title: $t('save'),
icon: mdiContentSave,
color: 'primary',
onAction: () => handleUpdateWorkflow(id, { enabled, name, description, trigger, steps }),
};
</script>
<OnEvents {onWorkflowUpdate} />
<AppShell>
<AppShellBar>
<ActionBar static {onClose} translations={{ close: $t('back') }} closeIcon={mdiArrowLeft}>
<ControlBarHeader>
<ControlBarTitle>{data.workflow.name}</ControlBarTitle>
<ControlBarDescription>{data.workflow.description}</ControlBarDescription>
</ControlBarHeader>
<ControlBarContent class="flex justify-end">
<HeaderActionButton action={Done} variant="filled" />
</ControlBarContent>
</ActionBar>
</AppShellBar>
<Container size="medium" class="pt-8 pb-24" center>
<VStack gap={4}>
<Card expandable>
<CardHeader>
<div class="flex place-items-start gap-3">
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
<div class="flex flex-col">
<CardTitle>
{$t('workflow_info')}
</CardTitle>
</div>
</div>
</CardHeader>
<CardBody>
<VStack gap={4}>
<div class="relative w-full overflow-hidden rounded-xl border p-4" class:bg-primary-50={enabled}>
<Field label={enabled ? $t('enabled') : $t('disabled')} color={enabled ? 'primary' : 'secondary'}>
<Switch bind:checked={enabled} />
</Field>
</div>
<Field label={$t('name')} required>
<Input
placeholder={$t('workflow_name')}
bind:value={() => name ?? '', (value) => (name = value || null)}
/>
</Field>
<Field label={$t('description')} for="workflow-description">
<Textarea
id="workflow-description"
grow
placeholder={$t('workflow_description')}
bind:value={() => description ?? '', (value) => (description = value || null)}
/>
</Field>
</VStack>
</CardBody>
</Card>
<div class="my-4 h-px w-[98%] bg-light-200"></div>
<Card>
<CardHeader class="bg-success-50">
<div class="flex items-start gap-3">
<Icon icon={mdiFlashOutline} size="20" class="mt-1 text-success" />
<div class="flex grow flex-col">
<CardTitle class="text-left text-success">{$t('trigger')}</CardTitle>
<CardDescription>{$t('trigger_description')}</CardDescription>
</div>
<div class="flex items-center justify-end">
<Button leadingIcon={mdiPencilOutline} size="small" color="secondary" onclick={onChangeTrigger}>
{$t('edit')}
</Button>
</div>
</div>
</CardHeader>
<CardBody>
<div class="flex flex-col items-start">
<Text>{getTriggerName($t, trigger)}</Text>
<Text size="small" color="muted">{getTriggerDescription($t, trigger)}</Text>
</div>
</CardBody>
</Card>
<Card>
<CardHeader class="bg-primary-50">
<div class="flex items-start gap-3">
<Icon icon={mdiFormatListBulletedSquare} size="20" class="mt-1 text-primary" />
<CardTitle class="text-left text-primary">{$t('steps')}</CardTitle>
</div>
</CardHeader>
<CardBody>
{#if steps.length === 0}
<Button leadingIcon={mdiPlus} onclick={handleAddStep}>{$t('add_step')}</Button>
{:else}
<Stack gap={2}>
{#each steps as step, index (index)}
{@const method = pluginManager.getMethod(step.method)}
{#if index > 0}
<hr />
{/if}
<div
// {@attach dragAndDrop({
// index,
// onDragStart: handleFilterDragStart,
// onDragEnter: handleFilterDragEnter,
// onDrop: handleFilterDrop,
// onDragEnd: handleFilterDragEnd,
// isDragging: draggedIndex === index,
// isDragOver: dragOverIndex === index,
// })}
class="flex cursor-move justify-between gap-2 rounded-2xl border-2 border-dashed bg-light-50 p-4 transition-all hover:border-light-300"
>
<div class="flex flex-col gap-1">
<Text>{pluginManager.getMethodLabel(step.method)}</Text>
{#if method?.description}
<Text color="muted" size="small">{method.description}</Text>
{/if}
</div>
<div class="flex gap-1">
<IconButton
icon={mdiPencilOutline}
aria-label={$t('edit')}
variant="ghost"
shape="round"
color="secondary"
onclick={() => handleEditStep(step)}
/>
<IconButton
icon={mdiTrashCanOutline}
aria-label={$t('delete')}
variant="ghost"
shape="round"
color="danger"
onclick={() => handleDeleteStep(index)}
/>
</div>
</div>
{/each}
<Button size="small" fullWidth variant="ghost" leadingIcon={mdiPlus} onclick={handleAddStep}>
{$t('add_step')}
</Button>
</Stack>
{/if}
</CardBody>
</Card>
</VStack>
</Container>
</AppShell>
@@ -0,0 +1,26 @@
import { searchWorkflows } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { Route } from '$lib/route';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url, params, depends }) => {
await authenticate(url);
const [[workflow]] = await Promise.all([searchWorkflows({ id: params.workflowId }), pluginManager.ready()]);
const $t = await getFormatter();
if (!workflow) {
redirect(307, Route.workflows());
}
depends('workflow:data');
return {
workflow,
meta: {
title: $t('edit_workflow'),
},
};
}) satisfies PageLoad;
@@ -1,5 +1,5 @@
<script lang="ts">
import type { WorkflowPayload } from '$lib/services/workflow.service';
import type { WorkflowResponseDto } from '@immich/sdk';
import {
Button,
Card,
@@ -16,9 +16,9 @@
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
type Props = {
jsonContent: WorkflowPayload;
jsonContent: WorkflowResponseDto;
onApply: () => void;
onContentChange: (content: WorkflowPayload) => void;
onContentChange: (content: WorkflowResponseDto) => void;
};
let { jsonContent, onApply, onContentChange }: Props = $props();
@@ -1,35 +1,16 @@
<script lang="ts">
import {
PluginTriggerType,
type PluginActionResponseDto,
type PluginFilterResponseDto,
type PluginTriggerResponseDto,
} from '@immich/sdk';
import { getTriggerName } from '$lib/utils/workflow';
import type { WorkflowResponseDto } from '@immich/sdk';
import { Icon, IconButton, Text } from '@immich/ui';
import { mdiClose, mdiFilterOutline, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
import { mdiClose, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
trigger: PluginTriggerResponseDto;
filters: PluginFilterResponseDto[];
actions: PluginActionResponseDto[];
workflow: WorkflowResponseDto;
};
let { trigger, filters, actions }: Props = $props();
const getTriggerName = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return $t('trigger_asset_uploaded');
}
case PluginTriggerType.PersonRecognized: {
return $t('trigger_person_recognized');
}
default: {
return triggerType;
}
}
};
let { workflow }: Props = $props();
const { trigger, steps } = $derived(workflow);
let isOpen = $state(false);
let position = $state({ x: 0, y: 0 });
@@ -115,7 +96,7 @@
<Icon icon={mdiFlashOutline} size="18" class="text-primary" />
<Text size="tiny" fontWeight="semi-bold">{$t('trigger')}</Text>
</div>
<p class="truncate pl-5 text-sm">{getTriggerName(trigger.type)}</p>
<p class="truncate pl-5 text-sm">{getTriggerName($t, trigger)}</p>
</div>
<!-- Connector -->
@@ -123,47 +104,21 @@
<div class="h-3 w-0.5 bg-light-400"></div>
</div>
<!-- Filters -->
{#if filters.length > 0}
<div class="rounded-lg border bg-light-100 p-3">
<div class="mb-2 flex items-center gap-2">
<Icon icon={mdiFilterOutline} size="18" class="text-warning" />
<Text size="tiny" fontWeight="semi-bold">{$t('filters')}</Text>
</div>
<div class="space-y-1 pl-5">
{#each filters as filter, index (index)}
<div class="flex items-center gap-2">
<span
class="flex size-4 shrink-0 items-center justify-center rounded-full bg-light-200 text-[10px] font-medium"
>{index + 1}</span
>
<p class="truncate text-sm">{filter.title}</p>
</div>
{/each}
</div>
</div>
<!-- Connector -->
<div class="flex justify-center">
<div class="h-3 w-0.5 bg-light-400"></div>
</div>
{/if}
<!-- Actions -->
{#if actions.length > 0}
<!-- Steps -->
{#if steps.length > 0}
<div class="rounded-lg border bg-light-100 p-3">
<div class="mb-2 flex items-center gap-2">
<Icon icon={mdiPlayCircleOutline} size="18" class="text-success" />
<Text size="tiny" fontWeight="semi-bold">{$t('actions')}</Text>
</div>
<div class="space-y-1 pl-5">
{#each actions as action, index (index)}
{#each steps as step, index (index)}
<div class="flex items-center gap-2">
<span
class="flex size-4 shrink-0 items-center justify-center rounded-full bg-light-200 text-[10px] font-medium"
>{index + 1}</span
>
<p class="truncate text-sm">{action.title}</p>
<p class="truncate text-sm">{step.method}</p>
</div>
{/each}
</div>