Compare commits

...

2 Commits

Author SHA1 Message Date
Alex Tran
12ecd65e61 refactor: picker field 2025-12-04 03:44:02 +00:00
Alex Tran
bd4355a75f pr feedback 2025-12-03 21:54:57 +00:00
31 changed files with 704 additions and 743 deletions

View File

@@ -1,4 +1,5 @@
{
"get_people_error": "Error getting people",
"about": "About",
"account": "Account",
"account_settings": "Account Settings",

View File

@@ -199,8 +199,8 @@ Class | Method | HTTP request | Description
*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
*PluginsApi* | [**getPluginTriggers**](doc//PluginsApi.md#getplugintriggers) | **GET** /plugins/triggers | List all plugin triggers
*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins
*PluginsApi* | [**getTriggers**](doc//PluginsApi.md#gettriggers) | **GET** /plugins/triggers | List all plugin triggers
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue
*QueuesApi* | [**getQueueJobs**](doc//QueuesApi.md#getqueuejobs) | **GET** /queues/{name}/jobs | Retrieve queue jobs
@@ -466,7 +466,7 @@ Class | Method | HTTP request | Description
- [PinCodeSetupDto](doc//PinCodeSetupDto.md)
- [PlacesResponseDto](doc//PlacesResponseDto.md)
- [PluginActionResponseDto](doc//PluginActionResponseDto.md)
- [PluginContext](doc//PluginContext.md)
- [PluginContextType](doc//PluginContextType.md)
- [PluginFilterResponseDto](doc//PluginFilterResponseDto.md)
- [PluginResponseDto](doc//PluginResponseDto.md)
- [PluginTriggerResponseDto](doc//PluginTriggerResponseDto.md)

View File

@@ -217,7 +217,7 @@ part 'model/pin_code_reset_dto.dart';
part 'model/pin_code_setup_dto.dart';
part 'model/places_response_dto.dart';
part 'model/plugin_action_response_dto.dart';
part 'model/plugin_context.dart';
part 'model/plugin_context_type.dart';
part 'model/plugin_filter_response_dto.dart';
part 'model/plugin_response_dto.dart';
part 'model/plugin_trigger_response_dto.dart';

View File

@@ -73,6 +73,57 @@ class PluginsApi {
return null;
}
/// List all plugin triggers
///
/// Retrieve a list of all available plugin triggers.
///
/// Note: This method returns the HTTP [Response].
Future<Response> getPluginTriggersWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/plugins/triggers';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// List all plugin triggers
///
/// Retrieve a list of all available plugin triggers.
Future<List<PluginTriggerResponseDto>?> getPluginTriggers() async {
final response = await getPluginTriggersWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<PluginTriggerResponseDto>') as List)
.cast<PluginTriggerResponseDto>()
.toList(growable: false);
}
return null;
}
/// List all plugins
///
/// Retrieve a list of plugins available to the authenticated user.
@@ -123,55 +174,4 @@ class PluginsApi {
}
return null;
}
/// List all plugin triggers
///
/// Retrieve a list of all available plugin triggers.
///
/// Note: This method returns the HTTP [Response].
Future<Response> getTriggersWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/plugins/triggers';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// List all plugin triggers
///
/// Retrieve a list of all available plugin triggers.
Future<List<PluginTriggerResponseDto>?> getTriggers() async {
final response = await getTriggersWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<PluginTriggerResponseDto>') as List)
.cast<PluginTriggerResponseDto>()
.toList(growable: false);
}
return null;
}
}

View File

@@ -482,8 +482,8 @@ class ApiClient {
return PlacesResponseDto.fromJson(value);
case 'PluginActionResponseDto':
return PluginActionResponseDto.fromJson(value);
case 'PluginContext':
return PluginContextTypeTransformer().decode(value);
case 'PluginContextType':
return PluginContextTypeTypeTransformer().decode(value);
case 'PluginFilterResponseDto':
return PluginFilterResponseDto.fromJson(value);
case 'PluginResponseDto':

View File

@@ -127,8 +127,8 @@ String parameterToString(dynamic value) {
if (value is Permission) {
return PermissionTypeTransformer().encode(value).toString();
}
if (value is PluginContext) {
return PluginContextTypeTransformer().encode(value).toString();
if (value is PluginContextType) {
return PluginContextTypeTypeTransformer().encode(value).toString();
}
if (value is PluginTriggerType) {
return PluginTriggerTypeTypeTransformer().encode(value).toString();

View File

@@ -32,7 +32,7 @@ class PluginActionResponseDto {
Object? schema;
List<PluginContext> supportedContexts;
List<PluginContextType> supportedContexts;
String title;
@@ -90,7 +90,7 @@ class PluginActionResponseDto {
methodName: mapValueOfType<String>(json, r'methodName')!,
pluginId: mapValueOfType<String>(json, r'pluginId')!,
schema: mapValueOfType<Object>(json, r'schema'),
supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']),
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
title: mapValueOfType<String>(json, r'title')!,
);
}

View File

@@ -11,9 +11,9 @@
part of openapi.api;
class PluginContext {
class PluginContextType {
/// Instantiate a new enum with the provided [value].
const PluginContext._(this.value);
const PluginContextType._(this.value);
/// The underlying value of this enum member.
final String value;
@@ -23,24 +23,24 @@ class PluginContext {
String toJson() => value;
static const asset = PluginContext._(r'asset');
static const album = PluginContext._(r'album');
static const person = PluginContext._(r'person');
static const asset = PluginContextType._(r'asset');
static const album = PluginContextType._(r'album');
static const person = PluginContextType._(r'person');
/// List of all possible values in this [enum][PluginContext].
static const values = <PluginContext>[
/// List of all possible values in this [enum][PluginContextType].
static const values = <PluginContextType>[
asset,
album,
person,
];
static PluginContext? fromJson(dynamic value) => PluginContextTypeTransformer().decode(value);
static PluginContextType? fromJson(dynamic value) => PluginContextTypeTypeTransformer().decode(value);
static List<PluginContext> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginContext>[];
static List<PluginContextType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginContextType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginContext.fromJson(row);
final value = PluginContextType.fromJson(row);
if (value != null) {
result.add(value);
}
@@ -50,16 +50,16 @@ class PluginContext {
}
}
/// Transformation class that can [encode] an instance of [PluginContext] to String,
/// and [decode] dynamic data back to [PluginContext].
class PluginContextTypeTransformer {
factory PluginContextTypeTransformer() => _instance ??= const PluginContextTypeTransformer._();
/// Transformation class that can [encode] an instance of [PluginContextType] to String,
/// and [decode] dynamic data back to [PluginContextType].
class PluginContextTypeTypeTransformer {
factory PluginContextTypeTypeTransformer() => _instance ??= const PluginContextTypeTypeTransformer._();
const PluginContextTypeTransformer._();
const PluginContextTypeTypeTransformer._();
String encode(PluginContext data) => data.value;
String encode(PluginContextType data) => data.value;
/// Decodes a [dynamic value][data] to a PluginContext.
/// Decodes a [dynamic value][data] to a PluginContextType.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
@@ -67,12 +67,12 @@ class PluginContextTypeTransformer {
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
PluginContext? decode(dynamic data, {bool allowNull = true}) {
PluginContextType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'asset': return PluginContext.asset;
case r'album': return PluginContext.album;
case r'person': return PluginContext.person;
case r'asset': return PluginContextType.asset;
case r'album': return PluginContextType.album;
case r'person': return PluginContextType.person;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
@@ -82,7 +82,7 @@ class PluginContextTypeTransformer {
return null;
}
/// Singleton [PluginContextTypeTransformer] instance.
static PluginContextTypeTransformer? _instance;
/// Singleton [PluginContextTypeTypeTransformer] instance.
static PluginContextTypeTypeTransformer? _instance;
}

View File

@@ -32,7 +32,7 @@ class PluginFilterResponseDto {
Object? schema;
List<PluginContext> supportedContexts;
List<PluginContextType> supportedContexts;
String title;
@@ -90,7 +90,7 @@ class PluginFilterResponseDto {
methodName: mapValueOfType<String>(json, r'methodName')!,
pluginId: mapValueOfType<String>(json, r'pluginId')!,
schema: mapValueOfType<Object>(json, r'schema'),
supportedContexts: PluginContext.listFromJson(json[r'supportedContexts']),
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
title: mapValueOfType<String>(json, r'title')!,
);
}

View File

@@ -13,44 +13,44 @@ part of openapi.api;
class PluginTriggerResponseDto {
/// Returns a new [PluginTriggerResponseDto] instance.
PluginTriggerResponseDto({
required this.context,
required this.contextType,
required this.description,
required this.name,
required this.triggerType,
required this.type,
});
PluginContext context;
PluginContextType contextType;
String description;
String name;
PluginTriggerType triggerType;
PluginTriggerType type;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginTriggerResponseDto &&
other.context == context &&
other.contextType == contextType &&
other.description == description &&
other.name == name &&
other.triggerType == triggerType;
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(context.hashCode) +
(contextType.hashCode) +
(description.hashCode) +
(name.hashCode) +
(triggerType.hashCode);
(type.hashCode);
@override
String toString() => 'PluginTriggerResponseDto[context=$context, description=$description, name=$name, triggerType=$triggerType]';
String toString() => 'PluginTriggerResponseDto[contextType=$contextType, description=$description, name=$name, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'context'] = this.context;
json[r'contextType'] = this.contextType;
json[r'description'] = this.description;
json[r'name'] = this.name;
json[r'triggerType'] = this.triggerType;
json[r'type'] = this.type;
return json;
}
@@ -63,10 +63,10 @@ class PluginTriggerResponseDto {
final json = value.cast<String, dynamic>();
return PluginTriggerResponseDto(
context: PluginContext.fromJson(json[r'context'])!,
contextType: PluginContextType.fromJson(json[r'contextType'])!,
description: mapValueOfType<String>(json, r'description')!,
name: mapValueOfType<String>(json, r'name')!,
triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!,
type: PluginTriggerType.fromJson(json[r'type'])!,
);
}
return null;
@@ -114,10 +114,10 @@ class PluginTriggerResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'context',
'contextType',
'description',
'name',
'triggerType',
'type',
};
}

View File

@@ -8023,7 +8023,7 @@
"/plugins/triggers": {
"get": {
"description": "Retrieve a list of all available plugin triggers.",
"operationId": "getTriggers",
"operationId": "getPluginTriggers",
"parameters": [],
"responses": {
"200": {
@@ -18331,7 +18331,7 @@
},
"supportedContexts": {
"items": {
"$ref": "#/components/schemas/PluginContext"
"$ref": "#/components/schemas/PluginContextType"
},
"type": "array"
},
@@ -18350,7 +18350,7 @@
],
"type": "object"
},
"PluginContext": {
"PluginContextType": {
"enum": [
"asset",
"album",
@@ -18378,7 +18378,7 @@
},
"supportedContexts": {
"items": {
"$ref": "#/components/schemas/PluginContext"
"$ref": "#/components/schemas/PluginContextType"
},
"type": "array"
},
@@ -18452,10 +18452,10 @@
},
"PluginTriggerResponseDto": {
"properties": {
"context": {
"contextType": {
"allOf": [
{
"$ref": "#/components/schemas/PluginContext"
"$ref": "#/components/schemas/PluginContextType"
}
]
},
@@ -18465,7 +18465,7 @@
"name": {
"type": "string"
},
"triggerType": {
"type": {
"allOf": [
{
"$ref": "#/components/schemas/PluginTriggerType"
@@ -18474,10 +18474,10 @@
}
},
"required": [
"context",
"contextType",
"description",
"name",
"triggerType"
"type"
],
"type": "object"
},

View File

@@ -942,7 +942,7 @@ export type PluginActionResponseDto = {
methodName: string;
pluginId: string;
schema: object | null;
supportedContexts: PluginContext[];
supportedContexts: PluginContextType[];
title: string;
};
export type PluginFilterResponseDto = {
@@ -951,7 +951,7 @@ export type PluginFilterResponseDto = {
methodName: string;
pluginId: string;
schema: object | null;
supportedContexts: PluginContext[];
supportedContexts: PluginContextType[];
title: string;
};
export type PluginResponseDto = {
@@ -967,10 +967,10 @@ export type PluginResponseDto = {
version: string;
};
export type PluginTriggerResponseDto = {
context: PluginContext;
contextType: PluginContextType;
description: string;
name: string;
triggerType: PluginTriggerType;
"type": PluginTriggerType;
};
export type QueueResponseDto = {
isPaused: boolean;
@@ -3666,7 +3666,7 @@ export function getPlugins(opts?: Oazapfts.RequestOpts) {
/**
* List all plugin triggers
*/
export function getTriggers(opts?: Oazapfts.RequestOpts) {
export function getPluginTriggers(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PluginTriggerResponseDto[];
@@ -5436,7 +5436,7 @@ export enum PartnerDirection {
SharedBy = "shared-by",
SharedWith = "shared-with"
}
export enum PluginContext {
export enum PluginContextType {
Asset = "asset",
Album = "album",
Person = "person"

View File

@@ -19,7 +19,7 @@ export class PluginController {
description: 'Retrieve a list of all available plugin triggers.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
getTriggers(): PluginTriggerResponseDto[] {
getPluginTriggers(): PluginTriggerResponseDto[] {
return this.service.getTriggers();
}

View File

@@ -1,16 +1,16 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { PluginAction, PluginFilter } from 'src/database';
import { PluginContext, PluginTriggerType } from 'src/enum';
import { PluginContext as PluginContextType, PluginTriggerType } from 'src/enum';
import type { JSONSchema } from 'src/types/plugin-schema.types';
import { ValidateEnum } from 'src/validation';
export class PluginTriggerResponseDto {
name!: string;
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' })
triggerType!: PluginTriggerType;
type!: PluginTriggerType;
description!: string;
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
context!: PluginContext;
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType' })
contextType!: PluginContextType;
}
export class PluginResponseDto {
@@ -33,8 +33,8 @@ export class PluginFilterResponseDto {
title!: string;
description!: string;
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
supportedContexts!: PluginContext[];
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType' })
supportedContexts!: PluginContextType[];
schema!: JSONSchema | null;
}
@@ -45,8 +45,8 @@ export class PluginActionResponseDto {
title!: string;
description!: string;
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
supportedContexts!: PluginContext[];
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType' })
supportedContexts!: PluginContextType[];
schema!: JSONSchema | null;
}

View File

@@ -2,22 +2,22 @@ import { PluginContext, PluginTriggerType } from 'src/enum';
export type PluginTrigger = {
name: string;
triggerType: PluginTriggerType;
type: PluginTriggerType;
description: string;
context: PluginContext;
contextType: PluginContext;
};
export const pluginTriggers: PluginTrigger[] = [
{
name: 'Asset Uploaded',
triggerType: PluginTriggerType.AssetCreate,
type: PluginTriggerType.AssetCreate,
description: 'Triggered when a new asset is uploaded',
context: PluginContext.Asset,
contextType: PluginContext.Asset,
},
{
name: 'Person Recognized',
triggerType: PluginTriggerType.PersonRecognized,
type: PluginTriggerType.PersonRecognized,
description: 'Triggered when a person is detected',
context: PluginContext.Person,
contextType: PluginContext.Person,
},
];

View File

@@ -7,6 +7,8 @@ from
"workflow"
where
"id" = $1
order by
"createdAt" desc
-- WorkflowRepository.getWorkflowsByOwner
select
@@ -16,7 +18,7 @@ from
where
"ownerId" = $1
order by
"name"
"createdAt" desc
-- WorkflowRepository.getWorkflowsByTrigger
select

View File

@@ -12,12 +12,22 @@ export class WorkflowRepository {
@GenerateSql({ params: [DummyValue.UUID] })
getWorkflow(id: string) {
return this.db.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirst();
return this.db
.selectFrom('workflow')
.selectAll()
.where('id', '=', id)
.orderBy('createdAt', 'desc')
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getWorkflowsByOwner(ownerId: string) {
return this.db.selectFrom('workflow').selectAll().where('ownerId', '=', ownerId).orderBy('name').execute();
return this.db
.selectFrom('workflow')
.selectAll()
.where('ownerId', '=', ownerId)
.orderBy('createdAt', 'desc')
.execute();
}
@GenerateSql({ params: [PluginTriggerType.AssetCreate] })

View File

@@ -116,12 +116,12 @@ export class PluginService extends BaseService {
}
private async loadPluginToDatabase(manifest: PluginManifestDto, basePath: string): Promise<void> {
// const currentPlugin = await this.pluginRepository.getPluginByName(manifest.name);
// if (currentPlugin != null && currentPlugin.version === manifest.version) {
// this.logger.log(`Plugin ${manifest.name} is up to date (version ${manifest.version}). Skipping`);
// return;
// }
//
const currentPlugin = await this.pluginRepository.getPluginByName(manifest.name);
if (currentPlugin != null && currentPlugin.version === manifest.version) {
this.logger.log(`Plugin ${manifest.name} is up to date (version ${manifest.version}). Skipping`);
return;
}
const { plugin, filters, actions } = await this.pluginRepository.loadPlugin(manifest, basePath);
this.logger.log(`Upserted plugin: ${plugin.name} (ID: ${plugin.id}, version: ${plugin.version})`);

View File

@@ -16,10 +16,10 @@ import { BaseService } from 'src/services/base.service';
@Injectable()
export class WorkflowService extends BaseService {
async create(auth: AuthDto, dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
const trigger = this.getTriggerOrFail(dto.triggerType);
const context = this.getContextForTrigger(dto.triggerType);
const filterInserts = await this.validateAndMapFilters(dto.filters, trigger.context);
const actionInserts = await this.validateAndMapActions(dto.actions, trigger.context);
const filterInserts = await this.validateAndMapFilters(dto.filters, context);
const actionInserts = await this.validateAndMapActions(dto.actions, context);
const workflow = await this.workflowRepository.createWorkflow(
{
@@ -56,11 +56,11 @@ export class WorkflowService extends BaseService {
}
const workflow = await this.findOrFail(id);
const trigger = this.getTriggerOrFail(dto.triggerType ?? workflow.triggerType);
const context = this.getContextForTrigger(dto.triggerType ?? workflow.triggerType);
const { filters, actions, ...workflowUpdate } = dto;
const filterInserts = filters && (await this.validateAndMapFilters(filters, trigger.context));
const actionInserts = actions && (await this.validateAndMapActions(actions, trigger.context));
const filterInserts = filters && (await this.validateAndMapFilters(filters, context));
const actionInserts = actions && (await this.validateAndMapActions(actions, context));
const updatedWorkflow = await this.workflowRepository.updateWorkflow(
id,
@@ -124,12 +124,12 @@ export class WorkflowService extends BaseService {
}));
}
private getTriggerOrFail(triggerType: PluginTriggerType) {
const trigger = pluginTriggers.find((t) => t.triggerType === triggerType);
private getContextForTrigger(type: PluginTriggerType) {
const trigger = pluginTriggers.find((t) => t.type === type);
if (!trigger) {
throw new BadRequestException(`Invalid trigger type: ${triggerType}`);
throw new BadRequestException(`Invalid trigger type: ${type}`);
}
return trigger;
return trigger.contextType;
}
private async findOrFail(id: string) {

View File

@@ -1,12 +1,7 @@
<script lang="ts">
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { formatLabel, getComponentFromSchema } from '$lib/utils/workflow';
import { getAlbumInfo, getPerson, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { Button, Field, Input, MultiSelect, Select, Switch, Text, modalManager, type SelectItem } from '@immich/ui';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import { Field, Input, MultiSelect, Select, Switch, Text, type SelectItem } from '@immich/ui';
import WorkflowPickerField from './WorkflowPickerField.svelte';
interface Props {
schema: object | null;
@@ -33,67 +28,6 @@
let selectValue = $state<SelectItem>();
let switchValue = $state<boolean>(false);
let multiSelectValue = $state<SelectItem[]>([]);
let pickerMetadata = $state<
Record<string, AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[]>
>({});
// Fetch metadata for existing picker values (albums/people)
$effect(() => {
if (!components) {
return;
}
const fetchMetadata = async () => {
const metadataUpdates: Record<
string,
AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[]
> = {};
for (const [key, component] of Object.entries(components)) {
const value = actualConfig[key];
if (!value || pickerMetadata[key]) {
continue; // Skip if no value or already loaded
}
const isAlbumPicker = component.subType === 'album-picker';
const isPeoplePicker = component.subType === 'people-picker';
if (!isAlbumPicker && !isPeoplePicker) {
continue;
}
try {
if (Array.isArray(value) && value.length > 0) {
// Multiple selection
if (isAlbumPicker) {
const albums = await Promise.all(value.map((id) => getAlbumInfo({ id })));
metadataUpdates[key] = albums;
} else if (isPeoplePicker) {
const people = await Promise.all(value.map((id) => getPerson({ id })));
metadataUpdates[key] = people;
}
} else if (typeof value === 'string' && value) {
// Single selection
if (isAlbumPicker) {
const album = await getAlbumInfo({ id: value });
metadataUpdates[key] = album;
} else if (isPeoplePicker) {
const person = await getPerson({ id: value });
metadataUpdates[key] = person;
}
}
} catch (error) {
console.error(`Failed to fetch metadata for ${key}:`, error);
}
}
if (Object.keys(metadataUpdates).length > 0) {
pickerMetadata = { ...pickerMetadata, ...metadataUpdates };
}
};
void fetchMetadata();
});
$effect(() => {
// Initialize config for actions/filters with empty schemas
@@ -148,151 +82,9 @@
}
});
const handleAlbumPicker = async (key: string, multiple: boolean) => {
const albums = await modalManager.show(AlbumPickerModal, { shared: false });
if (albums && albums.length > 0) {
const value = multiple ? albums.map((a) => a.id) : albums[0].id;
updateConfig(key, value);
pickerMetadata = {
...pickerMetadata,
[key]: multiple ? albums : albums[0],
};
}
};
const handlePeoplePicker = async (key: string, multiple: boolean) => {
const people = await modalManager.show(PeoplePickerModal, { multiple });
if (people && people.length > 0) {
const value = multiple ? people.map((p) => p.id) : people[0].id;
updateConfig(key, value);
pickerMetadata = {
...pickerMetadata,
[key]: multiple ? people : people[0],
};
}
};
const removeSelection = (key: string) => {
const { [key]: _, ...rest } = actualConfig;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [key]: _removed, ...restMetadata } = pickerMetadata;
config = configKey ? { ...config, [configKey]: rest } : rest;
pickerMetadata = restMetadata;
};
const removeItemFromSelection = (key: string, itemId: string) => {
const currentIds = actualConfig[key] as string[];
const currentMetadata = pickerMetadata[key] as (AlbumResponseDto | PersonResponseDto)[];
updateConfig(
key,
currentIds.filter((id) => id !== itemId),
);
pickerMetadata = {
...pickerMetadata,
[key]: currentMetadata.filter((item) => item.id !== itemId) as AlbumResponseDto[] | PersonResponseDto[],
};
};
const renderPicker = (subType: 'album-picker' | 'people-picker', multiple: boolean) => {
const isAlbum = subType === 'album-picker';
const handler = isAlbum ? handleAlbumPicker : handlePeoplePicker;
const selectSingleLabel = isAlbum ? 'select_album' : 'select_person';
const selectMultiLabel = isAlbum ? 'select_albums' : 'select_people';
const buttonText = multiple ? $t(selectMultiLabel) : $t(selectSingleLabel);
return { handler, buttonText };
};
const isPickerField = (subType: string | undefined) => subType === 'album-picker' || subType === 'people-picker';
</script>
{#snippet pickerItemCard(
item: AlbumResponseDto | PersonResponseDto,
isAlbum: boolean,
size: 'large' | 'small',
onRemove: () => void,
)}
{@const sizeClass = size === 'large' ? 'h-16 w-16' : 'h-12 w-12'}
{@const textSizeClass = size === 'large' ? 'font-medium' : 'font-medium text-sm'}
{@const iconSizeClass = size === 'large' ? 'h-5 w-5' : 'h-4 w-4'}
{@const countSizeClass = size === 'large' ? 'text-sm' : 'text-xs'}
<div
class="flex items-center gap-3 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-3 shadow-sm"
>
<div class="shrink-0">
{#if isAlbum && 'albumThumbnailAssetId' in item}
{#if item.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(item.albumThumbnailAssetId)}
alt={item.albumName}
class="{sizeClass} rounded-lg object-cover"
/>
{:else}
<div class="{sizeClass} rounded-lg bg-gray-200 dark:bg-gray-700"></div>
{/if}
{:else if !isAlbum && 'name' in item}
<img src={getPeopleThumbnailUrl(item)} alt={item.name} class="{sizeClass} rounded-full object-cover" />
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="{textSizeClass} text-gray-900 dark:text-gray-100 truncate">
{isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''}
</p>
{#if isAlbum && 'assetCount' in item}
<p class="{countSizeClass} text-gray-500 dark:text-gray-400">
{$t('items_count', { values: { count: item.assetCount } })}
</p>
{/if}
</div>
<button
type="button"
onclick={onRemove}
class="shrink-0 rounded-full p-1.5 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label={$t('remove')}
>
<svg class={iconSizeClass} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/snippet}
{#snippet pickerField(
subType: string,
key: string,
label: string,
component: { required?: boolean; description?: string },
multiple: boolean,
)}
{@const picker = renderPicker(subType as 'album-picker' | 'people-picker', multiple)}
{@const metadata = pickerMetadata[key]}
{@const isAlbum = subType === 'album-picker'}
<Field
{label}
required={component.required}
description={component.description}
requiredIndicator={component.required}
>
<div class="flex flex-col gap-3">
{#if metadata && !Array.isArray(metadata)}
{@render pickerItemCard(metadata, isAlbum, 'large', () => removeSelection(key))}
{:else if metadata && Array.isArray(metadata) && metadata.length > 0}
<div class="flex flex-col gap-2">
{#each metadata as item (item.id)}
{@render pickerItemCard(item, isAlbum, 'small', () => removeItemFromSelection(key, item.id))}
{/each}
</div>
{/if}
<Button size="small" variant="outline" leadingIcon={mdiPlus} onclick={() => picker.handler(key, multiple)}>
{picker.buttonText}
</Button>
</div>
</Field>
{/snippet}
{#if components}
<div class="flex flex-col gap-2">
{#each Object.entries(components) as [key, component] (key)}
@@ -301,8 +93,13 @@
<div class="flex flex-col gap-1 bg-light-50 border p-4 rounded-xl">
<!-- Select component -->
{#if component.type === 'select'}
{#if component.subType === 'album-picker' || component.subType === 'people-picker'}
{@render pickerField(component.subType, key, label, component, false)}
{#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) };
@@ -320,8 +117,13 @@
<!-- MultiSelect component -->
{:else if component.type === 'multiselect'}
{#if component.subType === 'album-picker' || component.subType === 'people-picker'}
{@render pickerField(component.subType, key, label, component, true)}
{#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) };
@@ -357,8 +159,13 @@
</Field>
<!-- Text input -->
{:else if component.subType === 'album-picker' || component.subType === 'people-picker'}
{@render pickerField(component.subType, key, label, component, false)}
{:else if isPickerField(component.subType)}
<WorkflowPickerField
{component}
configKey={key}
value={actualConfig[key] as string | string[]}
onchange={(value) => updateConfig(key, value)}
/>
{:else}
<Field
{label}

View File

@@ -0,0 +1,162 @@
<script lang="ts">
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import type { ComponentConfig } from '$lib/utils/workflow';
import { getAlbumInfo, getPerson, type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { Button, Card, CardBody, Field, IconButton, modalManager, Text } from '@immich/ui';
import { mdiClose, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
interface 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<AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[]>();
// Fetch metadata for existing picker values (albums/people)
$effect(() => {
if (!value) {
pickerMetadata = undefined;
return;
}
void fetchMetadata();
});
const fetchMetadata = async () => {
if (!value || pickerMetadata) {
return;
}
try {
if (Array.isArray(value) && value.length > 0) {
// Multiple selection
pickerMetadata = await (isAlbum
? Promise.all(value.map((id) => getAlbumInfo({ id })))
: Promise.all(value.map((id) => getPerson({ id }))));
} else if (typeof value === 'string' && value) {
// Single selection
pickerMetadata = await (isAlbum ? getAlbumInfo({ id: value }) : getPerson({ id: value }));
}
} catch (error) {
console.error(`Failed to fetch metadata for ${configKey}:`, error);
}
};
const handlePicker = async () => {
if (isAlbum) {
const albums = await modalManager.show(AlbumPickerModal, { shared: false });
if (albums && albums.length > 0) {
const newValue = multiple ? albums.map((a) => a.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((p) => p.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>
{#snippet pickerItemCard(item: AlbumResponseDto | PersonResponseDto, onRemove: () => void)}
<Card color="secondary">
<CardBody class="flex items-center gap-3">
<div class="shrink-0">
{#if isAlbum && 'albumThumbnailAssetId' in item}
{#if item.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(item.albumThumbnailAssetId)}
alt={item.albumName}
class="h-12 w-12 rounded-lg object-cover"
/>
{:else}
<div class="h-12 w-12 rounded-lg"></div>
{/if}
{:else if !isAlbum && 'name' in item}
<img src={getPeopleThumbnailUrl(item)} alt={item.name} class="h-12 w-12 rounded-full object-cover" />
{/if}
</div>
<div class="min-w-0 flex-1">
<Text class="font-semibold truncate">
{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>
{/snippet}
<Field {label} required={component.required} description={component.description} requiredIndicator={component.required}>
<div class="flex flex-col gap-3">
{#if pickerMetadata && !Array.isArray(pickerMetadata)}
{@render pickerItemCard(pickerMetadata, removeSelection)}
{:else if pickerMetadata && Array.isArray(pickerMetadata) && pickerMetadata.length > 0}
<div class="flex flex-col gap-2">
{#each pickerMetadata as item (item.id)}
{@render pickerItemCard(item, () => removeItemFromSelection(item.id))}
{/each}
</div>
{/if}
<Button size="small" variant="outline" leadingIcon={mdiPlus} onclick={handlePicker}>
{getButtonText()}
</Button>
</div>
</Field>

View File

@@ -63,7 +63,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={containerEl}
class="hidden sm:block fixed w-64 z-50 hover:cursor-grab select-none"
class="hidden sm:block fixed w-64 hover:cursor-grab select-none"
style="left: {position.x}px; top: {position.y}px;"
class:cursor-grabbing={isDragging}
onmousedown={handleMouseDown}
@@ -112,7 +112,7 @@
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('filters')}</span>
</div>
<div class="space-y-1 pl-5">
{#each filters as filter, index (filter.id)}
{#each filters as filter, index (index)}
<div class="flex items-center gap-2">
<span
class="shrink-0 h-4 w-4 rounded-full bg-light-200 text-[10px] font-medium flex items-center justify-center"
@@ -138,7 +138,7 @@
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('actions')}</span>
</div>
<div class="space-y-1 pl-5">
{#each actions as action, index (action.id)}
{#each actions as action, index (index)}
<div class="flex items-center gap-2">
<span
class="shrink-0 h-4 w-4 rounded-full bg-light-200 text-[10px] font-medium flex items-center justify-center"
@@ -156,7 +156,7 @@
{:else}
<button
type="button"
class="hidden sm:flex fixed right-6 bottom-6 z-50 h-14 w-14 items-center justify-center rounded-full bg-primary text-light shadow-lg hover:bg-primary/90 transition-colors"
class="hidden sm:flex fixed right-6 bottom-6 h-14 w-14 items-center justify-center rounded-full bg-primary text-light shadow-lg hover:bg-primary/90 transition-colors"
title={$t('workflow_summary')}
onclick={() => (isOpen = true)}
>

View File

@@ -39,12 +39,12 @@
? 'bg-primary text-light'
: 'text-light-100 bg-light-300 group-hover:bg-light-500'}"
>
<Icon icon={getTriggerIcon(trigger.triggerType)} size="24" />
<Icon icon={getTriggerIcon(trigger.type)} size="24" />
</div>
<div class="flex-1">
<Text class="font-semibold mb-1">{trigger.name}</Text>
{#if trigger.description}
<Text class="text-sm">{trigger.description}</Text>
<Text size="small">{trigger.description}</Text>
{/if}
</div>
</div>

View File

@@ -56,7 +56,6 @@ export enum AppRoute {
LARGE_FILES = '/utilities/large-files',
GEOLOCATION = '/utilities/geolocation',
WORKFLOWS = '/utilities/workflows',
WORKFLOWS_EDIT = '/utilities/workflows/edit',
FOLDERS = '/folders',
TAGS = '/tags',

View File

@@ -1,23 +1,17 @@
<script lang="ts">
import type { PluginActionResponseDto, PluginFilterResponseDto } from '@immich/sdk';
import { Icon, Modal, ModalBody } from '@immich/ui';
import { Icon, Modal, ModalBody, Text } from '@immich/ui';
import { mdiFilterOutline, mdiPlayCircleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
filters: PluginFilterResponseDto[];
actions: PluginActionResponseDto[];
addedFilters?: PluginFilterResponseDto[];
addedActions?: PluginActionResponseDto[];
onClose: (result?: { type: 'filter' | 'action'; item: PluginFilterResponseDto | PluginActionResponseDto }) => void;
type?: 'filter' | 'action';
}
let { filters, actions, addedFilters = [], addedActions = [], onClose, type }: Props = $props();
// Filter out already-added items
const availableFilters = $derived(filters.filter((f) => !addedFilters.some((af) => af.id === f.id)));
const availableActions = $derived(actions.filter((a) => !addedActions.some((aa) => aa.id === a.id)));
let { filters, actions, onClose, type }: Props = $props();
type StepType = 'filter' | 'action';
@@ -30,7 +24,7 @@
<ModalBody>
<div class="space-y-6">
<!-- Filters Section -->
{#if availableFilters.length > 0 && (!type || type === 'filter')}
{#if filters.length > 0 && (!type || type === 'filter')}
<div class="flex items-center gap-2 mb-3">
<div class="h-6 w-6 rounded-md bg-warning-100 flex items-center justify-center">
<Icon icon={mdiFilterOutline} size="16" class="text-warning" />
@@ -38,7 +32,7 @@
<h3 class="text-sm font-semibold">Filters</h3>
</div>
<div class="grid grid-cols-1 gap-2">
{#each availableFilters as filter (filter.id)}
{#each filters as filter (filter.id)}
<button
type="button"
onclick={() => handleSelect('filter', filter)}
@@ -56,7 +50,7 @@
{/if}
<!-- Actions Section -->
{#if availableActions.length > 0 && (!type || type === 'action')}
{#if actions.length > 0 && (!type || type === 'action')}
<div>
<div class="flex items-center gap-2 mb-3">
<div class="h-6 w-6 rounded-md bg-success-50 flex items-center justify-center">
@@ -65,7 +59,7 @@
<h3 class="text-sm font-semibold">Actions</h3>
</div>
<div class="grid grid-cols-1 gap-2">
{#each availableActions as action (action.id)}
{#each actions as action (action.id)}
<button
type="button"
onclick={() => handleSelect('action', action)}
@@ -74,7 +68,7 @@
<div class="flex-1">
<p class="font-medium text-sm">{action.title}</p>
{#if action.description}
<p class="text-xs text-light-500 mt-1">{action.description}</p>
<Text size="small" class="text-light-500 mt-1">{action.description}</Text>
{/if}
</div>
</button>

View File

@@ -2,6 +2,7 @@
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import SearchBar from '$lib/elements/SearchBar.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, HStack, LoadingSpinner, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { onMount } from 'svelte';
@@ -9,10 +10,11 @@
interface Props {
multiple?: boolean;
excludedIds?: string[];
onClose: (people?: PersonResponseDto[]) => void;
}
let { multiple = false, onClose }: Props = $props();
let { multiple = false, excludedIds = [], onClose }: Props = $props();
let people: PersonResponseDto[] = $state([]);
let loading = $state(true);
@@ -20,13 +22,20 @@
let selectedPeople: PersonResponseDto[] = $state([]);
const filteredPeople = $derived(
searchName ? people.filter((person) => person.name.toLowerCase().includes(searchName.toLowerCase())) : people,
people
.filter((person) => !excludedIds.includes(person.id))
.filter((person) => !searchName || person.name.toLowerCase().includes(searchName.toLowerCase())),
);
onMount(async () => {
const result = await getAllPeople({ withHidden: false });
people = result.people;
loading = false;
try {
loading = true;
const result = await getAllPeople({ withHidden: false });
people = result.people;
loading = false;
} catch (error) {
handleError(error, $t('get_people_error'));
}
});
const togglePerson = (person: PersonResponseDto) => {
@@ -86,11 +95,11 @@
</div>
</ModalBody>
{#if multiple && selectedPeople.length > 0}
{#if multiple}
<ModalFooter>
<HStack fullWidth gap={4}>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" fullWidth onclick={handleSubmit}>
<Button shape="round" fullWidth onclick={handleSubmit} disabled={selectedPeople.length === 0}>
{$t('select_count', { values: { count: selectedPeople.length } })}
</Button>
</HStack>

View File

@@ -2,9 +2,11 @@ import {
PluginTriggerType,
updateWorkflow as updateWorkflowApi,
type PluginActionResponseDto,
type PluginContext,
type PluginContextType,
type PluginFilterResponseDto,
type PluginTriggerResponseDto,
type WorkflowActionItemDto,
type WorkflowFilterItemDto,
type WorkflowResponseDto,
type WorkflowUpdateDto,
} from '@immich/sdk';
@@ -18,316 +20,280 @@ export interface WorkflowPayload {
actions: Record<string, unknown>[];
}
export class WorkflowService {
private availableTriggers: PluginTriggerResponseDto[];
private availableFilters: PluginFilterResponseDto[];
private availableActions: PluginActionResponseDto[];
/**
* Get filters that support the given context
*/
export const getFiltersByContext = (
availableFilters: PluginFilterResponseDto[],
context: PluginContextType,
): PluginFilterResponseDto[] => {
return availableFilters.filter((filter) => filter.supportedContexts.includes(context));
};
constructor(
triggers: PluginTriggerResponseDto[],
filters: PluginFilterResponseDto[],
actions: PluginActionResponseDto[],
) {
this.availableTriggers = triggers;
this.availableFilters = filters;
this.availableActions = actions;
/**
* Get actions that support the given context
*/
export const getActionsByContext = (
availableActions: PluginActionResponseDto[],
context: PluginContextType,
): PluginActionResponseDto[] => {
return availableActions.filter((action) => action.supportedContexts.includes(context));
};
/**
* Initialize filter configurations from existing workflow
*/
export const initializeFilterConfigs = (
workflow: WorkflowResponseDto,
availableFilters: PluginFilterResponseDto[],
): Record<string, unknown> => {
const configs: Record<string, unknown> = {};
if (workflow.filters) {
for (const workflowFilter of workflow.filters) {
const filterDef = availableFilters.find((f) => f.id === workflowFilter.pluginFilterId);
if (filterDef) {
configs[filterDef.methodName] = workflowFilter.filterConfig ?? {};
}
}
}
/**
* Get filters that support the given context
*/
getFiltersByContext(context: PluginContext): PluginFilterResponseDto[] {
return this.availableFilters.filter((filter) => filter.supportedContexts.includes(context));
return configs;
};
/**
* Initialize action configurations from existing workflow
*/
export const initializeActionConfigs = (
workflow: WorkflowResponseDto,
availableActions: PluginActionResponseDto[],
): Record<string, unknown> => {
const configs: Record<string, unknown> = {};
if (workflow.actions) {
for (const workflowAction of workflow.actions) {
const actionDef = availableActions.find((a) => a.id === workflowAction.pluginActionId);
if (actionDef) {
configs[actionDef.methodName] = workflowAction.actionConfig ?? {};
}
}
}
/**
* Get actions that support the given context
*/
getActionsByContext(context: PluginContext): PluginActionResponseDto[] {
return this.availableActions.filter((action) => action.supportedContexts.includes(context));
}
return configs;
};
/**
* Initialize filter configurations from existing workflow
*/
initializeFilterConfigs(
workflow: WorkflowResponseDto,
contextFilters?: PluginFilterResponseDto[],
): Record<string, unknown> {
const filters = contextFilters ?? this.availableFilters;
const configs: Record<string, unknown> = {};
/**
* Build workflow payload from current state
*/
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) => ({
[filter.methodName]: filterConfigs[filter.methodName] ?? {},
}));
if (workflow.filters) {
for (const workflowFilter of workflow.filters) {
const filterDef = filters.find((f) => f.id === workflowFilter.filterId);
if (filterDef) {
configs[filterDef.methodName] = workflowFilter.filterConfig ?? {};
const actions = orderedActions.map((action) => ({
[action.methodName]: actionConfigs[action.methodName] ?? {},
}));
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
const filters: PluginFilterResponseDto[] = [];
const filterConfigs: Record<string, unknown> = {};
if (Array.isArray(parsed.filters)) {
for (const filterObj of parsed.filters) {
const methodName = Object.keys(filterObj)[0];
const filter = availableFilters.find((f) => f.methodName === methodName);
if (filter) {
filters.push(filter);
filterConfigs[methodName] = (filterObj as Record<string, unknown>)[methodName];
}
}
}
return configs;
}
/**
* Initialize action configurations from existing workflow
*/
initializeActionConfigs(
workflow: WorkflowResponseDto,
contextActions?: PluginActionResponseDto[],
): Record<string, unknown> {
const actions = contextActions ?? this.availableActions;
const configs: Record<string, unknown> = {};
if (workflow.actions) {
for (const workflowAction of workflow.actions) {
const actionDef = actions.find((a) => a.id === workflowAction.actionId);
if (actionDef) {
configs[actionDef.methodName] = workflowAction.actionConfig ?? {};
// Parse actions
const actions: PluginActionResponseDto[] = [];
const actionConfigs: Record<string, unknown> = {};
if (Array.isArray(parsed.actions)) {
for (const actionObj of parsed.actions) {
const methodName = Object.keys(actionObj)[0];
const action = availableActions.find((a) => a.methodName === methodName);
if (action) {
actions.push(action);
actionConfigs[methodName] = (actionObj as Record<string, unknown>)[methodName];
}
}
}
return configs;
}
/**
* Initialize ordered filters from existing workflow
*/
initializeOrderedFilters(
workflow: WorkflowResponseDto,
contextFilters?: PluginFilterResponseDto[],
): PluginFilterResponseDto[] {
if (!workflow.filters) {
return [];
}
const filters = contextFilters ?? this.availableFilters;
return workflow.filters
.map((wf) => filters.find((f) => f.id === wf.filterId))
.filter(Boolean) as PluginFilterResponseDto[];
}
/**
* Initialize ordered actions from existing workflow
*/
initializeOrderedActions(
workflow: WorkflowResponseDto,
contextActions?: PluginActionResponseDto[],
): PluginActionResponseDto[] {
if (!workflow.actions) {
return [];
}
const actions = contextActions ?? this.availableActions;
return workflow.actions
.map((wa) => actions.find((a) => a.id === wa.actionId))
.filter(Boolean) as PluginActionResponseDto[];
}
/**
* Build workflow payload from current state
*/
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) => ({
[filter.methodName]: filterConfigs[filter.methodName] ?? {},
}));
const actions = orderedActions.map((action) => ({
[action.methodName]: actionConfigs[action.methodName] ?? {},
}));
return {
name,
description,
enabled,
triggerType,
filters,
actions,
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',
};
}
};
/**
* Parse JSON workflow and update state
*/
parseWorkflowJson(jsonString: string): {
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 = this.availableTriggers.find((t) => t.triggerType === parsed.triggerType);
// Parse filters
const filters: PluginFilterResponseDto[] = [];
const filterConfigs: Record<string, unknown> = {};
if (Array.isArray(parsed.filters)) {
for (const filterObj of parsed.filters) {
const methodName = Object.keys(filterObj)[0];
const filter = this.availableFilters.find((f) => f.methodName === methodName);
if (filter) {
filters.push(filter);
filterConfigs[methodName] = (filterObj as Record<string, unknown>)[methodName];
}
}
}
// Parse actions
const actions: PluginActionResponseDto[] = [];
const actionConfigs: Record<string, unknown> = {};
if (Array.isArray(parsed.actions)) {
for (const actionObj of parsed.actions) {
const methodName = Object.keys(actionObj)[0];
const action = this.availableActions.find((a) => a.methodName === methodName);
if (action) {
actions.push(action);
actionConfigs[methodName] = (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',
};
}
/**
* 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>,
availableFilters: PluginFilterResponseDto[],
availableActions: PluginActionResponseDto[],
): boolean => {
// Check enabled state
if (enabled !== previousWorkflow.enabled) {
return true;
}
/**
* Check if workflow has changes compared to previous version
*/
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;
}
// Check name or description
if (name !== (previousWorkflow.name ?? '') || description !== (previousWorkflow.description ?? '')) {
return true;
}
// Check trigger
if (triggerType !== previousWorkflow.triggerType) {
return true;
}
// Check filters order/items
const previousFilterIds = previousWorkflow.filters?.map((f) => f.filterId) ?? [];
const currentFilterIds = orderedFilters.map((f) => f.id);
if (JSON.stringify(previousFilterIds) !== JSON.stringify(currentFilterIds)) {
return true;
}
// Check actions order/items
const previousActionIds = previousWorkflow.actions?.map((a) => a.actionId) ?? [];
const currentActionIds = orderedActions.map((a) => a.id);
if (JSON.stringify(previousActionIds) !== JSON.stringify(currentActionIds)) {
return true;
}
// Check filter configs
const previousFilterConfigs: Record<string, unknown> = {};
for (const wf of previousWorkflow.filters ?? []) {
const filterDef = this.availableFilters.find((f) => f.id === wf.filterId);
if (filterDef) {
previousFilterConfigs[filterDef.methodName] = wf.filterConfig ?? {};
}
}
if (JSON.stringify(previousFilterConfigs) !== JSON.stringify(filterConfigs)) {
return true;
}
// Check action configs
const previousActionConfigs: Record<string, unknown> = {};
for (const wa of previousWorkflow.actions ?? []) {
const actionDef = this.availableActions.find((a) => a.id === wa.actionId);
if (actionDef) {
previousActionConfigs[actionDef.methodName] = wa.actionConfig ?? {};
}
}
if (JSON.stringify(previousActionConfigs) !== JSON.stringify(actionConfigs)) {
return true;
}
return false;
// Check name or description
if (name !== (previousWorkflow.name ?? '') || description !== (previousWorkflow.description ?? '')) {
return true;
}
async updateWorkflow(
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) => ({
filterId: filter.id,
filterConfig: filterConfigs[filter.methodName] ?? {},
}));
const actions = orderedActions.map((action) => ({
actionId: action.id,
actionConfig: actionConfigs[action.methodName] ?? {},
}));
const updateDto: WorkflowUpdateDto = {
name,
description,
enabled,
filters,
actions,
triggerType,
};
return updateWorkflowApi({ id: workflowId, workflowUpdateDto: updateDto });
// Check trigger
if (triggerType !== previousWorkflow.triggerType) {
return true;
}
}
// 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;
}
// 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
const previousFilterConfigs: Record<string, unknown> = {};
for (const wf of previousWorkflow.filters ?? []) {
const filterDef = availableFilters.find((f) => f.id === wf.pluginFilterId);
if (filterDef) {
previousFilterConfigs[filterDef.methodName] = wf.filterConfig ?? {};
}
}
if (JSON.stringify(previousFilterConfigs) !== JSON.stringify(filterConfigs)) {
return true;
}
// Check action configs
const previousActionConfigs: Record<string, unknown> = {};
for (const wa of previousWorkflow.actions ?? []) {
const actionDef = availableActions.find((a) => a.id === wa.pluginActionId);
if (actionDef) {
previousActionConfigs[actionDef.methodName] = wa.actionConfig ?? {};
}
}
if (JSON.stringify(previousActionConfigs) !== JSON.stringify(actionConfigs)) {
return true;
}
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) => ({
pluginFilterId: filter.id,
filterConfig: filterConfigs[filter.methodName] ?? {},
})) as WorkflowFilterItemDto[];
const actions = orderedActions.map((action) => ({
pluginActionId: action.id,
actionConfig: actionConfigs[action.methodName] ?? {},
})) as WorkflowActionItemDto[];
const updateDto: WorkflowUpdateDto = {
name,
description,
enabled,
filters,
actions,
triggerType,
};
return updateWorkflowApi({ id: workflowId, workflowUpdateDto: updateDto });
};

View File

@@ -76,15 +76,15 @@
enabled: workflow.enabled,
triggerType: workflow.triggerType,
filters: orderedFilters.map((filter) => {
const meta = pluginFilterLookup.get(filter.filterId);
const key = meta?.methodName ?? filter.filterId;
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.actionId);
const key = meta?.methodName ?? action.actionId;
const meta = pluginActionLookup.get(action.pluginActionId);
const key = meta?.methodName ?? action.pluginActionId;
return {
[key]: action.actionConfig ?? {},
};
@@ -123,7 +123,7 @@
};
const handleEditWorkflow = async (workflow: WorkflowResponseDto) => {
await goto(`${AppRoute.WORKFLOWS_EDIT}/${workflow.id}?editMode=visual`);
await goto(`${AppRoute.WORKFLOWS}/${workflow.id}`);
};
const handleCreateWorkflow = async () => {
@@ -137,7 +137,7 @@
},
});
await goto(`${AppRoute.WORKFLOWS_EDIT}/${workflow.id}?editMode=visual`);
await goto(`${AppRoute.WORKFLOWS}/${workflow.id}`);
};
const getFilterLabel = (filterId: string) => {
@@ -289,7 +289,7 @@
</span>
{:else}
{#each workflow.filters as workflowFilter (workflowFilter.id)}
{@render chipItem(getFilterLabel(workflowFilter.filterId))}
{@render chipItem(getFilterLabel(workflowFilter.pluginFilterId))}
{/each}
{/if}
</div>
@@ -309,7 +309,7 @@
{:else}
<div class="flex flex-wrap gap-2">
{#each workflow.actions as workflowAction (workflowAction.id)}
{@render chipItem(getActionLabel(workflowAction.actionId))}
{@render chipItem(getActionLabel(workflowAction.pluginActionId))}
{/each}
</div>
{/if}

View File

@@ -1,7 +1,7 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getPlugins, getWorkflows } from '@immich/sdk';
import type { PageLoad } from '../$types';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);

View File

@@ -11,7 +11,17 @@
import AddWorkflowStepModal from '$lib/modals/AddWorkflowStepModal.svelte';
import WorkflowNavigationConfirmModal from '$lib/modals/WorkflowNavigationConfirmModal.svelte';
import WorkflowTriggerUpdateConfirmModal from '$lib/modals/WorkflowTriggerUpdateConfirmModal.svelte';
import { WorkflowService, type WorkflowPayload } from '$lib/services/workflow.service';
import {
buildWorkflowPayload,
getActionsByContext,
getFiltersByContext,
handleUpdateWorkflow,
hasWorkflowChanged,
initializeActionConfigs,
initializeFilterConfigs,
parseWorkflowJson,
type WorkflowPayload,
} from '$lib/services/workflow.service';
import { handleError } from '$lib/utils/handle-error';
import type { PluginActionResponseDto, PluginFilterResponseDto, PluginTriggerResponseDto } from '@immich/sdk';
import {
@@ -57,7 +67,6 @@
const triggers = data.triggers;
const filters = data.plugins.flatMap((plugin) => plugin.filters);
const actions = data.plugins.flatMap((plugin) => plugin.actions);
const workflowService = new WorkflowService(triggers, filters, actions);
let previousWorkflow = data.workflow;
let editWorkflow = $state(data.workflow);
@@ -67,26 +76,28 @@
let name: string = $derived(editWorkflow.name ?? '');
let description: string = $derived(editWorkflow.description ?? '');
let selectedTrigger = $state(triggers.find((t) => t.triggerType === editWorkflow.triggerType) ?? triggers[0]);
let selectedTrigger = $state(triggers.find((t) => t.type === editWorkflow.triggerType) ?? triggers[0]);
let triggerType = $derived(selectedTrigger.triggerType);
let triggerType = $derived(selectedTrigger.type);
let supportFilters = $derived(workflowService.getFiltersByContext(selectedTrigger.context));
let supportActions = $derived(workflowService.getActionsByContext(selectedTrigger.context));
let supportFilters = $derived(getFiltersByContext(filters, selectedTrigger.contextType));
let supportActions = $derived(getActionsByContext(actions, selectedTrigger.contextType));
let orderedFilters: PluginFilterResponseDto[] = $derived(
workflowService.initializeOrderedFilters(editWorkflow, supportFilters),
let selectedFilters: PluginFilterResponseDto[] = $derived(
(editWorkflow.filters ?? []).flatMap((workflowFilter) =>
supportFilters.filter((supportedFilter) => supportedFilter.id === workflowFilter.pluginFilterId),
),
);
let orderedActions: PluginActionResponseDto[] = $derived(
workflowService.initializeOrderedActions(editWorkflow, supportActions),
);
let filterConfigs: Record<string, unknown> = $derived(
workflowService.initializeFilterConfigs(editWorkflow, supportFilters),
);
let actionConfigs: Record<string, unknown> = $derived(
workflowService.initializeActionConfigs(editWorkflow, supportActions),
let selectedActions: PluginActionResponseDto[] = $derived(
(editWorkflow.actions ?? []).flatMap((workflowAction) =>
supportActions.filter((supportedAction) => supportedAction.id === workflowAction.pluginActionId),
),
);
let filterConfigs: Record<string, unknown> = $derived(initializeFilterConfigs(editWorkflow, supportFilters));
let actionConfigs: Record<string, unknown> = $derived(initializeActionConfigs(editWorkflow, supportActions));
$effect(() => {
editWorkflow.triggerType = triggerType;
});
@@ -94,10 +105,10 @@
// Clear filters and actions when trigger changes (context changes)
let previousContext = $state<string | undefined>(undefined);
$effect(() => {
const currentContext = selectedTrigger.context;
const currentContext = selectedTrigger.contextType;
if (previousContext !== undefined && previousContext !== currentContext) {
orderedFilters = [];
orderedActions = [];
selectedFilters = [];
selectedActions = [];
filterConfigs = {};
actionConfigs = {};
}
@@ -106,14 +117,14 @@
const updateWorkflow = async () => {
try {
const updated = await workflowService.updateWorkflow(
const updated = await handleUpdateWorkflow(
editWorkflow.id,
name,
description,
editWorkflow.enabled,
triggerType,
orderedFilters,
orderedActions,
selectedFilters,
selectedActions,
filterConfigs,
actionConfigs,
);
@@ -131,13 +142,13 @@
};
const jsonContent = $derived(
workflowService.buildWorkflowPayload(
buildWorkflowPayload(
name,
description,
editWorkflow.enabled,
triggerType,
orderedFilters,
orderedActions,
selectedFilters,
selectedActions,
filterConfigs,
actionConfigs,
),
@@ -153,7 +164,7 @@
});
const syncFromJson = () => {
const result = workflowService.parseWorkflowJson(JSON.stringify(jsonEditorContent));
const result = parseWorkflowJson(JSON.stringify(jsonEditorContent), triggers, filters, actions);
if (!result.success) {
return;
@@ -168,24 +179,26 @@
selectedTrigger = result.data.trigger;
}
orderedFilters = result.data.filters;
orderedActions = result.data.actions;
selectedFilters = result.data.filters;
selectedActions = result.data.actions;
filterConfigs = result.data.filterConfigs;
actionConfigs = result.data.actionConfigs;
}
};
let hasChanges: boolean = $derived(
workflowService.hasWorkflowChanged(
hasWorkflowChanged(
previousWorkflow,
editWorkflow.enabled,
name,
description,
triggerType,
orderedFilters,
orderedActions,
selectedFilters,
selectedActions,
filterConfigs,
actionConfigs,
filters,
actions,
),
);
@@ -211,10 +224,10 @@
return;
}
const newFilters = [...orderedFilters];
const newFilters = [...selectedFilters];
const [draggedItem] = newFilters.splice(draggedFilterIndex, 1);
newFilters.splice(index, 0, draggedItem);
orderedFilters = newFilters;
selectedFilters = newFilters;
};
const handleFilterDragEnd = () => {
@@ -238,10 +251,10 @@
return;
}
const newActions = [...orderedActions];
const newActions = [...selectedActions];
const [draggedItem] = newActions.splice(draggedActionIndex, 1);
newActions.splice(index, 0, draggedItem);
orderedActions = newActions;
selectedActions = newActions;
};
const handleActionDragEnd = () => {
@@ -253,26 +266,24 @@
const result = (await modalManager.show(AddWorkflowStepModal, {
filters: supportFilters,
actions: supportActions,
addedFilters: orderedFilters,
addedActions: orderedActions,
type,
})) as { type: 'filter' | 'action'; item: PluginFilterResponseDto | PluginActionResponseDto } | undefined;
if (result) {
if (result.type === 'filter') {
orderedFilters = [...orderedFilters, result.item as PluginFilterResponseDto];
selectedFilters = [...selectedFilters, result.item as PluginFilterResponseDto];
} else if (result.type === 'action') {
orderedActions = [...orderedActions, result.item as PluginActionResponseDto];
selectedActions = [...selectedActions, result.item as PluginActionResponseDto];
}
}
};
const handleRemoveFilter = (index: number) => {
orderedFilters = orderedFilters.filter((_, i) => i !== index);
selectedFilters = selectedFilters.filter((_, i) => i !== index);
};
const handleRemoveAction = (index: number) => {
orderedActions = orderedActions.filter((_, i) => i !== index);
selectedActions = selectedActions.filter((_, i) => i !== index);
};
const handleTriggerChange = async (newTrigger: PluginTriggerResponseDto) => {
@@ -340,8 +351,6 @@
</svelte:head>
<main class="pt-24 immich-scrollbar">
<WorkflowSummarySidebar trigger={selectedTrigger} filters={orderedFilters} actions={orderedActions} />
<Container size="medium" class="p-4" center>
{#if viewMode === 'json'}
<WorkflowJsonEditor
@@ -392,7 +401,7 @@
{#each triggers as trigger (trigger.name)}
<WorkflowTriggerCard
{trigger}
selected={selectedTrigger.triggerType === trigger.triggerType}
selected={selectedTrigger.type === trigger.type}
onclick={() => handleTriggerChange(trigger)}
/>
{/each}
@@ -414,10 +423,10 @@
</CardHeader>
<CardBody>
{#if orderedFilters.length === 0}
{#if selectedFilters.length === 0}
{@render emptyCreateButton($t('add_filter'), $t('add_filter_description'), () => handleAddStep('filter'))}
{:else}
{#each orderedFilters as filter, index (filter.id)}
{#each selectedFilters as filter, index (index)}
{#if index > 0}
{@render stepSeparator()}
{/if}
@@ -483,10 +492,10 @@
</CardHeader>
<CardBody>
{#if orderedActions.length === 0}
{#if selectedActions.length === 0}
{@render emptyCreateButton($t('add_action'), $t('add_action_description'), () => handleAddStep('action'))}
{:else}
{#each orderedActions as action, index (action.id)}
{#each selectedActions as action, index (index)}
{#if index > 0}
{@render stepSeparator()}
{/if}
@@ -539,6 +548,8 @@
</VStack>
{/if}
</Container>
<WorkflowSummarySidebar trigger={selectedTrigger} filters={selectedFilters} actions={selectedActions} />
</main>
<ControlAppBar onClose={() => goto(AppRoute.WORKFLOWS)} backIcon={mdiArrowLeft} tailwindClasses="fixed! top-0! w-full">

View File

@@ -1,6 +1,6 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getPlugins, getTriggers, getWorkflow } from '@immich/sdk';
import { getPlugins, getPluginTriggers, getWorkflow } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ url, params }) => {
@@ -8,7 +8,7 @@ export const load = (async ({ url, params }) => {
const [plugins, workflow, triggers] = await Promise.all([
getPlugins(),
getWorkflow({ id: params.workflowId }),
getTriggers(),
getPluginTriggers(),
]);
const $t = await getFormatter();