feat: workflow ui

This commit is contained in:
Alex Tran
2025-11-15 18:35:06 +00:00
parent 4dcc049465
commit 272ad7c773
34 changed files with 2099 additions and 142 deletions

View File

@@ -993,10 +993,12 @@
"unable_to_complete_oauth_login": "Unable to complete OAuth login",
"unable_to_connect": "Unable to connect",
"unable_to_copy_to_clipboard": "Cannot copy to clipboard, make sure you are accessing the page through https",
"unable_to_create": "Unable to create workflow",
"unable_to_create_admin_account": "Unable to create admin account",
"unable_to_create_api_key": "Unable to create a new API Key",
"unable_to_create_library": "Unable to create library",
"unable_to_create_user": "Unable to create user",
"unable_to_delete": "Unable to delete workflow",
"unable_to_delete_album": "Unable to delete album",
"unable_to_delete_asset": "Unable to delete asset",
"unable_to_delete_assets": "Error deleting assets",
@@ -1801,7 +1803,16 @@
"second": "Second",
"see_all_people": "See all people",
"select": "Select",
"select_album": "Select album",
"select_album_cover": "Select album cover",
"select_albums": "Select albums",
"album_selected": "Album selected",
"albums_selected": "{count, plural, one {# album selected} other {# albums selected}}",
"select_person": "Select person",
"select_people": "Select people",
"person_selected": "Person selected",
"people_selected": "{count, plural, one {# person selected} other {# people selected}}",
"select_count": "{count, plural, one {Select #} other {Select #}}",
"select_all": "Select all",
"select_all_duplicates": "Select all duplicates",
"select_all_in": "Select all in {group}",
@@ -2182,6 +2193,11 @@
"welcome_to_immich": "Welcome to Immich",
"wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
"workflow_deleted": "Workflow deleted",
"workflow_json": "Workflow JSON",
"workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.",
"workflow_updated": "Workflow updated",
"workflows_help_text": "Workflows automate actions on your assets based on triggers and filters.",
"wrong_pin_code": "Wrong PIN code",
"year": "Year",
"years_ago": "{years, plural, one {# year} other {# years}} ago",
@@ -2189,5 +2205,30 @@
"you_dont_have_any_shared_links": "You don't have any shared links",
"your_wifi_name": "Your Wi-Fi name",
"zoom_image": "Zoom Image",
"zoom_to_bounds": "Zoom to bounds"
"zoom_to_bounds": "Zoom to bounds",
"add_action": "Add action",
"add_filter": "Add filter",
"array_field_not_fully_supported": "Array fields require manual JSON editing",
"asset_created": "Asset created",
"create_first_workflow": "Create first workflow",
"create_workflow": "Create workflow",
"edit_workflow": "Edit workflow",
"editor_mode": "Editor mode",
"hide_json": "Hide JSON",
"json_editor": "JSON editor",
"json_error": "JSON error",
"move_down": "Move down",
"move_up": "Move up",
"name_required": "Name is required",
"no_actions_added": "No actions added yet",
"no_configuration_needed": "No configuration needed",
"no_filters_added": "No filters added yet",
"no_workflows_yet": "No workflows yet",
"person_recognized": "Person recognized",
"show_json": "Show JSON",
"trigger_type": "Trigger type",
"unsupported_field_type": "Unsupported field type",
"untitled_workflow": "Untitled workflow",
"validation_error": "Validation error",
"visual_builder": "Visual builder"
}

View File

