Compare commits

..

26 Commits

Author SHA1 Message Date
Alex Tran
7ed646178e chore: clean up 2025-12-06 03:04:58 +00:00
Alex Tran
3d771127d2 fix: new schemaformfield has value of the same type 2025-12-06 02:49:00 +00:00
Alex Tran
5156438336 more refactor 2025-12-05 20:56:31 +00:00
Alex Tran
76ec9e3ebf refactor ActionItem 2025-12-05 16:55:10 +00:00
Alex Tran
1e238e7a48 refactor ActionItem 2025-12-05 16:37:10 +00:00
Alex Tran
63e38f347e pr feedback 2025-12-05 16:13:50 +00:00
Alex Tran
6222c4e97f use for Props 2025-12-05 15:15:09 +00:00
Alex Tran
1c64d21148 move enable button to info form 2025-12-04 21:02:25 +00:00
Alex Tran
fe931052e5 merge main 2025-12-04 19:13:12 +00:00
Alex Tran
4493f30b78 refactor step selection modal 2025-12-04 19:08:59 +00:00
Alex Tran
5537a869ea better test 2025-12-04 15:31:03 +00:00
Alex Tran
25ac9975e6 merge main 2025-12-04 15:18:00 +00:00
Alex Tran
5eccffc084 use showDialog directly 2025-12-04 15:16:14 +00:00
Alex Tran
288ba44825 refactor: picker field 2025-12-04 04:08:51 +00:00
Alex Tran
bd4355a75f pr feedback 2025-12-03 21:54:57 +00:00
Alex Tran
290de9d27c merge main 2025-12-02 20:45:59 +00:00
Alex Tran
84b031bbe4 merge main 2025-11-28 16:24:34 +00:00
Alex Tran
7cce100e96 merge main 2025-11-25 22:16:05 +00:00
Alex Tran
380d03476e wip 2025-11-25 01:50:39 +00:00
Alex Tran
1f25422958 Merge branch 'main' of github.com:immich-app/immich into workflow-ui 2025-11-24 16:51:29 +00:00
Alex Tran
2fe36e77d9 Merge branch 'main' of github.com:immich-app/immich into workflow-ui 2025-11-23 19:29:58 +00:00
Alex Tran
89360e7d8d Merge branch 'main' of github.com:immich-app/immich into workflow-ui 2025-11-21 21:38:44 +00:00
Alex Tran
8e5d21a2c0 wip 2025-11-20 21:42:42 +00:00
Alex Tran
69779f22f3 merge main 2025-11-20 20:12:30 +00:00
Alex Tran
7eecfc43df wip 2025-11-19 02:56:39 +00:00
Alex Tran
272ad7c773 feat: workflow ui 2025-11-18 21:29:11 +00:00
66 changed files with 3957 additions and 571 deletions

View File

@@ -5,6 +5,7 @@
"acknowledge": "Acknowledge",
"action": "Action",
"action_common_update": "Update",
"action_description": "A set of action to perform on the filtered assets",
"actions": "Actions",
"active": "Active",
"active_count": "Active: {count}",
@@ -15,9 +16,13 @@
"add_a_location": "Add a location",
"add_a_name": "Add a name",
"add_a_title": "Add a title",
"add_action": "Add action",
"add_action_description": "Click to add an action to perform",
"add_birthday": "Add a birthday",
"add_endpoint": "Add endpoint",
"add_exclusion_pattern": "Add exclusion pattern",
"add_filter": "Add filter",
"add_filter_description": "Click to add a filter condition",
"add_location": "Add location",
"add_more_users": "Add more users",
"add_partner": "Add partner",
@@ -36,6 +41,7 @@
"add_to_shared_album": "Add to shared album",
"add_upload_to_stack": "Add upload to stack",
"add_url": "Add URL",
"add_workflow_step": "Add workflow step",
"added_to_archive": "Added to archive",
"added_to_favorites": "Added to favorites",
"added_to_favorites_count": "Added {count, number} to favorites",
@@ -467,6 +473,7 @@
"album_remove_user": "Remove user?",
"album_remove_user_confirmation": "Are you sure you want to remove {user}?",
"album_search_not_found": "No albums found matching your search",
"album_selected": "Album selected",
"album_share_no_users": "Looks like you have shared this album with all users or you don't have any user to share with.",
"album_summary": "Album summary",
"album_updated": "Album updated",
@@ -488,6 +495,7 @@
"albums_default_sort_order_description": "Initial asset sort order when creating new albums.",
"albums_feature_description": "Collections of assets that can be shared with other users.",
"albums_on_device_count": "Albums on device ({count})",
"albums_selected": "{count, plural, one {# album selected} other {# albums selected}}",
"all": "All",
"all_albums": "All albums",
"all_people": "All people",
@@ -524,10 +532,12 @@
"archived_count": "{count, plural, other {Archived #}}",
"are_these_the_same_person": "Are these the same person?",
"are_you_sure_to_do_this": "Are you sure you want to do this?",
"array_field_not_fully_supported": "Array fields require manual JSON editing",
"asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping",
"asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping",
"asset_added_to_album": "Added to album",
"asset_adding_to_album": "Adding to album…",
"asset_created": "Asset created",
"asset_description_updated": "Asset description has been updated",
"asset_filename_is_offline": "Asset {filename} is offline",
"asset_has_unassigned_faces": "Asset has unassigned faces",
@@ -710,6 +720,8 @@
"change_password_form_password_mismatch": "Passwords do not match",
"change_password_form_reenter_new_password": "Re-enter New Password",
"change_pin_code": "Change PIN code",
"change_trigger": "Change trigger",
"change_trigger_prompt": "Are you sure you want to change the trigger? This will remove all existing actions and filters.",
"change_your_password": "Change your password",
"changed_visibility_successfully": "Changed visibility successfully",
"charging": "Charging",
@@ -785,6 +797,7 @@
"create_album": "Create album",
"create_album_page_untitled": "Untitled",
"create_api_key": "Create API key",
"create_first_workflow": "Create first workflow",
"create_library": "Create Library",
"create_link": "Create link",
"create_link_to_share": "Create link to share",
@@ -799,6 +812,7 @@
"create_tag": "Create tag",
"create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.",
"create_user": "Create user",
"create_workflow": "Create workflow",
"created": "Created",
"created_at": "Created",
"creating_linked_albums": "Creating linked albums...",
@@ -865,6 +879,7 @@
"deselect_all": "Deselect All",
"details": "Details",
"direction": "Direction",
"disable": "Disable",
"disabled": "Disabled",
"disallow_edits": "Disallow edits",
"discord": "Discord",
@@ -927,11 +942,13 @@
"edit_tag": "Edit tag",
"edit_title": "Edit Title",
"edit_user": "Edit user",
"edit_workflow": "Edit workflow",
"editor": "Editor",
"editor_close_without_save_prompt": "The changes will not be saved",
"editor_close_without_save_title": "Close editor?",
"editor_crop_tool_h2_aspect_ratios": "Aspect ratios",
"editor_crop_tool_h2_rotation": "Rotation",
"editor_mode": "Editor mode",
"email": "Email",
"email_notifications": "Email notifications",
"empty_folder": "This folder is empty",
@@ -1012,6 +1029,7 @@
"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",
@@ -1022,6 +1040,7 @@
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
"unable_to_delete_shared_link": "Unable to delete shared link",
"unable_to_delete_user": "Unable to delete user",
"unable_to_delete_workflow": "Unable to delete workflow",
"unable_to_download_files": "Unable to download files",
"unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
"unable_to_empty_trash": "Unable to empty trash",
@@ -1072,6 +1091,7 @@
"unable_to_update_settings": "Unable to update settings",
"unable_to_update_timeline_display_status": "Unable to update timeline display status",
"unable_to_update_user": "Unable to update user",
"unable_to_update_workflow": "Unable to update workflow",
"unable_to_upload_file": "Unable to upload file"
},
"exclusion_pattern": "Exclusion pattern",
@@ -1124,8 +1144,10 @@
"filename": "Filename",
"filetype": "Filetype",
"filter": "Filter",
"filter_description": "Conditions to filter the target assets",
"filter_people": "Filter people",
"filter_places": "Filter places",
"filters": "Filters",
"find_them_fast": "Find them fast by name with search",
"first": "First",
"fix_incorrect_match": "Fix incorrect match",
@@ -1141,6 +1163,7 @@
"general": "General",
"geolocation_instruction_location": "Click on an asset with GPS coordinates to use its location, or select a location directly from the map",
"get_help": "Get Help",
"get_people_error": "Error getting people",
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network",
"getting_started": "Getting Started",
"go_back": "Go back",
@@ -1172,6 +1195,7 @@
"hide_named_person": "Hide person {name}",
"hide_password": "Hide password",
"hide_person": "Hide person",
"hide_schema": "Hide schema",
"hide_text_recognition": "Hide text recognition",
"hide_unnamed_people": "Hide unnamed people",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
@@ -1244,6 +1268,8 @@
"ios_debug_info_processing_ran_at": "Processing ran {dateTime}",
"items_count": "{count, plural, one {# item} other {# items}}",
"jobs": "Jobs",
"json_editor": "JSON editor",
"json_error": "JSON error",
"keep": "Keep",
"keep_all": "Keep All",
"keep_this_delete_others": "Keep this, delete others",
@@ -1412,11 +1438,13 @@
"monthly_title_text_date_format": "MMMM y",
"more": "More",
"move": "Move",
"move_down": "Move down",
"move_off_locked_folder": "Move out of locked folder",
"move_to": "Move to",
"move_to_lock_folder_action_prompt": "{count} added to the locked folder",
"move_to_locked_folder": "Move to locked folder",
"move_to_locked_folder_confirmation": "These photos and video will be removed from all albums, and only viewable from the locked folder",
"move_up": "Move up",
"moved_to_archive": "Moved {count, plural, one {# asset} other {# assets}} to archive",
"moved_to_library": "Moved {count, plural, one {# asset} other {# assets}} to library",
"moved_to_trash": "Moved to trash",
@@ -1426,6 +1454,7 @@
"my_albums": "My albums",
"name": "Name",
"name_or_nickname": "Name or nickname",
"name_required": "Name is required",
"navigate": "Navigate",
"navigate_to_time": "Navigate to Time",
"network_requirement_photos_upload": "Use cellular data to backup photos",
@@ -1450,6 +1479,7 @@
"next": "Next",
"next_memory": "Next memory",
"no": "No",
"no_actions_added": "No actions added yet",
"no_albums_message": "Create an album to organize your photos and videos",
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
"no_albums_yet": "It looks like you do not have any albums yet.",
@@ -1459,11 +1489,13 @@
"no_cast_devices_found": "No cast devices found",
"no_checksum_local": "No checksum available - cannot fetch local assets",
"no_checksum_remote": "No checksum available - cannot fetch remote asset",
"no_configuration_needed": "No configuration needed",
"no_devices": "No authorized devices",
"no_duplicates_found": "No duplicates were found.",
"no_exif_info_available": "No exif info available",
"no_explore_results_message": "Upload more photos to explore your collection.",
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_filters_added": "No filters added yet",
"no_libraries_message": "Create an external library to view your photos and videos",
"no_local_assets_found": "No local assets found with this checksum",
"no_location_set": "No location set",
@@ -1559,6 +1591,7 @@
"people": "People",
"people_edits_count": "Edited {count, plural, one {# person} other {# people}}",
"people_feature_description": "Browsing photos and videos grouped by people",
"people_selected": "{count, plural, one {# person selected} other {# people selected}}",
"people_sidebar_description": "Display a link to People in the sidebar",
"permanent_deletion_warning": "Permanent deletion warning",
"permanent_deletion_warning_setting_description": "Show a warning when permanently deleting assets",
@@ -1583,6 +1616,8 @@
"person_age_years": "{years, plural, other {# years}} old",
"person_birthdate": "Born on {date}",
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
"person_recognized": "Person recognized",
"person_selected": "Person selected",
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
"photos": "Photos",
"photos_and_videos": "Photos & Videos",
@@ -1832,17 +1867,22 @@
"second": "Second",
"see_all_people": "See all people",
"select": "Select",
"select_album": "Select album",
"select_album_cover": "Select album cover",
"select_albums": "Select albums",
"select_all": "Select all",
"select_all_duplicates": "Select all duplicates",
"select_all_in": "Select all in {group}",
"select_avatar_color": "Select avatar color",
"select_count": "{count, plural, one {Select #} other {Select #}}",
"select_face": "Select face",
"select_featured_photo": "Select featured photo",
"select_from_computer": "Select from computer",
"select_keep_all": "Select keep all",
"select_library_owner": "Select library owner",
"select_new_face": "Select new face",
"select_people": "Select people",
"select_person": "Select person",
"select_person_to_tag": "Select a person to tag",
"select_photos": "Select photos",
"select_trash_all": "Select trash all",
@@ -1978,6 +2018,7 @@
"show_password": "Show password",
"show_person_options": "Show person options",
"show_progress_bar": "Show Progress Bar",
"show_schema": "Show schema",
"show_search_options": "Show search options",
"show_shared_links": "Show shared links",
"show_slideshow_transition": "Show slideshow transition",
@@ -2105,6 +2146,13 @@
"trash_page_select_assets_btn": "Select assets",
"trash_page_title": "Trash ({count})",
"trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.",
"trigger": "Trigger",
"trigger_asset_uploaded": "Asset Uploaded",
"trigger_asset_uploaded_description": "Triggered when a new asset is uploaded",
"trigger_description": "An event that kick off the workflow",
"trigger_person_recognized": "Person Recognized",
"trigger_person_recognized_description": "Triggered when a person is detected",
"trigger_type": "Trigger type",
"troubleshoot": "Troubleshoot",
"type": "Type",
"unable_to_change_pin_code": "Unable to change PIN code",
@@ -2135,7 +2183,9 @@
"unstack": "Un-stack",
"unstack_action_prompt": "{count} unstacked",
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
"unsupported_field_type": "Unsupported field type",
"untagged": "Untagged",
"untitled_workflow": "Untitled workflow",
"up_next": "Up next",
"update_location_action_prompt": "Update the location of {count} selected assets with:",
"updated_at": "Updated",
@@ -2181,6 +2231,7 @@
"utilities": "Utilities",
"validate": "Validate",
"validate_endpoint_error": "Please enter a valid URL",
"validation_error": "Validation error",
"variables": "Variables",
"version": "Version",
"version_announcement_closing": "Your friend, Alex",
@@ -2212,6 +2263,8 @@
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_unstack": "Un-Stack",
"visibility_changed": "Visibility changed for {count, plural, one {# person} other {# people}}",
"visual": "Visual",
"visual_builder": "Visual builder",
"waiting": "Waiting",
"waiting_count": "Waiting: {count}",
"warning": "Warning",
@@ -2219,7 +2272,19 @@
"welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich",
"wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
"workflow_delete_prompt": "Are you sure you want to delete this workflow?",
"workflow_deleted": "Workflow deleted",
"workflow_description": "Workflow description",
"workflow_info": "Workflow info",
"workflow_json": "Workflow JSON",
"workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.",
"workflow_name": "Workflow name",
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
"workflow_summary": "Workflow summary",
"workflow_update_success": "Workflow updated successfully",
"workflow_updated": "Workflow updated",
"workflows": "Workflows",
"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",

