Files
immich/web/src/lib/services/workflow.service.ts

486 lines
14 KiB
TypeScript
Raw Normal View History

2025-12-05 16:37:10 +00:00
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
2025-12-05 16:47:46 +00:00
import { eventManager } from '$lib/managers/event-manager.svelte';
2025-12-05 16:37:10 +00:00
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
2025-12-05 16:37:10 +00:00
createWorkflow,
deleteWorkflow,
2025-12-05 20:24:37 +00:00
getAlbumInfo,
getPerson,
PluginTriggerType,
2025-12-05 16:37:10 +00:00
updateWorkflow,
2025-12-05 20:24:37 +00:00
type AlbumResponseDto,
type PersonResponseDto,
type PluginActionResponseDto,
2025-12-02 21:50:07 +00:00
type PluginContextType,
type PluginFilterResponseDto,
type PluginTriggerResponseDto,
2025-12-02 21:50:07 +00:00
type WorkflowActionItemDto,
type WorkflowFilterItemDto,
type WorkflowResponseDto,
type WorkflowUpdateDto,
} from '@immich/sdk';
2025-12-05 16:37:10 +00:00
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiCodeJson, mdiDelete, mdiPause, mdiPencil, mdiPlay } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
2025-12-05 20:24:37 +00:00
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>[];
}
2025-12-02 21:50:07 +00:00
/**
* 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));
};
/**
* Remap configs when items are reordered (drag-drop)
* Moves config from old index to new index position
2025-12-02 21:50:07 +00:00
*/
export const remapConfigsOnReorder = (
configs: Record<string, unknown>,
prefix: 'filter' | 'action',
fromIndex: number,
toIndex: number,
totalCount: number,
2025-12-02 21:50:07 +00:00
): 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;
};
/**
* Initialize filter configurations from existing workflow
* Uses index-based keys to support multiple filters of the same type
*/
export const initializeFilterConfigs = (workflow: WorkflowResponseDto): Record<string, unknown> => {
2025-12-02 21:50:07 +00:00
const configs: Record<string, unknown> = {};
if (workflow.filters) {
for (const [index, workflowFilter] of workflow.filters.entries()) {
configs[`filter_${index}`] = workflowFilter.filterConfig ?? {};
2025-12-02 21:50:07 +00:00
}
}
2025-12-02 21:50:07 +00:00
return configs;
};
/**
* Initialize action configurations from existing workflow
* Uses index-based keys to support multiple actions of the same type
2025-12-02 21:50:07 +00:00
*/
export const initializeActionConfigs = (workflow: WorkflowResponseDto): Record<string, unknown> => {
2025-12-02 21:50:07 +00:00
const configs: Record<string, unknown> = {};
if (workflow.actions) {
for (const [index, workflowAction] of workflow.actions.entries()) {
configs[`action_${index}`] = workflowAction.actionConfig ?? {};
2025-12-02 21:50:07 +00:00
}
}
2025-12-02 21:50:07 +00:00
return configs;
};
/**
* Build workflow payload from current state
* Uses index-based keys to support multiple filters/actions of the same type
2025-12-02 21:50:07 +00:00
*/
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}`] ?? {},
2025-12-02 21:50:07 +00:00
}));
const actions = orderedActions.map((action, index) => ({
[action.methodName]: actionConfigs[`action_${index}`] ?? {},
2025-12-02 21:50:07 +00:00
}));
return {
name,
description,
enabled,
triggerType,
filters,
actions,
};
};
/**
* Parse JSON workflow and update state
*/
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);
// Find trigger
const trigger = availableTriggers.find((t) => t.type === parsed.triggerType);
// Parse filters (using index-based keys to support multiple of same type)
2025-12-02 21:50:07 +00:00
const filters: PluginFilterResponseDto[] = [];
const filterConfigs: Record<string, unknown> = {};
if (Array.isArray(parsed.filters)) {
for (const [index, filterObj] of parsed.filters.entries()) {
2025-12-02 21:50:07 +00:00
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];
}
}
}
// Parse actions (using index-based keys to support multiple of same type)
2025-12-02 21:50:07 +00:00
const actions: PluginActionResponseDto[] = [];
const actionConfigs: Record<string, unknown> = {};
if (Array.isArray(parsed.actions)) {
for (const [index, actionObj] of parsed.actions.entries()) {
2025-12-02 21:50:07 +00:00
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];
}
}
}
2025-12-02 21:50:07 +00:00
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',
};
}
2025-12-02 21:50:07 +00:00
};
/**
* Check if workflow has changes compared to previous version
*/
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 => {
// Check enabled state
if (enabled !== previousWorkflow.enabled) {
return true;
}
2025-12-02 21:50:07 +00:00
// Check name or description
if (name !== (previousWorkflow.name ?? '') || description !== (previousWorkflow.description ?? '')) {
return true;
}
2025-12-02 21:50:07 +00:00
// Check trigger
if (triggerType !== previousWorkflow.triggerType) {
return true;
}
2025-12-02 21:50:07 +00:00
// Check filters order/items
const previousFilterIds = previousWorkflow.filters?.map((f) => f.pluginFilterId) ?? [];
const currentFilterIds = orderedFilters.map((f) => f.id);
if (JSON.stringify(previousFilterIds) !== JSON.stringify(currentFilterIds)) {
return true;
}
2025-12-02 21:50:07 +00:00
// Check actions order/items
const previousActionIds = previousWorkflow.actions?.map((a) => a.pluginActionId) ?? [];
const currentActionIds = orderedActions.map((a) => a.id);
if (JSON.stringify(previousActionIds) !== JSON.stringify(currentActionIds)) {
return true;
}
// Check filter configs (using index-based keys)
2025-12-02 21:50:07 +00:00
const previousFilterConfigs: Record<string, unknown> = {};
for (const [index, wf] of (previousWorkflow.filters ?? []).entries()) {
previousFilterConfigs[`filter_${index}`] = wf.filterConfig ?? {};
2025-12-02 21:50:07 +00:00
}
if (JSON.stringify(previousFilterConfigs) !== JSON.stringify(filterConfigs)) {
return true;
}
// Check action configs (using index-based keys)
2025-12-02 21:50:07 +00:00
const previousActionConfigs: Record<string, unknown> = {};
for (const [index, wa] of (previousWorkflow.actions ?? []).entries()) {
previousActionConfigs[`action_${index}`] = wa.actionConfig ?? {};
}
2025-12-02 21:50:07 +00:00
if (JSON.stringify(previousActionConfigs) !== JSON.stringify(actionConfigs)) {
return true;
}
2025-12-02 21:50:07 +00:00
return false;
};
/**
* Update a workflow via API
*/
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) => ({
2025-12-02 21:50:07 +00:00
pluginFilterId: filter.id,
filterConfig: filterConfigs[`filter_${index}`] ?? {},
2025-12-02 21:50:07 +00:00
})) as WorkflowFilterItemDto[];
const actions = orderedActions.map((action, index) => ({
2025-12-02 21:50:07 +00:00
pluginActionId: action.id,
actionConfig: actionConfigs[`action_${index}`] ?? {},
2025-12-02 21:50:07 +00:00
})) as WorkflowActionItemDto[];
const updateDto: WorkflowUpdateDto = {
name,
description,
enabled,
filters,
actions,
triggerType,
};
2025-12-05 16:37:10 +00:00
return updateWorkflow({ id: workflowId, workflowUpdateDto: updateDto });
};
export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowResponseDto) => {
const ToggleEnabled: ActionItem = {
title: workflow.enabled ? $t('disable') : $t('enable'),
icon: workflow.enabled ? mdiPause : mdiPlay,
color: workflow.enabled ? 'danger' : 'primary',
onAction: async () => {
await handleToggleWorkflowEnabled(workflow);
},
};
const Edit: ActionItem = {
title: $t('edit'),
icon: mdiPencil,
onAction: () => handleNavigateToWorkflow(workflow),
};
const Delete: ActionItem = {
title: $t('delete'),
icon: mdiDelete,
color: 'danger',
onAction: async () => {
await handleDeleteWorkflow(workflow);
},
};
return { ToggleEnabled, Edit, Delete };
};
export const getWorkflowShowSchemaAction = (
$t: MessageFormatter,
isExpanded: boolean,
onToggle: () => void,
): ActionItem => ({
title: isExpanded ? $t('hide_schema') : $t('show_schema'),
icon: mdiCodeJson,
onAction: onToggle,
});
export const handleCreateWorkflow = async (): Promise<WorkflowResponseDto | undefined> => {
const $t = await getFormatter();
try {
const workflow = await createWorkflow({
workflowCreateDto: {
name: $t('untitled_workflow'),
triggerType: PluginTriggerType.AssetCreate,
filters: [],
actions: [],
enabled: false,
},
});
await goto(`${AppRoute.WORKFLOWS}/${workflow.id}`);
return workflow;
} catch (error) {
handleError(error, $t('errors.unable_to_create'));
}
};
export const handleToggleWorkflowEnabled = async (
workflow: WorkflowResponseDto,
): Promise<WorkflowResponseDto | undefined> => {
const $t = await getFormatter();
try {
const updated = await updateWorkflow({
id: workflow.id,
workflowUpdateDto: { enabled: !workflow.enabled },
});
2025-12-05 16:47:46 +00:00
eventManager.emit('WorkflowUpdate', updated);
2025-12-05 16:37:10 +00:00
toastManager.success($t('workflow_updated'));
return updated;
} catch (error) {
handleError(error, $t('errors.unable_to_update_workflow'));
}
};
export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promise<boolean> => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({
prompt: $t('workflow_delete_prompt'),
confirmColor: 'danger',
});
if (!confirmed) {
return false;
}
try {
await deleteWorkflow({ id: workflow.id });
2025-12-05 16:47:46 +00:00
eventManager.emit('WorkflowDelete', workflow);
2025-12-05 16:37:10 +00:00
toastManager.success($t('workflow_deleted'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_workflow'));
return false;
}
};
export const handleNavigateToWorkflow = async (workflow: WorkflowResponseDto): Promise<void> => {
await goto(`${AppRoute.WORKFLOWS}/${workflow.id}`);
2025-12-02 21:50:07 +00:00
};
2025-12-05 20:24:37 +00:00
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;
};