mirror of
https://github.com/immich-app/immich.git
synced 2025-12-17 01:11:13 +03:00
feat: workflow ui
This commit is contained in:
43
i18n/en.json
43
i18n/en.json
@@ -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"
|
||||
}
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -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)
|
||||
|
||||
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
@@ -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';
|
||||
|
||||
51
mobile/openapi/lib/api/plugins_api.dart
generated
51
mobile/openapi/lib/api/plugins_api.dart
generated
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
2
mobile/openapi/lib/api_client.dart
generated
2
mobile/openapi/lib/api_client.dart
generated
@@ -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':
|
||||
|
||||
135
mobile/openapi/lib/model/plugin_trigger_response_dto.dart
generated
Normal file
135
mobile/openapi/lib/model/plugin_trigger_response_dto.dart
generated
Normal 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',
|
||||
};
|
||||
}
|
||||
|
||||
78
mobile/openapi/lib/model/workflow_response_dto.dart
generated
78
mobile/openapi/lib/model/workflow_response_dto.dart
generated
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
10
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
142
web/src/lib/components/workflow/ActionBuilder.svelte
Normal file
142
web/src/lib/components/workflow/ActionBuilder.svelte
Normal 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>
|
||||
142
web/src/lib/components/workflow/FilterBuilder.svelte
Normal file
142
web/src/lib/components/workflow/FilterBuilder.svelte
Normal 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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
52
web/src/lib/constants/workflow-templates.ts
Normal file
52
web/src/lib/constants/workflow-templates.ts
Normal 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;
|
||||
99
web/src/lib/modals/PeoplePickerModal.svelte
Normal file
99
web/src/lib/modals/PeoplePickerModal.svelte
Normal 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>
|
||||
217
web/src/lib/modals/WorkflowEditorModal.svelte
Normal file
217
web/src/lib/modals/WorkflowEditorModal.svelte
Normal 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>
|
||||
112
web/src/lib/utils/workflow.ts
Normal file
112
web/src/lib/utils/workflow.ts
Normal 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();
|
||||
}
|
||||
226
web/src/routes/(user)/utilities/workflows/+page.svelte
Normal file
226
web/src/routes/(user)/utilities/workflows/+page.svelte
Normal 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>
|
||||
18
web/src/routes/(user)/utilities/workflows/+page.ts
Normal file
18
web/src/routes/(user)/utilities/workflows/+page.ts
Normal 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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user