View File

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

View File

@@ -217,9 +217,10 @@ part 'model/pin_code_reset_dto.dart';
part 'model/pin_code_setup_dto.dart';
part 'model/places_response_dto.dart';
part 'model/plugin_action_response_dto.dart';
part 'model/plugin_context.dart';
part 'model/plugin_context_type.dart';
part 'model/plugin_filter_response_dto.dart';
part 'model/plugin_response_dto.dart';
part 'model/plugin_trigger_response_dto.dart';
part 'model/plugin_trigger_type.dart';
part 'model/purchase_response.dart';
part 'model/purchase_update.dart';

View File

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

View File

@@ -482,12 +482,14 @@ class ApiClient {
return PlacesResponseDto.fromJson(value);
case 'PluginActionResponseDto':
return PluginActionResponseDto.fromJson(value);
case 'PluginContext':
return PluginContextTypeTransformer().decode(value);
case 'PluginContextType':
return PluginContextTypeTypeTransformer().decode(value);
case 'PluginFilterResponseDto':
return PluginFilterResponseDto.fromJson(value);
case 'PluginResponseDto':
return PluginResponseDto.fromJson(value);
case 'PluginTriggerResponseDto':
return PluginTriggerResponseDto.fromJson(value);
case 'PluginTriggerType':
return PluginTriggerTypeTypeTransformer().decode(value);
case 'PurchaseResponse':

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,107 @@
//
// 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.contextType,
required this.type,
});
PluginContextType contextType;
PluginTriggerType type;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginTriggerResponseDto &&
other.contextType == contextType &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(contextType.hashCode) +
(type.hashCode);
@override
String toString() => 'PluginTriggerResponseDto[contextType=$contextType, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'contextType'] = this.contextType;
json[r'type'] = this.type;
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(
contextType: PluginContextType.fromJson(json[r'contextType'])!,
type: PluginTriggerType.fromJson(json[r'type'])!,
);
}
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>{
'contextType',
'type',
};
}

View File

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

View File

@@ -18,6 +18,7 @@ class WorkflowUpdateDto {
this.enabled,
this.filters = const [],
this.name,
this.triggerType,
});
List<WorkflowActionItemDto> actions;
@@ -48,13 +49,22 @@ class WorkflowUpdateDto {
///
String? name;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
PluginTriggerType? triggerType;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto &&
_deepEquality.equals(other.actions, actions) &&
other.description == description &&
other.enabled == enabled &&
_deepEquality.equals(other.filters, filters) &&
other.name == name;
other.name == name &&
other.triggerType == triggerType;
@override
int get hashCode =>
@@ -63,10 +73,11 @@ class WorkflowUpdateDto {
(description == null ? 0 : description!.hashCode) +
(enabled == null ? 0 : enabled!.hashCode) +
(filters.hashCode) +
(name == null ? 0 : name!.hashCode);
(name == null ? 0 : name!.hashCode) +
(triggerType == null ? 0 : triggerType!.hashCode);
@override
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name]';
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -87,6 +98,11 @@ class WorkflowUpdateDto {
} else {
// json[r'name'] = null;
}
if (this.triggerType != null) {
json[r'triggerType'] = this.triggerType;
} else {
// json[r'triggerType'] = null;
}
return json;
}
@@ -104,6 +120,7 @@ class WorkflowUpdateDto {
enabled: mapValueOfType<bool>(json, r'enabled'),
filters: WorkflowFilterItemDto.listFromJson(json[r'filters']),
name: mapValueOfType<String>(json, r'name'),
triggerType: PluginTriggerType.fromJson(json[r'triggerType']),
);
}
return null;

View File

@@ -8020,6 +8020,55 @@
"x-immich-state": "Alpha"
}
},
"/plugins/triggers": {
"get": {
"description": "Retrieve a list of all available plugin triggers.",
"operationId": "getPluginTriggers",
"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.",
@@ -18282,7 +18331,7 @@
},
"supportedContexts": {
"items": {
"$ref": "#/components/schemas/PluginContext"
"$ref": "#/components/schemas/PluginContextType"
},
"type": "array"
},
@@ -18301,7 +18350,7 @@
],
"type": "object"
},
"PluginContext": {
"PluginContextType": {
"enum": [
"asset",
"album",
@@ -18329,7 +18378,7 @@
},
"supportedContexts": {
"items": {
"$ref": "#/components/schemas/PluginContext"
"$ref": "#/components/schemas/PluginContextType"
},
"type": "array"
},
@@ -18401,6 +18450,29 @@
],
"type": "object"
},
"PluginTriggerResponseDto": {
"properties": {
"contextType": {
"allOf": [
{
"$ref": "#/components/schemas/PluginContextType"
}
]
},
"type": {
"allOf": [
{
"$ref": "#/components/schemas/PluginTriggerType"
}
]
}
},
"required": [
"contextType",
"type"
],
"type": "object"
},
"PluginTriggerType": {
"enum": [
"AssetCreate",
@@ -23316,11 +23388,11 @@
"type": "string"
},
"triggerType": {
"enum": [
"AssetCreate",
"PersonRecognized"
],
"type": "string"
"allOf": [
{
"$ref": "#/components/schemas/PluginTriggerType"
}
]
}
},
"required": [
@@ -23358,6 +23430,13 @@
},
"name": {
"type": "string"
},
"triggerType": {
"allOf": [
{
"$ref": "#/components/schemas/PluginTriggerType"
}
]
}
},
"type": "object"

View File