@@ -196,6 +196,7 @@ Class | Method | HTTP request | Description
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins
*PluginsApi* | [**getTriggers**](doc//PluginsApi.md#gettriggers) | **GET** /plugins/triggers | List all plugin triggers
*SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | Retrieve assets by city
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | Retrieve explore data
*SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | Retrieve search suggestions
@@ -455,6 +456,7 @@ Class | Method | HTTP request | Description
- [PluginContext](doc//PluginContext.md)
- [PluginFilterResponseDto](doc//PluginFilterResponseDto.md)
- [PluginResponseDto](doc//PluginResponseDto.md)
- [PluginTriggerResponseDto](doc//PluginTriggerResponseDto.md)
- [PluginTriggerType](doc//PluginTriggerType.md)
- [PurchaseResponse](doc//PurchaseResponse.md)
- [PurchaseUpdate](doc//PurchaseUpdate.md)

View File

@@ -214,6 +214,7 @@ part 'model/plugin_action_response_dto.dart';
part 'model/plugin_context.dart';
part 'model/plugin_filter_response_dto.dart';
part 'model/plugin_response_dto.dart';
part 'model/plugin_trigger_response_dto.dart';
part 'model/plugin_trigger_type.dart';
part 'model/purchase_response.dart';
part 'model/purchase_update.dart';

View File

@@ -123,4 +123,55 @@ 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

@@ -480,6 +480,8 @@ class ApiClient {
return PluginFilterResponseDto.fromJson(value);
case 'PluginResponseDto':
return PluginResponseDto.fromJson(value);
case 'PluginTriggerResponseDto':
return PluginTriggerResponseDto.fromJson(value);
case 'PluginTriggerType':
return PluginTriggerTypeTypeTransformer().decode(value);
case 'PurchaseResponse':

View File

@@ -0,0 +1,135 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PluginTriggerResponseDto {
/// Returns a new [PluginTriggerResponseDto] instance.
PluginTriggerResponseDto({
required this.context,
required this.description,
required this.name,
required this.schema,
required this.triggerType,
});
PluginContext context;
String description;
String name;
Object? schema;
PluginTriggerType triggerType;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginTriggerResponseDto &&
other.context == context &&
other.description == description &&
other.name == name &&
other.schema == schema &&
other.triggerType == triggerType;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(context.hashCode) +
(description.hashCode) +
(name.hashCode) +
(schema == null ? 0 : schema!.hashCode) +
(triggerType.hashCode);
@override
String toString() => 'PluginTriggerResponseDto[context=$context, description=$description, name=$name, schema=$schema, triggerType=$triggerType]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'context'] = this.context;
json[r'description'] = this.description;
json[r'name'] = this.name;
if (this.schema != null) {
json[r'schema'] = this.schema;
} else {
// json[r'schema'] = null;
}
json[r'triggerType'] = this.triggerType;
return json;
}
/// Returns a new [PluginTriggerResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginTriggerResponseDto? fromJson(dynamic value) {
upgradeDto(value, "PluginTriggerResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginTriggerResponseDto(
context: PluginContext.fromJson(json[r'context'])!,
description: mapValueOfType<String>(json, r'description')!,
name: mapValueOfType<String>(json, r'name')!,
schema: mapValueOfType<Object>(json, r'schema'),
triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!,
);
}
return null;
}
static List<PluginTriggerResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginTriggerResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginTriggerResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginTriggerResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginTriggerResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginTriggerResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginTriggerResponseDto-objects as value to a dart map
static Map<String, List<PluginTriggerResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginTriggerResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginTriggerResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'context',
'description',
'name',
'schema',
'triggerType',
};
}

View File

@@ -40,7 +40,7 @@ class WorkflowResponseDto {
String ownerId;
WorkflowResponseDtoTriggerTypeEnum triggerType;
PluginTriggerType triggerType;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto &&
@@ -105,7 +105,7 @@ class WorkflowResponseDto {
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name'),
ownerId: mapValueOfType<String>(json, r'ownerId')!,
triggerType: WorkflowResponseDtoTriggerTypeEnum.fromJson(json[r'triggerType'])!,
triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!,
);
}
return null;
@@ -165,77 +165,3 @@ class WorkflowResponseDto {
};
}
class WorkflowResponseDtoTriggerTypeEnum {
/// Instantiate a new enum with the provided [value].
const WorkflowResponseDtoTriggerTypeEnum._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const assetCreate = WorkflowResponseDtoTriggerTypeEnum._(r'AssetCreate');
static const personRecognized = WorkflowResponseDtoTriggerTypeEnum._(r'PersonRecognized');
/// List of all possible values in this [enum][WorkflowResponseDtoTriggerTypeEnum].
static const values = <WorkflowResponseDtoTriggerTypeEnum>[
assetCreate,
personRecognized,
];
static WorkflowResponseDtoTriggerTypeEnum? fromJson(dynamic value) => WorkflowResponseDtoTriggerTypeEnumTypeTransformer().decode(value);
static List<WorkflowResponseDtoTriggerTypeEnum> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowResponseDtoTriggerTypeEnum>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowResponseDtoTriggerTypeEnum.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [WorkflowResponseDtoTriggerTypeEnum] to String,
/// and [decode] dynamic data back to [WorkflowResponseDtoTriggerTypeEnum].
class WorkflowResponseDtoTriggerTypeEnumTypeTransformer {
factory WorkflowResponseDtoTriggerTypeEnumTypeTransformer() => _instance ??= const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._();
const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._();
String encode(WorkflowResponseDtoTriggerTypeEnum data) => data.value;
/// Decodes a [dynamic value][data] to a WorkflowResponseDtoTriggerTypeEnum.
///
/// 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]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// 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.
WorkflowResponseDtoTriggerTypeEnum? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'AssetCreate': return WorkflowResponseDtoTriggerTypeEnum.assetCreate;
case r'PersonRecognized': return WorkflowResponseDtoTriggerTypeEnum.personRecognized;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [WorkflowResponseDtoTriggerTypeEnumTypeTransformer] instance.
static WorkflowResponseDtoTriggerTypeEnumTypeTransformer? _instance;
}

View File

@@ -7914,6 +7914,55 @@
"x-immich-state": "Alpha"
}
},
"/plugins/triggers": {
"get": {
"description": "Retrieve a list of all available plugin triggers.",
"operationId": "getTriggers",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/PluginTriggerResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "List all plugin triggers",
"tags": [
"Plugins"
],
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-permission": "plugin.read",
"x-immich-state": "Alpha"
}
},
"/plugins/{id}": {
"get": {
"description": "Retrieve information about a specific plugin by its ID.",
@@ -17897,6 +17946,42 @@
],
"type": "object"
},
"PluginTriggerResponseDto": {
"properties": {
"context": {
"allOf": [
{
"$ref": "#/components/schemas/PluginContext"
}
]
},
"description": {
"type": "string"
},
"name": {
"type": "string"
},
"schema": {
"nullable": true,
"type": "object"
},
"triggerType": {
"allOf": [
{
"$ref": "#/components/schemas/PluginTriggerType"
}
]
}
},
"required": [
"context",
"description",
"name",
"schema",
"triggerType"
],
"type": "object"
},
"PluginTriggerType": {
"enum": [
"AssetCreate",
@@ -22705,11 +22790,11 @@
"type": "string"
},
"triggerType": {
"enum": [
"AssetCreate",
"PersonRecognized"
],
"type": "string"
"allOf": [
{
"$ref": "#/components/schemas/PluginTriggerType"
}
]
}
},
"required": [

View File

@@ -957,6 +957,13 @@ export type PluginResponseDto = {
updatedAt: string;
version: string;
};
export type PluginTriggerResponseDto = {
context: PluginContext;
description: string;
name: string;
schema: object | null;
triggerType: PluginTriggerType;
};
export type SearchExploreItem = {
data: AssetResponseDto;
value: string;
@@ -1722,7 +1729,7 @@ export type WorkflowResponseDto = {
id: string;
name: string | null;
ownerId: string;
triggerType: TriggerType;
triggerType: PluginTriggerType;
};
export type WorkflowActionItemDto = {
actionConfig?: object;
@@ -3601,6 +3608,17 @@ export function getPlugins(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
/**
* List all plugin triggers
*/
export function getTriggers(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PluginTriggerResponseDto[];
}>("/plugins/triggers", {
...opts
}));
}
/**
* Retrieve a plugin
*/
@@ -5288,6 +5306,10 @@ export enum PluginContext {
Album = "album",
Person = "person"
}
export enum PluginTriggerType {
AssetCreate = "AssetCreate",
PersonRecognized = "PersonRecognized"
}
export enum SearchSuggestionType {
Country = "country",
State = "state",
@@ -5439,11 +5461,3 @@ export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic"
}
export enum TriggerType {
AssetCreate = "AssetCreate",
PersonRecognized = "PersonRecognized"
}
export enum PluginTriggerType {
AssetCreate = "AssetCreate",
PersonRecognized = "PersonRecognized"
}

View File

@@ -1,30 +1,36 @@
{
"name": "immich-core",
"version": "2.0.0",
"version": "2.0.1",
"title": "Immich Core",
"description": "Core workflow capabilities for Immich",
"author": "Immich Team",
"wasm": {
"path": "dist/plugin.wasm"
},
"filters": [
{
"methodName": "filterFileName",
"title": "Filter by filename",
"description": "Filter assets by filename pattern using text matching or regular expressions",
"supportedContexts": ["asset"],
"supportedContexts": [
"asset"
],
"schema": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"title": "Filename pattern",
"description": "Text or regex pattern to match against filename"
},
"matchType": {
"type": "string",
"enum": ["contains", "regex", "exact"],
"title": "Match type",
"enum": [
"contains",
"regex",
"exact"
],
"default": "contains",
"description": "Type of pattern matching to perform"
},
@@ -34,43 +40,57 @@
"description": "Whether matching should be case-sensitive"
}
},
"required": ["pattern"]
"required": [
"pattern"
]
}
},
{
"methodName": "filterFileType",
"title": "Filter by file type",
"description": "Filter assets by file type",
"supportedContexts": ["asset"],
"supportedContexts": [
"asset"
],
"schema": {
"type": "object",
"properties": {
"fileTypes": {
"type": "array",
"title": "File types",
"items": {
"type": "string",
"enum": ["IMAGE", "VIDEO"]
"enum": [
"image",
"video"
]
},
"description": "Allowed file types"
}
},
"required": ["fileTypes"]
"required": [
"fileTypes"
]
}
},
{
"methodName": "filterPerson",
"title": "Filter by person",
"description": "Filter by detected person",
"supportedContexts": ["person"],
"supportedContexts": [
"person"
],
"schema": {
"type": "object",
"properties": {
"personIds": {
"type": "array",
"title": "Person IDs",
"items": {
"type": "string"
},
"description": "List of person to match"
"description": "List of person to match",
"subType": "people-picker"
},
"matchAny": {
"type": "boolean",
@@ -78,24 +98,29 @@
"description": "Match any name (true) or require all names (false)"
}
},
"required": ["personIds"]
"required": [
"personIds"
]
}
}
],
"actions": [
{
"methodName": "actionArchive",
"title": "Archive",
"description": "Move the asset to archive",
"supportedContexts": ["asset"],
"supportedContexts": [
"asset"
],
"schema": {}
},
{
"methodName": "actionFavorite",
"title": "Favorite",
"description": "Mark the asset as favorite or unfavorite",
"supportedContexts": ["asset"],
"supportedContexts": [
"asset"
],
"schema": {
"type": "object",
"properties": {
@@ -111,16 +136,23 @@
"methodName": "actionAddToAlbum",
"title": "Add to Album",
"description": "Add the item to a specified album",
"supportedContexts": ["asset", "person"],
"supportedContexts": [
"asset",
"person"
],
"schema": {
"type": "object",
"properties": {
"albumId": {
"type": "string",
"description": "Target album ID"
"title": "Album ID",
"description": "Target album ID",
"subType": "album-picker"
}
},
"required": ["albumId"]
"required": [
"albumId"
]
}
}
]

10
pnpm-lock.yaml generated
View File

@@ -708,8 +708,8 @@ importers:
specifier: file:../open-api/typescript-sdk
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.43.0
version: 0.43.0(@internationalized/date@3.8.2)(svelte@5.43.0)
specifier: ^0.44.0
version: 0.44.0(@internationalized/date@3.8.2)(svelte@5.43.0)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.2.3
version: 0.2.3(mapbox-gl@1.13.3)
@@ -2811,8 +2811,8 @@ packages:
peerDependencies:
svelte: ^5.0.0
'@immich/ui@0.43.0':
resolution: {integrity: sha512-dwWIURsGghsbeFnqxCqUyWslyRU2vQjih7uewNr0nsW68bJ5/esl+V/Kiw2opiNiwI4Q3HEcuTRY57k4Hq+X3Q==}
'@immich/ui@0.44.0':
resolution: {integrity: sha512-APlSvNdaHNyD20hncJH5d0TDS1eGMhn0XeTr01UdR02FJvlfBBIEHCug0LG57p3pWZecbow2JjtU2GBhNDQb6g==}
peerDependencies:
svelte: ^5.0.0
@@ -14429,7 +14429,7 @@ snapshots:
dependencies:
svelte: 5.43.0
'@immich/ui@0.43.0(@internationalized/date@3.8.2)(svelte@5.43.0)':
'@immich/ui@0.44.0(@internationalized/date@3.8.2)(svelte@5.43.0)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.43.0)
'@mdi/js': 7.4.47

View File

@@ -1,7 +1,7 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { PluginResponseDto } from 'src/dtos/plugin.dto';
import { PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { PluginService } from 'src/services/plugin.service';
@@ -12,6 +12,17 @@ import { UUIDParamDto } from 'src/validation';
export class PluginController {
constructor(private service: PluginService) {}
@Get('triggers')
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
summary: 'List all plugin triggers',
description: 'Retrieve a list of all available plugin triggers.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
getTriggers(): PluginTriggerResponseDto[] {
return this.service.getTriggers();
}
@Get()
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({

View File

@@ -1,9 +1,19 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { PluginAction, PluginFilter } from 'src/database';
import { PluginContext } from 'src/enum';
import { PluginContext, 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;
description!: string;
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
context!: PluginContext;
schema!: JSONSchema | null;
}
export class PluginResponseDto {
id!: string;
name!: string;

View File

@@ -74,6 +74,7 @@ export class WorkflowUpdateDto {
export class WorkflowResponseDto {
id!: string;
ownerId!: string;
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' })
triggerType!: PluginTriggerType;
name!: string | null;
description!: string;

View File

@@ -3,7 +3,7 @@ import { JSONSchema } from 'src/types/plugin-schema.types';
export type PluginTrigger = {
name: string;
type: PluginTriggerType;
triggerType: PluginTriggerType;
description: string;
context: PluginContext;
schema: JSONSchema | null;
@@ -12,25 +12,15 @@ export type PluginTrigger = {
export const pluginTriggers: PluginTrigger[] = [
{
name: 'Asset Uploaded',
type: PluginTriggerType.AssetCreate,
triggerType: PluginTriggerType.AssetCreate,
description: 'Triggered when a new asset is uploaded',
context: PluginContext.Asset,
schema: {
type: 'object',
properties: {
assetType: {
type: 'string',
description: 'Type of the asset',
default: 'ALL',
enum: ['Image', 'Video', 'All'],
},
},
},
schema: null,
},
{
name: 'Person Recognized',
type: PluginTriggerType.PersonRecognized,
description: 'Triggered when a person is detected in an asset',
triggerType: PluginTriggerType.PersonRecognized,
description: 'Triggered when a person is detected',
context: PluginContext.Person,
schema: null,
},

View File

@@ -6,8 +6,9 @@ import { join } from 'node:path';
import { Asset, WorkflowAction, WorkflowFilter } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import { mapPlugin, PluginResponseDto } from 'src/dtos/plugin.dto';
import { mapPlugin, PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum';
import { pluginTriggers } from 'src/plugins';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { PluginHostFunctions } from 'src/services/plugin-host.functions';
@@ -50,6 +51,10 @@ export class PluginService extends BaseService {
await this.loadPlugins();
}
getTriggers(): PluginTriggerResponseDto[] {
return pluginTriggers;
}
//
// CRUD operations for plugins
//
@@ -111,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

@@ -125,7 +125,7 @@ export class WorkflowService extends BaseService {
}
private getTriggerOrFail(triggerType: PluginTriggerType) {
const trigger = pluginTriggers.find((t) => t.type === triggerType);
const trigger = pluginTriggers.find((t) => t.triggerType === triggerType);
if (!trigger) {
throw new BadRequestException(`Invalid trigger type: ${triggerType}`);
}

View File

@@ -28,7 +28,7 @@
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.43.0",
"@immich/ui": "^0.44.0",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",

View File

@@ -60,12 +60,12 @@
{/if}
<main class="relative">
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2" use:useActions={use}>
<div class="{scrollbarClass} absolute {hasTitleClass} w-full overflow-y-auto p-2 z-10" use:useActions={use}>
{@render children?.()}
</div>
{#if title || buttons}
<div class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 text-dark">
<div class="absolute flex h-16 w-full place-items-center justify-between border-b p-2 text-dark z-10">
<div class="flex gap-2 items-center">
{#if title}
<div class="font-medium outline-none pe-8" tabindex="-1" id={headerId}>{title}</div>

View File

@@ -4,6 +4,7 @@
import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte';
import { Icon, modalManager } from '@immich/ui';
import {
mdiAutoFix,
mdiCellphoneArrowDownVariant,
mdiContentDuplicate,
mdiCrosshairsGps,
@@ -16,6 +17,7 @@
{ href: AppRoute.DUPLICATES, icon: mdiContentDuplicate, label: $t('review_duplicates') },
{ href: AppRoute.LARGE_FILES, icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
{ href: AppRoute.GEOLOCATION, icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
{ href: AppRoute.WORKFLOWS, icon: mdiAutoFix, label: $t('workflow') },
];
</script>

View File

@@ -0,0 +1,142 @@
<script lang="ts">
import { type PluginResponseDto, PluginContext } from '@immich/sdk';
import { Button, Field, Icon, IconButton } from '@immich/ui';
import { mdiChevronDown, mdiChevronUp, mdiClose, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import SchemaFormFields from './schema-form/SchemaFormFields.svelte';
interface Props {
actions: Array<{ actionId: string; actionConfig?: object }>;
triggerType: 'AssetCreate' | 'PersonRecognized';
plugins: PluginResponseDto[];
}
let { actions = $bindable([]), triggerType, plugins }: Props = $props();
// Map trigger type to context
const getTriggerContext = (trigger: string): PluginContext => {
const contextMap: Record<string, PluginContext> = {
AssetCreate: PluginContext.Asset,
PersonRecognized: PluginContext.Person,
};
return contextMap[trigger] || PluginContext.Asset;
};
const triggerContext = $derived(getTriggerContext(triggerType));
// Get all available actions that match the trigger context
const availableActions = $derived(
plugins.flatMap((plugin) => plugin.actions.filter((action) => action.supportedContexts.includes(triggerContext))),
);
const addAction = () => {
if (availableActions.length > 0) {
actions = [...actions, { actionId: availableActions[0].id, actionConfig: {} }];
}
};
const removeAction = (index: number) => {
actions = actions.filter((_, i) => i !== index);
};
const moveUp = (index: number) => {
if (index > 0) {
const newActions = [...actions];
[newActions[index - 1], newActions[index]] = [newActions[index], newActions[index - 1]];
actions = newActions;
}
};
const moveDown = (index: number) => {
if (index < actions.length - 1) {
const newActions = [...actions];
[newActions[index], newActions[index + 1]] = [newActions[index + 1], newActions[index]];
actions = newActions;
}
};
const getActionById = (actionId: string) => {
for (const plugin of plugins) {
const action = plugin.actions.find((a) => a.id === actionId);
if (action) {
return action;
}
}
return null;
};
</script>
{#if actions.length === 0}
<div
class="rounded-lg border border-dashed border-gray-300 p-4 text-center text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
>
{$t('no_actions_added')}
</div>
{:else}
<div class="flex flex-col gap-3">
{#each actions as action, index (index)}
{@const actionDef = getActionById(action.actionId)}
<div class="rounded-lg border border-gray-300 p-3 dark:border-gray-700">
<div class="mb-2 flex items-center justify-between">
<div class="flex-1">
<Field label={$t('action')}>
<select
bind:value={action.actionId}
class="immich-form-input w-full"
onchange={() => {
action.actionConfig = {};
}}
>
{#each availableActions as availAction (availAction.id)}
<option value={availAction.id}>{availAction.title}</option>
{/each}
</select>
</Field>
</div>
<div class="ml-2 flex gap-1">
<IconButton
shape="round"
color="secondary"
icon={mdiChevronUp}
aria-label={$t('move_up')}
onclick={() => moveUp(index)}
disabled={index === 0}
size="small"
/>
<IconButton
shape="round"
color="secondary"
icon={mdiChevronDown}
aria-label={$t('move_down')}
onclick={() => moveDown(index)}
disabled={index === actions.length - 1}
size="small"
/>
<IconButton
shape="round"
color="secondary"
icon={mdiClose}
aria-label={$t('remove')}
onclick={() => removeAction(index)}
size="small"
/>
</div>
</div>
{#if actionDef}
<div class="text-xs text-gray-600 dark:text-gray-400 mb-2">
{actionDef.description}
</div>
{#if actionDef.schema}
<SchemaFormFields schema={actionDef.schema} bind:config={action.actionConfig} />
{/if}
{/if}
</div>
{/each}
</div>
{/if}
<Button shape="round" size="small" onclick={addAction} disabled={availableActions.length === 0} class="mt-2">
<Icon icon={mdiPlus} size="18" />
{$t('add_action')}
</Button>

View File

@@ -0,0 +1,142 @@
<script lang="ts">
import { type PluginResponseDto, PluginContext } from '@immich/sdk';
import { Button, Field, Icon, IconButton } from '@immich/ui';
import { mdiChevronDown, mdiChevronUp, mdiClose, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import SchemaFormFields from './schema-form/SchemaFormFields.svelte';
interface Props {
filters: Array<{ filterId: string; filterConfig?: object }>;
triggerType: 'AssetCreate' | 'PersonRecognized';
plugins: PluginResponseDto[];
}
let { filters = $bindable([]), triggerType, plugins }: Props = $props();
// Map trigger type to context
const getTriggerContext = (trigger: string): PluginContext => {
const contextMap: Record<string, PluginContext> = {
AssetCreate: PluginContext.Asset,
PersonRecognized: PluginContext.Person,
};
return contextMap[trigger] || PluginContext.Asset;
};
const triggerContext = $derived(getTriggerContext(triggerType));
// Get all available filters that match the trigger context
const availableFilters = $derived(
plugins.flatMap((plugin) => plugin.filters.filter((filter) => filter.supportedContexts.includes(triggerContext))),
);
const addFilter = () => {
if (availableFilters.length > 0) {
filters = [...filters, { filterId: availableFilters[0].id, filterConfig: {} }];
}
};
const removeFilter = (index: number) => {
filters = filters.filter((_, i) => i !== index);
};
const moveUp = (index: number) => {
if (index > 0) {
const newFilters = [...filters];
[newFilters[index - 1], newFilters[index]] = [newFilters[index], newFilters[index - 1]];
filters = newFilters;
}
};
const moveDown = (index: number) => {
if (index < filters.length - 1) {
const newFilters = [...filters];
[newFilters[index], newFilters[index + 1]] = [newFilters[index + 1], newFilters[index]];
filters = newFilters;
}
};
const getFilterById = (filterId: string) => {
for (const plugin of plugins) {
const filter = plugin.filters.find((f) => f.id === filterId);
if (filter) {
return filter;
}
}
return null;
};
</script>
{#if filters.length === 0}
<div
class="rounded-lg border border-dashed border-gray-300 p-4 text-center text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
>
{$t('no_filters_added')}
</div>
{:else}
<div class="flex flex-col gap-3">
{#each filters as filter, index (index)}
{@const filterDef = getFilterById(filter.filterId)}
<div class="rounded-lg border border-gray-300 p-3 dark:border-gray-700">
<div class="mb-2 flex items-center justify-between">
<div class="flex-1">
<Field label={$t('filter')}>
<select
bind:value={filter.filterId}
class="immich-form-input w-full"
onchange={() => {
filter.filterConfig = {};
}}
>
{#each availableFilters as availFilter (availFilter.id)}
<option value={availFilter.id}>{availFilter.title}</option>
{/each}
</select>
</Field>
</div>
<div class="ml-2 flex gap-1">
<IconButton
shape="round"
color="secondary"
icon={mdiChevronUp}
aria-label={$t('move_up')}
onclick={() => moveUp(index)}
disabled={index === 0}
size="small"
/>
<IconButton
shape="round"
color="secondary"
icon={mdiChevronDown}
aria-label={$t('move_down')}
onclick={() => moveDown(index)}
disabled={index === filters.length - 1}
size="small"
/>
<IconButton
shape="round"
color="secondary"
icon={mdiClose}
aria-label={$t('remove')}
onclick={() => removeFilter(index)}
size="small"
/>
</div>
</div>
{#if filterDef}
<div class="text-xs text-gray-600 dark:text-gray-400 mb-2">
{filterDef.description}
</div>
{#if filterDef.schema}
<SchemaFormFields schema={filterDef.schema} bind:config={filter.filterConfig} />
{/if}
{/if}
</div>
{/each}
</div>
{/if}
<Button shape="round" size="small" onclick={addFilter} disabled={availableFilters.length === 0} class="mt-2">
<Icon icon={mdiPlus} size="18" />
{$t('add_filter')}
</Button>

View File

@@ -0,0 +1,326 @@
<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 type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
import { Button, Field, Input, MultiSelect, Select, Switch, modalManager, type SelectItem } from '@immich/ui';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
interface 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 };
};
let selectValue = $state<SelectItem>();
let switchValue = $state<boolean>(false);
let multiSelectValue = $state<SelectItem[]>([]);
let pickerMetadata = $state<
Record<string, AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[]>
>({});
$effect(() => {
// Initialize config for actions/filters with empty schemas
if (configKey && !config[configKey]) {
config = { ...config, [configKey]: {} };
}
if (components) {
const updates: Record<string, unknown> = {};
for (const [key, component] of Object.entries(components)) {
// Only initialize if the key doesn't exist in config yet
if (actualConfig[key] === undefined) {
// Use default value if available, otherwise use appropriate empty value based on type
const hasDefault = component.defaultValue !== undefined;
if (hasDefault) {
updates[key] = component.defaultValue;
} else {
// Initialize with appropriate empty value based on component type
if (
component.type === 'multiselect' ||
(component.type === 'text' && component.subType === 'people-picker')
) {
updates[key] = [];
} else if (component.type === 'switch') {
updates[key] = false;
} else {
updates[key] = '';
}
}
// Update UI state for components with default values
if (hasDefault) {
if (component.type === 'select') {
selectValue = {
label: formatLabel(String(component.defaultValue)),
value: String(component.defaultValue),
};
}
if (component.type === 'switch') {
switchValue = Boolean(component.defaultValue);
}
}
}
}
if (Object.keys(updates).length > 0) {
updateConfigBatch(updates);
}
}
});
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 };
};
</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)}
{@const label = component.title || component.label || key}
<div
class="flex flex-col gap-1 bg-gray-50 dark:bg-subtle border border-gray-200 dark:border-gray-700 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)}
{: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 data={options} onChange={(opt) => updateConfig(key, opt.value)} bind:value={selectValue} />
</Field>
{/if}
<!-- MultiSelect component -->
{:else if component.type === 'multiselect'}
{#if component.subType === 'album-picker' || component.subType === 'people-picker'}
{@render pickerField(component.subType, key, label, component, true)}
{: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
data={options}
onChange={(opt) =>
updateConfig(
key,
opt.map((o) => o.value),
)}
bind:values={multiSelectValue}
/>
</Field>
{/if}
<!-- Switch component -->
{:else if component.type === 'switch'}
<Field
{label}
description={component.description}
requiredIndicator={component.required}
required={component.required}
>
<Switch bind:checked={switchValue} onCheckedChange={(check) => updateConfig(key, check)} />
</Field>
<!-- Text input -->
{:else if component.subType === 'album-picker' || component.subType === 'people-picker'}
{@render pickerField(component.subType, key, label, component, false)}
{: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}
<p class="text-sm text-gray-500">No configuration required</p>
{/if}

View File

@@ -0,0 +1,43 @@
<script lang="ts">
interface 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="absolute inset-0 bg-linear-to-b from-transparent via-primary to-transparent flow-pulse"></div>
{/if}
<!-- Connection nodes -->
<div class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2">
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
</div>
<div class="absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2">
<div class="h-2 w-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>

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import { PluginTriggerType, type PluginTriggerResponseDto } from '@immich/sdk';
import { Icon, Text } from '@immich/ui';
import { mdiFaceRecognition, mdiFileUploadOutline, mdiLightningBolt } from '@mdi/js';
interface 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;
}
}
};
</script>
<button
type="button"
{onclick}
class="rounded-xl p-4 w-full text-left transition-all cursor-pointer border-2 {selected
? 'border-primary text-primary'
: 'border-gray-300 dark:border-gray-700 text-gray-600 dark:text-gray-200'}"
>
<div class="flex items-center gap-3">
<div
class="rounded-xl p-2 bg-gray-200 {selected
? 'bg-primary text-light'
: 'text-gray-400 dark:text-gray-400 dark:bg-gray-900'}"
>
<Icon icon={getTriggerIcon(trigger.triggerType)} size="24" />
</div>
<div class="flex-1">
<Text class="font-semibold mb-1">{trigger.name}</Text>
{#if trigger.description}
<Text class="text-sm text-muted-foreground">{trigger.description}</Text>
{/if}
</div>
</div>
</button>

View File

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

View File

@@ -0,0 +1,52 @@
// Preset workflow templates for common use cases
export interface WorkflowTemplate {
name: string;
description: string;
triggerType: 'AssetCreate' | 'PersonRecognized';
enabled: boolean;
filters: Array<{ filterId: string; filterConfig?: object }>;
actions: Array<{ actionId: string; actionConfig?: object }>;
}
// Note: These templates use placeholder filter/action IDs that need to be resolved
// from the actual plugin data at runtime
export const workflowTemplates = {
archiveOldPhotos: {
name: 'Archive Old Photos',
description: 'Automatically archive photos matching certain criteria',
triggerType: 'AssetCreate' as const,
enabled: false,
filters: [
// This will need to be populated with actual filter IDs from plugins
],
actions: [
// This will need to be populated with actual action IDs from plugins
],
},
favoritePhotos: {
name: 'Auto-Favorite Photos',
description: 'Automatically mark photos as favorites based on criteria',
triggerType: 'AssetCreate' as const,
enabled: false,
filters: [],
actions: [],
},
addToAlbum: {
name: 'Add to Album',
description: 'Automatically add assets to a specific album',
triggerType: 'AssetCreate' as const,
enabled: false,
filters: [],
actions: [],
},
blank: {
name: 'New Workflow',
description: '',
triggerType: 'AssetCreate' as const,
enabled: true,
filters: [],
actions: [],
},
};
export type WorkflowTemplateName = keyof typeof workflowTemplates;

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import SearchBar from '$lib/elements/SearchBar.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, HStack, LoadingSpinner, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
multiple?: boolean;
onClose: (people?: PersonResponseDto[]) => void;
}
let { multiple = false, onClose }: Props = $props();
let people: PersonResponseDto[] = $state([]);
let loading = $state(true);
let searchName = $state('');
let selectedPeople: PersonResponseDto[] = $state([]);
const filteredPeople = $derived(
searchName ? people.filter((person) => person.name.toLowerCase().includes(searchName.toLowerCase())) : people,
);
onMount(async () => {
const result = await getAllPeople({ withHidden: false });
people = result.people;
loading = false;
});
const togglePerson = (person: PersonResponseDto) => {
if (multiple) {
const index = selectedPeople.findIndex((p) => p.id === person.id);
selectedPeople = index === -1 ? [...selectedPeople, person] : selectedPeople.filter((p) => p.id !== person.id);
} else {
onClose([person]);
}
};
const handleSubmit = () => {
if (selectedPeople.length > 0) {
onClose(selectedPeople);
} else {
onClose();
}
};
</script>
<Modal title={multiple ? $t('select_people') : $t('select_person')} {onClose} size="small">
<ModalBody>
<div class="flex flex-col gap-4">
<SearchBar bind:name={searchName} placeholder={$t('search_people')} showLoadingSpinner={false} />
<div class="immich-scrollbar max-h-96 overflow-y-auto">
{#if loading}
<div class="flex justify-center p-8">
<LoadingSpinner />
</div>
{:else if filteredPeople.length > 0}
<div class="grid grid-cols-3 gap-4 p-2">
{#each filteredPeople as person (person.id)}
{@const isSelected = selectedPeople.some((p) => p.id === person.id)}
<button
type="button"
onclick={() => togglePerson(person)}
class="flex flex-col items-center gap-2 rounded-xl p-2 transition-all hover:bg-subtle {isSelected
? 'bg-primary/10 ring-2 ring-primary'
: ''}"
>
<ImageThumbnail
circle
shadow
url={getPeopleThumbnailUrl(person)}
altText={person.name}
widthStyle="100%"
/>
<p class="line-clamp-2 text-center text-sm font-medium">{person.name}</p>
</button>
{/each}
</div>
{:else}
<p class="py-8 text-center text-sm text-gray-500">{$t('no_people_found')}</p>
{/if}
</div>
</div>
</ModalBody>
{#if multiple && selectedPeople.length > 0}
<ModalFooter>
<HStack fullWidth gap={4}>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" fullWidth onclick={handleSubmit}>
{$t('select_count', { values: { count: selectedPeople.length } })}
</Button>
</HStack>
</ModalFooter>
{/if}
</Modal>

View File

@@ -0,0 +1,217 @@
<script lang="ts">
import ActionBuilder from '$lib/components/workflow/ActionBuilder.svelte';
import FilterBuilder from '$lib/components/workflow/FilterBuilder.svelte';
import GroupTab from '$lib/elements/GroupTab.svelte';
import { handleError } from '$lib/utils/handle-error';
import {
createWorkflow,
PluginTriggerType,
updateWorkflow,
type PluginResponseDto,
type WorkflowResponseDto,
} from '@immich/sdk';
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Switch, Textarea } from '@immich/ui';
import { mdiAutoFix } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
workflow?: WorkflowResponseDto;
plugins: PluginResponseDto[];
onClose: () => void;
onSave: (workflow: WorkflowResponseDto) => void;
}
let { workflow, plugins, onClose, onSave }: Props = $props();
const isEditMode = !!workflow;
// Form state
let name = $state(workflow?.name || '');
let description = $state(workflow?.description || '');
let triggerType = $state<PluginTriggerType>(workflow?.triggerType || PluginTriggerType.AssetCreate);
let enabled = $state(workflow?.enabled ?? true);
let filters = $state<Array<{ filterId: string; filterConfig?: object }>>(
workflow?.filters.map((f) => ({ filterId: f.filterId, filterConfig: f.filterConfig || undefined })) || [],
);
let actions = $state<Array<{ actionId: string; actionConfig?: object }>>(
workflow?.actions.map((a) => ({ actionId: a.actionId, actionConfig: a.actionConfig || undefined })) || [],
);
// Editor mode state
let editorMode = $state<'visual' | 'json'>('visual');
let jsonText = $state('');
let jsonError = $state('');
// Sync JSON when switching to JSON mode
const syncToJson = () => {
const workflowData = {
...(isEditMode ? { id: workflow!.id } : {}),
name,
description,
triggerType,
enabled,
filters,
actions,
};
jsonText = JSON.stringify(workflowData, null, 2);
jsonError = '';
};
// Sync visual form when switching from JSON mode
const syncFromJson = () => {
try {
const parsed = JSON.parse(jsonText);
name = parsed.name || '';
description = parsed.description || '';
triggerType = parsed.triggerType || PluginTriggerType.AssetCreate;
enabled = parsed.enabled ?? true;
filters = parsed.filters || [];
actions = parsed.actions || [];
jsonError = '';
} catch (error) {
jsonError = error instanceof Error ? error.message : 'Invalid JSON';
}
};
const handleModeChange = (newMode: 'visual' | 'json') => {
if (newMode === 'json' && editorMode === 'visual') {
syncToJson();
} else if (newMode === 'visual' && editorMode === 'json') {
syncFromJson();
}
editorMode = newMode;
};
const handleSubmit = async () => {
// If in JSON mode, sync from JSON first
if (editorMode === 'json') {
syncFromJson();
if (jsonError) {
return;
}
}
if (!name.trim()) {
handleError(new Error($t('name_required')), $t('validation_error'));
return;
}
const trigger =
triggerType === PluginTriggerType.AssetCreate
? PluginTriggerType.AssetCreate
: PluginTriggerType.PersonRecognized;
try {
let result: WorkflowResponseDto;
result = await (isEditMode
? updateWorkflow({
id: workflow!.id,
workflowUpdateDto: {
name,
description: description || undefined,
enabled,
filters,
actions,
},
})
: createWorkflow({
workflowCreateDto: {
name,
description: description || undefined,
triggerType: trigger,
enabled,
filters,
actions,
},
}));
onSave(result);
onClose();
} catch (error) {
handleError(error, isEditMode ? $t('errors.unable_to_create') : $t('errors.unable_to_create'));
}
};
</script>
<Modal title={isEditMode ? $t('edit_workflow') : $t('create_workflow')} icon={mdiAutoFix} {onClose} size="large">
<ModalBody>
<div class="mb-4">
<GroupTab
filters={['visual', 'json']}
labels={[$t('visual_builder'), $t('json_editor')]}
selected={editorMode}
label={$t('editor_mode')}
onSelect={(mode) => handleModeChange(mode as 'visual' | 'json')}
/>
</div>
{#if editorMode === 'visual'}
<form
id="workflow-form"
onsubmit={(e) => {
e.preventDefault();
void handleSubmit();
}}
class="mt-4 flex flex-col gap-4"
>
<Field label={$t('name')} required>
<Input bind:value={name} required />
</Field>
<Field label={$t('description')}>
<Textarea bind:value={description} />
</Field>
{#if !isEditMode}
<Field label={$t('trigger_type')} required>
<select bind:value={triggerType} class="immich-form-input w-full" required>
<option value={PluginTriggerType.AssetCreate}>{$t('asset_created')}</option>
<option value={PluginTriggerType.PersonRecognized}>{$t('person_recognized')}</option>
</select>
</Field>
{:else}
<Field label={$t('trigger_type')}>
<Input value={triggerType} disabled />
</Field>
{/if}
<Field label={$t('enabled')}>
<Switch bind:checked={enabled} />
</Field>
<div class="border-t pt-4 dark:border-gray-700">
<h3 class="mb-2 font-semibold">{$t('filter')}</h3>
<FilterBuilder bind:filters {triggerType} {plugins} />
</div>
<div class="border-t pt-4 dark:border-gray-700">
<h3 class="mb-2 font-semibold">{$t('actions')}</h3>
<ActionBuilder bind:actions {triggerType} {plugins} />
</div>
</form>
{:else}
<div class="mt-4 flex flex-col gap-4">
{#if jsonError}
<div class="rounded-lg bg-red-100 p-3 text-sm text-red-800 dark:bg-red-900 dark:text-red-200">
{$t('json_error')}: {jsonError}
</div>
{/if}
<Field label={$t('workflow_json')}>
<textarea bind:value={jsonText} class="immich-form-input h-96 w-full font-mono text-sm" spellcheck="false"
></textarea>
</Field>
<p class="text-sm text-gray-600 dark:text-gray-400">
{$t('workflow_json_help')}
</p>
</div>
{/if}
</ModalBody>
<ModalFooter>
<HStack fullWidth>
<Button color="secondary" fullWidth onclick={onClose}>{$t('cancel')}</Button>
<Button type="submit" fullWidth form="workflow-form" onclick={handleSubmit}>
{isEditMode ? $t('save') : $t('create')}
</Button>
</HStack>
</ModalFooter>
</Modal>

View File

@@ -0,0 +1,112 @@
export type ComponentType = 'select' | 'multiselect' | 'text' | 'switch' | 'checkbox';
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 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;
}
}
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;
}
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();
}

View File

@@ -0,0 +1,226 @@
<script lang="ts">
import { goto } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { AppRoute } from '$lib/constants';
import { copyToClipboard } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import {
createWorkflow,
deleteWorkflow,
PluginTriggerType,
updateWorkflow,
type WorkflowResponseDto,
} from '@immich/sdk';
import { Button, Card, CardBody, CardTitle, HStack, Icon, IconButton, toastManager } from '@immich/ui';
import {
mdiChevronDown,
mdiChevronUp,
mdiContentCopy,
mdiDelete,
mdiPencil,
mdiPlay,
mdiPlayPause,
mdiPlus,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
let workflows = $state<WorkflowResponseDto[]>(data.workflows);
// svelte-ignore non_reactive_update
let expandedWorkflows = new SvelteSet();
const toggleExpanded = (id: string) => {
const newExpanded = new SvelteSet(expandedWorkflows);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
expandedWorkflows = newExpanded;
};
const handleCopyWorkflow = async (workflow: WorkflowResponseDto) => {
const workflowJson = JSON.stringify(workflow, null, 2);
await copyToClipboard(workflowJson);
};
const handleToggleEnabled = async (workflow: WorkflowResponseDto) => {
try {
const updated = await updateWorkflow({
id: workflow.id,
workflowUpdateDto: { enabled: !workflow.enabled },
});
workflows = workflows.map((w) => (w.id === updated.id ? updated : w));
toastManager.success($t('workflow_updated'));
} catch (error) {
// @ts-expect-error - translation type issue
handleError(error, $t('errors.unable_to_update') as string);
}
};
const handleDeleteWorkflow = async (workflow: WorkflowResponseDto) => {
try {
await deleteWorkflow({ id: workflow.id });
workflows = workflows.filter((w) => w.id !== workflow.id);
toastManager.success($t('workflow_deleted'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete') as string);
}
};
const handleEditWorkflow = async (workflow: WorkflowResponseDto) => {
await goto(`${AppRoute.WORKFLOWS_EDIT}/${workflow.id}`);
};
const handleCreateWorkflow = async () => {
const workflow = await createWorkflow({
workflowCreateDto: {
name: 'New workflow',
triggerType: PluginTriggerType.AssetCreate,
filters: [],
actions: [],
enabled: false,
},
});
await goto(`${AppRoute.WORKFLOWS_EDIT}/${workflow.id}`);
};
const getTriggerLabel = (triggerType: string) => {
const labels: Record<string, string> = {
AssetCreate: $t('asset_created'),
PersonRecognized: $t('person_recognized'),
};
return labels[triggerType] || triggerType;
};
</script>
<UserPageLayout title={data.meta.title} scrollbar={false}>
{#snippet buttons()}
<HStack gap={1}>
<Button shape="round" color="primary" onclick={handleCreateWorkflow}>
<Icon icon={mdiPlus} size="18" />
{$t('create_workflow')}
</Button>
</HStack>
{/snippet}
<section class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 sm:w-5/6 md:w-4xl">
{#if workflows.length === 0}
<div class="flex flex-col items-center justify-center gap-4 py-20">
<Icon icon={mdiPlay} size="64" class="text-immich-primary dark:text-immich-dark-primary" />
<h2 class="text-2xl font-semibold">{$t('no_workflows_yet')}</h2>
<p class="text-center text-sm text-gray-600 dark:text-gray-400">
{$t('workflows_help_text')}
</p>
<Button shape="round" color="primary" onclick={handleCreateWorkflow}>
<Icon icon={mdiPlus} size="18" />
{$t('create_first_workflow')}
</Button>
</div>
{:else}
<div class="my-5 flex flex-col gap-4">
{#each workflows as workflow (workflow.id)}
<Card color="secondary">
<CardBody>
<div class="flex flex-col gap-2">
<div class="flex items-start justify-between">
<div class="flex-1">
<CardTitle>{workflow.name || $t('untitled_workflow')}</CardTitle>
{#if workflow.description}
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{workflow.description}</p>
{/if}
</div>
<HStack gap={1}>
<IconButton
shape="round"
color="secondary"
icon={workflow.enabled ? mdiPlay : mdiPlayPause}
aria-label={workflow.enabled ? $t('disabled') : $t('enabled')}
onclick={() => handleToggleEnabled(workflow)}
class={workflow.enabled ? 'text-green-500' : 'text-gray-400'}
/>
<IconButton
shape="round"
color="secondary"
icon={mdiContentCopy}
aria-label={$t('copy_to_clipboard')}
onclick={() => handleCopyWorkflow(workflow)}
/>
<IconButton
shape="round"
color="secondary"
icon={mdiPencil}
aria-label={$t('edit')}
onclick={() => handleEditWorkflow(workflow)}
/>
<IconButton
shape="round"
color="secondary"
icon={mdiDelete}
aria-label={$t('delete')}
onclick={() => handleDeleteWorkflow(workflow)}
/>
</HStack>
</div>
<div class="flex flex-wrap gap-2 text-sm">
<span
class="rounded-full px-3 py-1 {workflow.enabled
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'}"
>
{workflow.enabled ? $t('enabled') : $t('disabled')}
</span>
<span class="rounded-full bg-blue-100 px-3 py-1 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{getTriggerLabel(workflow.triggerType)}
</span>
<span
class="rounded-full bg-purple-100 px-3 py-1 text-purple-800 dark:bg-purple-900 dark:text-purple-200"
>
{workflow.filters.length}
{workflow.filters.length === 1 ? $t('filter') : $t('filter')}
</span>
<span
class="rounded-full bg-orange-100 px-3 py-1 text-orange-800 dark:bg-orange-900 dark:text-orange-200"
>
{workflow.actions.length}
{workflow.actions.length === 1 ? $t('action') : $t('actions')}
</span>
</div>
<button
type="button"
onclick={() => toggleExpanded(workflow.id)}
class="flex items-center gap-1 text-sm text-primary hover:underline"
>
<Icon icon={expandedWorkflows.has(workflow.id) ? mdiChevronUp : mdiChevronDown} size="18" />
{expandedWorkflows.has(workflow.id) ? $t('hide_json') : $t('show_json')}
</button>
{#if expandedWorkflows.has(workflow.id)}
<div class="mt-2">
<pre class="overflow-x-auto rounded-lg bg-gray-100 p-4 text-xs dark:bg-gray-800">{JSON.stringify(
workflow,
null,
2,
)}</pre>
</div>
{/if}
</div>
</CardBody>
</Card>
{/each}
</div>
{/if}
</section>
</section>
</UserPageLayout>

View File

@@ -0,0 +1,18 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getPlugins, getWorkflows } from '@immich/sdk';
import type { PageLoad } from '../$types';
export const load = (async ({ url }) => {
await authenticate(url);
const [workflows, plugins] = await Promise.all([getWorkflows(), getPlugins()]);
const $t = await getFormatter();
return {
workflows,
plugins,
meta: {
title: $t('workflow'),
},
};
}) satisfies PageLoad;

View File

@@ -0,0 +1,196 @@
<script lang="ts">
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import SchemaFormFields from '$lib/components/workflow/schema-form/SchemaFormFields.svelte';
import WorkflowCardConnector from '$lib/components/workflows/workflow-card-connector.svelte';
import WorkflowTriggerCard from '$lib/components/workflows/workflow-trigger-card.svelte';
import type { PluginResponseDto } from '@immich/sdk';
import {
Button,
Card,
CardBody,
CardDescription,
CardHeader,
CardTitle,
CodeBlock,
Container,
Field,
HStack,
Icon,
Input,
Switch,
Text,
Textarea,
VStack,
} from '@immich/ui';
import {
mdiContentSave,
mdiFilterOutline,
mdiFlashOutline,
mdiInformationOutline,
mdiPlayCircleOutline,
} from '@mdi/js';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
let plugins = $state<PluginResponseDto[]>(data.plugins);
let triggers = data.triggers;
let filters = plugins.flatMap((plugin) => plugin.filters);
let action = plugins.flatMap((plugin) => plugin.actions);
let previousWorkflow = data.workflow;
let editWorkflow = $state(data.workflow);
let name: string = $state(editWorkflow.name ?? '');
let description: string = $state(editWorkflow.description ?? '');
let selectedTrigger = $state(triggers.find((t) => t.triggerType === editWorkflow.triggerType) ?? triggers[0]);
let triggerType = $derived(selectedTrigger.triggerType);
let supportFilters = $derived(filters.filter((filter) => filter.supportedContexts.includes(selectedTrigger.context)));
let supportActions = $derived(action.filter((action) => action.supportedContexts.includes(selectedTrigger.context)));
$effect(() => {
editWorkflow.triggerType = triggerType;
});
const updateWorkflow = async () => {};
let canSave: boolean = $derived(!isEqual(previousWorkflow, editWorkflow));
let filterConfigs = $state({});
let actionConfigs = $state({});
$inspect(filterConfigs).with(console.log);
</script>
<UserPageLayout title={data.meta.title} scrollbar={false}>
{#snippet buttons()}
<HStack gap={4} class="me-4">
<HStack gap={2}>
<Text class="text-sm">{editWorkflow.enabled ? 'ON' : 'OFF'}</Text>
<Switch bind:checked={editWorkflow.enabled} />
</HStack>
<Button
leadingIcon={mdiContentSave}
size="small"
shape="round"
color="primary"
onclick={updateWorkflow}
disabled={!canSave}
>
{$t('save')}
</Button>
</HStack>
{/snippet}
<Container size="medium" class="p-4" center>
<VStack gap={0}>
<Card expandable expanded={false}>
<CardHeader>
<div class="flex place-items-start gap-3">
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
<div class="flex flex-col">
<CardTitle>Basic information</CardTitle>
<CardDescription>Describing the workflow</CardDescription>
</div>
</div>
</CardHeader>
<CardBody>
<VStack gap={6}>
<Field class="text-sm" label="Name" for="workflow-name" required>
<Input placeholder="Workflow name" bind:value={name} />
</Field>
<Field class="text-sm" label="Description" for="workflow-description">
<Textarea placeholder="Workflow description" bind:value={description} />
</Field>
</VStack>
</CardBody>
</Card>
<div class="my-10 h-px w-[98%] bg-gray-200 dark:bg-gray-700"></div>
<Card expandable expanded={true}>
<CardHeader class="bg-indigo-50 dark:bg-primary/20">
<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">Trigger</CardTitle>
<CardDescription>An event that kick off the workflow</CardDescription>
</div>
</div>
</CardHeader>
<CardBody>
<div class="grid grid-cols-2 gap-4">
{#each triggers as trigger (trigger.name)}
<WorkflowTriggerCard
{trigger}
selected={selectedTrigger.triggerType === trigger.triggerType}
onclick={() => (selectedTrigger = trigger)}
/>
{/each}
</div>
</CardBody>
</Card>
<WorkflowCardConnector />
<Card expandable expanded={true}>
<CardHeader class="bg-amber-50 dark:bg-[#5e4100]">
<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">Filter</CardTitle>
<CardDescription>Conditions to filter the target assets</CardDescription>
</div>
</div>
</CardHeader>
<CardBody>
<div class="my-4">
<p>Payload</p>
<CodeBlock code={JSON.stringify(filterConfigs, null, 2)} lineNumbers></CodeBlock>
</div>
{#each supportFilters as filter (filter.id)}
<h1 class="grid grid-cols-2 gap-4 font-bold mt-5 mb-2">{filter.title}</h1>
<SchemaFormFields schema={filter.schema} bind:config={filterConfigs} configKey={filter.methodName} />
{/each}
</CardBody>
</Card>
<WorkflowCardConnector />
<Card expandable>
<CardHeader class="bg-teal-50 dark:bg-teal-950">
<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">Action</CardTitle>
<CardDescription>A set of action to perform on the filtered assets</CardDescription>
</div>
</div>
</CardHeader>
<CardBody>
<div class="my-4">
<p>Payload</p>
<CodeBlock code={JSON.stringify(actionConfigs, null, 2)} lineNumbers></CodeBlock>
</div>
{#each supportActions as action (action.id)}
<h1 class="grid grid-cols-2 gap-4 font-bold">{action.title}</h1>
<SchemaFormFields schema={action.schema} bind:config={actionConfigs} configKey={action.methodName} />
{/each}
</CardBody>
</Card>
</VStack>
</Container>
</UserPageLayout>

View File

@@ -0,0 +1,23 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getPlugins, getTriggers, getWorkflow } from '@immich/sdk';
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 }),
getTriggers(),
]);
const $t = await getFormatter();
return {
plugins,
workflow,
triggers,
meta: {
title: $t('edit_workflow'),
},
};
}) satisfies PageLoad;