@@ -942,7 +942,7 @@ export type PluginActionResponseDto = {
methodName: string;
pluginId: string;
schema: object | null;
supportedContexts: PluginContext[];
supportedContexts: PluginContextType[];
title: string;
};
export type PluginFilterResponseDto = {
@@ -951,7 +951,7 @@ export type PluginFilterResponseDto = {
methodName: string;
pluginId: string;
schema: object | null;
supportedContexts: PluginContext[];
supportedContexts: PluginContextType[];
title: string;
};
export type PluginResponseDto = {
@@ -966,6 +966,10 @@ export type PluginResponseDto = {
updatedAt: string;
version: string;
};
export type PluginTriggerResponseDto = {
contextType: PluginContextType;
"type": PluginTriggerType;
};
export type QueueResponseDto = {
isPaused: boolean;
name: QueueName;
@@ -1750,7 +1754,7 @@ export type WorkflowResponseDto = {
id: string;
name: string | null;
ownerId: string;
triggerType: TriggerType;
triggerType: PluginTriggerType;
};
export type WorkflowActionItemDto = {
actionConfig?: object;
@@ -1774,6 +1778,7 @@ export type WorkflowUpdateDto = {
enabled?: boolean;
filters?: WorkflowFilterItemDto[];
name?: string;
triggerType?: PluginTriggerType;
};
/**
* List all activities
@@ -3656,6 +3661,17 @@ export function getPlugins(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
/**
* List all plugin triggers
*/
export function getPluginTriggers(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PluginTriggerResponseDto[];
}>("/plugins/triggers", {
...opts
}));
}
/**
* Retrieve a plugin
*/
@@ -5418,11 +5434,15 @@ export enum PartnerDirection {
SharedBy = "shared-by",
SharedWith = "shared-with"
}
export enum PluginContext {
export enum PluginContextType {
Asset = "asset",
Album = "album",
Person = "person"
}
export enum PluginTriggerType {
AssetCreate = "AssetCreate",
PersonRecognized = "PersonRecognized"
}
export enum QueueJobStatus {
Active = "active",
Failed = "failed",
@@ -5639,11 +5659,3 @@ export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic"
}
export enum TriggerType {
AssetCreate = "AssetCreate",
PersonRecognized = "PersonRecognized"
}
export enum PluginTriggerType {
AssetCreate = "AssetCreate",
PersonRecognized = "PersonRecognized"
}

View File

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

642
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -240,7 +240,7 @@ export type Session = {
isPendingSyncReset: boolean;
};
export type Exif = Omit<Selectable<AssetExifTable>, 'updatedAt' | 'updateId' | 'lockedProperties'>;
export type Exif = Omit<Selectable<AssetExifTable>, 'updatedAt' | 'updateId'>;
export type Person = {
createdAt: Date;

View File

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

View File

@@ -48,6 +48,9 @@ export class WorkflowCreateDto {
}
export class WorkflowUpdateDto {
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', optional: true })
triggerType?: PluginTriggerType;
@IsString()
@IsNotEmpty()
@Optional()
@@ -74,6 +77,7 @@ export class WorkflowUpdateDto {
export class WorkflowResponseDto {
id!: string;
ownerId!: string;
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' })
triggerType!: PluginTriggerType;
name!: string | null;
description!: string;

View File

@@ -1,37 +1,17 @@
import { PluginContext, PluginTriggerType } from 'src/enum';
import { JSONSchema } from 'src/types/plugin-schema.types';
export type PluginTrigger = {
name: string;
type: PluginTriggerType;
description: string;
context: PluginContext;
schema: JSONSchema | null;
contextType: PluginContext;
};
export const pluginTriggers: PluginTrigger[] = [
{
name: 'Asset Uploaded',
type: 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'],
},
},
},
contextType: PluginContext.Asset,
},
{
name: 'Person Recognized',
type: PluginTriggerType.PersonRecognized,
description: 'Triggered when a person is detected in an asset',
context: PluginContext.Person,
schema: null,
contextType: PluginContext.Person,
},
];

View File

@@ -50,11 +50,9 @@ select
where
"asset"."id" = "tag_asset"."assetId"
) as agg
) as "tags",
to_json("asset_exif") as "exifInfo"
) as "tags"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."id" = $2::uuid
limit
@@ -226,14 +224,6 @@ from
where
"asset"."id" = $2
-- AssetJobRepository.getLockedPropertiesForMetadataExtraction
select
"asset_exif"."lockedProperties"
from
"asset_exif"
where
"asset_exif"."assetId" = $1
-- AssetJobRepository.getAlbumThumbnailFiles
select
"asset_file"."id",

View File

@@ -3,31 +3,17 @@
-- AssetRepository.updateAllExif
update "asset_exif"
set
"model" = $1,
"lockedProperties" = nullif(
array(
select distinct
unnest("asset_exif"."lockedProperties" || $2)
),
'{}'
)
"model" = $1
where
"assetId" in ($3)
"assetId" in ($2)
-- AssetRepository.updateDateTimeOriginal
update "asset_exif"
set
"dateTimeOriginal" = "dateTimeOriginal" + $1::interval,
"timeZone" = $2,
"lockedProperties" = nullif(
array(
select distinct
unnest("asset_exif"."lockedProperties" || $3)
),
'{}'
)
"timeZone" = $2
where
"assetId" in ($4)
"assetId" in ($3)
returning
"assetId",
"dateTimeOriginal",

View File

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

View File

@@ -50,7 +50,6 @@ export class AssetJobRepository {
.whereRef('asset.id', '=', 'tag_asset.assetId'),
).as('tags'),
)
.$call(withExifInner)
.limit(1)
.executeTakeFirst();
}
@@ -129,16 +128,6 @@ export class AssetJobRepository {
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getLockedPropertiesForMetadataExtraction(assetId: string) {
return this.db
.selectFrom('asset_exif')
.select('asset_exif.lockedProperties')
.where('asset_exif.assetId', '=', assetId)
.executeTakeFirst()
.then((row) => row?.lockedProperties ?? []);
}
@GenerateSql({ params: [DummyValue.UUID, AssetFileType.Thumbnail] })
getAlbumThumbnailFiles(id: string, fileType?: AssetFileType) {
return this.db

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
import { Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Stack } from 'src/database';
@@ -7,7 +7,7 @@ import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { AssetExifTable, LockableProperty } from 'src/schema/tables/asset-exif.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
import { AssetTable } from 'src/schema/tables/asset.table';
@@ -113,69 +113,51 @@ interface GetByIdsRelations {
tags?: boolean;
}
const distinctLocked = <T extends LockableProperty[] | null>(eb: ExpressionBuilder<DB, 'asset_exif'>, columns: T) =>
sql<T>`nullif(array(select distinct unnest(${eb.ref('asset_exif.lockedProperties')} || ${columns})), '{}')`;
@Injectable()
export class AssetRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
async upsertExif(
exif: Insertable<AssetExifTable>,
{ lockedPropertiesBehavior }: { lockedPropertiesBehavior: 'none' | 'update' | 'skip' },
): Promise<void> {
async upsertExif(exif: Insertable<AssetExifTable>): Promise<void> {
const value = { ...exif, assetId: asUuid(exif.assetId) };
await this.db
.insertInto('asset_exif')
.values(exif)
.values(value)
.onConflict((oc) =>
oc.column('assetId').doUpdateSet((eb) => {
const updateLocked = <T extends keyof AssetExifTable>(col: T) => eb.ref(`excluded.${col}`);
const skipLocked = <T extends keyof AssetExifTable>(col: T) =>
eb
.case()
.when(sql`${col}`, '=', eb.fn.any('asset_exif.lockedProperties'))
.then(eb.ref(`asset_exif.${col}`))
.else(eb.ref(`excluded.${col}`))
.end();
const ref = lockedPropertiesBehavior === 'update' ? updateLocked : skipLocked;
return removeUndefinedKeys(
oc.column('assetId').doUpdateSet((eb) =>
removeUndefinedKeys(
{
description: ref('description'),
exifImageWidth: ref('exifImageWidth'),
exifImageHeight: ref('exifImageHeight'),
fileSizeInByte: ref('fileSizeInByte'),
orientation: ref('orientation'),
dateTimeOriginal: ref('dateTimeOriginal'),
modifyDate: ref('modifyDate'),
timeZone: ref('timeZone'),
latitude: ref('latitude'),
longitude: ref('longitude'),
projectionType: ref('projectionType'),
city: ref('city'),
livePhotoCID: ref('livePhotoCID'),
autoStackId: ref('autoStackId'),
state: ref('state'),
country: ref('country'),
make: ref('make'),
model: ref('model'),
lensModel: ref('lensModel'),
fNumber: ref('fNumber'),
focalLength: ref('focalLength'),
iso: ref('iso'),
exposureTime: ref('exposureTime'),
profileDescription: ref('profileDescription'),
colorspace: ref('colorspace'),
bitsPerSample: ref('bitsPerSample'),
rating: ref('rating'),
fps: ref('fps'),
lockedProperties:
exif.lockedProperties !== undefined && lockedPropertiesBehavior !== 'none'
? distinctLocked(eb, exif.lockedProperties)
: exif.lockedProperties,
description: eb.ref('excluded.description'),
exifImageWidth: eb.ref('excluded.exifImageWidth'),
exifImageHeight: eb.ref('excluded.exifImageHeight'),
fileSizeInByte: eb.ref('excluded.fileSizeInByte'),
orientation: eb.ref('excluded.orientation'),
dateTimeOriginal: eb.ref('excluded.dateTimeOriginal'),
modifyDate: eb.ref('excluded.modifyDate'),
timeZone: eb.ref('excluded.timeZone'),
latitude: eb.ref('excluded.latitude'),
longitude: eb.ref('excluded.longitude'),
projectionType: eb.ref('excluded.projectionType'),
city: eb.ref('excluded.city'),
livePhotoCID: eb.ref('excluded.livePhotoCID'),
autoStackId: eb.ref('excluded.autoStackId'),
state: eb.ref('excluded.state'),
country: eb.ref('excluded.country'),
make: eb.ref('excluded.make'),
model: eb.ref('excluded.model'),
lensModel: eb.ref('excluded.lensModel'),
fNumber: eb.ref('excluded.fNumber'),
focalLength: eb.ref('excluded.focalLength'),
iso: eb.ref('excluded.iso'),
exposureTime: eb.ref('excluded.exposureTime'),
profileDescription: eb.ref('excluded.profileDescription'),
colorspace: eb.ref('excluded.colorspace'),
bitsPerSample: eb.ref('excluded.bitsPerSample'),
rating: eb.ref('excluded.rating'),
fps: eb.ref('excluded.fps'),
},
exif,
);
}),
value,
),
),
)
.execute();
}
@@ -187,30 +169,19 @@ export class AssetRepository {
return;
}
await this.db
.updateTable('asset_exif')
.set((eb) => ({
...options,
lockedProperties: distinctLocked(eb, Object.keys(options) as LockableProperty[]),
}))
.where('assetId', 'in', ids)
.execute();
await this.db.updateTable('asset_exif').set(options).where('assetId', 'in', ids).execute();
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.NUMBER, DummyValue.STRING] })
@Chunked()
updateDateTimeOriginal(ids: string[], delta?: number, timeZone?: string) {
if (ids.length === 0) {
return;
}
return this.db
async updateDateTimeOriginal(
ids: string[],
delta?: number,
timeZone?: string,
): Promise<{ assetId: string; dateTimeOriginal: Date | null; timeZone: string | null }[]> {
return await this.db
.updateTable('asset_exif')
.set((eb) => ({
dateTimeOriginal: sql`"dateTimeOriginal" + ${(delta ?? 0) + ' minute'}::interval`,
timeZone,
lockedProperties: distinctLocked(eb, ['dateTimeOriginal', 'timeZone']),
}))
.set({ dateTimeOriginal: sql`"dateTimeOriginal" + ${(delta ?? 0) + ' minute'}::interval`, timeZone })
.where('assetId', 'in', ids)
.returning(['assetId', 'dateTimeOriginal', 'timeZone'])
.execute();

View File

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

View File

@@ -1,9 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_exif" ADD "lockedProperties" character varying[];`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_exif" DROP COLUMN "lockedProperties";`.execute(db);
}

View File

@@ -2,16 +2,6 @@ import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetTable } from 'src/schema/tables/asset.table';
import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from 'src/sql-tools';
export type LockableProperty = (typeof lockableProperties)[number];
export const lockableProperties = [
'description',
'dateTimeOriginal',
'latitude',
'longitude',
'rating',
'timeZone',
] as const;
@Table('asset_exif')
@UpdatedAtTrigger('asset_exif_updatedAt')
export class AssetExifTable {
@@ -107,7 +97,4 @@ export class AssetExifTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@Column({ type: 'character varying', array: true, nullable: true })
lockedProperties!: Array<LockableProperty> | null;
}

View File

@@ -370,7 +370,7 @@ export class AssetMediaService extends BaseService {
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size }, { lockedPropertiesBehavior: 'none' });
await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size });
await this.jobRepository.queue({
name: JobName.AssetExtractMetadata,
data: { id: assetId, source: 'upload' },
@@ -399,10 +399,7 @@ export class AssetMediaService extends BaseService {
});
const { size } = await this.storageRepository.stat(created.originalPath);
await this.assetRepository.upsertExif(
{ assetId: created.id, fileSizeInByte: size },
{ lockedPropertiesBehavior: 'none' },
);
await this.assetRepository.upsertExif({ assetId: created.id, fileSizeInByte: size });
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: created.id, source: 'copy' } });
return created;
}
@@ -443,10 +440,7 @@ export class AssetMediaService extends BaseService {
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
}
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif(
{ assetId: asset.id, fileSizeInByte: file.size },
{ lockedPropertiesBehavior: 'none' },
);
await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size });
await this.eventRepository.emit('AssetCreate', { asset });

View File

@@ -225,10 +225,7 @@ describe(AssetService.name, () => {
await sut.update(authStub.admin, 'asset-1', { description: 'Test description' });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{ assetId: 'asset-1', description: 'Test description' },
{ lockedPropertiesBehavior: 'update' },
);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' });
});
it('should update the exif rating', async () => {
@@ -238,13 +235,7 @@ describe(AssetService.name, () => {
await sut.update(authStub.admin, 'asset-1', { rating: 3 });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{
assetId: 'asset-1',
rating: 3,
},
{ lockedPropertiesBehavior: 'update' },
);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', rating: 3 });
});
it('should fail linking a live video if the motion part could not be found', async () => {

View File

@@ -30,7 +30,7 @@ import {
QueueName,
} from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
import { requireElevatedPermission } from 'src/utils/access';
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
@@ -143,9 +143,9 @@ export class AssetService extends BaseService {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids });
const assetDto = { isFavorite, visibility, duplicateId };
const exifDto = _.omitBy({ latitude, longitude, rating, description, dateTimeOriginal }, _.isUndefined);
const exifDto = { latitude, longitude, rating, description, dateTimeOriginal };
const isExifChanged = Object.keys(exifDto).length > 0;
const isExifChanged = Object.values(exifDto).some((v) => v !== undefined);
if (isExifChanged) {
await this.assetRepository.updateAllExif(ids, exifDto);
}
@@ -456,25 +456,12 @@ export class AssetService extends BaseService {
return asset;
}
private async updateExif(dto: {
id: string;
description?: string;
dateTimeOriginal?: string;
latitude?: number;
longitude?: number;
rating?: number;
}) {
private async updateExif(dto: ISidecarWriteJob) {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
if (Object.keys(writes).length > 0) {
await this.assetRepository.upsertExif(
{
assetId: id,
...writes,
},
{ lockedPropertiesBehavior: 'update' },
);
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id } });
await this.assetRepository.upsertExif({ assetId: id, ...writes });
await this.jobRepository.queue({ name: JobName.SidecarWrite, data: { id, ...writes } });
}
}
}

View File

@@ -187,9 +187,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.sidecar.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate }), {
lockedPropertiesBehavior: 'skip',
});
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate }));
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
id: assetStub.image.id,
@@ -216,7 +214,6 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ dateTimeOriginal: fileModifiedAt }),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.image.id,
@@ -241,10 +238,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ dateTimeOriginal: fileCreatedAt }),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: fileCreatedAt }));
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.image.id,
duration: null,
@@ -264,7 +258,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({
dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'),
}),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith(
@@ -288,9 +281,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 }), {
lockedPropertiesBehavior: 'skip',
});
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 }));
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.image.id,
duration: null,
@@ -319,7 +310,6 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ city: null, state: null, country: null }),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.withLocation.id,
@@ -349,7 +339,6 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.withLocation.id,
@@ -369,10 +358,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ latitude: null, longitude: null }),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null }));
});
it('should extract tags from TagsList', async () => {
@@ -585,7 +571,6 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }),
{ lockedPropertiesBehavior: 'skip' },
);
});
@@ -894,40 +879,37 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{
assetId: assetStub.image.id,
bitsPerSample: expect.any(Number),
autoStackId: null,
colorspace: tags.ColorSpace,
dateTimeOriginal: dateForTest,
description: tags.ImageDescription,
exifImageHeight: null,
exifImageWidth: null,
exposureTime: tags.ExposureTime,
fNumber: null,
fileSizeInByte: 123_456,
focalLength: tags.FocalLength,
fps: null,
iso: tags.ISO,
latitude: null,
lensModel: tags.LensModel,
livePhotoCID: tags.MediaGroupUUID,
longitude: null,
make: tags.Make,
model: tags.Model,
modifyDate: expect.any(Date),
orientation: tags.Orientation?.toString(),
profileDescription: tags.ProfileDescription,
projectionType: 'EQUIRECTANGULAR',
timeZone: tags.tz,
rating: tags.Rating,
country: null,
state: null,
city: null,
},
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith({
assetId: assetStub.image.id,
bitsPerSample: expect.any(Number),
autoStackId: null,
colorspace: tags.ColorSpace,
dateTimeOriginal: dateForTest,
description: tags.ImageDescription,
exifImageHeight: null,
exifImageWidth: null,
exposureTime: tags.ExposureTime,
fNumber: null,
fileSizeInByte: 123_456,
focalLength: tags.FocalLength,
fps: null,
iso: tags.ISO,
latitude: null,
lensModel: tags.LensModel,
livePhotoCID: tags.MediaGroupUUID,
longitude: null,
make: tags.Make,
model: tags.Model,
modifyDate: expect.any(Date),
orientation: tags.Orientation?.toString(),
profileDescription: tags.ProfileDescription,
projectionType: 'EQUIRECTANGULAR',
timeZone: tags.tz,
rating: tags.Rating,
country: null,
state: null,
city: null,
});
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
id: assetStub.image.id,
@@ -961,7 +943,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({
timeZone: 'UTC+0',
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
@@ -1108,7 +1089,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({
description: '',
}),
{ lockedPropertiesBehavior: 'skip' },
);
mockReadTags({ ImageDescription: ' my\n description' });
@@ -1117,7 +1097,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({
description: 'my\n description',
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
@@ -1130,7 +1109,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({
description: '1000',
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
@@ -1354,7 +1332,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({
modifyDate: expect.any(Date),
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
@@ -1367,7 +1344,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({
rating: null,
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
@@ -1380,7 +1356,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({
rating: 5,
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
@@ -1393,7 +1368,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({
rating: -1,
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
@@ -1515,9 +1489,7 @@ describe(MetadataService.name, () => {
mockReadTags(exif);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining(expected), {
lockedPropertiesBehavior: 'skip',
});
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining(expected));
});
it.each([
@@ -1543,7 +1515,6 @@ describe(MetadataService.name, () => {
expect.objectContaining({
lensModel: expected,
}),
{ lockedPropertiesBehavior: 'skip' },
);
});
});
@@ -1652,14 +1623,12 @@ describe(MetadataService.name, () => {
describe('handleSidecarWrite', () => {
it('should skip assets that no longer exist', async () => {
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([]);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(void 0);
await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.Failed);
expect(mocks.metadata.writeTags).not.toHaveBeenCalled();
});
it('should skip jobs with no metadata', async () => {
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([]);
const asset = factory.jobAssets.sidecarWrite();
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Skipped);
@@ -1672,22 +1641,20 @@ describe(MetadataService.name, () => {
const gps = 12;
const date = '2023-11-22T04:56:12.196Z';
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([
'description',
'latitude',
'longitude',
'dateTimeOriginal',
]);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
await expect(
sut.handleSidecarWrite({
id: asset.id,
description,
latitude: gps,
longitude: gps,
dateTimeOriginal: date,
}),
).resolves.toBe(JobStatus.Success);
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, {
DateTimeOriginal: date,
Description: description,
ImageDescription: description,
DateTimeOriginal: date,
GPSLatitude: gps,
GPSLongitude: gps,
});

View File

@@ -289,7 +289,7 @@ export class MetadataService extends BaseService {
};
const promises: Promise<unknown>[] = [
this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' }),
this.assetRepository.upsertExif(exifData),
this.assetRepository.update({
id: asset.id,
duration: this.getDuration(exifTags),
@@ -392,34 +392,22 @@ export class MetadataService extends BaseService {
@OnJob({ name: JobName.SidecarWrite, queue: QueueName.Sidecar })
async handleSidecarWrite(job: JobOf<JobName.SidecarWrite>): Promise<JobStatus> {
const { id, tags } = job;
const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job;
const asset = await this.assetJobRepository.getForSidecarWriteJob(id);
if (!asset) {
return JobStatus.Failed;
}
const lockedProperties = await this.assetJobRepository.getLockedPropertiesForMetadataExtraction(id);
const tagsList = (asset.tags || []).map((tag) => tag.value);
const { sidecarFile } = getAssetFiles(asset.files);
const sidecarPath = sidecarFile?.path || `${asset.originalPath}.xmp`;
const { description, dateTimeOriginal, latitude, longitude, rating } = _.pick(
{
description: asset.exifInfo.description,
dateTimeOriginal: asset.exifInfo.dateTimeOriginal,
latitude: asset.exifInfo.latitude,
longitude: asset.exifInfo.longitude,
rating: asset.exifInfo.rating,
},
lockedProperties,
);
const exif = _.omitBy(
<Tags>{
Description: description,
ImageDescription: description,
DateTimeOriginal: dateTimeOriginal == null ? undefined : String(dateTimeOriginal),
DateTimeOriginal: dateTimeOriginal,
GPSLatitude: latitude,
GPSLongitude: longitude,
Rating: rating,

View File

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

View File

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

View File

@@ -222,6 +222,11 @@ export interface IDeleteFilesJob extends IBaseJob {
}
export interface ISidecarWriteJob extends IEntityJob {
description?: string;
dateTimeOriginal?: string;
latitude?: number;
longitude?: number;
rating?: number;
tags?: true;
}

View File

@@ -202,7 +202,7 @@ export class MediumTestContext<S extends BaseService = BaseService> {
}
async newExif(dto: Insertable<AssetExifTable>) {
const result = await this.get(AssetRepository).upsertExif(dto, { lockedPropertiesBehavior: 'none' });
const result = await this.get(AssetRepository).upsertExif(dto);
return { result };
}

View File

@@ -95,7 +95,6 @@ describe(MetadataService.name, () => {
dateTimeOriginal: new Date(expected.dateTimeOriginal),
timeZone: expected.timeZone,
}),
{ lockedPropertiesBehavior: 'skip' },
);
expect(mocks.asset.update).toHaveBeenCalledWith(

View File

@@ -611,6 +611,100 @@ describe(WorkflowService.name, () => {
sut.update(auth, created.id, { actions: [{ pluginActionId: factory.uuid(), actionConfig: {} }] }),
).rejects.toThrow();
});
it('should update trigger type', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.PersonRecognized,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [],
actions: [],
});
await sut.update(auth, created.id, {
triggerType: PluginTriggerType.AssetCreate,
});
const fetched = await sut.get(auth, created.id);
expect(fetched.triggerType).toBe(PluginTriggerType.AssetCreate);
});
it('should add filters', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [],
actions: [],
});
await sut.update(auth, created.id, {
filters: [
{ pluginFilterId: testFilterId, filterConfig: { first: true } },
{ pluginFilterId: testFilterId, filterConfig: { second: true } },
],
});
const fetched = await sut.get(auth, created.id);
expect(fetched.filters).toHaveLength(2);
expect(fetched.filters[0].filterConfig).toEqual({ first: true });
expect(fetched.filters[1].filterConfig).toEqual({ second: true });
});
it('should replace existing filters', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [{ pluginFilterId: testFilterId, filterConfig: { original: true } }],
actions: [],
});
await sut.update(auth, created.id, {
filters: [{ pluginFilterId: testFilterId, filterConfig: { replaced: true } }],
});
const fetched = await sut.get(auth, created.id);
expect(fetched.filters).toHaveLength(1);
expect(fetched.filters[0].filterConfig).toEqual({ replaced: true });
});
it('should remove existing filters', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [{ pluginFilterId: testFilterId, filterConfig: { toRemove: true } }],
actions: [],
});
await sut.update(auth, created.id, {
filters: [],
});
const fetched = await sut.get(auth, created.id);
expect(fetched.filters).toHaveLength(0);
});
});
describe('delete', () => {

View File

@@ -288,13 +288,10 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
// update the asset
const assetRepository = ctx.get(AssetRepository);
await assetRepository.upsertExif(
{
assetId: asset.id,
city: 'New City',
},
{ lockedPropertiesBehavior: 'update' },
);
await assetRepository.upsertExif({
assetId: asset.id,
city: 'New City',
});
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([
{
@@ -349,13 +346,10 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
// update the asset
const assetRepository = ctx.get(AssetRepository);
await assetRepository.upsertExif(
{
assetId: assetDelayedExif.id,
city: 'Delayed Exif',
},
{ lockedPropertiesBehavior: 'update' },
);
await assetRepository.upsertExif({
assetId: assetDelayedExif.id,
city: 'Delayed Exif',
});
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([
{

View File

@@ -4,7 +4,6 @@ import {
AuthApiKey,
AuthSharedLink,
AuthUser,
Exif,
Library,
Memory,
Partner,
@@ -320,28 +319,18 @@ const versionHistoryFactory = () => ({
version: '1.123.45',
});
const assetSidecarWriteFactory = () => {
const id = newUuid();
return {
id,
originalPath: '/path/to/original-path.jpg.xmp',
tags: [],
files: [
{
id: newUuid(),
path: '/path/to/original-path.jpg.xmp',
type: AssetFileType.Sidecar,
},
],
exifInfo: {
assetId: id,
description: 'this is a description',
latitude: 12,
longitude: 12,
dateTimeOriginal: '2023-11-22T04:56:12.196Z',
} as unknown as Exif,
};
};
const assetSidecarWriteFactory = () => ({
id: newUuid(),
originalPath: '/path/to/original-path.jpg.xmp',
tags: [],
files: [
{
id: newUuid(),
path: '/path/to/original-path.jpg.xmp',
type: AssetFileType.Sidecar,
},
],
});
const assetOcrFactory = (
ocr: {

View File

@@ -58,6 +58,7 @@
"socket.io-client": "~4.8.0",
"svelte-gestures": "^5.2.2",
"svelte-i18n": "^4.0.1",
"svelte-jsoneditor": "^3.10.0",
"svelte-maplibre": "^1.2.5",
"svelte-persisted-store": "^0.12.0",
"tabbable": "^6.2.0",

View File

@@ -0,0 +1,118 @@
export interface DragAndDropOptions {
index: number;
onDragStart?: (index: number) => void;
onDragEnter?: (index: number) => void;
onDrop?: (e: DragEvent, index: number) => void;
onDragEnd?: () => void;
isDragging?: boolean;
isDragOver?: boolean;
}
export function dragAndDrop(node: HTMLElement, options: DragAndDropOptions) {
let { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
const isFormElement = (element: HTMLElement) => {
return element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT';
};
const handleDragStart = (e: DragEvent) => {
// Prevent drag if it originated from an input, textarea, or select element
const target = e.target as HTMLElement;
if (isFormElement(target)) {
e.preventDefault();
return;
}
onDragStart?.(index);
};
const handleDragEnter = () => {
onDragEnter?.(index);
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
};
const handleDrop = (e: DragEvent) => {
onDrop?.(e, index);
};
const handleDragEnd = () => {
onDragEnd?.();
};
// Disable draggable when focusing on form elements (fixes Firefox input interaction)
const handleFocusIn = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (isFormElement(target)) {
node.setAttribute('draggable', 'false');
}
};
const handleFocusOut = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (isFormElement(target)) {
node.setAttribute('draggable', 'true');
}
};
node.setAttribute('draggable', 'true');
node.setAttribute('role', 'button');
node.setAttribute('tabindex', '0');
node.addEventListener('dragstart', handleDragStart);
node.addEventListener('dragenter', handleDragEnter);
node.addEventListener('dragover', handleDragOver);
node.addEventListener('drop', handleDrop);
node.addEventListener('dragend', handleDragEnd);
node.addEventListener('focusin', handleFocusIn);
node.addEventListener('focusout', handleFocusOut);
// Update classes based on drag state
const updateClasses = (dragging: boolean, dragOver: boolean) => {
// Remove all drag-related classes first
node.classList.remove('opacity-50', 'border-gray-400', 'dark:border-gray-500', 'border-solid');
// Add back only the active ones
if (dragging) {
node.classList.add('opacity-50');
}
if (dragOver) {
node.classList.add('border-gray-400', 'dark:border-gray-500', 'border-solid');
node.classList.remove('border-transparent');
} else {
node.classList.add('border-transparent');
}
};
updateClasses(isDragging || false, isDragOver || false);
return {
update(newOptions: DragAndDropOptions) {
index = newOptions.index;
onDragStart = newOptions.onDragStart;
onDragEnter = newOptions.onDragEnter;
onDrop = newOptions.onDrop;
onDragEnd = newOptions.onDragEnd;
const newIsDragging = newOptions.isDragging || false;
const newIsDragOver = newOptions.isDragOver || false;
if (newIsDragging !== isDragging || newIsDragOver !== isDragOver) {
isDragging = newIsDragging;
isDragOver = newIsDragOver;
updateClasses(isDragging, isDragOver);
}
},
destroy() {
node.removeEventListener('dragstart', handleDragStart);
node.removeEventListener('dragenter', handleDragEnter);
node.removeEventListener('dragover', handleDragOver);
node.removeEventListener('drop', handleDrop);
node.removeEventListener('dragend', handleDragEnd);
node.removeEventListener('focusin', handleFocusIn);
node.removeEventListener('focusout', handleFocusOut);
},
};
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -0,0 +1,105 @@
import type { Attachment } from 'svelte/attachments';
export interface DragAndDropOptions {
index: number;
onDragStart?: (index: number) => void;
onDragEnter?: (index: number) => void;
onDrop?: (e: DragEvent, index: number) => void;
onDragEnd?: () => void;
isDragging?: boolean;
isDragOver?: boolean;
}
export function dragAndDrop(options: DragAndDropOptions): Attachment {
return (node: Element) => {
const element = node as HTMLElement;
const { index, onDragStart, onDragEnter, onDrop, onDragEnd, isDragging, isDragOver } = options;
const isFormElement = (el: HTMLElement) => {
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT';
};
const handleDragStart = (e: DragEvent) => {
// Prevent drag if it originated from an input, textarea, or select element
const target = e.target as HTMLElement;
if (isFormElement(target)) {
e.preventDefault();
return;
}
onDragStart?.(index);
};
const handleDragEnter = () => {
onDragEnter?.(index);
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
};
const handleDrop = (e: DragEvent) => {
onDrop?.(e, index);
};
const handleDragEnd = () => {
onDragEnd?.();
};
// Disable draggable when focusing on form elements (fixes Firefox input interaction)
const handleFocusIn = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (isFormElement(target)) {
element.setAttribute('draggable', 'false');
}
};
const handleFocusOut = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (isFormElement(target)) {
element.setAttribute('draggable', 'true');
}
};
// Update classes based on drag state
const updateClasses = (dragging: boolean, dragOver: boolean) => {
// Remove all drag-related classes first
element.classList.remove('opacity-50', 'border-light-500', 'border-solid');
// Add back only the active ones
if (dragging) {
element.classList.add('opacity-50');
}
if (dragOver) {
element.classList.add('border-light-500', 'border-solid');
element.classList.remove('border-transparent');
} else {
element.classList.add('border-transparent');
}
};
element.setAttribute('draggable', 'true');
element.setAttribute('role', 'button');
element.setAttribute('tabindex', '0');
element.addEventListener('dragstart', handleDragStart);
element.addEventListener('dragenter', handleDragEnter);
element.addEventListener('dragover', handleDragOver);
element.addEventListener('drop', handleDrop);
element.addEventListener('dragend', handleDragEnd);
element.addEventListener('focusin', handleFocusIn);
element.addEventListener('focusout', handleFocusOut);
updateClasses(isDragging || false, isDragOver || false);
return () => {
element.removeEventListener('dragstart', handleDragStart);
element.removeEventListener('dragenter', handleDragEnter);
element.removeEventListener('dragover', handleDragOver);
element.removeEventListener('drop', handleDrop);
element.removeEventListener('dragend', handleDragEnd);
element.removeEventListener('focusin', handleFocusIn);
element.removeEventListener('focusout', handleFocusOut);
};
};
}

View File

@@ -9,6 +9,7 @@
mdiCrosshairsGps,
mdiImageSizeSelectLarge,
mdiLinkEdit,
mdiStateMachine,
} from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -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: mdiStateMachine, label: $t('workflows') },
];
</script>

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import { getComponentDefaultValue, getComponentFromSchema } from '$lib/utils/workflow';
import { Field, Input, MultiSelect, Select, Switch, Text } from '@immich/ui';
import WorkflowPickerField from './WorkflowPickerField.svelte';
type Props = {
schema: object | null;
config: Record<string, unknown>;
configKey?: string;
};
let { schema = null, config = $bindable({}), configKey }: Props = $props();
const components = $derived(getComponentFromSchema(schema));
// Get the actual config object to work with
const actualConfig = $derived(configKey ? (config[configKey] as Record<string, unknown>) || {} : config);
// Update function that handles nested config
const updateConfig = (key: string, value: unknown) => {
config = configKey ? { ...config, [configKey]: { ...actualConfig, [key]: value } } : { ...config, [key]: value };
};
const updateConfigBatch = (updates: Record<string, unknown>) => {
config = configKey ? { ...config, [configKey]: { ...actualConfig, ...updates } } : { ...config, ...updates };
};
// Derive which keys need initialization (missing from actualConfig)
const uninitializedKeys = $derived.by(() => {
if (!components) {
return [];
}
return Object.entries(components)
.filter(([key]) => actualConfig[key] === undefined)
.map(([key, component]) => ({ key, component, defaultValue: getComponentDefaultValue(component) }));
});
// Derive the batch updates needed
const pendingUpdates = $derived.by(() => {
const updates: Record<string, unknown> = {};
for (const { key, defaultValue } of uninitializedKeys) {
updates[key] = defaultValue;
}
return updates;
});
// Initialize config namespace if needed
$effect(() => {
if (configKey && !config[configKey]) {
config = { ...config, [configKey]: {} };
}
});
// Apply pending config updates
$effect(() => {
if (Object.keys(pendingUpdates).length > 0) {
updateConfigBatch(pendingUpdates);
}
});
const isPickerField = (subType: string | undefined) => subType === 'album-picker' || subType === 'people-picker';
</script>
{#if components}
<div class="flex flex-col gap-2">
{#each Object.entries(components) as [key, component] (key)}
{@const label = component.title || component.label || key}
<div class="flex flex-col gap-1 border bg-light p-4 rounded-xl">
<!-- Select component -->
{#if component.type === 'select'}
{#if isPickerField(component.subType)}
<WorkflowPickerField
{component}
configKey={key}
value={actualConfig[key] as string | string[]}
onchange={(value) => updateConfig(key, value)}
/>
{:else}
{@const options = component.options?.map((opt) => {
return { label: opt.label, value: String(opt.value) };
}) || [{ label: 'N/A', value: '' }]}
{@const currentValue = actualConfig[key]}
{@const selectedItem = options.find((opt) => opt.value === String(currentValue)) ?? options[0]}
<Field
{label}
required={component.required}
description={component.description}
requiredIndicator={component.required}
>
<Select data={options} onChange={(opt) => updateConfig(key, opt.value)} value={selectedItem} />
</Field>
{/if}
<!-- MultiSelect component -->
{:else if component.type === 'multiselect'}
{#if isPickerField(component.subType)}
<WorkflowPickerField
{component}
configKey={key}
value={actualConfig[key] as string | string[]}
onchange={(value) => updateConfig(key, value)}
/>
{:else}
{@const options = component.options?.map((opt) => {
return { label: opt.label, value: String(opt.value) };
}) || [{ label: 'N/A', value: '' }]}
{@const currentValues = (actualConfig[key] as string[]) ?? []}
{@const selectedItems = options.filter((opt) => currentValues.includes(opt.value))}
<Field
{label}
required={component.required}
description={component.description}
requiredIndicator={component.required}
>
<MultiSelect
data={options}
onChange={(opt) =>
updateConfig(
key,
opt.map((o) => o.value),
)}
values={selectedItems}
/>
</Field>
{/if}
<!-- Switch component -->
{:else if component.type === 'switch'}
{@const checked = Boolean(actualConfig[key])}
<Field
{label}
description={component.description}
requiredIndicator={component.required}
required={component.required}
>
<Switch {checked} onCheckedChange={(check) => updateConfig(key, check)} />
</Field>
<!-- Text input -->
{:else if isPickerField(component.subType)}
<WorkflowPickerField
{component}
configKey={key}
value={actualConfig[key] as string | string[]}
onchange={(value) => updateConfig(key, value)}
/>
{:else}
<Field
{label}
description={component.description}
requiredIndicator={component.required}
required={component.required}
>
<Input
id={key}
value={actualConfig[key] as string}
oninput={(e) => updateConfig(key, e.currentTarget.value)}
required={component.required}
/>
</Field>
{/if}
</div>
{/each}
</div>
{:else}
<Text size="small" color="muted">No configuration required</Text>
{/if}

View File

@@ -0,0 +1,42 @@
<script lang="ts">
type Props = {
animated?: boolean;
};
let { animated = true }: Props = $props();
</script>
<div class="flex justify-center py-2">
<div class="relative h-12 w-0.5">
<div class="absolute inset-0 bg-linear-to-b from-primary/30 via-primary/50 to-primary/30"></div>
{#if animated}
<div class="absolute inset-0 bg-linear-to-b from-transparent via-primary to-transparent flow-pulse"></div>
{/if}
<div class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2">
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
</div>
<div class="absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2">
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
</div>
</div>
</div>
<style>
@keyframes flow {
0% {
transform: translateY(-25%);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: translateY(25%);
opacity: 0;
}
}
.flow-pulse {
animation: flow 2s ease-in-out infinite;
}
</style>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { themeManager } from '$lib/managers/theme-manager.svelte';
import type { WorkflowPayload } from '$lib/services/workflow.service';
import { Button, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, VStack } from '@immich/ui';
import { mdiCodeJson } from '@mdi/js';
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
type Props = {
jsonContent: WorkflowPayload;
onApply: () => void;
onContentChange: (content: WorkflowPayload) => void;
};
let { jsonContent, onApply, onContentChange }: Props = $props();
let content: Content = $derived({ json: jsonContent });
let canApply = $state(false);
let editorClass = $derived(themeManager.isDark ? 'jse-theme-dark' : '');
const handleChange = (updated: Content, _: Content, status: OnChangeStatus) => {
if (status.contentErrors) {
return;
}
canApply = true;
if ('text' in updated && updated.text !== undefined) {
try {
const parsed = JSON.parse(updated.text);
onContentChange(parsed);
} catch (error_) {
console.error('Invalid JSON in text mode:', error_);
}
}
};
const handleApply = () => {
onApply();
canApply = false;
};
</script>
<VStack gap={4}>
<Card>
<CardHeader>
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-3">
<Icon icon={mdiCodeJson} size="20" class="mt-1" />
<div class="flex flex-col">
<CardTitle>Workflow JSON</CardTitle>
<CardDescription>Edit the workflow configuration directly in JSON format</CardDescription>
</div>
</div>
<Button size="small" color="primary" onclick={handleApply} disabled={!canApply}>Apply Changes</Button>
</div>
</CardHeader>
<CardBody>
<VStack gap={2}>
<div class="w-full h-[600px] rounded-lg overflow-hidden border {editorClass}">
<JSONEditor {content} onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
</div>
</VStack>
</CardBody>
</Card>
</VStack>
<style>
@import 'svelte-jsoneditor/themes/jse-theme-dark.css';
</style>

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import WorkflowPickerItemCard from '$lib/components/workflows/WorkflowPickerItemCard.svelte';
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
import { fetchPickerMetadata, type PickerMetadata } from '$lib/services/workflow.service';
import type { ComponentConfig } from '$lib/utils/workflow';
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
import { Button, Field, modalManager } from '@immich/ui';
import { mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
component: ComponentConfig;
configKey: string;
value: string | string[] | undefined;
onchange: (value: string | string[]) => void;
};
let { component, configKey, value = $bindable(), onchange }: Props = $props();
const label = $derived(component.title || component.label || configKey);
const subType = $derived(component.subType as 'album-picker' | 'people-picker');
const isAlbum = $derived(subType === 'album-picker');
const multiple = $derived(component.type === 'multiselect' || Array.isArray(value));
let pickerMetadata = $state<PickerMetadata | undefined>();
$effect(() => {
if (!value) {
pickerMetadata = undefined;
return;
}
if (!pickerMetadata) {
void loadMetadata();
}
});
const loadMetadata = async () => {
pickerMetadata = await fetchPickerMetadata(value, subType);
};
const handlePicker = async () => {
if (isAlbum) {
const albums = await modalManager.show(AlbumPickerModal, { shared: false });
if (albums && albums.length > 0) {
const newValue = multiple ? albums.map((album) => album.id) : albums[0].id;
onchange(newValue);
pickerMetadata = multiple ? albums : albums[0];
}
} else {
const currentIds = (Array.isArray(value) ? value : []) as string[];
const excludedIds = multiple ? currentIds : [];
const people = await modalManager.show(PeoplePickerModal, { multiple, excludedIds });
if (people && people.length > 0) {
const newValue = multiple ? people.map((person) => person.id) : people[0].id;
onchange(newValue);
pickerMetadata = multiple ? people : people[0];
}
}
};
const removeSelection = () => {
onchange(multiple ? [] : '');
pickerMetadata = undefined;
};
const removeItemFromSelection = (itemId: string) => {
if (!Array.isArray(value)) {
return;
}
const newValue = value.filter((id) => id !== itemId);
onchange(newValue);
if (Array.isArray(pickerMetadata)) {
pickerMetadata = pickerMetadata.filter((item) => item.id !== itemId) as AlbumResponseDto[] | PersonResponseDto[];
}
};
const getButtonText = () => {
if (isAlbum) {
return multiple ? $t('select_albums') : $t('select_album');
}
return multiple ? $t('select_people') : $t('select_person');
};
</script>
<Field {label} required={component.required} description={component.description} requiredIndicator={component.required}>
<div class="flex flex-col gap-3">
{#if pickerMetadata && !Array.isArray(pickerMetadata)}
<WorkflowPickerItemCard item={pickerMetadata} {isAlbum} onRemove={removeSelection} />
{:else if pickerMetadata && Array.isArray(pickerMetadata) && pickerMetadata.length > 0}
<div class="flex flex-col gap-2">
{#each pickerMetadata as item (item.id)}
<WorkflowPickerItemCard {item} {isAlbum} onRemove={() => removeItemFromSelection(item.id)} />
{/each}
</div>
{/if}
<Button size="small" variant="outline" leadingIcon={mdiPlus} onclick={handlePicker}>
{getButtonText()}
</Button>
</div>
</Field>

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
import { Card, CardBody, IconButton, Text } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
item: AlbumResponseDto | PersonResponseDto;
isAlbum: boolean;
onRemove: () => void;
};
let { item, isAlbum, onRemove }: Props = $props();
</script>
<Card color="secondary">
<CardBody class="flex items-center gap-3">
<div class="shrink-0">
{#if isAlbum && 'albumThumbnailAssetId' in item}
{#if item.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(item.albumThumbnailAssetId)}
alt={item.albumName}
class="h-12 w-12 rounded-lg object-cover"
/>
{:else}
<div class="h-12 w-12 rounded-lg"></div>
{/if}
{:else if !isAlbum && 'name' in item}
<img src={getPeopleThumbnailUrl(item)} alt={item.name} class="h-12 w-12 rounded-full object-cover" />
{/if}
</div>
<div class="min-w-0 flex-1">
<Text class="font-semibold truncate">
{isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''}
</Text>
{#if isAlbum && 'assetCount' in item}
<Text size="small" color="muted">
{$t('items_count', { values: { count: item.assetCount } })}
</Text>
{/if}
</div>
<IconButton
type="button"
onclick={onRemove}
class="shrink-0"
shape="round"
aria-label={$t('remove')}
icon={mdiClose}
size="small"
variant="ghost"
color="secondary"
/>
</CardBody>
</Card>

View File

@@ -0,0 +1,184 @@
<script lang="ts">
import {
PluginTriggerType,
type PluginActionResponseDto,
type PluginFilterResponseDto,
type PluginTriggerResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, Text } from '@immich/ui';
import { mdiClose, mdiFilterOutline, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
trigger: PluginTriggerResponseDto;
filters: PluginFilterResponseDto[];
actions: PluginActionResponseDto[];
};
let { trigger, filters, actions }: Props = $props();
const getTriggerName = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return $t('trigger_asset_uploaded');
}
case PluginTriggerType.PersonRecognized: {
return $t('trigger_person_recognized');
}
default: {
return triggerType;
}
}
};
let isOpen = $state(false);
let position = $state({ x: 0, y: 0 });
let isDragging = $state(false);
let dragOffset = $state({ x: 0, y: 0 });
let containerEl: HTMLDivElement | undefined = $state();
const handleMouseDown = (e: MouseEvent) => {
if (!containerEl) {
return;
}
isDragging = true;
const rect = containerEl.getBoundingClientRect();
dragOffset = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) {
return;
}
position = {
x: e.clientX - dragOffset.x,
y: e.clientY - dragOffset.y,
};
};
const handleMouseUp = () => {
isDragging = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
$effect(() => {
// Initialize position to bottom-right on mount
if (globalThis.window && position.x === 0 && position.y === 0) {
position = {
x: globalThis.innerWidth - 280,
y: globalThis.innerHeight - 400,
};
}
});
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={containerEl}
class="hidden sm:block fixed w-64 hover:cursor-grab select-none"
style="left: {position.x}px; top: {position.y}px;"
class:cursor-grabbing={isDragging}
onmousedown={handleMouseDown}
>
<div
class="rounded-xl border-transparent border-2 hover:shadow-xl hover:border-dashed bg-light-50 shadow-sm p-4 hover:border-light-300 transition-all"
>
<div class="flex items-center justify-between mb-4 cursor-grab select-none">
<Text size="small" class="font-semibold">{$t('workflow_summary')}</Text>
<div class="flex items-center gap-1">
<IconButton
icon={mdiClose}
size="small"
variant="ghost"
color="secondary"
title="Close summary"
aria-label="Close summary"
onclick={(e: MouseEvent) => {
e.stopPropagation();
isOpen = false;
}}
/>
</div>
</div>
<div class="space-y-2">
<!-- Trigger -->
<div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-1">
<Icon icon={mdiFlashOutline} size="18" class="text-primary" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('trigger')}</span>
</div>
<p class="text-sm truncate pl-5">{getTriggerName(trigger.type)}</p>
</div>
<!-- Connector -->
<div class="flex justify-center">
<div class="w-0.5 h-3 bg-light-400"></div>
</div>
<!-- Filters -->
{#if filters.length > 0}
<div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-2">
<Icon icon={mdiFilterOutline} size="18" class="text-warning" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('filters')}</span>
</div>
<div class="space-y-1 pl-5">
{#each filters as filter, index (index)}
<div class="flex items-center gap-2">
<span
class="shrink-0 h-4 w-4 rounded-full bg-light-200 text-[10px] font-medium flex items-center justify-center"
>{index + 1}</span
>
<p class="text-sm truncate">{filter.title}</p>
</div>
{/each}
</div>
</div>
<!-- Connector -->
<div class="flex justify-center">
<div class="w-0.5 h-3 bg-light-400"></div>
</div>
{/if}
<!-- Actions -->
{#if actions.length > 0}
<div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-2">
<Icon icon={mdiPlayCircleOutline} size="18" class="text-success" />
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('actions')}</span>
</div>
<div class="space-y-1 pl-5">
{#each actions as action, index (index)}
<div class="flex items-center gap-2">
<span
class="shrink-0 h-4 w-4 rounded-full bg-light-200 text-[10px] font-medium flex items-center justify-center"
>{index + 1}</span
>
<p class="text-sm truncate">{action.title}</p>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
{:else}
<button
type="button"
class="hidden sm:flex fixed right-6 bottom-6 h-14 w-14 items-center justify-center rounded-full bg-primary text-light shadow-lg hover:bg-primary/90 transition-colors"
title={$t('workflow_summary')}
onclick={() => (isOpen = true)}
>
<Icon icon={mdiViewDashboardOutline} size="24" />
</button>
{/if}

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import { PluginTriggerType, type PluginTriggerResponseDto } from '@immich/sdk';
import { Icon, Text } from '@immich/ui';
import { mdiFaceRecognition, mdiFileUploadOutline, mdiLightningBolt } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
trigger: PluginTriggerResponseDto;
selected: boolean;
onclick: () => void;
};
let { trigger, selected, onclick }: Props = $props();
const getTriggerIcon = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return mdiFileUploadOutline;
}
case PluginTriggerType.PersonRecognized: {
return mdiFaceRecognition;
}
default: {
return mdiLightningBolt;
}
}
};
const getTriggerName = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return $t('trigger_asset_uploaded');
}
case PluginTriggerType.PersonRecognized: {
return $t('trigger_person_recognized');
}
default: {
return triggerType;
}
}
};
const getTriggerDescription = (triggerType: PluginTriggerType) => {
switch (triggerType) {
case PluginTriggerType.AssetCreate: {
return $t('trigger_asset_uploaded_description');
}
case PluginTriggerType.PersonRecognized: {
return $t('trigger_person_recognized_description');
}
default: {
return '';
}
}
};
</script>
<button
type="button"
{onclick}
class="group rounded-xl p-4 w-full text-left cursor-pointer border-2 {selected
? 'border-primary text-primary'
: 'border-light-100 hover:border-light-200 text-light-400 hover:text-light-700'}"
>
<div class="flex items-center gap-3">
<div
class="rounded-xl p-2 {selected
? 'bg-primary text-light'
: 'text-light-100 bg-light-300 group-hover:bg-light-500'}"
>
<Icon icon={getTriggerIcon(trigger.type)} size="24" />
</div>
<div class="flex-1">
<Text class="font-semibold mb-1">{getTriggerName(trigger.type)}</Text>
{#if getTriggerDescription(trigger.type)}
<Text size="small">{getTriggerDescription(trigger.type)}</Text>
{/if}
</div>
</div>
</button>

View File

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

View File

@@ -8,6 +8,7 @@ import type {
SharedLinkResponseDto,
SystemConfigDto,
UserAdminResponseDto,
WorkflowResponseDto,
} from '@immich/sdk';
export type Events = {
@@ -42,6 +43,9 @@ export type Events = {
LibraryUpdate: [LibraryResponseDto];
LibraryDelete: [{ id: string }];
WorkflowUpdate: [WorkflowResponseDto];
WorkflowDelete: [WorkflowResponseDto];
ReleaseEvent: [ReleaseEvent];
};

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import type { PluginActionResponseDto, PluginFilterResponseDto } from '@immich/sdk';
import { Modal, ModalBody, Text } from '@immich/ui';
import { mdiFilterOutline, mdiPlayCircleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
filters: PluginFilterResponseDto[];
actions: PluginActionResponseDto[];
onClose: (result?: { type: 'filter' | 'action'; item: PluginFilterResponseDto | PluginActionResponseDto }) => void;
type?: 'filter' | 'action';
};
let { filters, actions, onClose, type }: Props = $props();
type StepType = 'filter' | 'action';
const handleSelect = (type: StepType, item: PluginFilterResponseDto | PluginActionResponseDto) => {
onClose({ type, item });
};
const getModalTitle = () => {
if (type === 'filter') {
return $t('add_filter');
} else if (type === 'action') {
return $t('add_action');
} else {
return $t('add_workflow_step');
}
};
const getModalIcon = () => {
if (type === 'filter') {
return mdiFilterOutline;
} else if (type === 'action') {
return mdiPlayCircleOutline;
} else {
return false;
}
};
</script>
{#snippet stepButton(title: string, description?: string, onclick?: () => void)}
<button
type="button"
{onclick}
class="flex items-start gap-3 p-3 rounded-lg text-left bg-light-100 hover:border-primary border text-dark"
>
<div class="flex-1">
<Text color="primary" class="font-medium">{title}</Text>
{#if description}
<Text size="small" class="mt-1">{description}</Text>
{/if}
</div>
</button>
{/snippet}
<Modal title={getModalTitle()} icon={getModalIcon()} onClose={() => onClose()}>
<ModalBody>
<div class="space-y-6">
<!-- Filters Section -->
{#if filters.length > 0 && (!type || type === 'filter')}
<div class="grid grid-cols-1 gap-2">
{#each filters as filter (filter.id)}
{@render stepButton(filter.title, filter.description, () => handleSelect('filter', filter))}
{/each}
</div>
{/if}
<!-- Actions Section -->
{#if actions.length > 0 && (!type || type === 'action')}
<div class="grid grid-cols-1 gap-2">
{#each actions as action (action.id)}
{@render stepButton(action.title, action.description, () => handleSelect('action', action))}
{/each}
</div>
{/if}
</div>
</ModalBody>
</Modal>

View File

@@ -0,0 +1,108 @@
<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 { handleError } from '$lib/utils/handle-error';
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, HStack, LoadingSpinner, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
type Props = {
multiple?: boolean;
excludedIds?: string[];
onClose: (people?: PersonResponseDto[]) => void;
};
let { multiple = false, excludedIds = [], onClose }: Props = $props();
let people: PersonResponseDto[] = $state([]);
let loading = $state(true);
let searchName = $state('');
let selectedPeople: PersonResponseDto[] = $state([]);
const filteredPeople = $derived(
people
.filter((person) => !excludedIds.includes(person.id))
.filter((person) => !searchName || person.name.toLowerCase().includes(searchName.toLowerCase())),
);
onMount(async () => {
try {
loading = true;
const result = await getAllPeople({ withHidden: false });
people = result.people;
loading = false;
} catch (error) {
handleError(error, $t('get_people_error'));
}
});
const togglePerson = (person: PersonResponseDto) => {
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}
<ModalFooter>
<HStack fullWidth gap={4}>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
<Button shape="round" fullWidth onclick={handleSubmit} disabled={selectedPeople.length === 0}>
{$t('select_count', { values: { count: selectedPeople.length } })}
</Button>
</HStack>
</ModalFooter>
{/if}
</Modal>

View File

@@ -241,7 +241,7 @@ export const asQueueItem = ($t: MessageFormatter, queue: { name: QueueName }): Q
},
[QueueName.Workflow]: {
icon: mdiStateMachine,
title: $t('workflow'),
title: $t('workflows'),
},
};

View File

@@ -0,0 +1,451 @@
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
createWorkflow,
deleteWorkflow,
getAlbumInfo,
getPerson,
PluginTriggerType,
updateWorkflow,
type AlbumResponseDto,
type PersonResponseDto,
type PluginActionResponseDto,
type PluginContextType,
type PluginFilterResponseDto,
type PluginTriggerResponseDto,
type WorkflowActionItemDto,
type WorkflowFilterItemDto,
type WorkflowResponseDto,
type WorkflowUpdateDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiCodeJson, mdiDelete, mdiPause, mdiPencil, mdiPlay } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export type PickerSubType = 'album-picker' | 'people-picker';
export type PickerMetadata = AlbumResponseDto | PersonResponseDto | AlbumResponseDto[] | PersonResponseDto[];
export interface WorkflowPayload {
name: string;
description: string;
enabled: boolean;
triggerType: string;
filters: Record<string, unknown>[];
actions: Record<string, unknown>[];
}
/**
* Get filters that support the given context
*/
export const getFiltersByContext = (
availableFilters: PluginFilterResponseDto[],
context: PluginContextType,
): PluginFilterResponseDto[] => {
return availableFilters.filter((filter) => filter.supportedContexts.includes(context));
};
/**
* Get actions that support the given context
*/
export const getActionsByContext = (
availableActions: PluginActionResponseDto[],
context: PluginContextType,
): PluginActionResponseDto[] => {
return availableActions.filter((action) => action.supportedContexts.includes(context));
};
export const remapConfigsOnReorder = (
configs: Record<string, unknown>,
prefix: 'filter' | 'action',
fromIndex: number,
toIndex: number,
totalCount: number,
): Record<string, unknown> => {
const newConfigs: Record<string, unknown> = {};
// Create an array of configs in order
const configArray: unknown[] = [];
for (let i = 0; i < totalCount; i++) {
configArray.push(configs[`${prefix}_${i}`] ?? {});
}
// Move the item from fromIndex to toIndex
const [movedItem] = configArray.splice(fromIndex, 1);
configArray.splice(toIndex, 0, movedItem);
// Rebuild the configs object with new indices
for (let i = 0; i < configArray.length; i++) {
newConfigs[`${prefix}_${i}`] = configArray[i];
}
return newConfigs;
};
/**
* Remap configs when an item is removed
* Shifts all configs after the removed index down by one
*/
export const remapConfigsOnRemove = (
configs: Record<string, unknown>,
prefix: 'filter' | 'action',
removedIndex: number,
totalCount: number,
): Record<string, unknown> => {
const newConfigs: Record<string, unknown> = {};
let newIndex = 0;
for (let i = 0; i < totalCount; i++) {
if (i !== removedIndex) {
newConfigs[`${prefix}_${newIndex}`] = configs[`${prefix}_${i}`] ?? {};
newIndex++;
}
}
return newConfigs;
};
export const initializeConfigs = (
type: 'action' | 'filter',
workflow: WorkflowResponseDto,
): Record<string, unknown> => {
const configs: Record<string, unknown> = {};
if (workflow.filters && type == 'filter') {
for (const [index, workflowFilter] of workflow.filters.entries()) {
configs[`filter_${index}`] = workflowFilter.filterConfig ?? {};
}
}
if (workflow.actions && type == 'action') {
for (const [index, workflowAction] of workflow.actions.entries()) {
configs[`action_${index}`] = workflowAction.actionConfig ?? {};
}
}
return configs;
};
/**
* Build workflow payload from current state
* Uses index-based keys to support multiple filters/actions of the same type
*/
export const buildWorkflowPayload = (
name: string,
description: string,
enabled: boolean,
triggerType: string,
orderedFilters: PluginFilterResponseDto[],
orderedActions: PluginActionResponseDto[],
filterConfigs: Record<string, unknown>,
actionConfigs: Record<string, unknown>,
): WorkflowPayload => {
const filters = orderedFilters.map((filter, index) => ({
[filter.methodName]: filterConfigs[`filter_${index}`] ?? {},
}));
const actions = orderedActions.map((action, index) => ({
[action.methodName]: actionConfigs[`action_${index}`] ?? {},
}));
return {
name,
description,
enabled,
triggerType,
filters,
actions,
};
};
export const parseWorkflowJson = (
jsonString: string,
availableTriggers: PluginTriggerResponseDto[],
availableFilters: PluginFilterResponseDto[],
availableActions: PluginActionResponseDto[],
): {
success: boolean;
error?: string;
data?: {
name: string;
description: string;
enabled: boolean;
trigger?: PluginTriggerResponseDto;
filters: PluginFilterResponseDto[];
actions: PluginActionResponseDto[];
filterConfigs: Record<string, unknown>;
actionConfigs: Record<string, unknown>;
};
} => {
try {
const parsed = JSON.parse(jsonString);
const trigger = availableTriggers.find((t) => t.type === parsed.triggerType);
const filters: PluginFilterResponseDto[] = [];
const filterConfigs: Record<string, unknown> = {};
if (Array.isArray(parsed.filters)) {
for (const [index, filterObj] of parsed.filters.entries()) {
const methodName = Object.keys(filterObj)[0];
const filter = availableFilters.find((f) => f.methodName === methodName);
if (filter) {
filters.push(filter);
filterConfigs[`filter_${index}`] = (filterObj as Record<string, unknown>)[methodName];
}
}
}
const actions: PluginActionResponseDto[] = [];
const actionConfigs: Record<string, unknown> = {};
if (Array.isArray(parsed.actions)) {
for (const [index, actionObj] of parsed.actions.entries()) {
const methodName = Object.keys(actionObj)[0];
const action = availableActions.find((a) => a.methodName === methodName);
if (action) {
actions.push(action);
actionConfigs[`action_${index}`] = (actionObj as Record<string, unknown>)[methodName];
}
}
}
return {
success: true,
data: {
name: parsed.name ?? '',
description: parsed.description ?? '',
enabled: parsed.enabled ?? false,
trigger,
filters,
actions,
filterConfigs,
actionConfigs,
},
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Invalid JSON',
};
}
};
export const hasWorkflowChanged = (
previousWorkflow: WorkflowResponseDto,
enabled: boolean,
name: string,
description: string,
triggerType: string,
orderedFilters: PluginFilterResponseDto[],
orderedActions: PluginActionResponseDto[],
filterConfigs: Record<string, unknown>,
actionConfigs: Record<string, unknown>,
): boolean => {
if (enabled !== previousWorkflow.enabled) {
return true;
}
if (name !== (previousWorkflow.name ?? '') || description !== (previousWorkflow.description ?? '')) {
return true;
}
if (triggerType !== previousWorkflow.triggerType) {
return true;
}
const previousFilterIds = previousWorkflow.filters?.map((f) => f.pluginFilterId) ?? [];
const currentFilterIds = orderedFilters.map((f) => f.id);
if (JSON.stringify(previousFilterIds) !== JSON.stringify(currentFilterIds)) {
return true;
}
const previousActionIds = previousWorkflow.actions?.map((a) => a.pluginActionId) ?? [];
const currentActionIds = orderedActions.map((a) => a.id);
if (JSON.stringify(previousActionIds) !== JSON.stringify(currentActionIds)) {
return true;
}
const previousFilterConfigs: Record<string, unknown> = {};
for (const [index, wf] of (previousWorkflow.filters ?? []).entries()) {
previousFilterConfigs[`filter_${index}`] = wf.filterConfig ?? {};
}
if (JSON.stringify(previousFilterConfigs) !== JSON.stringify(filterConfigs)) {
return true;
}
const previousActionConfigs: Record<string, unknown> = {};
for (const [index, wa] of (previousWorkflow.actions ?? []).entries()) {
previousActionConfigs[`action_${index}`] = wa.actionConfig ?? {};
}
if (JSON.stringify(previousActionConfigs) !== JSON.stringify(actionConfigs)) {
return true;
}
return false;
};
export const handleUpdateWorkflow = async (
workflowId: string,
name: string,
description: string,
enabled: boolean,
triggerType: PluginTriggerType,
orderedFilters: PluginFilterResponseDto[],
orderedActions: PluginActionResponseDto[],
filterConfigs: Record<string, unknown>,
actionConfigs: Record<string, unknown>,
): Promise<WorkflowResponseDto> => {
const filters = orderedFilters.map((filter, index) => ({
pluginFilterId: filter.id,
filterConfig: filterConfigs[`filter_${index}`] ?? {},
})) as WorkflowFilterItemDto[];
const actions = orderedActions.map((action, index) => ({
pluginActionId: action.id,
actionConfig: actionConfigs[`action_${index}`] ?? {},
})) as WorkflowActionItemDto[];
const updateDto: WorkflowUpdateDto = {
name,
description,
enabled,
filters,
actions,
triggerType,
};
return updateWorkflow({ id: workflowId, workflowUpdateDto: updateDto });
};
export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowResponseDto) => {
const ToggleEnabled: ActionItem = {
title: workflow.enabled ? $t('disable') : $t('enable'),
icon: workflow.enabled ? mdiPause : mdiPlay,
color: workflow.enabled ? 'danger' : 'primary',
onAction: async () => {
await handleToggleWorkflowEnabled(workflow);
},
};
const Edit: ActionItem = {
title: $t('edit'),
icon: mdiPencil,
onAction: () => handleNavigateToWorkflow(workflow),
};
const Delete: ActionItem = {
title: $t('delete'),
icon: mdiDelete,
color: 'danger',
onAction: async () => {
await handleDeleteWorkflow(workflow);
},
};
return { ToggleEnabled, Edit, Delete };
};
export const getWorkflowShowSchemaAction = (
$t: MessageFormatter,
isExpanded: boolean,
onToggle: () => void,
): ActionItem => ({
title: isExpanded ? $t('hide_schema') : $t('show_schema'),
icon: mdiCodeJson,
onAction: onToggle,
});
export const handleCreateWorkflow = async (): Promise<WorkflowResponseDto | undefined> => {
const $t = await getFormatter();
try {
const workflow = await createWorkflow({
workflowCreateDto: {
name: $t('untitled_workflow'),
triggerType: PluginTriggerType.AssetCreate,
filters: [],
actions: [],
enabled: false,
},
});
await goto(`${AppRoute.WORKFLOWS}/${workflow.id}`);
return workflow;
} catch (error) {
handleError(error, $t('errors.unable_to_create'));
}
};
export const handleToggleWorkflowEnabled = async (
workflow: WorkflowResponseDto,
): Promise<WorkflowResponseDto | undefined> => {
const $t = await getFormatter();
try {
const updated = await updateWorkflow({
id: workflow.id,
workflowUpdateDto: { enabled: !workflow.enabled },
});
eventManager.emit('WorkflowUpdate', updated);
toastManager.success($t('workflow_updated'));
return updated;
} catch (error) {
handleError(error, $t('errors.unable_to_update_workflow'));
}
};
export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promise<boolean> => {
const $t = await getFormatter();
const confirmed = await modalManager.showDialog({
prompt: $t('workflow_delete_prompt'),
confirmColor: 'danger',
});
if (!confirmed) {
return false;
}
try {
await deleteWorkflow({ id: workflow.id });
eventManager.emit('WorkflowDelete', workflow);
toastManager.success($t('workflow_deleted'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_workflow'));
return false;
}
};
export const handleNavigateToWorkflow = async (workflow: WorkflowResponseDto): Promise<void> => {
await goto(`${AppRoute.WORKFLOWS}/${workflow.id}`);
};
export const fetchPickerMetadata = async (
value: string | string[] | undefined,
subType: PickerSubType,
): Promise<PickerMetadata | undefined> => {
if (!value) {
return undefined;
}
const isAlbum = subType === 'album-picker';
try {
if (Array.isArray(value) && value.length > 0) {
// Multiple selection
return isAlbum
? await Promise.all(value.map((id) => getAlbumInfo({ id })))
: await Promise.all(value.map((id) => getPerson({ id })));
} else if (typeof value === 'string' && value) {
// Single selection
return isAlbum ? await getAlbumInfo({ id: value }) : await getPerson({ id: value });
}
} catch (error) {
console.error(`Failed to fetch picker metadata:`, error);
}
return undefined;
};

View File

@@ -162,7 +162,7 @@ export const getQueueName = derived(t, ($t) => {
[QueueName.Notifications]: $t('notifications'),
[QueueName.BackupDatabase]: $t('admin.backup_database'),
[QueueName.Ocr]: $t('admin.machine_learning_ocr'),
[QueueName.Workflow]: $t('workflow'),
[QueueName.Workflow]: $t('workflows'),
};
return names[name];

View File

@@ -0,0 +1,128 @@
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 getComponentDefaultValue = (component: ComponentConfig): unknown => {
if (component.defaultValue !== undefined) {
return component.defaultValue;
}
if (component.type === 'multiselect' || (component.type === 'text' && component.subType === 'people-picker')) {
return [];
}
if (component.type === 'switch') {
return false;
}
return '';
};
export const getComponentFromSchema = (schema: object | null): Record<string, ComponentConfig> | null => {
if (!schema || !isJSONSchema(schema) || !schema.properties) {
return null;
}
const components: Record<string, ComponentConfig> = {};
const requiredFields = schema.required || [];
for (const [propertyName, property] of Object.entries(schema.properties)) {
const config = getComponentForProperty(property, propertyName);
if (config) {
config.required = requiredFields.includes(propertyName);
components[propertyName] = config;
}
}
return Object.keys(components).length > 0 ? components : null;
};
function isJSONSchema(obj: object): obj is JSONSchema {
return 'properties' in obj || 'type' in obj;
}
function getComponentForProperty(property: JSONSchemaProperty, propertyName: string): ComponentConfig | null {
const { type, title, enum: enumValues, description, default: defaultValue, items } = property;
const config: ComponentConfig = {
type: 'text',
label: formatLabel(propertyName),
description,
defaultValue,
title,
};
if (enumValues && enumValues.length > 0) {
config.type = 'select';
config.options = enumValues.map((value: unknown) => ({
label: formatLabel(String(value)),
value: value as string | number | boolean,
}));
return config;
}
if (type === 'array' && items?.enum && items.enum.length > 0) {
config.type = 'multiselect';
config.subType = items.subType;
config.options = items.enum.map((value: unknown) => ({
label: formatLabel(String(value)),
value: value as string | number | boolean,
}));
return config;
}
if (type === 'boolean') {
config.type = 'switch';
return config;
}
if (type === 'string') {
config.type = 'text';
config.subType = property.subType;
config.placeholder = description;
return config;
}
if (type === 'array') {
config.type = 'multiselect';
config.subType = property.subType;
return config;
}
return config;
}
export function formatLabel(propertyName: string): string {
return propertyName
.replaceAll(/([A-Z])/g, ' $1')
.replaceAll('_', ' ')
.replace(/^./, (str) => str.toUpperCase())
.trim();
}

View File

@@ -0,0 +1,281 @@
<script lang="ts">
import emptyWorkflows from '$lib/assets/empty-workflows.svg';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import {
getWorkflowActions,
getWorkflowShowSchemaAction,
handleCreateWorkflow,
type WorkflowPayload,
} from '$lib/services/workflow.service';
import type { PluginFilterResponseDto, WorkflowResponseDto } from '@immich/sdk';
import {
Button,
Card,
CardBody,
CardDescription,
CardHeader,
CardTitle,
CodeBlock,
HStack,
Icon,
IconButton,
MenuItemType,
menuManager,
Text,
VStack,
} from '@immich/ui';
import { mdiClose, mdiDotsVertical, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
let { data }: Props = $props();
let workflows = $state<WorkflowResponseDto[]>(data.workflows);
const expandedWorkflows = new SvelteSet<string>();
const pluginFilterLookup = new SvelteMap<string, PluginFilterResponseDto>();
const pluginActionLookup = new SvelteMap<string, PluginFilterResponseDto>();
for (const plugin of data.plugins) {
for (const filter of plugin.filters ?? []) {
pluginFilterLookup.set(filter.id, { ...filter });
}
for (const action of plugin.actions ?? []) {
pluginActionLookup.set(action.id, { ...action });
}
}
const toggleShowingSchema = (id: string) => {
if (expandedWorkflows.has(id)) {
expandedWorkflows.delete(id);
} else {
expandedWorkflows.add(id);
}
};
const constructPayload = (workflow: WorkflowResponseDto): WorkflowPayload => {
const orderedFilters = [...(workflow.filters ?? [])].sort((a, b) => a.order - b.order);
const orderedActions = [...(workflow.actions ?? [])].sort((a, b) => a.order - b.order);
return {
name: workflow.name ?? '',
description: workflow.description ?? '',
enabled: workflow.enabled,
triggerType: workflow.triggerType,
filters: orderedFilters.map((filter) => {
const meta = pluginFilterLookup.get(filter.pluginFilterId);
const key = meta?.methodName ?? filter.pluginFilterId;
return {
[key]: filter.filterConfig ?? {},
};
}),
actions: orderedActions.map((action) => {
const meta = pluginActionLookup.get(action.pluginActionId);
const key = meta?.methodName ?? action.pluginActionId;
return {
[key]: action.actionConfig ?? {},
};
}),
};
};
const getJson = (workflow: WorkflowResponseDto) => JSON.stringify(constructPayload(workflow), null, 2);
const onWorkflowUpdate = (updatedWorkflow: WorkflowResponseDto) => {
workflows = workflows.map((currentWorkflow) =>
currentWorkflow.id === updatedWorkflow.id ? updatedWorkflow : currentWorkflow,
);
};
const onWorkflowDelete = (deletedWorkflow: WorkflowResponseDto) => {
workflows = workflows.filter((currentWorkflow) => currentWorkflow.id !== deletedWorkflow.id);
};
const getFilterLabel = (filterId: string) => {
const meta = pluginFilterLookup.get(filterId);
return meta?.title ?? $t('filter');
};
const getActionLabel = (actionId: string) => {
const meta = pluginActionLookup.get(actionId);
return meta?.title ?? $t('action');
};
const getTriggerLabel = (triggerType: string) => {
const labels: Record<string, string> = {
AssetCreate: $t('asset_created'),
PersonRecognized: $t('person_recognized'),
};
return labels[triggerType] || triggerType;
};
const formatTimestamp = (createdAt: string) =>
new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(createdAt));
const showWorkflowMenu = (event: MouseEvent, workflow: WorkflowResponseDto) => {
const { ToggleEnabled, Edit, Delete } = getWorkflowActions($t, workflow);
void menuManager.show({
target: event.currentTarget as HTMLElement,
position: 'top-left',
items: [
ToggleEnabled,
Edit,
getWorkflowShowSchemaAction($t, expandedWorkflows.has(workflow.id), () => toggleShowingSchema(workflow.id)),
MenuItemType.Divider,
Delete,
],
});
};
</script>
<OnEvents {onWorkflowUpdate} {onWorkflowDelete} />
{#snippet chipItem(title: string)}
<span class="rounded-xl border border-gray-200/80 px-3 py-1.5 text-sm dark:border-gray-600 bg-light">
<span class="font-medium text-dark">{title}</span>
</span>
{/snippet}
<UserPageLayout title={data.meta.title} scrollbar={false}>
{#snippet buttons()}
<HStack gap={1}>
<Button size="small" variant="ghost" color="secondary" 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}
<EmptyPlaceholder
title={$t('create_first_workflow')}
text={$t('workflows_help_text')}
onClick={handleCreateWorkflow}
src={emptyWorkflows}
class="mt-10 mx-auto"
/>
{:else}
<div class="my-6 grid gap-6">
{#each workflows as workflow (workflow.id)}
<Card class="border border-light-200">
<CardHeader
class={`flex flex-row px-8 py-6 gap-4 sm:items-center sm:gap-6 ${
workflow.enabled
? 'bg-linear-to-r from-green-50 to-white dark:from-green-800/50 dark:to-green-950/45'
: 'bg-neutral-50 dark:bg-neutral-900'
}`}
>
<div class="flex-1">
<div class="flex items-center gap-3">
<span
class="rounded-full {workflow.enabled ? 'h-3 w-3 bg-success' : 'h-3 w-3 rounded-full bg-muted'}"
></span>
<CardTitle>{workflow.name}</CardTitle>
</div>
<CardDescription class="mt-1 text-sm">
{workflow.description || $t('workflows_help_text')}
</CardDescription>
</div>
<div class="flex items-center gap-4">
<div class="text-right hidden sm:block">
<Text size="tiny">{$t('created_at')}</Text>
<Text size="small" class="font-medium">
{formatTimestamp(workflow.createdAt)}
</Text>
</div>
<IconButton
shape="round"
variant="ghost"
color="secondary"
icon={mdiDotsVertical}
aria-label={$t('menu')}
onclick={(event: MouseEvent) => showWorkflowMenu(event, workflow)}
/>
</div>
</CardHeader>
<CardBody class="space-y-6">
<div class="grid gap-4 md:grid-cols-3">
<!-- Trigger Section -->
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3">
<Text class="text-xs font-semibold uppercase tracking-widest" color="muted">{$t('trigger')}</Text>
</div>
{@render chipItem(getTriggerLabel(workflow.triggerType))}
</div>
<!-- Filters Section -->
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3">
<Text class="text-xs font-semibold uppercase tracking-widest" color="muted">{$t('filters')}</Text>
</div>
<div class="flex flex-wrap gap-2">
{#if workflow.filters.length === 0}
<span class="text-sm text-light-600">
{$t('no_filters_added')}
</span>
{:else}
{#each workflow.filters as workflowFilter (workflowFilter.id)}
{@render chipItem(getFilterLabel(workflowFilter.pluginFilterId))}
{/each}
{/if}
</div>
</div>
<!-- Actions Section -->
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3">
<Text class="text-xs font-semibold uppercase tracking-widest" color="muted">{$t('actions')}</Text>
</div>
<div>
{#if workflow.actions.length === 0}
<span class="text-sm text-light-600">
{$t('no_actions_added')}
</span>
{:else}
<div class="flex flex-wrap gap-2">
{#each workflow.actions as workflowAction (workflowAction.id)}
{@render chipItem(getActionLabel(workflowAction.pluginActionId))}
{/each}
</div>
{/if}
</div>
</div>
</div>
{#if expandedWorkflows.has(workflow.id)}
<VStack gap={2} class="w-full rounded-2xl border bg-light-50 p-4 border-light-200 ">
<CodeBlock code={getJson(workflow)} lineNumbers />
<Button
leadingIcon={mdiClose}
fullWidth
variant="ghost"
color="secondary"
onclick={() => toggleShowingSchema(workflow.id)}>{$t('close')}</Button
>
</VStack>
{/if}
</CardBody>
</Card>
{/each}
</div>
{/if}
</section>
</section>
</UserPageLayout>

View File

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

View File

@@ -0,0 +1,619 @@
<script lang="ts">
import { beforeNavigate, goto } from '$app/navigation';
import { dragAndDrop } from '$lib/attachments/drag-and-drop.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import SchemaFormFields from '$lib/components/workflows/SchemaFormFields.svelte';
import WorkflowCardConnector from '$lib/components/workflows/WorkflowCardConnector.svelte';
import WorkflowJsonEditor from '$lib/components/workflows/WorkflowJsonEditor.svelte';
import WorkflowSummarySidebar from '$lib/components/workflows/WorkflowSummary.svelte';
import WorkflowTriggerCard from '$lib/components/workflows/WorkflowTriggerCard.svelte';
import { AppRoute } from '$lib/constants';
import AddWorkflowStepModal from '$lib/modals/AddWorkflowStepModal.svelte';
import {
buildWorkflowPayload,
getActionsByContext,
getFiltersByContext,
handleUpdateWorkflow,
hasWorkflowChanged,
initializeConfigs,
parseWorkflowJson,
remapConfigsOnRemove,
remapConfigsOnReorder,
type WorkflowPayload,
} from '$lib/services/workflow.service';
import { handleError } from '$lib/utils/handle-error';
import type { PluginActionResponseDto, PluginFilterResponseDto, PluginTriggerResponseDto } from '@immich/sdk';
import {
Button,
Card,
CardBody,
CardDescription,
CardHeader,
CardTitle,
Container,
Field,
HStack,
Icon,
Input,
Switch,
Text,
Textarea,
VStack,
modalManager,
toastManager,
} from '@immich/ui';
import {
mdiArrowLeft,
mdiCodeJson,
mdiContentSave,
mdiFilterOutline,
mdiFlashOutline,
mdiInformationOutline,
mdiPlayCircleOutline,
mdiPlus,
mdiTrashCanOutline,
mdiViewDashboard,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
let { data }: Props = $props();
const triggers = data.triggers;
const filters = data.plugins.flatMap((plugin) => plugin.filters);
const actions = data.plugins.flatMap((plugin) => plugin.actions);
let previousWorkflow = data.workflow;
let editWorkflow = $state(data.workflow);
let viewMode: 'visual' | 'json' = $state('visual');
let name: string = $derived(editWorkflow.name ?? '');
let description: string = $derived(editWorkflow.description ?? '');
let selectedTrigger = $state(triggers.find((t) => t.type === editWorkflow.triggerType) ?? triggers[0]);
let triggerType = $derived(selectedTrigger.type);
let supportFilters = $derived(getFiltersByContext(filters, selectedTrigger.contextType));
let supportActions = $derived(getActionsByContext(actions, selectedTrigger.contextType));
let selectedFilters: PluginFilterResponseDto[] = $derived(
(editWorkflow.filters ?? []).flatMap((workflowFilter) =>
supportFilters.filter((supportedFilter) => supportedFilter.id === workflowFilter.pluginFilterId),
),
);
let selectedActions: PluginActionResponseDto[] = $derived(
(editWorkflow.actions ?? []).flatMap((workflowAction) =>
supportActions.filter((supportedAction) => supportedAction.id === workflowAction.pluginActionId),
),
);
let filterConfigs: Record<string, unknown> = $derived(initializeConfigs('filter', editWorkflow));
let actionConfigs: Record<string, unknown> = $derived(initializeConfigs('action', editWorkflow));
$effect(() => {
editWorkflow.triggerType = triggerType;
});
// Clear filters and actions when trigger changes (context changes)
let previousContext = $state<string | undefined>(undefined);
$effect(() => {
const currentContext = selectedTrigger.contextType;
if (previousContext !== undefined && previousContext !== currentContext) {
selectedFilters = [];
selectedActions = [];
filterConfigs = {};
actionConfigs = {};
}
previousContext = currentContext;
});
const updateWorkflow = async () => {
try {
const updated = await handleUpdateWorkflow(
editWorkflow.id,
name,
description,
editWorkflow.enabled,
triggerType,
selectedFilters,
selectedActions,
filterConfigs,
actionConfigs,
);
previousWorkflow = updated;
editWorkflow = updated;
toastManager.success($t('workflow_update_success'), {
closable: true,
});
} catch (error) {
handleError(error, 'Failed to update workflow');
}
};
const jsonContent = $derived(
buildWorkflowPayload(
name,
description,
editWorkflow.enabled,
triggerType,
selectedFilters,
selectedActions,
filterConfigs,
actionConfigs,
),
);
let jsonEditorContent: WorkflowPayload = $state({
name: '',
description: '',
enabled: false,
triggerType: '',
filters: [],
actions: [],
});
const syncFromJson = () => {
const result = parseWorkflowJson(JSON.stringify(jsonEditorContent), triggers, filters, actions);
if (!result.success) {
return;
}
if (result.data) {
name = result.data.name;
description = result.data.description;
editWorkflow.enabled = result.data.enabled;
if (result.data.trigger) {
selectedTrigger = result.data.trigger;
}
selectedFilters = result.data.filters;
selectedActions = result.data.actions;
filterConfigs = result.data.filterConfigs;
actionConfigs = result.data.actionConfigs;
}
};
let hasChanges: boolean = $derived(
hasWorkflowChanged(
previousWorkflow,
editWorkflow.enabled,
name,
description,
triggerType,
selectedFilters,
selectedActions,
filterConfigs,
actionConfigs,
),
);
let draggedFilterIndex: number | null = $state(null);
let draggedActionIndex: number | null = $state(null);
let dragOverFilterIndex: number | null = $state(null);
let dragOverActionIndex: number | null = $state(null);
const handleFilterDragStart = (index: number) => {
draggedFilterIndex = index;
};
const handleFilterDragEnter = (index: number) => {
if (draggedFilterIndex !== null && draggedFilterIndex !== index) {
dragOverFilterIndex = index;
}
};
const handleFilterDrop = (e: DragEvent, index: number) => {
e.preventDefault();
if (draggedFilterIndex === null || draggedFilterIndex === index) {
return;
}
// Remap configs to follow the new order
filterConfigs = remapConfigsOnReorder(filterConfigs, 'filter', draggedFilterIndex, index, selectedFilters.length);
const newFilters = [...selectedFilters];
const [draggedItem] = newFilters.splice(draggedFilterIndex, 1);
newFilters.splice(index, 0, draggedItem);
selectedFilters = newFilters;
};
const handleFilterDragEnd = () => {
draggedFilterIndex = null;
dragOverFilterIndex = null;
};
const handleActionDragStart = (index: number) => {
draggedActionIndex = index;
};
const handleActionDragEnter = (index: number) => {
if (draggedActionIndex !== null && draggedActionIndex !== index) {
dragOverActionIndex = index;
}
};
const handleActionDrop = (e: DragEvent, index: number) => {
e.preventDefault();
if (draggedActionIndex === null || draggedActionIndex === index) {
return;
}
actionConfigs = remapConfigsOnReorder(actionConfigs, 'action', draggedActionIndex, index, selectedActions.length);
const newActions = [...selectedActions];
const [draggedItem] = newActions.splice(draggedActionIndex, 1);
newActions.splice(index, 0, draggedItem);
selectedActions = newActions;
};
const handleActionDragEnd = () => {
draggedActionIndex = null;
dragOverActionIndex = null;
};
const handleAddStep = async (type: 'action' | 'filter') => {
const result = await modalManager.show(AddWorkflowStepModal, {
filters: supportFilters,
actions: supportActions,
type,
});
if (result) {
if (result.type === 'filter') {
selectedFilters = [...selectedFilters, result.item as PluginFilterResponseDto];
} else if (result.type === 'action') {
selectedActions = [...selectedActions, result.item as PluginActionResponseDto];
}
}
};
const handleRemoveFilter = (index: number) => {
filterConfigs = remapConfigsOnRemove(filterConfigs, 'filter', index, selectedFilters.length);
selectedFilters = selectedFilters.filter((_, i) => i !== index);
};
const handleRemoveAction = (index: number) => {
actionConfigs = remapConfigsOnRemove(actionConfigs, 'action', index, selectedActions.length);
selectedActions = selectedActions.filter((_, i) => i !== index);
};
const handleTriggerChange = async (newTrigger: PluginTriggerResponseDto) => {
const confirmed = await modalManager.showDialog({
prompt: $t('change_trigger_prompt'),
title: $t('change_trigger'),
confirmColor: 'primary',
});
if (!confirmed) {
return;
}
selectedTrigger = newTrigger;
};
let allowNavigation = $state(false);
beforeNavigate(({ cancel, to }) => {
if (hasChanges && !allowNavigation) {
cancel();
modalManager
.showDialog({
prompt: $t('workflow_navigation_prompt'),
confirmColor: 'primary',
})
.then((isConfirmed) => {
if (isConfirmed && to) {
allowNavigation = true;
void goto(to.url);
}
})
.catch(() => {});
}
});
</script>
{#snippet cardOrder(index: number)}
<div class="h-8 w-8 rounded-lg flex place-items-center place-content-center shrink-0 border bg-light-50">
<Text size="small" class="font-mono font-bold">
{index + 1}
</Text>
</div>
{/snippet}
{#snippet stepSeparator()}
<div class="relative flex justify-center py-4">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t-2 border-dashed border-light-200"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-white dark:bg-black px-2 font-semibold text-light-500">THEN</span>
</div>
</div>
{/snippet}
{#snippet emptyCreateButton(title: string, description: string, onclick: () => Promise<void>)}
<button
type="button"
{onclick}
class="w-full p-8 rounded-lg border-2 border-dashed hover:border-light-400 hover:bg-light-50 transition-all flex flex-col items-center justify-center gap-2"
>
<Icon icon={mdiPlus} size="32" />
<Text size="small" class="font-medium">{title}</Text>
<Text size="tiny">{description}</Text>
</button>
{/snippet}
<svelte:head>
<title>{data.meta.title} - Immich</title>
</svelte:head>
<main class="pt-24 immich-scrollbar">
<Container size="medium" class="p-4" center>
{#if viewMode === 'json'}
<WorkflowJsonEditor
jsonContent={jsonEditorContent}
onApply={syncFromJson}
onContentChange={(content) => (jsonEditorContent = content)}
/>
{:else}
<VStack gap={0}>
<Card expandable>
<CardHeader>
<div class="flex place-items-start gap-3">
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
<div class="flex flex-col">
<CardTitle>
{$t('workflow_info')}
</CardTitle>
</div>
</div>
</CardHeader>
<CardBody>
<VStack gap={4}>
<div
class="relative overflow-hidden border p-4 w-full rounded-xl"
class:bg-primary-50={editWorkflow.enabled}
>
<Field
label={editWorkflow.enabled ? $t('enabled') : $t('disabled')}
for="workflow-enabled"
color={editWorkflow.enabled ? 'primary' : 'secondary'}
>
<Switch id="workflow-enabled" bind:checked={editWorkflow.enabled} />
</Field>
</div>
<Field label={$t('name')} for="workflow-name" required>
<Input id="workflow-name" placeholder={$t('workflow_name')} bind:value={name} />
</Field>
<Field label={$t('description')} for="workflow-description">
<Textarea
id="workflow-description"
grow
placeholder={$t('workflow_description')}
bind:value={description}
/>
</Field>
</VStack>
</CardBody>
</Card>
<div class="my-10 h-px w-[98%] bg-light-200"></div>
<Card expandable>
<CardHeader class="bg-primary-50">
<div class="flex items-start gap-3">
<Icon icon={mdiFlashOutline} size="20" class="mt-1 text-primary" />
<div class="flex flex-col">
<CardTitle class="text-left text-primary">{$t('trigger')}</CardTitle>
<CardDescription>{$t('trigger_description')}</CardDescription>
</div>
</div>
</CardHeader>
<CardBody>
<div class="grid grid-cols-2 gap-4">
{#each triggers as trigger (trigger.type)}
<WorkflowTriggerCard
{trigger}
selected={selectedTrigger.type === trigger.type}
onclick={() => handleTriggerChange(trigger)}
/>
{/each}
</div>
</CardBody>
</Card>
<WorkflowCardConnector />
<Card expandable>
<CardHeader class="bg-warning-50">
<div class="flex items-start gap-3">
<Icon icon={mdiFilterOutline} size="20" class="mt-1 text-warning" />
<div class="flex flex-col">
<CardTitle class="text-left text-warning">{$t('filter')}</CardTitle>
<CardDescription>{$t('filter_description')}</CardDescription>
</div>
</div>
</CardHeader>
<CardBody>
{#if selectedFilters.length === 0}
{@render emptyCreateButton($t('add_filter'), $t('add_filter_description'), () => handleAddStep('filter'))}
{:else}
{#each selectedFilters as filter, index (index)}
{#if index > 0}
{@render stepSeparator()}
{/if}
<div
{@attach dragAndDrop({
index,
onDragStart: handleFilterDragStart,
onDragEnter: handleFilterDragEnter,
onDrop: handleFilterDrop,
onDragEnd: handleFilterDragEnd,
isDragging: draggedFilterIndex === index,
isDragOver: dragOverFilterIndex === index,
})}
class="mb-4 cursor-move rounded-2xl border-2 p-4 transition-all bg-light-50 border-dashed hover:border-light-300"
>
<div class="flex items-start gap-4">
{@render cardOrder(index)}
<div class="flex-1">
<h1 class="font-bold text-lg mb-3">{filter.title}</h1>
<SchemaFormFields
schema={filter.schema}
bind:config={filterConfigs}
configKey={`filter_${index}`}
/>
</div>
<div class="flex flex-col gap-2">
<Button
size="medium"
variant="ghost"
color="danger"
onclick={() => handleRemoveFilter(index)}
leadingIcon={mdiTrashCanOutline}
/>
</div>
</div>
</div>
{/each}
<Button
size="small"
fullWidth
variant="ghost"
leadingIcon={mdiPlus}
onclick={() => handleAddStep('filter')}
>
{$t('add_filter')}
</Button>
{/if}
</CardBody>
</Card>
<WorkflowCardConnector />
<Card expandable expanded>
<CardHeader class="bg-success-50">
<div class="flex items-start gap-3">
<Icon icon={mdiPlayCircleOutline} size="20" class="mt-1 text-success" />
<div class="flex flex-col">
<CardTitle class="text-left text-success">{$t('action')}</CardTitle>
<CardDescription>{$t('action_description')}</CardDescription>
</div>
</div>
</CardHeader>
<CardBody>
{#if selectedActions.length === 0}
{@render emptyCreateButton($t('add_action'), $t('add_action_description'), () => handleAddStep('action'))}
{:else}
{#each selectedActions as action, index (index)}
{#if index > 0}
{@render stepSeparator()}
{/if}
<div
{@attach dragAndDrop({
index,
onDragStart: handleActionDragStart,
onDragEnter: handleActionDragEnter,
onDrop: handleActionDrop,
onDragEnd: handleActionDragEnd,
isDragging: draggedActionIndex === index,
isDragOver: dragOverActionIndex === index,
})}
class="mb-4 cursor-move rounded-2xl border-2 p-4 transition-all bg-light-50 border-dashed hover:border-light-300"
>
<div class="flex items-start gap-4">
{@render cardOrder(index)}
<div class="flex-1">
<h1 class="font-bold text-lg mb-3">{action.title}</h1>
<SchemaFormFields
schema={action.schema}
bind:config={actionConfigs}
configKey={`action_${index}`}
/>
</div>
<div class="flex flex-col gap-2">
<Button
size="medium"
variant="ghost"
color="danger"
onclick={() => handleRemoveAction(index)}
leadingIcon={mdiTrashCanOutline}
/>
</div>
</div>
</div>
{/each}
<Button
size="small"
fullWidth
variant="ghost"
leadingIcon={mdiPlus}
onclick={() => handleAddStep('action')}
>
{$t('add_action')}
</Button>
{/if}
</CardBody>
</Card>
</VStack>
{/if}
</Container>
<WorkflowSummarySidebar trigger={selectedTrigger} filters={selectedFilters} actions={selectedActions} />
</main>
<ControlAppBar onClose={() => goto(AppRoute.WORKFLOWS)} backIcon={mdiArrowLeft} tailwindClasses="fixed! top-0! w-full">
{#snippet leading()}
<Text>{data.meta.title}</Text>
{/snippet}
{#snippet trailing()}
<HStack gap={4}>
<HStack gap={1} class="border rounded-lg p-1 border-light-300">
<Button
size="small"
variant={viewMode === 'visual' ? 'outline' : 'ghost'}
color={viewMode === 'visual' ? 'primary' : 'secondary'}
leadingIcon={mdiViewDashboard}
onclick={() => (viewMode = 'visual')}
>
{$t('visual')}
</Button>
<Button
size="small"
variant={viewMode === 'json' ? 'outline' : 'ghost'}
color={viewMode === 'json' ? 'primary' : 'secondary'}
leadingIcon={mdiCodeJson}
onclick={() => {
viewMode = 'json';
jsonEditorContent = jsonContent;
}}
>
JSON
</Button>
</HStack>
<Button leadingIcon={mdiContentSave} size="small" color="primary" onclick={updateWorkflow} disabled={!hasChanges}>
{$t('save')}
</Button>
</HStack>
{/snippet}
</ControlAppBar>

View File

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