diff --git a/.devcontainer/server/container-compose-overrides.yml b/.devcontainer/server/container-compose-overrides.yml index 5c312efd07..db20390255 100644 --- a/.devcontainer/server/container-compose-overrides.yml +++ b/.devcontainer/server/container-compose-overrides.yml @@ -16,7 +16,7 @@ services: - ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data - /etc/localtime:/etc/localtime:ro - pnpm_store_server:/buildcache/pnpm-store - - ../plugins:/build/corePlugin + - ../packages/plugin-core:/build/plugins/immich-plugin-core immich-web: env_file: !reset [] immich-machine-learning: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1cad2b0023..2c15354116 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -90,6 +90,8 @@ jobs: - name: Run formatter run: pnpm format if: ${{ !cancelled() }} + - name: Build app + run: pnpm run --filter immich --filter @immich/plugin-sdk build - name: Run tsc run: pnpm check if: ${{ !cancelled() }} @@ -394,6 +396,8 @@ jobs: cache-dependency-path: '**/pnpm-lock.yaml' - name: Run pnpm install run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile + - name: Build plugin + run: pnpm run --filter @immich/plugin-sdk --filter @immich/plugin-core build - name: Run medium tests run: pnpm test:medium if: ${{ !cancelled() }} @@ -722,7 +726,7 @@ jobs: - name: Install server dependencies run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile - name: Build the app - run: pnpm --filter immich build + run: pnpm --filter immich --filter @immich/plugin-sdk build - name: Run API generation run: ./bin/generate-open-api.sh working-directory: open-api @@ -784,7 +788,7 @@ jobs: - name: Install server dependencies run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile - name: Build the app - run: pnpm build + run: pnpm run --filter immich --filter @immich/plugin-sdk build - name: Run existing migrations run: pnpm migrations:run - name: Test npm run schema:reset command works diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index c132c224aa..8718c95c2a 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -73,7 +73,7 @@ services: - ${UPLOAD_LOCATION}/photos:/data - /etc/localtime:/etc/localtime:ro - pnpm_store_server:/buildcache/pnpm-store - - ../plugins:/build/corePlugin + - ../packages/plugin-core:/build/plugins/immich-plugin-core env_file: - .env environment: diff --git a/i18n/en.json b/i18n/en.json index 43f325a34a..6cd56f35bf 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -22,13 +22,12 @@ "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", "add_path": "Add path", "add_photos": "Add photos", + "add_step": "Add step", "add_tag": "Add tag", "add_to": "Add to…", "add_to_album": "Add to album", @@ -42,7 +41,6 @@ "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", @@ -805,6 +803,7 @@ "comments_are_disabled": "Comments are disabled", "common_create_new_album": "Create new album", "completed": "Completed", + "configuration": "Configuration", "confirm": "Confirm", "confirm_admin_password": "Confirm Admin Password", "confirm_delete_face": "Are you sure you want to delete {name} face from the asset?", @@ -1583,7 +1582,6 @@ "next": "Next", "next_memory": "Next memory", "no": "No", - "no_actions_added": "No actions added yet", "no_albums_found": "No albums found", "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.", @@ -1600,7 +1598,6 @@ "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", @@ -1613,6 +1610,7 @@ "no_results": "No results", "no_results_description": "Try a synonym or more general keyword", "no_shared_albums_message": "Create an album to share photos and videos with people in your network", + "no_steps": "No steps added yet", "no_uploads_in_progress": "No uploads in progress", "none": "None", "not_allowed": "Not allowed", @@ -2181,6 +2179,7 @@ "start_date_before_end_date": "Start date must be before end date", "state": "State", "status": "Status", + "steps": "Steps", "stop_casting": "Stop casting", "stop_motion_photo": "Stop Motion Photo", "stop_photo_sharing": "Stop sharing your photos?", @@ -2271,7 +2270,7 @@ "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": "Asset Upload", "trigger_asset_uploaded_description": "Triggered when a new asset is uploaded", "trigger_description": "An event that kicks off the workflow", "trigger_person_recognized": "Person Recognized", @@ -2311,7 +2310,6 @@ "unsupported_field_type": "Unsupported field type", "unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.", "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", @@ -2402,6 +2400,7 @@ "welcome_to_immich": "Welcome to Immich", "width": "Width", "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", diff --git a/mise.toml b/mise.toml index 0ec32de20c..8dcc294c2a 100644 --- a/mise.toml +++ b/mise.toml @@ -2,7 +2,7 @@ experimental_monorepo_root = true [monorepo] config_roots = [ - "plugins", + "packages/plugin-core", "server", "cli", "deployment", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index bb437787cb..d0afb235fa 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -211,8 +211,8 @@ Class | Method | HTTP request | Description *PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people *PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person *PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin -*PluginsApi* | [**getPluginTriggers**](doc//PluginsApi.md#getplugintriggers) | **GET** /plugins/triggers | List all plugin triggers -*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins +*PluginsApi* | [**searchPluginMethods**](doc//PluginsApi.md#searchpluginmethods) | **GET** /plugins/methods | Retrieve plugin methods +*PluginsApi* | [**searchPlugins**](doc//PluginsApi.md#searchplugins) | **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 *QueuesApi* | [**getQueueJobs**](doc//QueuesApi.md#getqueuejobs) | **GET** /queues/{name}/jobs | Retrieve queue jobs @@ -323,7 +323,9 @@ Class | Method | HTTP request | Description *WorkflowsApi* | [**createWorkflow**](doc//WorkflowsApi.md#createworkflow) | **POST** /workflows | Create a workflow *WorkflowsApi* | [**deleteWorkflow**](doc//WorkflowsApi.md#deleteworkflow) | **DELETE** /workflows/{id} | Delete a workflow *WorkflowsApi* | [**getWorkflow**](doc//WorkflowsApi.md#getworkflow) | **GET** /workflows/{id} | Retrieve a workflow -*WorkflowsApi* | [**getWorkflows**](doc//WorkflowsApi.md#getworkflows) | **GET** /workflows | List all workflows +*WorkflowsApi* | [**getWorkflowForShare**](doc//WorkflowsApi.md#getworkflowforshare) | **GET** /workflows/{id}/share | Retrieve a workflow +*WorkflowsApi* | [**getWorkflowTriggers**](doc//WorkflowsApi.md#getworkflowtriggers) | **GET** /workflows/triggers | List all workflow triggers +*WorkflowsApi* | [**searchWorkflows**](doc//WorkflowsApi.md#searchworkflows) | **GET** /workflows | List all workflows *WorkflowsApi* | [**updateWorkflow**](doc//WorkflowsApi.md#updateworkflow) | **PUT** /workflows/{id} | Update a workflow @@ -498,12 +500,8 @@ Class | Method | HTTP request | Description - [PinCodeResetDto](doc//PinCodeResetDto.md) - [PinCodeSetupDto](doc//PinCodeSetupDto.md) - [PlacesResponseDto](doc//PlacesResponseDto.md) - - [PluginActionResponseDto](doc//PluginActionResponseDto.md) - - [PluginContextType](doc//PluginContextType.md) - - [PluginFilterResponseDto](doc//PluginFilterResponseDto.md) + - [PluginMethodResponseDto](doc//PluginMethodResponseDto.md) - [PluginResponseDto](doc//PluginResponseDto.md) - - [PluginTriggerResponseDto](doc//PluginTriggerResponseDto.md) - - [PluginTriggerType](doc//PluginTriggerType.md) - [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseUpdate](doc//PurchaseUpdate.md) - [QueueCommand](doc//QueueCommand.md) @@ -675,12 +673,14 @@ Class | Method | HTTP request | Description - [VersionCheckStateResponseDto](doc//VersionCheckStateResponseDto.md) - [VideoCodec](doc//VideoCodec.md) - [VideoContainer](doc//VideoContainer.md) - - [WorkflowActionItemDto](doc//WorkflowActionItemDto.md) - - [WorkflowActionResponseDto](doc//WorkflowActionResponseDto.md) - [WorkflowCreateDto](doc//WorkflowCreateDto.md) - - [WorkflowFilterItemDto](doc//WorkflowFilterItemDto.md) - - [WorkflowFilterResponseDto](doc//WorkflowFilterResponseDto.md) - [WorkflowResponseDto](doc//WorkflowResponseDto.md) + - [WorkflowShareResponseDto](doc//WorkflowShareResponseDto.md) + - [WorkflowShareStepDto](doc//WorkflowShareStepDto.md) + - [WorkflowStepDto](doc//WorkflowStepDto.md) + - [WorkflowTrigger](doc//WorkflowTrigger.md) + - [WorkflowTriggerResponseDto](doc//WorkflowTriggerResponseDto.md) + - [WorkflowType](doc//WorkflowType.md) - [WorkflowUpdateDto](doc//WorkflowUpdateDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 253e8a6811..d48bd38eb1 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -237,12 +237,8 @@ part 'model/pin_code_change_dto.dart'; 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_type.dart'; -part 'model/plugin_filter_response_dto.dart'; +part 'model/plugin_method_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'; part 'model/queue_command.dart'; @@ -414,12 +410,14 @@ part 'model/validate_library_response_dto.dart'; part 'model/version_check_state_response_dto.dart'; part 'model/video_codec.dart'; part 'model/video_container.dart'; -part 'model/workflow_action_item_dto.dart'; -part 'model/workflow_action_response_dto.dart'; part 'model/workflow_create_dto.dart'; -part 'model/workflow_filter_item_dto.dart'; -part 'model/workflow_filter_response_dto.dart'; part 'model/workflow_response_dto.dart'; +part 'model/workflow_share_response_dto.dart'; +part 'model/workflow_share_step_dto.dart'; +part 'model/workflow_step_dto.dart'; +part 'model/workflow_trigger.dart'; +part 'model/workflow_trigger_response_dto.dart'; +part 'model/workflow_type.dart'; part 'model/workflow_update_dto.dart'; diff --git a/mobile/openapi/lib/api/plugins_api.dart b/mobile/openapi/lib/api/plugins_api.dart index 5735fba379..21fa13382e 100644 --- a/mobile/openapi/lib/api/plugins_api.dart +++ b/mobile/openapi/lib/api/plugins_api.dart @@ -73,14 +73,36 @@ class PluginsApi { return null; } - /// List all plugin triggers + /// Retrieve plugin methods /// - /// Retrieve a list of all available plugin triggers. + /// Retrieve a list of plugin methods /// /// Note: This method returns the HTTP [Response]. - Future getPluginTriggersWithHttpInfo() async { + /// + /// Parameters: + /// + /// * [String] description: + /// + /// * [bool] enabled: + /// Whether the plugin method is enabled + /// + /// * [String] id: + /// Plugin method ID + /// + /// * [String] name: + /// + /// * [String] pluginName: + /// + /// * [String] pluginVersion: + /// + /// * [String] title: + /// + /// * [WorkflowTrigger] trigger: + /// + /// * [WorkflowType] type: + Future searchPluginMethodsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, String? pluginName, String? pluginVersion, String? title, WorkflowTrigger? trigger, WorkflowType? type, }) async { // ignore: prefer_const_declarations - final apiPath = r'/plugins/triggers'; + final apiPath = r'/plugins/methods'; // ignore: prefer_final_locals Object? postBody; @@ -89,6 +111,34 @@ class PluginsApi { final headerParams = {}; final formParams = {}; + if (description != null) { + queryParams.addAll(_queryParams('', 'description', description)); + } + if (enabled != null) { + queryParams.addAll(_queryParams('', 'enabled', enabled)); + } + if (id != null) { + queryParams.addAll(_queryParams('', 'id', id)); + } + if (name != null) { + queryParams.addAll(_queryParams('', 'name', name)); + } + if (pluginName != null) { + queryParams.addAll(_queryParams('', 'pluginName', pluginName)); + } + if (pluginVersion != null) { + queryParams.addAll(_queryParams('', 'pluginVersion', pluginVersion)); + } + if (title != null) { + queryParams.addAll(_queryParams('', 'title', title)); + } + if (trigger != null) { + queryParams.addAll(_queryParams('', 'trigger', trigger)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + const contentTypes = []; @@ -103,11 +153,33 @@ class PluginsApi { ); } - /// List all plugin triggers + /// Retrieve plugin methods /// - /// Retrieve a list of all available plugin triggers. - Future?> getPluginTriggers() async { - final response = await getPluginTriggersWithHttpInfo(); + /// Retrieve a list of plugin methods + /// + /// Parameters: + /// + /// * [String] description: + /// + /// * [bool] enabled: + /// Whether the plugin method is enabled + /// + /// * [String] id: + /// Plugin method ID + /// + /// * [String] name: + /// + /// * [String] pluginName: + /// + /// * [String] pluginVersion: + /// + /// * [String] title: + /// + /// * [WorkflowTrigger] trigger: + /// + /// * [WorkflowType] type: + Future?> searchPluginMethods({ String? description, bool? enabled, String? id, String? name, String? pluginName, String? pluginVersion, String? title, WorkflowTrigger? trigger, WorkflowType? type, }) async { + final response = await searchPluginMethodsWithHttpInfo( description: description, enabled: enabled, id: id, name: name, pluginName: pluginName, pluginVersion: pluginVersion, title: title, trigger: trigger, type: type, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -116,8 +188,8 @@ class PluginsApi { // 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') as List) - .cast() + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() .toList(growable: false); } @@ -129,7 +201,23 @@ class PluginsApi { /// Retrieve a list of plugins available to the authenticated user. /// /// Note: This method returns the HTTP [Response]. - Future getPluginsWithHttpInfo() async { + /// + /// Parameters: + /// + /// * [String] description: + /// + /// * [bool] enabled: + /// Whether the plugin is enabled + /// + /// * [String] id: + /// Plugin ID + /// + /// * [String] name: + /// + /// * [String] title: + /// + /// * [String] version: + Future searchPluginsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, String? title, String? version, }) async { // ignore: prefer_const_declarations final apiPath = r'/plugins'; @@ -140,6 +228,25 @@ class PluginsApi { final headerParams = {}; final formParams = {}; + if (description != null) { + queryParams.addAll(_queryParams('', 'description', description)); + } + if (enabled != null) { + queryParams.addAll(_queryParams('', 'enabled', enabled)); + } + if (id != null) { + queryParams.addAll(_queryParams('', 'id', id)); + } + if (name != null) { + queryParams.addAll(_queryParams('', 'name', name)); + } + if (title != null) { + queryParams.addAll(_queryParams('', 'title', title)); + } + if (version != null) { + queryParams.addAll(_queryParams('', 'version', version)); + } + const contentTypes = []; @@ -157,8 +264,24 @@ class PluginsApi { /// List all plugins /// /// Retrieve a list of plugins available to the authenticated user. - Future?> getPlugins() async { - final response = await getPluginsWithHttpInfo(); + /// + /// Parameters: + /// + /// * [String] description: + /// + /// * [bool] enabled: + /// Whether the plugin is enabled + /// + /// * [String] id: + /// Plugin ID + /// + /// * [String] name: + /// + /// * [String] title: + /// + /// * [String] version: + Future?> searchPlugins({ String? description, bool? enabled, String? id, String? name, String? title, String? version, }) async { + final response = await searchPluginsWithHttpInfo( description: description, enabled: enabled, id: id, name: name, title: title, version: version, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/workflows_api.dart b/mobile/openapi/lib/api/workflows_api.dart index c589ec9823..12b33b7238 100644 --- a/mobile/openapi/lib/api/workflows_api.dart +++ b/mobile/openapi/lib/api/workflows_api.dart @@ -178,14 +178,19 @@ class WorkflowsApi { return null; } - /// List all workflows + /// Retrieve a workflow /// - /// Retrieve a list of workflows available to the authenticated user. + /// Retrieve a workflow details without ids, default values, etc. /// /// Note: This method returns the HTTP [Response]. - Future getWorkflowsWithHttpInfo() async { + /// + /// Parameters: + /// + /// * [String] id (required): + Future getWorkflowForShareWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final apiPath = r'/workflows'; + final apiPath = r'/workflows/{id}/share' + .replaceAll('{id}', id); // ignore: prefer_final_locals Object? postBody; @@ -208,11 +213,164 @@ class WorkflowsApi { ); } + /// Retrieve a workflow + /// + /// Retrieve a workflow details without ids, default values, etc. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getWorkflowForShare(String id,) async { + final response = await getWorkflowForShareWithHttpInfo(id,); + 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) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'WorkflowShareResponseDto',) as WorkflowShareResponseDto; + + } + return null; + } + + /// List all workflow triggers + /// + /// Retrieve a list of all available workflow triggers. + /// + /// Note: This method returns the HTTP [Response]. + Future getWorkflowTriggersWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/workflows/triggers'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// List all workflow triggers + /// + /// Retrieve a list of all available workflow triggers. + Future?> getWorkflowTriggers() async { + final response = await getWorkflowTriggersWithHttpInfo(); + 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') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// List all workflows /// /// Retrieve a list of workflows available to the authenticated user. - Future?> getWorkflows() async { - final response = await getWorkflowsWithHttpInfo(); + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] description: + /// Workflow description + /// + /// * [bool] enabled: + /// Workflow enabled + /// + /// * [String] id: + /// Workflow ID + /// + /// * [String] name: + /// Workflow name + /// + /// * [WorkflowTrigger] trigger: + /// Workflow trigger type + Future searchWorkflowsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, WorkflowTrigger? trigger, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/workflows'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (description != null) { + queryParams.addAll(_queryParams('', 'description', description)); + } + if (enabled != null) { + queryParams.addAll(_queryParams('', 'enabled', enabled)); + } + if (id != null) { + queryParams.addAll(_queryParams('', 'id', id)); + } + if (name != null) { + queryParams.addAll(_queryParams('', 'name', name)); + } + if (trigger != null) { + queryParams.addAll(_queryParams('', 'trigger', trigger)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// List all workflows + /// + /// Retrieve a list of workflows available to the authenticated user. + /// + /// Parameters: + /// + /// * [String] description: + /// Workflow description + /// + /// * [bool] enabled: + /// Workflow enabled + /// + /// * [String] id: + /// Workflow ID + /// + /// * [String] name: + /// Workflow name + /// + /// * [WorkflowTrigger] trigger: + /// Workflow trigger type + Future?> searchWorkflows({ String? description, bool? enabled, String? id, String? name, WorkflowTrigger? trigger, }) async { + final response = await searchWorkflowsWithHttpInfo( description: description, enabled: enabled, id: id, name: name, trigger: trigger, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index bfe469e7c0..16808d8399 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -520,18 +520,10 @@ class ApiClient { return PinCodeSetupDto.fromJson(value); case 'PlacesResponseDto': return PlacesResponseDto.fromJson(value); - case 'PluginActionResponseDto': - return PluginActionResponseDto.fromJson(value); - case 'PluginContextType': - return PluginContextTypeTypeTransformer().decode(value); - case 'PluginFilterResponseDto': - return PluginFilterResponseDto.fromJson(value); + case 'PluginMethodResponseDto': + return PluginMethodResponseDto.fromJson(value); case 'PluginResponseDto': return PluginResponseDto.fromJson(value); - case 'PluginTriggerResponseDto': - return PluginTriggerResponseDto.fromJson(value); - case 'PluginTriggerType': - return PluginTriggerTypeTypeTransformer().decode(value); case 'PurchaseResponse': return PurchaseResponse.fromJson(value); case 'PurchaseUpdate': @@ -874,18 +866,22 @@ class ApiClient { return VideoCodecTypeTransformer().decode(value); case 'VideoContainer': return VideoContainerTypeTransformer().decode(value); - case 'WorkflowActionItemDto': - return WorkflowActionItemDto.fromJson(value); - case 'WorkflowActionResponseDto': - return WorkflowActionResponseDto.fromJson(value); case 'WorkflowCreateDto': return WorkflowCreateDto.fromJson(value); - case 'WorkflowFilterItemDto': - return WorkflowFilterItemDto.fromJson(value); - case 'WorkflowFilterResponseDto': - return WorkflowFilterResponseDto.fromJson(value); case 'WorkflowResponseDto': return WorkflowResponseDto.fromJson(value); + case 'WorkflowShareResponseDto': + return WorkflowShareResponseDto.fromJson(value); + case 'WorkflowShareStepDto': + return WorkflowShareStepDto.fromJson(value); + case 'WorkflowStepDto': + return WorkflowStepDto.fromJson(value); + case 'WorkflowTrigger': + return WorkflowTriggerTypeTransformer().decode(value); + case 'WorkflowTriggerResponseDto': + return WorkflowTriggerResponseDto.fromJson(value); + case 'WorkflowType': + return WorkflowTypeTypeTransformer().decode(value); case 'WorkflowUpdateDto': return WorkflowUpdateDto.fromJson(value); default: diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 830325a5b6..58281eb0e2 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -130,12 +130,6 @@ String parameterToString(dynamic value) { if (value is Permission) { return PermissionTypeTransformer().encode(value).toString(); } - if (value is PluginContextType) { - return PluginContextTypeTypeTransformer().encode(value).toString(); - } - if (value is PluginTriggerType) { - return PluginTriggerTypeTypeTransformer().encode(value).toString(); - } if (value is QueueCommand) { return QueueCommandTypeTransformer().encode(value).toString(); } @@ -193,6 +187,12 @@ String parameterToString(dynamic value) { if (value is VideoContainer) { return VideoContainerTypeTransformer().encode(value).toString(); } + if (value is WorkflowTrigger) { + return WorkflowTriggerTypeTransformer().encode(value).toString(); + } + if (value is WorkflowType) { + return WorkflowTypeTypeTransformer().encode(value).toString(); + } return value.toString(); } diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart index 96b9339b7d..bb669a4b51 100644 --- a/mobile/openapi/lib/model/job_name.dart +++ b/mobile/openapi/lib/model/job_name.dart @@ -78,7 +78,7 @@ class JobName { static const versionCheck = JobName._(r'VersionCheck'); static const ocrQueueAll = JobName._(r'OcrQueueAll'); static const ocr = JobName._(r'Ocr'); - static const workflowRun = JobName._(r'WorkflowRun'); + static const workflowAssetCreate = JobName._(r'WorkflowAssetCreate'); /// List of all possible values in this [enum][JobName]. static const values = [ @@ -137,7 +137,7 @@ class JobName { versionCheck, ocrQueueAll, ocr, - workflowRun, + workflowAssetCreate, ]; static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value); @@ -231,7 +231,7 @@ class JobNameTypeTransformer { case r'VersionCheck': return JobName.versionCheck; case r'OcrQueueAll': return JobName.ocrQueueAll; case r'Ocr': return JobName.ocr; - case r'WorkflowRun': return JobName.workflowRun; + case r'WorkflowAssetCreate': return JobName.workflowAssetCreate; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/plugin_context_type.dart b/mobile/openapi/lib/model/plugin_context_type.dart deleted file mode 100644 index 6f4ac91fdb..0000000000 --- a/mobile/openapi/lib/model/plugin_context_type.dart +++ /dev/null @@ -1,88 +0,0 @@ -// -// 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; - -/// Context type -class PluginContextType { - /// Instantiate a new enum with the provided [value]. - const PluginContextType._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - 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][PluginContextType]. - static const values = [ - asset, - album, - person, - ]; - - static PluginContextType? fromJson(dynamic value) => PluginContextTypeTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = PluginContextType.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// 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 PluginContextTypeTypeTransformer._(); - - String encode(PluginContextType data) => data.value; - - /// 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] - /// 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. - PluginContextType? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - 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'); - } - } - } - return null; - } - - /// Singleton [PluginContextTypeTypeTransformer] instance. - static PluginContextTypeTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/plugin_filter_response_dto.dart b/mobile/openapi/lib/model/plugin_filter_response_dto.dart deleted file mode 100644 index ea6411a9c1..0000000000 --- a/mobile/openapi/lib/model/plugin_filter_response_dto.dart +++ /dev/null @@ -1,158 +0,0 @@ -// -// 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 PluginFilterResponseDto { - /// Returns a new [PluginFilterResponseDto] instance. - PluginFilterResponseDto({ - required this.description, - required this.id, - required this.methodName, - required this.pluginId, - required this.schema, - this.supportedContexts = const [], - required this.title, - }); - - /// Filter description - String description; - - /// Filter ID - String id; - - /// Method name - String methodName; - - /// Plugin ID - String pluginId; - - /// Filter schema - Object? schema; - - /// Supported contexts - List supportedContexts; - - /// Filter title - String title; - - @override - bool operator ==(Object other) => identical(this, other) || other is PluginFilterResponseDto && - other.description == description && - other.id == id && - other.methodName == methodName && - other.pluginId == pluginId && - other.schema == schema && - _deepEquality.equals(other.supportedContexts, supportedContexts) && - other.title == title; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (description.hashCode) + - (id.hashCode) + - (methodName.hashCode) + - (pluginId.hashCode) + - (schema == null ? 0 : schema!.hashCode) + - (supportedContexts.hashCode) + - (title.hashCode); - - @override - String toString() => 'PluginFilterResponseDto[description=$description, id=$id, methodName=$methodName, pluginId=$pluginId, schema=$schema, supportedContexts=$supportedContexts, title=$title]'; - - Map toJson() { - final json = {}; - json[r'description'] = this.description; - json[r'id'] = this.id; - json[r'methodName'] = this.methodName; - json[r'pluginId'] = this.pluginId; - if (this.schema != null) { - json[r'schema'] = this.schema; - } else { - // json[r'schema'] = null; - } - json[r'supportedContexts'] = this.supportedContexts; - json[r'title'] = this.title; - return json; - } - - /// Returns a new [PluginFilterResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static PluginFilterResponseDto? fromJson(dynamic value) { - upgradeDto(value, "PluginFilterResponseDto"); - if (value is Map) { - final json = value.cast(); - - return PluginFilterResponseDto( - description: mapValueOfType(json, r'description')!, - id: mapValueOfType(json, r'id')!, - methodName: mapValueOfType(json, r'methodName')!, - pluginId: mapValueOfType(json, r'pluginId')!, - schema: mapValueOfType(json, r'schema'), - supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']), - title: mapValueOfType(json, r'title')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = PluginFilterResponseDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = PluginFilterResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of PluginFilterResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = PluginFilterResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'description', - 'id', - 'methodName', - 'pluginId', - 'schema', - 'supportedContexts', - 'title', - }; -} - diff --git a/mobile/openapi/lib/model/plugin_action_response_dto.dart b/mobile/openapi/lib/model/plugin_method_response_dto.dart similarity index 50% rename from mobile/openapi/lib/model/plugin_action_response_dto.dart rename to mobile/openapi/lib/model/plugin_method_response_dto.dart index 34fa314ba9..14d72757f6 100644 --- a/mobile/openapi/lib/model/plugin_action_response_dto.dart +++ b/mobile/openapi/lib/model/plugin_method_response_dto.dart @@ -10,105 +10,97 @@ part of openapi.api; -class PluginActionResponseDto { - /// Returns a new [PluginActionResponseDto] instance. - PluginActionResponseDto({ +class PluginMethodResponseDto { + /// Returns a new [PluginMethodResponseDto] instance. + PluginMethodResponseDto({ required this.description, - required this.id, - required this.methodName, - required this.pluginId, + required this.key, + required this.name, required this.schema, - this.supportedContexts = const [], required this.title, + this.types = const [], }); - /// Action description + /// Description String description; - /// Action ID - String id; + /// Key + String key; - /// Method name - String methodName; + /// Name + String name; - /// Plugin ID - String pluginId; - - /// Action schema + /// Schema Object? schema; - /// Supported contexts - List supportedContexts; - - /// Action title + /// Title String title; + /// Workflow types + List types; + @override - bool operator ==(Object other) => identical(this, other) || other is PluginActionResponseDto && + bool operator ==(Object other) => identical(this, other) || other is PluginMethodResponseDto && other.description == description && - other.id == id && - other.methodName == methodName && - other.pluginId == pluginId && + other.key == key && + other.name == name && other.schema == schema && - _deepEquality.equals(other.supportedContexts, supportedContexts) && - other.title == title; + other.title == title && + _deepEquality.equals(other.types, types); @override int get hashCode => // ignore: unnecessary_parenthesis (description.hashCode) + - (id.hashCode) + - (methodName.hashCode) + - (pluginId.hashCode) + + (key.hashCode) + + (name.hashCode) + (schema == null ? 0 : schema!.hashCode) + - (supportedContexts.hashCode) + - (title.hashCode); + (title.hashCode) + + (types.hashCode); @override - String toString() => 'PluginActionResponseDto[description=$description, id=$id, methodName=$methodName, pluginId=$pluginId, schema=$schema, supportedContexts=$supportedContexts, title=$title]'; + String toString() => 'PluginMethodResponseDto[description=$description, key=$key, name=$name, schema=$schema, title=$title, types=$types]'; Map toJson() { final json = {}; json[r'description'] = this.description; - json[r'id'] = this.id; - json[r'methodName'] = this.methodName; - json[r'pluginId'] = this.pluginId; + json[r'key'] = this.key; + json[r'name'] = this.name; if (this.schema != null) { json[r'schema'] = this.schema; } else { // json[r'schema'] = null; } - json[r'supportedContexts'] = this.supportedContexts; json[r'title'] = this.title; + json[r'types'] = this.types; return json; } - /// Returns a new [PluginActionResponseDto] instance and imports its values from + /// Returns a new [PluginMethodResponseDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static PluginActionResponseDto? fromJson(dynamic value) { - upgradeDto(value, "PluginActionResponseDto"); + static PluginMethodResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PluginMethodResponseDto"); if (value is Map) { final json = value.cast(); - return PluginActionResponseDto( + return PluginMethodResponseDto( description: mapValueOfType(json, r'description')!, - id: mapValueOfType(json, r'id')!, - methodName: mapValueOfType(json, r'methodName')!, - pluginId: mapValueOfType(json, r'pluginId')!, + key: mapValueOfType(json, r'key')!, + name: mapValueOfType(json, r'name')!, schema: mapValueOfType(json, r'schema'), - supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']), title: mapValueOfType(json, r'title')!, + types: WorkflowType.listFromJson(json[r'types']), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = PluginActionResponseDto.fromJson(row); + final value = PluginMethodResponseDto.fromJson(row); if (value != null) { result.add(value); } @@ -117,12 +109,12 @@ class PluginActionResponseDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = PluginActionResponseDto.fromJson(entry.value); + final value = PluginMethodResponseDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -131,14 +123,14 @@ class PluginActionResponseDto { return map; } - // maps a json object with a list of PluginActionResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of PluginMethodResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = PluginActionResponseDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = PluginMethodResponseDto.listFromJson(entry.value, growable: growable,); } } return map; @@ -147,12 +139,11 @@ class PluginActionResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'description', - 'id', - 'methodName', - 'pluginId', + 'key', + 'name', 'schema', - 'supportedContexts', 'title', + 'types', }; } diff --git a/mobile/openapi/lib/model/plugin_response_dto.dart b/mobile/openapi/lib/model/plugin_response_dto.dart index 7a99896475..1bdb366f9e 100644 --- a/mobile/openapi/lib/model/plugin_response_dto.dart +++ b/mobile/openapi/lib/model/plugin_response_dto.dart @@ -13,21 +13,17 @@ part of openapi.api; class PluginResponseDto { /// Returns a new [PluginResponseDto] instance. PluginResponseDto({ - this.actions = const [], required this.author, required this.createdAt, required this.description, - this.filters = const [], required this.id, + this.methods = const [], required this.name, required this.title, required this.updatedAt, required this.version, }); - /// Plugin actions - List actions; - /// Plugin author String author; @@ -37,12 +33,12 @@ class PluginResponseDto { /// Plugin description String description; - /// Plugin filters - List filters; - /// Plugin ID String id; + /// Plugin methods + List methods; + /// Plugin name String name; @@ -57,12 +53,11 @@ class PluginResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is PluginResponseDto && - _deepEquality.equals(other.actions, actions) && other.author == author && other.createdAt == createdAt && other.description == description && - _deepEquality.equals(other.filters, filters) && other.id == id && + _deepEquality.equals(other.methods, methods) && other.name == name && other.title == title && other.updatedAt == updatedAt && @@ -71,28 +66,26 @@ class PluginResponseDto { @override int get hashCode => // ignore: unnecessary_parenthesis - (actions.hashCode) + (author.hashCode) + (createdAt.hashCode) + (description.hashCode) + - (filters.hashCode) + (id.hashCode) + + (methods.hashCode) + (name.hashCode) + (title.hashCode) + (updatedAt.hashCode) + (version.hashCode); @override - String toString() => 'PluginResponseDto[actions=$actions, author=$author, createdAt=$createdAt, description=$description, filters=$filters, id=$id, name=$name, title=$title, updatedAt=$updatedAt, version=$version]'; + String toString() => 'PluginResponseDto[author=$author, createdAt=$createdAt, description=$description, id=$id, methods=$methods, name=$name, title=$title, updatedAt=$updatedAt, version=$version]'; Map toJson() { final json = {}; - json[r'actions'] = this.actions; json[r'author'] = this.author; json[r'createdAt'] = this.createdAt; json[r'description'] = this.description; - json[r'filters'] = this.filters; json[r'id'] = this.id; + json[r'methods'] = this.methods; json[r'name'] = this.name; json[r'title'] = this.title; json[r'updatedAt'] = this.updatedAt; @@ -109,12 +102,11 @@ class PluginResponseDto { final json = value.cast(); return PluginResponseDto( - actions: PluginActionResponseDto.listFromJson(json[r'actions']), author: mapValueOfType(json, r'author')!, createdAt: mapValueOfType(json, r'createdAt')!, description: mapValueOfType(json, r'description')!, - filters: PluginFilterResponseDto.listFromJson(json[r'filters']), id: mapValueOfType(json, r'id')!, + methods: PluginMethodResponseDto.listFromJson(json[r'methods']), name: mapValueOfType(json, r'name')!, title: mapValueOfType(json, r'title')!, updatedAt: mapValueOfType(json, r'updatedAt')!, @@ -166,12 +158,11 @@ class PluginResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'actions', 'author', 'createdAt', 'description', - 'filters', 'id', + 'methods', 'name', 'title', 'updatedAt', diff --git a/mobile/openapi/lib/model/plugin_trigger_response_dto.dart b/mobile/openapi/lib/model/plugin_trigger_response_dto.dart deleted file mode 100644 index 16a9604bcd..0000000000 --- a/mobile/openapi/lib/model/plugin_trigger_response_dto.dart +++ /dev/null @@ -1,109 +0,0 @@ -// -// 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, - }); - - /// Context type - PluginContextType contextType; - - /// Trigger type - 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 toJson() { - final json = {}; - 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(); - - return PluginTriggerResponseDto( - contextType: PluginContextType.fromJson(json[r'contextType'])!, - type: PluginTriggerType.fromJson(json[r'type'])!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - 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 mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // 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> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - 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 = { - 'contextType', - 'type', - }; -} - diff --git a/mobile/openapi/lib/model/plugin_trigger_type.dart b/mobile/openapi/lib/model/plugin_trigger_type.dart deleted file mode 100644 index 9ae64acf6c..0000000000 --- a/mobile/openapi/lib/model/plugin_trigger_type.dart +++ /dev/null @@ -1,85 +0,0 @@ -// -// 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; - -/// Trigger type -class PluginTriggerType { - /// Instantiate a new enum with the provided [value]. - const PluginTriggerType._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const assetCreate = PluginTriggerType._(r'AssetCreate'); - static const personRecognized = PluginTriggerType._(r'PersonRecognized'); - - /// List of all possible values in this [enum][PluginTriggerType]. - static const values = [ - assetCreate, - personRecognized, - ]; - - static PluginTriggerType? fromJson(dynamic value) => PluginTriggerTypeTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = PluginTriggerType.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [PluginTriggerType] to String, -/// and [decode] dynamic data back to [PluginTriggerType]. -class PluginTriggerTypeTypeTransformer { - factory PluginTriggerTypeTypeTransformer() => _instance ??= const PluginTriggerTypeTypeTransformer._(); - - const PluginTriggerTypeTypeTransformer._(); - - String encode(PluginTriggerType data) => data.value; - - /// Decodes a [dynamic value][data] to a PluginTriggerType. - /// - /// 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. - PluginTriggerType? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'AssetCreate': return PluginTriggerType.assetCreate; - case r'PersonRecognized': return PluginTriggerType.personRecognized; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [PluginTriggerTypeTypeTransformer] instance. - static PluginTriggerTypeTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/workflow_action_item_dto.dart b/mobile/openapi/lib/model/workflow_action_item_dto.dart deleted file mode 100644 index 9222dd6ba7..0000000000 --- a/mobile/openapi/lib/model/workflow_action_item_dto.dart +++ /dev/null @@ -1,118 +0,0 @@ -// -// 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 WorkflowActionItemDto { - /// Returns a new [WorkflowActionItemDto] instance. - WorkflowActionItemDto({ - this.actionConfig, - required this.pluginActionId, - }); - - /// Action configuration - /// - /// 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. - /// - Object? actionConfig; - - /// Plugin action ID - String pluginActionId; - - @override - bool operator ==(Object other) => identical(this, other) || other is WorkflowActionItemDto && - other.actionConfig == actionConfig && - other.pluginActionId == pluginActionId; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (actionConfig == null ? 0 : actionConfig!.hashCode) + - (pluginActionId.hashCode); - - @override - String toString() => 'WorkflowActionItemDto[actionConfig=$actionConfig, pluginActionId=$pluginActionId]'; - - Map toJson() { - final json = {}; - if (this.actionConfig != null) { - json[r'actionConfig'] = this.actionConfig; - } else { - // json[r'actionConfig'] = null; - } - json[r'pluginActionId'] = this.pluginActionId; - return json; - } - - /// Returns a new [WorkflowActionItemDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static WorkflowActionItemDto? fromJson(dynamic value) { - upgradeDto(value, "WorkflowActionItemDto"); - if (value is Map) { - final json = value.cast(); - - return WorkflowActionItemDto( - actionConfig: mapValueOfType(json, r'actionConfig'), - pluginActionId: mapValueOfType(json, r'pluginActionId')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = WorkflowActionItemDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = WorkflowActionItemDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of WorkflowActionItemDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = WorkflowActionItemDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'pluginActionId', - }; -} - diff --git a/mobile/openapi/lib/model/workflow_action_response_dto.dart b/mobile/openapi/lib/model/workflow_action_response_dto.dart deleted file mode 100644 index 8f77e9cf2b..0000000000 --- a/mobile/openapi/lib/model/workflow_action_response_dto.dart +++ /dev/null @@ -1,140 +0,0 @@ -// -// 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 WorkflowActionResponseDto { - /// Returns a new [WorkflowActionResponseDto] instance. - WorkflowActionResponseDto({ - required this.actionConfig, - required this.id, - required this.order, - required this.pluginActionId, - required this.workflowId, - }); - - /// Action configuration - Object? actionConfig; - - /// Action ID - String id; - - /// Action order - num order; - - /// Plugin action ID - String pluginActionId; - - /// Workflow ID - String workflowId; - - @override - bool operator ==(Object other) => identical(this, other) || other is WorkflowActionResponseDto && - other.actionConfig == actionConfig && - other.id == id && - other.order == order && - other.pluginActionId == pluginActionId && - other.workflowId == workflowId; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (actionConfig == null ? 0 : actionConfig!.hashCode) + - (id.hashCode) + - (order.hashCode) + - (pluginActionId.hashCode) + - (workflowId.hashCode); - - @override - String toString() => 'WorkflowActionResponseDto[actionConfig=$actionConfig, id=$id, order=$order, pluginActionId=$pluginActionId, workflowId=$workflowId]'; - - Map toJson() { - final json = {}; - if (this.actionConfig != null) { - json[r'actionConfig'] = this.actionConfig; - } else { - // json[r'actionConfig'] = null; - } - json[r'id'] = this.id; - json[r'order'] = this.order; - json[r'pluginActionId'] = this.pluginActionId; - json[r'workflowId'] = this.workflowId; - return json; - } - - /// Returns a new [WorkflowActionResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static WorkflowActionResponseDto? fromJson(dynamic value) { - upgradeDto(value, "WorkflowActionResponseDto"); - if (value is Map) { - final json = value.cast(); - - return WorkflowActionResponseDto( - actionConfig: mapValueOfType(json, r'actionConfig'), - id: mapValueOfType(json, r'id')!, - order: num.parse('${json[r'order']}'), - pluginActionId: mapValueOfType(json, r'pluginActionId')!, - workflowId: mapValueOfType(json, r'workflowId')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = WorkflowActionResponseDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = WorkflowActionResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of WorkflowActionResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = WorkflowActionResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'actionConfig', - 'id', - 'order', - 'pluginActionId', - 'workflowId', - }; -} - diff --git a/mobile/openapi/lib/model/workflow_create_dto.dart b/mobile/openapi/lib/model/workflow_create_dto.dart index 38665a1912..e62b241b14 100644 --- a/mobile/openapi/lib/model/workflow_create_dto.dart +++ b/mobile/openapi/lib/model/workflow_create_dto.dart @@ -13,24 +13,14 @@ part of openapi.api; class WorkflowCreateDto { /// Returns a new [WorkflowCreateDto] instance. WorkflowCreateDto({ - this.actions = const [], this.description, this.enabled, - this.filters = const [], - required this.name, - required this.triggerType, + this.name, + this.steps = const [], + required this.trigger, }); - /// Workflow actions - List actions; - /// Workflow description - /// - /// 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. - /// String? description; /// Workflow enabled @@ -42,40 +32,36 @@ class WorkflowCreateDto { /// bool? enabled; - /// Workflow filters - List filters; - /// Workflow name - String name; + String? name; + + List steps; /// Workflow trigger type - PluginTriggerType triggerType; + WorkflowTrigger trigger; @override bool operator ==(Object other) => identical(this, other) || other is WorkflowCreateDto && - _deepEquality.equals(other.actions, actions) && other.description == description && other.enabled == enabled && - _deepEquality.equals(other.filters, filters) && other.name == name && - other.triggerType == triggerType; + _deepEquality.equals(other.steps, steps) && + other.trigger == trigger; @override int get hashCode => // ignore: unnecessary_parenthesis - (actions.hashCode) + (description == null ? 0 : description!.hashCode) + (enabled == null ? 0 : enabled!.hashCode) + - (filters.hashCode) + - (name.hashCode) + - (triggerType.hashCode); + (name == null ? 0 : name!.hashCode) + + (steps.hashCode) + + (trigger.hashCode); @override - String toString() => 'WorkflowCreateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]'; + String toString() => 'WorkflowCreateDto[description=$description, enabled=$enabled, name=$name, steps=$steps, trigger=$trigger]'; Map toJson() { final json = {}; - json[r'actions'] = this.actions; if (this.description != null) { json[r'description'] = this.description; } else { @@ -86,9 +72,13 @@ class WorkflowCreateDto { } else { // json[r'enabled'] = null; } - json[r'filters'] = this.filters; + if (this.name != null) { json[r'name'] = this.name; - json[r'triggerType'] = this.triggerType; + } else { + // json[r'name'] = null; + } + json[r'steps'] = this.steps; + json[r'trigger'] = this.trigger; return json; } @@ -101,12 +91,11 @@ class WorkflowCreateDto { final json = value.cast(); return WorkflowCreateDto( - actions: WorkflowActionItemDto.listFromJson(json[r'actions']), description: mapValueOfType(json, r'description'), enabled: mapValueOfType(json, r'enabled'), - filters: WorkflowFilterItemDto.listFromJson(json[r'filters']), - name: mapValueOfType(json, r'name')!, - triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!, + name: mapValueOfType(json, r'name'), + steps: WorkflowStepDto.listFromJson(json[r'steps']), + trigger: WorkflowTrigger.fromJson(json[r'trigger'])!, ); } return null; @@ -154,10 +143,7 @@ class WorkflowCreateDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'actions', - 'filters', - 'name', - 'triggerType', + 'trigger', }; } diff --git a/mobile/openapi/lib/model/workflow_filter_item_dto.dart b/mobile/openapi/lib/model/workflow_filter_item_dto.dart deleted file mode 100644 index 52e29c3e93..0000000000 --- a/mobile/openapi/lib/model/workflow_filter_item_dto.dart +++ /dev/null @@ -1,118 +0,0 @@ -// -// 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 WorkflowFilterItemDto { - /// Returns a new [WorkflowFilterItemDto] instance. - WorkflowFilterItemDto({ - this.filterConfig, - required this.pluginFilterId, - }); - - /// Filter configuration - /// - /// 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. - /// - Object? filterConfig; - - /// Plugin filter ID - String pluginFilterId; - - @override - bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterItemDto && - other.filterConfig == filterConfig && - other.pluginFilterId == pluginFilterId; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (filterConfig == null ? 0 : filterConfig!.hashCode) + - (pluginFilterId.hashCode); - - @override - String toString() => 'WorkflowFilterItemDto[filterConfig=$filterConfig, pluginFilterId=$pluginFilterId]'; - - Map toJson() { - final json = {}; - if (this.filterConfig != null) { - json[r'filterConfig'] = this.filterConfig; - } else { - // json[r'filterConfig'] = null; - } - json[r'pluginFilterId'] = this.pluginFilterId; - return json; - } - - /// Returns a new [WorkflowFilterItemDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static WorkflowFilterItemDto? fromJson(dynamic value) { - upgradeDto(value, "WorkflowFilterItemDto"); - if (value is Map) { - final json = value.cast(); - - return WorkflowFilterItemDto( - filterConfig: mapValueOfType(json, r'filterConfig'), - pluginFilterId: mapValueOfType(json, r'pluginFilterId')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = WorkflowFilterItemDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = WorkflowFilterItemDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of WorkflowFilterItemDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = WorkflowFilterItemDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'pluginFilterId', - }; -} - diff --git a/mobile/openapi/lib/model/workflow_filter_response_dto.dart b/mobile/openapi/lib/model/workflow_filter_response_dto.dart deleted file mode 100644 index 355378adac..0000000000 --- a/mobile/openapi/lib/model/workflow_filter_response_dto.dart +++ /dev/null @@ -1,140 +0,0 @@ -// -// 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 WorkflowFilterResponseDto { - /// Returns a new [WorkflowFilterResponseDto] instance. - WorkflowFilterResponseDto({ - required this.filterConfig, - required this.id, - required this.order, - required this.pluginFilterId, - required this.workflowId, - }); - - /// Filter configuration - Object? filterConfig; - - /// Filter ID - String id; - - /// Filter order - num order; - - /// Plugin filter ID - String pluginFilterId; - - /// Workflow ID - String workflowId; - - @override - bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterResponseDto && - other.filterConfig == filterConfig && - other.id == id && - other.order == order && - other.pluginFilterId == pluginFilterId && - other.workflowId == workflowId; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (filterConfig == null ? 0 : filterConfig!.hashCode) + - (id.hashCode) + - (order.hashCode) + - (pluginFilterId.hashCode) + - (workflowId.hashCode); - - @override - String toString() => 'WorkflowFilterResponseDto[filterConfig=$filterConfig, id=$id, order=$order, pluginFilterId=$pluginFilterId, workflowId=$workflowId]'; - - Map toJson() { - final json = {}; - if (this.filterConfig != null) { - json[r'filterConfig'] = this.filterConfig; - } else { - // json[r'filterConfig'] = null; - } - json[r'id'] = this.id; - json[r'order'] = this.order; - json[r'pluginFilterId'] = this.pluginFilterId; - json[r'workflowId'] = this.workflowId; - return json; - } - - /// Returns a new [WorkflowFilterResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static WorkflowFilterResponseDto? fromJson(dynamic value) { - upgradeDto(value, "WorkflowFilterResponseDto"); - if (value is Map) { - final json = value.cast(); - - return WorkflowFilterResponseDto( - filterConfig: mapValueOfType(json, r'filterConfig'), - id: mapValueOfType(json, r'id')!, - order: num.parse('${json[r'order']}'), - pluginFilterId: mapValueOfType(json, r'pluginFilterId')!, - workflowId: mapValueOfType(json, r'workflowId')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = WorkflowFilterResponseDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = WorkflowFilterResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of WorkflowFilterResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = WorkflowFilterResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'filterConfig', - 'id', - 'order', - 'pluginFilterId', - 'workflowId', - }; -} - diff --git a/mobile/openapi/lib/model/workflow_response_dto.dart b/mobile/openapi/lib/model/workflow_response_dto.dart index ae3e6510aa..5156d43338 100644 --- a/mobile/openapi/lib/model/workflow_response_dto.dart +++ b/mobile/openapi/lib/model/workflow_response_dto.dart @@ -13,87 +13,84 @@ part of openapi.api; class WorkflowResponseDto { /// Returns a new [WorkflowResponseDto] instance. WorkflowResponseDto({ - this.actions = const [], required this.createdAt, required this.description, required this.enabled, - this.filters = const [], required this.id, required this.name, - required this.ownerId, - required this.triggerType, + this.steps = const [], + required this.trigger, + required this.updatedAt, }); - /// Workflow actions - List actions; - /// Creation date String createdAt; /// Workflow description - String description; + String? description; /// Workflow enabled bool enabled; - /// Workflow filters - List filters; - /// Workflow ID String id; /// Workflow name String? name; - /// Owner user ID - String ownerId; + /// Workflow steps + List steps; /// Workflow trigger type - PluginTriggerType triggerType; + WorkflowTrigger trigger; + + /// Update date + String updatedAt; @override bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto && - _deepEquality.equals(other.actions, actions) && other.createdAt == createdAt && other.description == description && other.enabled == enabled && - _deepEquality.equals(other.filters, filters) && other.id == id && other.name == name && - other.ownerId == ownerId && - other.triggerType == triggerType; + _deepEquality.equals(other.steps, steps) && + other.trigger == trigger && + other.updatedAt == updatedAt; @override int get hashCode => // ignore: unnecessary_parenthesis - (actions.hashCode) + (createdAt.hashCode) + - (description.hashCode) + + (description == null ? 0 : description!.hashCode) + (enabled.hashCode) + - (filters.hashCode) + (id.hashCode) + (name == null ? 0 : name!.hashCode) + - (ownerId.hashCode) + - (triggerType.hashCode); + (steps.hashCode) + + (trigger.hashCode) + + (updatedAt.hashCode); @override - String toString() => 'WorkflowResponseDto[actions=$actions, createdAt=$createdAt, description=$description, enabled=$enabled, filters=$filters, id=$id, name=$name, ownerId=$ownerId, triggerType=$triggerType]'; + String toString() => 'WorkflowResponseDto[createdAt=$createdAt, description=$description, enabled=$enabled, id=$id, name=$name, steps=$steps, trigger=$trigger, updatedAt=$updatedAt]'; Map toJson() { final json = {}; - json[r'actions'] = this.actions; json[r'createdAt'] = this.createdAt; + if (this.description != null) { json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } json[r'enabled'] = this.enabled; - json[r'filters'] = this.filters; json[r'id'] = this.id; if (this.name != null) { json[r'name'] = this.name; } else { // json[r'name'] = null; } - json[r'ownerId'] = this.ownerId; - json[r'triggerType'] = this.triggerType; + json[r'steps'] = this.steps; + json[r'trigger'] = this.trigger; + json[r'updatedAt'] = this.updatedAt; return json; } @@ -106,15 +103,14 @@ class WorkflowResponseDto { final json = value.cast(); return WorkflowResponseDto( - actions: WorkflowActionResponseDto.listFromJson(json[r'actions']), createdAt: mapValueOfType(json, r'createdAt')!, - description: mapValueOfType(json, r'description')!, + description: mapValueOfType(json, r'description'), enabled: mapValueOfType(json, r'enabled')!, - filters: WorkflowFilterResponseDto.listFromJson(json[r'filters']), id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name'), - ownerId: mapValueOfType(json, r'ownerId')!, - triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!, + steps: WorkflowStepDto.listFromJson(json[r'steps']), + trigger: WorkflowTrigger.fromJson(json[r'trigger'])!, + updatedAt: mapValueOfType(json, r'updatedAt')!, ); } return null; @@ -162,15 +158,14 @@ class WorkflowResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'actions', 'createdAt', 'description', 'enabled', - 'filters', 'id', 'name', - 'ownerId', - 'triggerType', + 'steps', + 'trigger', + 'updatedAt', }; } diff --git a/mobile/openapi/lib/model/workflow_share_response_dto.dart b/mobile/openapi/lib/model/workflow_share_response_dto.dart new file mode 100644 index 0000000000..c323608624 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_share_response_dto.dart @@ -0,0 +1,153 @@ +// +// 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 WorkflowShareResponseDto { + /// Returns a new [WorkflowShareResponseDto] instance. + WorkflowShareResponseDto({ + required this.description, + this.enabled, + required this.name, + this.steps = const [], + required this.trigger, + }); + + /// Workflow description + String? description; + + /// Workflow enabled + /// + /// 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. + /// + bool? enabled; + + /// Workflow name + String? name; + + /// Workflow steps + List steps; + + /// Workflow trigger type + WorkflowTrigger trigger; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowShareResponseDto && + other.description == description && + other.enabled == enabled && + other.name == name && + _deepEquality.equals(other.steps, steps) && + other.trigger == trigger; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (description == null ? 0 : description!.hashCode) + + (enabled == null ? 0 : enabled!.hashCode) + + (name == null ? 0 : name!.hashCode) + + (steps.hashCode) + + (trigger.hashCode); + + @override + String toString() => 'WorkflowShareResponseDto[description=$description, enabled=$enabled, name=$name, steps=$steps, trigger=$trigger]'; + + Map toJson() { + final json = {}; + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + if (this.name != null) { + json[r'name'] = this.name; + } else { + // json[r'name'] = null; + } + json[r'steps'] = this.steps; + json[r'trigger'] = this.trigger; + return json; + } + + /// Returns a new [WorkflowShareResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowShareResponseDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowShareResponseDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowShareResponseDto( + description: mapValueOfType(json, r'description'), + enabled: mapValueOfType(json, r'enabled'), + name: mapValueOfType(json, r'name'), + steps: WorkflowShareStepDto.listFromJson(json[r'steps']), + trigger: WorkflowTrigger.fromJson(json[r'trigger'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowShareResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowShareResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowShareResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowShareResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'description', + 'name', + 'steps', + 'trigger', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_share_step_dto.dart b/mobile/openapi/lib/model/workflow_share_step_dto.dart new file mode 100644 index 0000000000..1d8f037626 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_share_step_dto.dart @@ -0,0 +1,130 @@ +// +// 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 WorkflowShareStepDto { + /// Returns a new [WorkflowShareStepDto] instance. + WorkflowShareStepDto({ + this.config, + this.enabled, + required this.method, + }); + + /// Step configuration + Object? config; + + /// Step is enabled + /// + /// 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. + /// + bool? enabled; + + /// Step plugin method + String method; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowShareStepDto && + other.config == config && + other.enabled == enabled && + other.method == method; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (config == null ? 0 : config!.hashCode) + + (enabled == null ? 0 : enabled!.hashCode) + + (method.hashCode); + + @override + String toString() => 'WorkflowShareStepDto[config=$config, enabled=$enabled, method=$method]'; + + Map toJson() { + final json = {}; + if (this.config != null) { + json[r'config'] = this.config; + } else { + // json[r'config'] = null; + } + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + json[r'method'] = this.method; + return json; + } + + /// Returns a new [WorkflowShareStepDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowShareStepDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowShareStepDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowShareStepDto( + config: mapValueOfType(json, r'config'), + enabled: mapValueOfType(json, r'enabled'), + method: mapValueOfType(json, r'method')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowShareStepDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowShareStepDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowShareStepDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowShareStepDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'method', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_step_dto.dart b/mobile/openapi/lib/model/workflow_step_dto.dart new file mode 100644 index 0000000000..35e444a515 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_step_dto.dart @@ -0,0 +1,121 @@ +// +// 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 WorkflowStepDto { + /// Returns a new [WorkflowStepDto] instance. + WorkflowStepDto({ + this.config, + required this.enabled, + required this.method, + }); + + /// Step configuration + Object? config; + + /// Step is enabled + bool enabled; + + /// Step plugin method + String method; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowStepDto && + other.config == config && + other.enabled == enabled && + other.method == method; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (config == null ? 0 : config!.hashCode) + + (enabled.hashCode) + + (method.hashCode); + + @override + String toString() => 'WorkflowStepDto[config=$config, enabled=$enabled, method=$method]'; + + Map toJson() { + final json = {}; + if (this.config != null) { + json[r'config'] = this.config; + } else { + // json[r'config'] = null; + } + json[r'enabled'] = this.enabled; + json[r'method'] = this.method; + return json; + } + + /// Returns a new [WorkflowStepDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowStepDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowStepDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowStepDto( + config: mapValueOfType(json, r'config'), + enabled: mapValueOfType(json, r'enabled')!, + method: mapValueOfType(json, r'method')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowStepDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowStepDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowStepDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowStepDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'enabled', + 'method', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_trigger.dart b/mobile/openapi/lib/model/workflow_trigger.dart new file mode 100644 index 0000000000..2bd90af0d7 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_trigger.dart @@ -0,0 +1,85 @@ +// +// 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 WorkflowTrigger { + /// Instantiate a new enum with the provided [value]. + const WorkflowTrigger._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const assetCreate = WorkflowTrigger._(r'AssetCreate'); + static const personRecognized = WorkflowTrigger._(r'PersonRecognized'); + + /// List of all possible values in this [enum][WorkflowTrigger]. + static const values = [ + assetCreate, + personRecognized, + ]; + + static WorkflowTrigger? fromJson(dynamic value) => WorkflowTriggerTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowTrigger.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [WorkflowTrigger] to String, +/// and [decode] dynamic data back to [WorkflowTrigger]. +class WorkflowTriggerTypeTransformer { + factory WorkflowTriggerTypeTransformer() => _instance ??= const WorkflowTriggerTypeTransformer._(); + + const WorkflowTriggerTypeTransformer._(); + + String encode(WorkflowTrigger data) => data.value; + + /// Decodes a [dynamic value][data] to a WorkflowTrigger. + /// + /// 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. + WorkflowTrigger? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'AssetCreate': return WorkflowTrigger.assetCreate; + case r'PersonRecognized': return WorkflowTrigger.personRecognized; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [WorkflowTriggerTypeTransformer] instance. + static WorkflowTriggerTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/workflow_trigger_response_dto.dart b/mobile/openapi/lib/model/workflow_trigger_response_dto.dart new file mode 100644 index 0000000000..0c589b36a9 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_trigger_response_dto.dart @@ -0,0 +1,109 @@ +// +// 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 WorkflowTriggerResponseDto { + /// Returns a new [WorkflowTriggerResponseDto] instance. + WorkflowTriggerResponseDto({ + required this.trigger, + this.types = const [], + }); + + /// Trigger type + WorkflowTrigger trigger; + + /// Workflow types + List types; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowTriggerResponseDto && + other.trigger == trigger && + _deepEquality.equals(other.types, types); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (trigger.hashCode) + + (types.hashCode); + + @override + String toString() => 'WorkflowTriggerResponseDto[trigger=$trigger, types=$types]'; + + Map toJson() { + final json = {}; + json[r'trigger'] = this.trigger; + json[r'types'] = this.types; + return json; + } + + /// Returns a new [WorkflowTriggerResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowTriggerResponseDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowTriggerResponseDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowTriggerResponseDto( + trigger: WorkflowTrigger.fromJson(json[r'trigger'])!, + types: WorkflowType.listFromJson(json[r'types']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowTriggerResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowTriggerResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowTriggerResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowTriggerResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'trigger', + 'types', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_type.dart b/mobile/openapi/lib/model/workflow_type.dart new file mode 100644 index 0000000000..8fb0551240 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_type.dart @@ -0,0 +1,85 @@ +// +// 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; + +/// Workflow types +class WorkflowType { + /// Instantiate a new enum with the provided [value]. + const WorkflowType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const assetV1 = WorkflowType._(r'AssetV1'); + static const assetPersonV1 = WorkflowType._(r'AssetPersonV1'); + + /// List of all possible values in this [enum][WorkflowType]. + static const values = [ + assetV1, + assetPersonV1, + ]; + + static WorkflowType? fromJson(dynamic value) => WorkflowTypeTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [WorkflowType] to String, +/// and [decode] dynamic data back to [WorkflowType]. +class WorkflowTypeTypeTransformer { + factory WorkflowTypeTypeTransformer() => _instance ??= const WorkflowTypeTypeTransformer._(); + + const WorkflowTypeTypeTransformer._(); + + String encode(WorkflowType data) => data.value; + + /// Decodes a [dynamic value][data] to a WorkflowType. + /// + /// 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. + WorkflowType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'AssetV1': return WorkflowType.assetV1; + case r'AssetPersonV1': return WorkflowType.assetPersonV1; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [WorkflowTypeTypeTransformer] instance. + static WorkflowTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/workflow_update_dto.dart b/mobile/openapi/lib/model/workflow_update_dto.dart index 9891fff079..3c920f6194 100644 --- a/mobile/openapi/lib/model/workflow_update_dto.dart +++ b/mobile/openapi/lib/model/workflow_update_dto.dart @@ -13,24 +13,14 @@ part of openapi.api; class WorkflowUpdateDto { /// Returns a new [WorkflowUpdateDto] instance. WorkflowUpdateDto({ - this.actions = const [], this.description, this.enabled, - this.filters = const [], this.name, - this.triggerType, + this.steps = const [], + this.trigger, }); - /// Workflow actions - List actions; - /// Workflow description - /// - /// 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. - /// String? description; /// Workflow enabled @@ -42,18 +32,11 @@ class WorkflowUpdateDto { /// bool? enabled; - /// Workflow filters - List filters; - /// Workflow 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. - /// String? name; + List steps; + /// Workflow trigger type /// /// Please note: This property should have been non-nullable! Since the specification file @@ -61,33 +44,30 @@ class WorkflowUpdateDto { /// 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; + WorkflowTrigger? trigger; @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.triggerType == triggerType; + _deepEquality.equals(other.steps, steps) && + other.trigger == trigger; @override int get hashCode => // ignore: unnecessary_parenthesis - (actions.hashCode) + (description == null ? 0 : description!.hashCode) + (enabled == null ? 0 : enabled!.hashCode) + - (filters.hashCode) + (name == null ? 0 : name!.hashCode) + - (triggerType == null ? 0 : triggerType!.hashCode); + (steps.hashCode) + + (trigger == null ? 0 : trigger!.hashCode); @override - String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]'; + String toString() => 'WorkflowUpdateDto[description=$description, enabled=$enabled, name=$name, steps=$steps, trigger=$trigger]'; Map toJson() { final json = {}; - json[r'actions'] = this.actions; if (this.description != null) { json[r'description'] = this.description; } else { @@ -98,16 +78,16 @@ class WorkflowUpdateDto { } else { // json[r'enabled'] = null; } - json[r'filters'] = this.filters; if (this.name != null) { json[r'name'] = this.name; } else { // json[r'name'] = null; } - if (this.triggerType != null) { - json[r'triggerType'] = this.triggerType; + json[r'steps'] = this.steps; + if (this.trigger != null) { + json[r'trigger'] = this.trigger; } else { - // json[r'triggerType'] = null; + // json[r'trigger'] = null; } return json; } @@ -121,12 +101,11 @@ class WorkflowUpdateDto { final json = value.cast(); return WorkflowUpdateDto( - actions: WorkflowActionItemDto.listFromJson(json[r'actions']), description: mapValueOfType(json, r'description'), enabled: mapValueOfType(json, r'enabled'), - filters: WorkflowFilterItemDto.listFromJson(json[r'filters']), name: mapValueOfType(json, r'name'), - triggerType: PluginTriggerType.fromJson(json[r'triggerType']), + steps: WorkflowStepDto.listFromJson(json[r'steps']), + trigger: WorkflowTrigger.fromJson(json[r'trigger']), ); } return null; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 38e1fe8e01..9ec5484303 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8660,8 +8660,60 @@ "/plugins": { "get": { "description": "Retrieve a list of plugins available to the authenticated user.", - "operationId": "getPlugins", - "parameters": [], + "operationId": "searchPlugins", + "parameters": [ + { + "name": "description", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "enabled", + "required": false, + "in": "query", + "description": "Whether the plugin is enabled", + "schema": { + "type": "boolean" + } + }, + { + "name": "id", + "required": false, + "in": "query", + "description": "Plugin ID", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "name", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "title", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "version", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "content": { @@ -8694,30 +8746,101 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "plugin.read", - "x-immich-state": "Alpha" + "x-immich-permission": "plugin.read" } }, - "/plugins/triggers": { + "/plugins/methods": { "get": { - "description": "Retrieve a list of all available plugin triggers.", - "operationId": "getPluginTriggers", - "parameters": [], + "description": "Retrieve a list of plugin methods", + "operationId": "searchPluginMethods", + "parameters": [ + { + "name": "description", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "enabled", + "required": false, + "in": "query", + "description": "Whether the plugin method is enabled", + "schema": { + "type": "boolean" + } + }, + { + "name": "id", + "required": false, + "in": "query", + "description": "Plugin method ID", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "name", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "pluginName", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "pluginVersion", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "title", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "trigger", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/WorkflowTrigger" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/WorkflowType" + } + } + ], "responses": { "200": { "content": { "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/PluginTriggerResponseDto" + "$ref": "#/components/schemas/PluginMethodResponseDto" }, "type": "array" } @@ -8737,22 +8860,17 @@ "api_key": [] } ], - "summary": "List all plugin triggers", + "summary": "Retrieve plugin methods", "tags": [ "Plugins" ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "plugin.read", - "x-immich-state": "Alpha" + "x-immich-permission": "plugin.read" } }, "/plugins/{id}": { @@ -8799,16 +8917,11 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "plugin.read", - "x-immich-state": "Alpha" + "x-immich-permission": "plugin.read" } }, "/queues": { @@ -14910,8 +15023,55 @@ "/workflows": { "get": { "description": "Retrieve a list of workflows available to the authenticated user.", - "operationId": "getWorkflows", - "parameters": [], + "operationId": "searchWorkflows", + "parameters": [ + { + "name": "description", + "required": false, + "in": "query", + "description": "Workflow description", + "schema": { + "type": "string" + } + }, + { + "name": "enabled", + "required": false, + "in": "query", + "description": "Workflow enabled", + "schema": { + "type": "boolean" + } + }, + { + "name": "id", + "required": false, + "in": "query", + "description": "Workflow ID", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "name", + "required": false, + "in": "query", + "description": "Workflow name", + "schema": { + "type": "string" + } + }, + { + "name": "trigger", + "required": false, + "in": "query", + "description": "Workflow trigger type", + "schema": { + "$ref": "#/components/schemas/WorkflowTrigger" + } + } + ], "responses": { "200": { "content": { @@ -14944,16 +15104,11 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "workflow.read", - "x-immich-state": "Alpha" + "x-immich-permission": "workflow.read" }, "post": { "description": "Create a new workflow, the workflow can also be created with empty filters and actions.", @@ -14998,16 +15153,54 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "workflow.create", - "x-immich-state": "Alpha" + "x-immich-permission": "workflow.create" + } + }, + "/workflows/triggers": { + "get": { + "description": "Retrieve a list of all available workflow triggers.", + "operationId": "getWorkflowTriggers", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/WorkflowTriggerResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List all workflow triggers", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v3.0.0", + "state": "Added" + } + ] } }, "/workflows/{id}": { @@ -15047,16 +15240,11 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "workflow.delete", - "x-immich-state": "Alpha" + "x-immich-permission": "workflow.delete" }, "get": { "description": "Retrieve information about a specific workflow by its ID.", @@ -15101,16 +15289,11 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "workflow.read", - "x-immich-state": "Alpha" + "x-immich-permission": "workflow.read" }, "put": { "description": "Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.", @@ -15165,16 +15348,62 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "workflow.update", - "x-immich-state": "Alpha" + "x-immich-permission": "workflow.update" + } + }, + "/workflows/{id}/share": { + "get": { + "description": "Retrieve a workflow details without ids, default values, etc.", + "operationId": "getWorkflowForShare", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowShareResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve a workflow", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v3.0.0", + "state": "Added" + } + ], + "x-immich-permission": "workflow.read" } } }, @@ -18207,7 +18436,7 @@ "VersionCheck", "OcrQueueAll", "Ocr", - "WorkflowRun" + "WorkflowAssetCreate" ], "type": "string" }, @@ -20174,116 +20403,49 @@ ], "type": "object" }, - "PluginActionResponseDto": { + "PluginMethodResponseDto": { "properties": { "description": { - "description": "Action description", + "description": "Description", "type": "string" }, - "id": { - "description": "Action ID", + "key": { + "description": "Key", "type": "string" }, - "methodName": { - "description": "Method name", - "type": "string" - }, - "pluginId": { - "description": "Plugin ID", + "name": { + "description": "Name", "type": "string" }, "schema": { - "description": "Action schema", + "description": "Schema", "nullable": true, "type": "object" }, - "supportedContexts": { - "description": "Supported contexts", + "title": { + "description": "Title", + "type": "string" + }, + "types": { + "description": "Workflow types", "items": { - "$ref": "#/components/schemas/PluginContextType" + "$ref": "#/components/schemas/WorkflowType" }, "type": "array" - }, - "title": { - "description": "Action title", - "type": "string" } }, "required": [ "description", - "id", - "methodName", - "pluginId", + "key", + "name", "schema", - "supportedContexts", - "title" - ], - "type": "object" - }, - "PluginContextType": { - "description": "Context type", - "enum": [ - "asset", - "album", - "person" - ], - "type": "string" - }, - "PluginFilterResponseDto": { - "properties": { - "description": { - "description": "Filter description", - "type": "string" - }, - "id": { - "description": "Filter ID", - "type": "string" - }, - "methodName": { - "description": "Method name", - "type": "string" - }, - "pluginId": { - "description": "Plugin ID", - "type": "string" - }, - "schema": { - "description": "Filter schema", - "nullable": true, - "type": "object" - }, - "supportedContexts": { - "description": "Supported contexts", - "items": { - "$ref": "#/components/schemas/PluginContextType" - }, - "type": "array" - }, - "title": { - "description": "Filter title", - "type": "string" - } - }, - "required": [ - "description", - "id", - "methodName", - "pluginId", - "schema", - "supportedContexts", - "title" + "title", + "types" ], "type": "object" }, "PluginResponseDto": { "properties": { - "actions": { - "description": "Plugin actions", - "items": { - "$ref": "#/components/schemas/PluginActionResponseDto" - }, - "type": "array" - }, "author": { "description": "Plugin author", "type": "string" @@ -20296,17 +20458,17 @@ "description": "Plugin description", "type": "string" }, - "filters": { - "description": "Plugin filters", - "items": { - "$ref": "#/components/schemas/PluginFilterResponseDto" - }, - "type": "array" - }, "id": { "description": "Plugin ID", "type": "string" }, + "methods": { + "description": "Plugin methods", + "items": { + "$ref": "#/components/schemas/PluginMethodResponseDto" + }, + "type": "array" + }, "name": { "description": "Plugin name", "type": "string" @@ -20325,12 +20487,11 @@ } }, "required": [ - "actions", "author", "createdAt", "description", - "filters", "id", + "methods", "name", "title", "updatedAt", @@ -20338,39 +20499,6 @@ ], "type": "object" }, - "PluginTriggerResponseDto": { - "properties": { - "contextType": { - "allOf": [ - { - "$ref": "#/components/schemas/PluginContextType" - } - ], - "description": "Context type" - }, - "type": { - "allOf": [ - { - "$ref": "#/components/schemas/PluginTriggerType" - } - ], - "description": "Trigger type" - } - }, - "required": [ - "contextType", - "type" - ], - "type": "object" - }, - "PluginTriggerType": { - "description": "Trigger type", - "enum": [ - "AssetCreate", - "PersonRecognized" - ], - "type": "string" - }, "PurchaseResponse": { "properties": { "hideBuyButtonUntil": { @@ -26008,179 +26136,57 @@ ], "type": "string" }, - "WorkflowActionItemDto": { - "properties": { - "actionConfig": { - "description": "Action configuration", - "type": "object" - }, - "pluginActionId": { - "description": "Plugin action ID", - "format": "uuid", - "type": "string" - } - }, - "required": [ - "pluginActionId" - ], - "type": "object" - }, - "WorkflowActionResponseDto": { - "properties": { - "actionConfig": { - "description": "Action configuration", - "nullable": true, - "type": "object" - }, - "id": { - "description": "Action ID", - "type": "string" - }, - "order": { - "description": "Action order", - "type": "number" - }, - "pluginActionId": { - "description": "Plugin action ID", - "type": "string" - }, - "workflowId": { - "description": "Workflow ID", - "type": "string" - } - }, - "required": [ - "actionConfig", - "id", - "order", - "pluginActionId", - "workflowId" - ], - "type": "object" - }, "WorkflowCreateDto": { "properties": { - "actions": { - "description": "Workflow actions", - "items": { - "$ref": "#/components/schemas/WorkflowActionItemDto" - }, - "type": "array" - }, "description": { "description": "Workflow description", + "nullable": true, "type": "string" }, "enabled": { "description": "Workflow enabled", "type": "boolean" }, - "filters": { - "description": "Workflow filters", + "name": { + "description": "Workflow name", + "nullable": true, + "type": "string" + }, + "steps": { "items": { - "$ref": "#/components/schemas/WorkflowFilterItemDto" + "$ref": "#/components/schemas/WorkflowStepDto" }, "type": "array" }, - "name": { - "description": "Workflow name", - "type": "string" - }, - "triggerType": { + "trigger": { "allOf": [ { - "$ref": "#/components/schemas/PluginTriggerType" + "$ref": "#/components/schemas/WorkflowTrigger" } ], "description": "Workflow trigger type" } }, "required": [ - "actions", - "filters", - "name", - "triggerType" - ], - "type": "object" - }, - "WorkflowFilterItemDto": { - "properties": { - "filterConfig": { - "description": "Filter configuration", - "type": "object" - }, - "pluginFilterId": { - "description": "Plugin filter ID", - "format": "uuid", - "type": "string" - } - }, - "required": [ - "pluginFilterId" - ], - "type": "object" - }, - "WorkflowFilterResponseDto": { - "properties": { - "filterConfig": { - "description": "Filter configuration", - "nullable": true, - "type": "object" - }, - "id": { - "description": "Filter ID", - "type": "string" - }, - "order": { - "description": "Filter order", - "type": "number" - }, - "pluginFilterId": { - "description": "Plugin filter ID", - "type": "string" - }, - "workflowId": { - "description": "Workflow ID", - "type": "string" - } - }, - "required": [ - "filterConfig", - "id", - "order", - "pluginFilterId", - "workflowId" + "trigger" ], "type": "object" }, "WorkflowResponseDto": { "properties": { - "actions": { - "description": "Workflow actions", - "items": { - "$ref": "#/components/schemas/WorkflowActionResponseDto" - }, - "type": "array" - }, "createdAt": { "description": "Creation date", "type": "string" }, "description": { "description": "Workflow description", + "nullable": true, "type": "string" }, "enabled": { "description": "Workflow enabled", "type": "boolean" }, - "filters": { - "description": "Workflow filters", - "items": { - "$ref": "#/components/schemas/WorkflowFilterResponseDto" - }, - "type": "array" - }, "id": { "description": "Workflow ID", "type": "string" @@ -26190,64 +26196,186 @@ "nullable": true, "type": "string" }, - "ownerId": { - "description": "Owner user ID", - "type": "string" - }, - "triggerType": { - "allOf": [ - { - "$ref": "#/components/schemas/PluginTriggerType" - } - ], - "description": "Workflow trigger type" - } - }, - "required": [ - "actions", - "createdAt", - "description", - "enabled", - "filters", - "id", - "name", - "ownerId", - "triggerType" - ], - "type": "object" - }, - "WorkflowUpdateDto": { - "properties": { - "actions": { - "description": "Workflow actions", + "steps": { + "description": "Workflow steps", "items": { - "$ref": "#/components/schemas/WorkflowActionItemDto" + "$ref": "#/components/schemas/WorkflowStepDto" }, "type": "array" }, + "trigger": { + "allOf": [ + { + "$ref": "#/components/schemas/WorkflowTrigger" + } + ], + "description": "Workflow trigger type" + }, + "updatedAt": { + "description": "Update date", + "type": "string" + } + }, + "required": [ + "createdAt", + "description", + "enabled", + "id", + "name", + "steps", + "trigger", + "updatedAt" + ], + "type": "object" + }, + "WorkflowShareResponseDto": { + "properties": { "description": { "description": "Workflow description", + "nullable": true, "type": "string" }, "enabled": { "description": "Workflow enabled", "type": "boolean" }, - "filters": { - "description": "Workflow filters", + "name": { + "description": "Workflow name", + "nullable": true, + "type": "string" + }, + "steps": { + "description": "Workflow steps", "items": { - "$ref": "#/components/schemas/WorkflowFilterItemDto" + "$ref": "#/components/schemas/WorkflowShareStepDto" }, "type": "array" }, - "name": { - "description": "Workflow name", - "type": "string" - }, - "triggerType": { + "trigger": { "allOf": [ { - "$ref": "#/components/schemas/PluginTriggerType" + "$ref": "#/components/schemas/WorkflowTrigger" + } + ], + "description": "Workflow trigger type" + } + }, + "required": [ + "description", + "name", + "steps", + "trigger" + ], + "type": "object" + }, + "WorkflowShareStepDto": { + "properties": { + "config": { + "description": "Step configuration", + "nullable": true, + "type": "object" + }, + "enabled": { + "description": "Step is enabled", + "type": "boolean" + }, + "method": { + "description": "Step plugin method", + "type": "string" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "WorkflowStepDto": { + "properties": { + "config": { + "description": "Step configuration", + "nullable": true, + "type": "object" + }, + "enabled": { + "description": "Step is enabled", + "type": "boolean" + }, + "method": { + "description": "Step plugin method", + "type": "string" + } + }, + "required": [ + "enabled", + "method" + ], + "type": "object" + }, + "WorkflowTrigger": { + "enum": [ + "AssetCreate", + "PersonRecognized" + ], + "type": "string" + }, + "WorkflowTriggerResponseDto": { + "properties": { + "trigger": { + "allOf": [ + { + "$ref": "#/components/schemas/WorkflowTrigger" + } + ], + "description": "Trigger type" + }, + "types": { + "description": "Workflow types", + "items": { + "$ref": "#/components/schemas/WorkflowType" + }, + "type": "array" + } + }, + "required": [ + "trigger", + "types" + ], + "type": "object" + }, + "WorkflowType": { + "description": "Workflow types", + "enum": [ + "AssetV1", + "AssetPersonV1" + ], + "type": "string" + }, + "WorkflowUpdateDto": { + "properties": { + "description": { + "description": "Workflow description", + "nullable": true, + "type": "string" + }, + "enabled": { + "description": "Workflow enabled", + "type": "boolean" + }, + "name": { + "description": "Workflow name", + "nullable": true, + "type": "string" + }, + "steps": { + "items": { + "$ref": "#/components/schemas/WorkflowStepDto" + }, + "type": "array" + }, + "trigger": { + "allOf": [ + { + "$ref": "#/components/schemas/WorkflowTrigger" } ], "description": "Workflow trigger type" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 1ae12cd091..04268ccdb7 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1561,51 +1561,31 @@ export type PersonStatisticsResponseDto = { /** Number of assets */ assets: number; }; -export type PluginActionResponseDto = { - /** Action description */ +export type PluginMethodResponseDto = { + /** Description */ description: string; - /** Action ID */ - id: string; - /** Method name */ - methodName: string; - /** Plugin ID */ - pluginId: string; - /** Action schema */ + /** Key */ + key: string; + /** Name */ + name: string; + /** Schema */ schema: object | null; - /** Supported contexts */ - supportedContexts: PluginContextType[]; - /** Action title */ - title: string; -}; -export type PluginFilterResponseDto = { - /** Filter description */ - description: string; - /** Filter ID */ - id: string; - /** Method name */ - methodName: string; - /** Plugin ID */ - pluginId: string; - /** Filter schema */ - schema: object | null; - /** Supported contexts */ - supportedContexts: PluginContextType[]; - /** Filter title */ + /** Title */ title: string; + /** Workflow types */ + types: WorkflowType[]; }; export type PluginResponseDto = { - /** Plugin actions */ - actions: PluginActionResponseDto[]; /** Plugin author */ author: string; /** Creation date */ createdAt: string; /** Plugin description */ description: string; - /** Plugin filters */ - filters: PluginFilterResponseDto[]; /** Plugin ID */ id: string; + /** Plugin methods */ + methods: PluginMethodResponseDto[]; /** Plugin name */ name: string; /** Plugin title */ @@ -1615,12 +1595,6 @@ export type PluginResponseDto = { /** Plugin version */ version: string; }; -export type PluginTriggerResponseDto = { - /** Context type */ - contextType: PluginContextType; - /** Trigger type */ - "type": PluginTriggerType; -}; export type QueueResponseDto = { /** Whether the queue is paused */ isPaused: boolean; @@ -2829,89 +2803,79 @@ export type CreateProfileImageResponseDto = { /** User ID */ userId: string; }; -export type WorkflowActionResponseDto = { - /** Action configuration */ - actionConfig: object | null; - /** Action ID */ - id: string; - /** Action order */ - order: number; - /** Plugin action ID */ - pluginActionId: string; - /** Workflow ID */ - workflowId: string; -}; -export type WorkflowFilterResponseDto = { - /** Filter configuration */ - filterConfig: object | null; - /** Filter ID */ - id: string; - /** Filter order */ - order: number; - /** Plugin filter ID */ - pluginFilterId: string; - /** Workflow ID */ - workflowId: string; +export type WorkflowStepDto = { + /** Step configuration */ + config?: object | null; + /** Step is enabled */ + enabled: boolean; + /** Step plugin method */ + method: string; }; export type WorkflowResponseDto = { - /** Workflow actions */ - actions: WorkflowActionResponseDto[]; /** Creation date */ createdAt: string; /** Workflow description */ - description: string; + description: string | null; /** Workflow enabled */ enabled: boolean; - /** Workflow filters */ - filters: WorkflowFilterResponseDto[]; /** Workflow ID */ id: string; /** Workflow name */ name: string | null; - /** Owner user ID */ - ownerId: string; + /** Workflow steps */ + steps: WorkflowStepDto[]; /** Workflow trigger type */ - triggerType: PluginTriggerType; -}; -export type WorkflowActionItemDto = { - /** Action configuration */ - actionConfig?: object; - /** Plugin action ID */ - pluginActionId: string; -}; -export type WorkflowFilterItemDto = { - /** Filter configuration */ - filterConfig?: object; - /** Plugin filter ID */ - pluginFilterId: string; + trigger: WorkflowTrigger; + /** Update date */ + updatedAt: string; }; export type WorkflowCreateDto = { - /** Workflow actions */ - actions: WorkflowActionItemDto[]; /** Workflow description */ - description?: string; + description?: string | null; /** Workflow enabled */ enabled?: boolean; - /** Workflow filters */ - filters: WorkflowFilterItemDto[]; /** Workflow name */ - name: string; + name?: string | null; + steps?: WorkflowStepDto[]; /** Workflow trigger type */ - triggerType: PluginTriggerType; + trigger: WorkflowTrigger; +}; +export type WorkflowTriggerResponseDto = { + /** Trigger type */ + trigger: WorkflowTrigger; + /** Workflow types */ + types: WorkflowType[]; }; export type WorkflowUpdateDto = { - /** Workflow actions */ - actions?: WorkflowActionItemDto[]; /** Workflow description */ - description?: string; + description?: string | null; /** Workflow enabled */ enabled?: boolean; - /** Workflow filters */ - filters?: WorkflowFilterItemDto[]; /** Workflow name */ - name?: string; + name?: string | null; + steps?: WorkflowStepDto[]; /** Workflow trigger type */ - triggerType?: PluginTriggerType; + trigger?: WorkflowTrigger; +}; +export type WorkflowShareStepDto = { + /** Step configuration */ + config?: object | null; + /** Step is enabled */ + enabled?: boolean; + /** Step plugin method */ + method: string; +}; +export type WorkflowShareResponseDto = { + /** Workflow description */ + description: string | null; + /** Workflow enabled */ + enabled?: boolean; + /** Workflow name */ + name: string | null; + /** Workflow steps */ + steps: WorkflowShareStepDto[]; + /** Workflow trigger type */ + trigger: WorkflowTrigger; }; export type SyncAckV1 = {}; export type SyncAlbumDeleteV1 = { @@ -5309,22 +5273,56 @@ export function getPersonThumbnail({ id }: { /** * List all plugins */ -export function getPlugins(opts?: Oazapfts.RequestOpts) { +export function searchPlugins({ description, enabled, id, name, title, version }: { + description?: string; + enabled?: boolean; + id?: string; + name?: string; + title?: string; + version?: string; +}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: PluginResponseDto[]; - }>("/plugins", { + }>(`/plugins${QS.query(QS.explode({ + description, + enabled, + id, + name, + title, + version + }))}`, { ...opts })); } /** - * List all plugin triggers + * Retrieve plugin methods */ -export function getPluginTriggers(opts?: Oazapfts.RequestOpts) { +export function searchPluginMethods({ description, enabled, id, name, pluginName, pluginVersion, title, trigger, $type }: { + description?: string; + enabled?: boolean; + id?: string; + name?: string; + pluginName?: string; + pluginVersion?: string; + title?: string; + trigger?: WorkflowTrigger; + $type?: WorkflowType; +}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: PluginTriggerResponseDto[]; - }>("/plugins/triggers", { + data: PluginMethodResponseDto[]; + }>(`/plugins/methods${QS.query(QS.explode({ + description, + enabled, + id, + name, + pluginName, + pluginVersion, + title, + trigger, + "type": $type + }))}`, { ...opts })); } @@ -6753,11 +6751,23 @@ export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) { /** * List all workflows */ -export function getWorkflows(opts?: Oazapfts.RequestOpts) { +export function searchWorkflows({ description, enabled, id, name, trigger }: { + description?: string; + enabled?: boolean; + id?: string; + name?: string; + trigger?: WorkflowTrigger; +}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: WorkflowResponseDto[]; - }>("/workflows", { + }>(`/workflows${QS.query(QS.explode({ + description, + enabled, + id, + name, + trigger + }))}`, { ...opts })); } @@ -6776,6 +6786,17 @@ export function createWorkflow({ workflowCreateDto }: { body: workflowCreateDto }))); } +/** + * List all workflow triggers + */ +export function getWorkflowTriggers(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowTriggerResponseDto[]; + }>("/workflows/triggers", { + ...opts + })); +} /** * Delete a workflow */ @@ -6816,6 +6837,19 @@ export function updateWorkflow({ id, workflowUpdateDto }: { body: workflowUpdateDto }))); } +/** + * Retrieve a workflow + */ +export function getWorkflowForShare({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowShareResponseDto; + }>(`/workflows/${encodeURIComponent(id)}/share`, { + ...opts + })); +} export enum ReactionLevel { Album = "album", Asset = "asset" @@ -7145,12 +7179,11 @@ export enum PartnerDirection { SharedBy = "shared-by", SharedWith = "shared-with" } -export enum PluginContextType { - Asset = "asset", - Album = "album", - Person = "person" +export enum WorkflowType { + AssetV1 = "AssetV1", + AssetPersonV1 = "AssetPersonV1" } -export enum PluginTriggerType { +export enum WorkflowTrigger { AssetCreate = "AssetCreate", PersonRecognized = "PersonRecognized" } @@ -7218,7 +7251,7 @@ export enum JobName { VersionCheck = "VersionCheck", OcrQueueAll = "OcrQueueAll", Ocr = "Ocr", - WorkflowRun = "WorkflowRun" + WorkflowAssetCreate = "WorkflowAssetCreate" } export enum SearchSuggestionType { Country = "country", diff --git a/packages/plugin-core/.gitignore b/packages/plugin-core/.gitignore new file mode 100644 index 0000000000..c925c21d56 --- /dev/null +++ b/packages/plugin-core/.gitignore @@ -0,0 +1,2 @@ +/dist +/node_modules diff --git a/packages/plugin-core/esbuild.js b/packages/plugin-core/esbuild.js new file mode 100644 index 0000000000..4e1eda47e2 --- /dev/null +++ b/packages/plugin-core/esbuild.js @@ -0,0 +1,11 @@ +const esbuild = require('esbuild'); + +esbuild.build({ + entryPoints: ['src/index.ts'], + outdir: 'dist', + bundle: true, + sourcemap: false, + minify: false, // might want to use true for production build + format: 'cjs', // needs to be CJS for now + target: ['es2020'], // don't go over es2020 because quickjs doesn't support it +}); diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json new file mode 100644 index 0000000000..b0e558cc3c --- /dev/null +++ b/packages/plugin-core/manifest.json @@ -0,0 +1,188 @@ +{ + "name": "immich-plugin-core", + "version": "2.0.1", + "title": "Immich Core Plugin", + "description": "Core workflow capabilities for Immich", + "author": "Immich Team", + "wasmPath": "dist/plugin.wasm", + "methods": [ + { + "name": "filterFileName", + "title": "Filter by filename", + "description": "Filter assets by filename pattern using text matching or regular expressions", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "title": "Filename pattern", + "description": "Text or regex pattern to match against filename" + }, + "matchType": { + "type": "string", + "title": "Match type", + "enum": ["contains", "regex", "exact"], + "default": "contains", + "description": "Type of pattern matching to perform" + }, + "caseSensitive": { + "type": "boolean", + "default": false, + "title": "Case sensitive", + "description": "Whether matching should be case-sensitive" + } + }, + "required": ["pattern"] + } + }, + { + "name": "filterFileType", + "title": "Filter by file type", + "description": "Filter assets by file type", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "fileTypes": { + "title": "File types", + "description": "Allowed file types", + "type": "string", + "array": true, + "enum": ["image", "video"] + } + }, + "required": ["fileTypes"] + } + }, + { + "name": "filterPerson", + "title": "Filter by person", + "description": "Filter by detected person", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "personIds": { + "type": "string", + "array": true, + "title": "Person IDs", + "description": "List of person to match", + "uiHint": "personI" + }, + "matchAny": { + "type": "boolean", + "default": true, + "description": "Match any name (true) or require all names (false)" + } + }, + "required": ["personIds"] + } + }, + { + "name": "assetArchive", + "title": "Archive", + "description": "Move the asset to archive", + "types": ["AssetV1"], + "schema": {} + }, + { + "name": "assetFavorite", + "title": "Favorite", + "description": "Mark the asset as favorite or unfavorite", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "inverse": { + "type": "boolean", + "title": "Inverse", + "description": "Unfavorite by default, set to true to favorite instead", + "default": false + } + } + } + }, + { + "name": "albumAddAssets", + "title": "Add to Album", + "description": "Add the item to a specified album", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "albumId": { + "type": "string", + "title": "Album ID", + "description": "Target album ID", + "uiHint": "albumId" + } + }, + "required": ["albumId"] + } + }, + { + "name": "test", + "title": "Test", + "description": "Test method with complete configuration examples", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "number1": { + "type": "number", + "title": "Number 1", + "description": "Basic number" + }, + "number2": { + "type": "number", + "title": "Number 2", + "array": true, + "description": "List of numbers" + }, + "string1": { + "type": "string", + "title": "String 1", + "description": "Basic string" + }, + "string2": { + "type": "string", + "title": "String 2", + "array": true, + "description": "List of strings" + }, + "string3": { + "type": "string", + "title": "String 3", + "enum": ["choice-1", "choice-2"], + "description": "Select from a list" + }, + "nested": { + "type": "object", + "title": "Nested", + "description": "Nested properties for nesting", + "properties": { + "nested1": { + "type": "string", + "title": "Nested 1", + "description": "Nested string" + }, + "nested2": { + "type": "number", + "title": "Nested 2", + "description": "Nested number" + } + } + }, + "albumId": { + "type": "string", + "title": "Album ID", + "description": "Target album ID", + "uiHint": "albumId" + } + }, + "required": ["albumId"] + } + } + ] +} diff --git a/plugins/mise.toml b/packages/plugin-core/mise.toml similarity index 72% rename from plugins/mise.toml rename to packages/plugin-core/mise.toml index 66a107674d..c2ef38b9f7 100644 --- a/plugins/mise.toml +++ b/packages/plugin-core/mise.toml @@ -8,4 +8,4 @@ run = "pnpm install --frozen-lockfile" [tasks.build] depends = ["install"] -run = "pnpm run build" +run = "pnpm run --filter @immich/plugin-sdk --filter @immich/plugin-core build" diff --git a/plugins/package.json b/packages/plugin-core/package.json similarity index 80% rename from plugins/package.json rename to packages/plugin-core/package.json index abeabe7161..050276dd38 100644 --- a/plugins/package.json +++ b/packages/plugin-core/package.json @@ -1,5 +1,5 @@ { - "name": "plugins", + "name": "@immich/plugin-core", "version": "1.0.0", "description": "", "main": "src/index.ts", @@ -14,6 +14,7 @@ "devDependencies": { "@extism/js-pdk": "^1.0.1", "esbuild": "^0.27.0", - "typescript": "^5.3.2" + "typescript": "^5.3.2", + "@immich/plugin-sdk": "workspace:*" } } diff --git a/packages/plugin-core/src/index.d.ts b/packages/plugin-core/src/index.d.ts new file mode 100644 index 0000000000..49d55fda34 --- /dev/null +++ b/packages/plugin-core/src/index.d.ts @@ -0,0 +1,17 @@ +// copy from +// import '@immich/plugin-sdk/host-functions'; +declare module 'extism:host' { + interface user { + albumAddAssets(ptr: PTR): I64; + } +} + +declare module 'main' { + export function assetFileFilter(): I32; + export function assetArchive(): I32; + export function assetFavorite(): I32; + export function assetLock(): I32; + export function assetTrash(): I32; + export function albumAddAssets(): I32; + export function test(): I32; +} diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts new file mode 100644 index 0000000000..67c43c30ed --- /dev/null +++ b/packages/plugin-core/src/index.ts @@ -0,0 +1,112 @@ +import { + AssetStatus, + AssetVisibility, + WorkflowType, + wrapper, +} from '@immich/plugin-sdk'; + +type AssetFileFilterConfig = { + pattern: string; + matchType?: 'contains' | 'exact' | 'regex'; + caseSensitive?: boolean; +}; +export const assetFileFilter = () => { + return wrapper(({ data, config }) => { + const { + pattern, + matchType = 'contains', + caseSensitive = false, + } = config as AssetFileFilterConfig; + + const { asset } = data; + + const fileName = asset.originalFileName || ''; + const searchName = caseSensitive ? fileName : fileName.toLowerCase(); + const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); + + if (matchType === 'exact') { + return { workflow: { continue: searchName === searchPattern } }; + } + + if (matchType === 'regex') { + const flags = caseSensitive ? '' : 'i'; + const regex = new RegExp(searchPattern, flags); + return { workflow: { continue: regex.test(fileName) } }; + } + + return { workflow: { continue: searchName.includes(searchPattern) } }; + }); +}; + +type AssetArchiveConfig = { + inverse?: boolean; +}; +export const assetArchive = () => { + return wrapper( + ({ config, data }) => { + const target: AssetVisibility = config.inverse + ? AssetVisibility.Timeline + : AssetVisibility.Archive; + if (target !== data.asset.visibility) { + return { + changes: { + asset: { visibility: target }, + }, + }; + } + } + ); +}; + +type AssetFavoriteConfig = { + inverse?: boolean; +}; +export const assetFavorite = () => { + return wrapper( + ({ config, data }) => { + const target = config.inverse ? false : true; + if (target !== data.asset.isFavorite) { + return { + changes: { + asset: { isFavorite: target }, + }, + }; + } + } + ); +}; + +export const assetLock = () => { + return wrapper(() => ({ + changes: { asset: { visibility: AssetVisibility.Locked } }, + })); +}; + +type AssetTrashConfig = { + inverse?: boolean; +}; +export const assetTrash = () => { + return wrapper(({ config }) => ({ + changes: { + asset: config.inverse + ? { deletedAt: null, status: AssetStatus.Active } + : { deletedAt: new Date().toISOString(), status: AssetStatus.Trashed }, + }, + })); +}; + +type AssetAddToAlbumConfig = { + albumId: string; +}; +export const albumAddAssets = () => { + return wrapper( + ({ config, data, functions }) => { + functions.albumAddAssets(config.albumId, [data.asset.id]); + return {}; + } + ); +}; + +export const test = () => { + return wrapper(() => ({})); +}; diff --git a/plugins/tsconfig.json b/packages/plugin-core/tsconfig.json similarity index 51% rename from plugins/tsconfig.json rename to packages/plugin-core/tsconfig.json index 86c9e766bf..3d138bcbbd 100644 --- a/plugins/tsconfig.json +++ b/packages/plugin-core/tsconfig.json @@ -1,19 +1,17 @@ { "compilerOptions": { "target": "es2020", // Specify ECMAScript target version - "module": "commonjs", // Specify module code generation - "lib": [ - "es2020" - ], // Specify a list of library files to be included in the compilation - "types": [ - "@extism/js-pdk", - "./src/index.d.ts" - ], // Specify a list of type definition files to be included in the compilation + "module": "nodenext", // Specify module code generation + "outDir": "./dist", + "lib": ["es2020"], // Specify a list of library files to be included in the compilation + "types": ["@extism/js-pdk"], // Specify a list of type definition files to be included in the compilation "strict": true, // Enable all strict type-checking options "esModuleInterop": true, // Enables compatibility with Babel-style module imports + "moduleResolution": "nodenext", + "declaration": true, + "emitDeclarationOnly": true, "skipLibCheck": true, // Skip type checking of declaration files - "allowJs": true, // Allow JavaScript files to be compiled - "noEmit": true // Do not emit outputs (no .js or .d.ts files) + "allowJs": true // Allow JavaScript files to be compiled }, "include": [ "src/**/*.ts" // Include all TypeScript files in src directory diff --git a/packages/plugin-sdk/.gitignore b/packages/plugin-sdk/.gitignore new file mode 100644 index 0000000000..c925c21d56 --- /dev/null +++ b/packages/plugin-sdk/.gitignore @@ -0,0 +1,2 @@ +/dist +/node_modules diff --git a/packages/plugin-sdk/esbuild.js b/packages/plugin-sdk/esbuild.js new file mode 100644 index 0000000000..d2e036e5c7 --- /dev/null +++ b/packages/plugin-sdk/esbuild.js @@ -0,0 +1,11 @@ +import esbuild from 'esbuild'; + +esbuild.build({ + entryPoints: ['src/index.ts'], + outdir: 'dist', + bundle: true, + sourcemap: false, + minify: false, + format: 'esm', + target: ['es2020'], +}); diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json new file mode 100644 index 0000000000..ff06c681fb --- /dev/null +++ b/packages/plugin-sdk/package.json @@ -0,0 +1,38 @@ +{ + "name": "@immich/plugin-sdk", + "version": "0.0.0", + "description": "", + "main": "index.js", + "type": "module", + "exports": { + "./host-functions": { + "import": "./dist/host-functions.js", + "types": "./dist/host-functions.d.ts" + }, + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "node esbuild.js && tsc --emitDeclarationOnly && tsc-alias" + }, + "files": [ + "dist" + ], + "keywords": [], + "author": "", + "license": "GNU Affero General Public License version 3", + "packageManager": "pnpm@10.30.3", + "devDependencies": { + "@extism/js-pdk": "^1.1.1", + "@types/node": "^24.11.0", + "esbuild": "^0.27.3", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@extism/js-pdk": "^1.1.1" + } +} diff --git a/packages/plugin-sdk/src/enum.ts b/packages/plugin-sdk/src/enum.ts new file mode 100644 index 0000000000..a11dab64da --- /dev/null +++ b/packages/plugin-sdk/src/enum.ts @@ -0,0 +1,33 @@ +export enum WorkflowTrigger { + AssetCreate = 'AssetCreate', + PersonRecognized = 'PersonRecognized', +} + +export enum WorkflowType { + AssetV1 = 'AssetV1', + AssetPersonV1 = 'AssetPersonV1', +} + +export enum AssetType { + Image = 'IMAGE', + Video = 'VIDEO', + Audio = 'AUDIO', + Other = 'OTHER', +} + +export enum AssetStatus { + Active = 'active', + Trashed = 'trashed', + Deleted = 'deleted', +} + +export enum AssetVisibility { + Archive = 'archive', + Timeline = 'timeline', + + /** + * Video part of the LivePhotos and MotionPhotos + */ + Hidden = 'hidden', + Locked = 'locked', +} diff --git a/packages/plugin-sdk/src/host-functions.ts b/packages/plugin-sdk/src/host-functions.ts new file mode 100644 index 0000000000..00727d1e15 --- /dev/null +++ b/packages/plugin-sdk/src/host-functions.ts @@ -0,0 +1,41 @@ +declare module 'extism:host' { + interface user { + albumAddAssets(ptr: PTR): I64; + } +} + +const host = Host.getFunctions(); +type HostFunctionName = keyof typeof host; + +const call = (name: HostFunctionName, authToken: string, args: T) => { + const pointer1 = Memory.fromString(JSON.stringify({ authToken, args })); + const fn = host[name]; + const handler = Memory.find(fn(pointer1.offset)); + + try { + const result = JSON.parse(handler.readString()) as + | { + success: true; + response: R; + } + | { success: false; status: number; message: string }; + + if (result.success) { + return result.response; + } + + throw new Error( + `Failed to call host function "${name}", received ${ + result.status + } - ${JSON.stringify(result.message)}` + ); + } finally { + handler.free(); + pointer1.free(); + } +}; + +export const hostFunctions = (authToken: string) => ({ + albumAddAssets: (albumId: string, assetIds: string[]) => + call('albumAddAssets', authToken, [albumId, { ids: assetIds }]), +}); diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts new file mode 100644 index 0000000000..6d4deb2053 --- /dev/null +++ b/packages/plugin-sdk/src/index.ts @@ -0,0 +1,4 @@ +export * from 'src/enum.js'; +export * from 'src/host-functions.js'; +export * from 'src/sdk.js'; +export * from 'src/types.js'; diff --git a/packages/plugin-sdk/src/sdk.ts b/packages/plugin-sdk/src/sdk.ts new file mode 100644 index 0000000000..ff160dcfdb --- /dev/null +++ b/packages/plugin-sdk/src/sdk.ts @@ -0,0 +1,47 @@ +import type { WorkflowType } from 'src/enum.js'; +import { hostFunctions } from 'src/host-functions.js'; +import type { + ConfigValue, + WorkflowEventPayload, + WorkflowResponse, +} from 'src/types.js'; + +export const wrapper = < + T extends WorkflowType = WorkflowType, + TConfig extends ConfigValue = ConfigValue +>( + fn: ( + payload: WorkflowEventPayload & { + functions: ReturnType; + } + ) => WorkflowResponse | undefined +) => { + try { + const input = Host.inputString(); + const event = JSON.parse(input) as WorkflowEventPayload; + const debug = event.workflow.debug ?? false; + + if (debug) { + console.trace(`Event trigger: ${event.trigger}`); + console.trace(`Event type: ${event.type}`); + console.trace(`Event data: ${JSON.stringify(event.data)}`); + console.trace(`Event config: ${JSON.stringify(event.config)}`); + } + + const response = + fn({ ...event, functions: hostFunctions(event.workflow.authToken) }) ?? + {}; + + if (debug) { + console.trace(`Output workflow: ${JSON.stringify(response.workflow)}`); + console.trace(`Output changes: ${JSON.stringify(response.changes)}`); + console.trace(`Output data: ${JSON.stringify(response.data)}`); + } + + const output = JSON.stringify(response); + Host.outputString(output); + } catch (error: Error | any) { + console.error(`Unhandled plugin exception: ${error.message || error}`); + return { workflow: { continue: false } }; + } +}; diff --git a/packages/plugin-sdk/src/types.ts b/packages/plugin-sdk/src/types.ts new file mode 100644 index 0000000000..d352dc8cf2 --- /dev/null +++ b/packages/plugin-sdk/src/types.ts @@ -0,0 +1,124 @@ +import type { + AssetStatus, + AssetType, + AssetVisibility, + WorkflowTrigger, + WorkflowType, +} from 'src/enum.js'; + +type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + +export type WorkflowEventMap = { + [WorkflowType.AssetV1]: AssetV1; + [WorkflowType.AssetPersonV1]: AssetPersonV1; +}; + +export type WorkflowEventData = WorkflowEventMap[T]; + +export type WorkflowEventPayload< + T extends WorkflowType = WorkflowType, + TConfig = WorkflowStepConfig +> = { + trigger: WorkflowTrigger; + type: T; + data: WorkflowEventData; + config: TConfig; + workflow: { + id: string; + authToken: string; + stepId: string; + debug?: boolean; + }; +}; + +export type WorkflowResponse = { + workflow?: { + /** stop the workflow */ + continue?: boolean; + }; + changes?: DeepPartial>; + /** data to be passed to the next workflow step */ + data?: Record; +}; + +export type WorkflowStepConfig = { + [key: string]: ConfigValue; +}; + +export type ConfigValue = + | string + | number + | boolean + | null + | ConfigValue[] + | { [key: string]: ConfigValue }; + +export type AssetV1 = { + asset: { + id: string; + ownerId: string; + type: AssetType; + originalPath: string; + fileCreatedAt: Date; + fileModifiedAt: Date; + isFavorite: boolean; + checksum: Buffer; // sha1 checksum + livePhotoVideoId: string | null; + updatedAt: Date; + createdAt: Date; + originalFileName: string; + isOffline: boolean; + libraryId: string | null; + isExternal: boolean; + deletedAt: Date | null; + localDateTime: Date; + stackId: string | null; + duplicateId: string | null; + status: AssetStatus; + visibility: AssetVisibility; + isEdited: boolean; + exifInfo: { + make: string | null; + model: string | null; + exifImageWidth: number | null; + exifImageHeight: number | null; + fileSizeInByte: number | null; + orientation: string | null; + dateTimeOriginal: Date | null; + modifyDate: Date | null; + lensModel: string | null; + fNumber: number | null; + focalLength: number | null; + iso: number | null; + latitude: number | null; + longitude: number | null; + city: string | null; + state: string | null; + country: string | null; + description: string | null; + fps: number | null; + exposureTime: string | null; + livePhotoCID: string | null; + timeZone: string | null; + projectionType: string | null; + profileDescription: string | null; + colorspace: string | null; + bitsPerSample: number | null; + autoStackId: string | null; + rating: number | null; + tags: string[] | null; + updatedAt: Date | null; + } | null; + }; +}; + +export type AssetPersonV1 = AssetV1 & { + person: { + id: string; + name: string; + }; +}; diff --git a/packages/plugin-sdk/tsconfig.json b/packages/plugin-sdk/tsconfig.json new file mode 100644 index 0000000000..2b58a192db --- /dev/null +++ b/packages/plugin-sdk/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "module": "nodenext", + "target": "esnext", + "strict": true, + "removeComments": true, + "lib": ["esnext"], + "outDir": "./dist", + "types": ["node", "@extism/js-pdk"], + "sourceMap": false, + "declaration": true, + "emitDeclarationOnly": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "baseUrl": "./", + "paths": { + "src/*": ["./src/*"] + }, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "noUncheckedSideEffectImports": true, + "moduleDetection": "force", + "skipLibCheck": true, + "esModuleInterop": true + } +} diff --git a/plugins/.gitignore b/plugins/.gitignore deleted file mode 100644 index 76add878f8..0000000000 --- a/plugins/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -dist \ No newline at end of file diff --git a/plugins/LICENSE b/plugins/LICENSE deleted file mode 100644 index 53f0fa6953..0000000000 --- a/plugins/LICENSE +++ /dev/null @@ -1,26 +0,0 @@ -Copyright 2024, The Extism Authors. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its contributors - may be used to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/plugins/esbuild.js b/plugins/esbuild.js deleted file mode 100644 index 04cb6e85aa..0000000000 --- a/plugins/esbuild.js +++ /dev/null @@ -1,12 +0,0 @@ -const esbuild = require('esbuild'); - -esbuild - .build({ - entryPoints: ['src/index.ts'], - outdir: 'dist', - bundle: true, - sourcemap: true, - minify: false, // might want to use true for production build - format: 'cjs', // needs to be CJS for now - target: ['es2020'] // don't go over es2020 because quickjs doesn't support it - }) \ No newline at end of file diff --git a/plugins/manifest.json b/plugins/manifest.json deleted file mode 100644 index 4d2de275ca..0000000000 --- a/plugins/manifest.json +++ /dev/null @@ -1,159 +0,0 @@ -{ - "name": "immich-core", - "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" - ], - "schema": { - "type": "object", - "properties": { - "pattern": { - "type": "string", - "title": "Filename pattern", - "description": "Text or regex pattern to match against filename" - }, - "matchType": { - "type": "string", - "title": "Match type", - "enum": [ - "contains", - "regex", - "exact" - ], - "default": "contains", - "description": "Type of pattern matching to perform" - }, - "caseSensitive": { - "type": "boolean", - "default": false, - "description": "Whether matching should be case-sensitive" - } - }, - "required": [ - "pattern" - ] - } - }, - { - "methodName": "filterFileType", - "title": "Filter by file type", - "description": "Filter assets by file type", - "supportedContexts": [ - "asset" - ], - "schema": { - "type": "object", - "properties": { - "fileTypes": { - "type": "array", - "title": "File types", - "items": { - "type": "string", - "enum": [ - "image", - "video" - ] - }, - "description": "Allowed file types" - } - }, - "required": [ - "fileTypes" - ] - } - }, - { - "methodName": "filterPerson", - "title": "Filter by person", - "description": "Filter by detected person", - "supportedContexts": [ - "person" - ], - "schema": { - "type": "object", - "properties": { - "personIds": { - "type": "array", - "title": "Person IDs", - "items": { - "type": "string" - }, - "description": "List of person to match", - "subType": "people-picker" - }, - "matchAny": { - "type": "boolean", - "default": true, - "description": "Match any name (true) or require all names (false)" - } - }, - "required": [ - "personIds" - ] - } - } - ], - "actions": [ - { - "methodName": "actionArchive", - "title": "Archive", - "description": "Move the asset to archive", - "supportedContexts": [ - "asset" - ], - "schema": {} - }, - { - "methodName": "actionFavorite", - "title": "Favorite", - "description": "Mark the asset as favorite or unfavorite", - "supportedContexts": [ - "asset" - ], - "schema": { - "type": "object", - "properties": { - "favorite": { - "type": "boolean", - "default": true, - "description": "Set favorite (true) or unfavorite (false)" - } - } - } - }, - { - "methodName": "actionAddToAlbum", - "title": "Add to Album", - "description": "Add the item to a specified album", - "supportedContexts": [ - "asset", - "person" - ], - "schema": { - "type": "object", - "properties": { - "albumId": { - "type": "string", - "title": "Album ID", - "description": "Target album ID", - "subType": "album-picker" - } - }, - "required": [ - "albumId" - ] - } - } - ] -} diff --git a/plugins/package-lock.json b/plugins/package-lock.json deleted file mode 100644 index 9ebaa59a02..0000000000 --- a/plugins/package-lock.json +++ /dev/null @@ -1,533 +0,0 @@ -{ - "name": "plugins", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "plugins", - "version": "1.0.0", - "license": "AGPL-3.0", - "devDependencies": { - "@extism/js-pdk": "^1.0.1", - "esbuild": "^0.27.0", - "typescript": "^5.3.2" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@extism/js-pdk": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@extism/js-pdk/-/js-pdk-1.1.1.tgz", - "integrity": "sha512-VZLn/dX0ttA1uKk2PZeR/FL3N+nA1S5Vc7E5gdjkR60LuUIwCZT9cYON245V4HowHlBA7YOegh0TLjkx+wNbrA==", - "dev": true, - "license": "BSD-Clause-3", - "dependencies": { - "urlpattern-polyfill": "^8.0.2" - } - }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/urlpattern-polyfill": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz", - "integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/plugins/src/index.d.ts b/plugins/src/index.d.ts deleted file mode 100644 index 7f805aafe6..0000000000 --- a/plugins/src/index.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -declare module 'main' { - export function filterFileName(): I32; - export function actionAddToAlbum(): I32; - export function actionArchive(): I32; -} - -declare module 'extism:host' { - interface user { - updateAsset(ptr: PTR): I32; - addAssetToAlbum(ptr: PTR): I32; - } -} diff --git a/plugins/src/index.ts b/plugins/src/index.ts deleted file mode 100644 index 9566c02cd8..0000000000 --- a/plugins/src/index.ts +++ /dev/null @@ -1,71 +0,0 @@ -const { updateAsset, addAssetToAlbum } = Host.getFunctions(); - -function parseInput() { - return JSON.parse(Host.inputString()); -} - -function returnOutput(output: any) { - Host.outputString(JSON.stringify(output)); - return 0; -} - -export function filterFileName() { - const input = parseInput(); - const { data, config } = input; - const { pattern, matchType = 'contains', caseSensitive = false } = config; - - const fileName = data.asset.originalFileName || data.asset.fileName || ''; - const searchName = caseSensitive ? fileName : fileName.toLowerCase(); - const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); - - let passed = false; - - if (matchType === 'exact') { - passed = searchName === searchPattern; - } else if (matchType === 'regex') { - const flags = caseSensitive ? '' : 'i'; - const regex = new RegExp(searchPattern, flags); - passed = regex.test(fileName); - } else { - // contains - passed = searchName.includes(searchPattern); - } - - return returnOutput({ passed }); -} - -export function actionAddToAlbum() { - const input = parseInput(); - const { authToken, config, data } = input; - const { albumId } = config; - - const ptr = Memory.fromString( - JSON.stringify({ - authToken, - assetId: data.asset.id, - albumId: albumId, - }) - ); - - addAssetToAlbum(ptr.offset); - ptr.free(); - - return returnOutput({ success: true }); -} - -export function actionArchive() { - const input = parseInput(); - const { authToken, data } = input; - const ptr = Memory.fromString( - JSON.stringify({ - authToken, - id: data.asset.id, - visibility: 'archive', - }) - ); - - updateAsset(ptr.offset); - ptr.free(); - - return returnOutput({ success: true }); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a026d30d90..91fb5f6a7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -329,11 +329,14 @@ importers: specifier: ^5.3.3 version: 5.9.3 - plugins: + packages/plugin-core: devDependencies: '@extism/js-pdk': specifier: ^1.0.1 version: 1.1.1 + '@immich/plugin-sdk': + specifier: workspace:* + version: link:../plugin-sdk esbuild: specifier: ^0.27.0 version: 0.27.3 @@ -341,11 +344,32 @@ importers: specifier: ^5.3.2 version: 5.9.3 + packages/plugin-sdk: + devDependencies: + '@extism/js-pdk': + specifier: ^1.1.1 + version: 1.1.1 + '@types/node': + specifier: ^24.11.0 + version: 24.11.0 + esbuild: + specifier: ^0.27.3 + version: 0.27.3 + tsc-alias: + specifier: ^1.8.16 + version: 1.8.16 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + server: dependencies: '@extism/extism': specifier: 2.0.0-rc13 version: 2.0.0-rc13 + '@immich/plugin-sdk': + specifier: workspace:* + version: link:../packages/plugin-sdk '@immich/sql-tools': specifier: ^0.3.2 version: 0.3.2 @@ -747,8 +771,8 @@ importers: specifier: workspace:* version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.64.0 - version: 0.64.0(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) + specifier: ^0.65.2 + version: 0.65.2(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5) '@mapbox/mapbox-gl-rtl-text': specifier: 0.3.0 version: 0.3.0 @@ -3029,8 +3053,8 @@ packages: peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.64.0': - resolution: {integrity: sha512-jbPN1x9KAAcW18h4RO7skbFYjkR4Lg+mEVjSDzsPC2NBNzSi4IA0PIHhFEwnD5dk4OS7+UjRG8m5/QTyotrm4A==} + '@immich/ui@0.65.2': + resolution: {integrity: sha512-CHxc0a7AgL822Lk94UP2dYN0AFnJE91yASLsQxaBrn494vxI/BWcEc0L/o+gn4+uevSERZzoye9vVNdK6wvloA==} peerDependencies: svelte: ^5.0.0 @@ -6121,6 +6145,10 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + comment-json@4.4.1: resolution: {integrity: sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==} engines: {node: '>= 6'} @@ -9136,6 +9164,10 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + mylas@2.1.14: + resolution: {integrity: sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==} + engines: {node: '>=16.0.0'} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -9656,6 +9688,10 @@ packages: engines: {node: '>=18'} hasBin: true + plimit-lit@1.6.1: + resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} + engines: {node: '>=12'} + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -10288,6 +10324,10 @@ packages: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} + queue-lit@1.5.2: + resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==} + engines: {node: '>=12'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -11510,6 +11550,11 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tsc-alias@1.8.16: + resolution: {integrity: sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==} + engines: {node: '>=16.20.2'} + hasBin: true + tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} @@ -14951,7 +14996,7 @@ snapshots: node-emoji: 2.2.0 svelte: 5.53.5 - '@immich/ui@0.64.0(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)': + '@immich/ui@0.65.2(@sveltejs/kit@2.53.3(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.5)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.3)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.5)': dependencies: '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.5) '@internationalized/date': 3.10.0 @@ -18363,6 +18408,8 @@ snapshots: commander@8.3.0: {} + commander@9.5.0: {} + comment-json@4.4.1: dependencies: array-timsort: 1.0.3 @@ -22075,6 +22122,8 @@ snapshots: mute-stream@2.0.0: {} + mylas@2.1.14: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -22618,6 +22667,10 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + plimit-lit@1.6.1: + dependencies: + queue-lit: 1.5.2 + pluralize@8.0.0: {} pmtiles@3.2.1: @@ -23284,6 +23337,8 @@ snapshots: dependencies: side-channel: 1.1.0 + queue-lit@1.5.2: {} + queue-microtask@1.2.3: {} quick-lru@5.1.1: {} @@ -24876,6 +24931,16 @@ snapshots: ts-interface-checker@0.1.13: {} + tsc-alias@1.8.16: + dependencies: + chokidar: 3.6.0 + commander: 9.5.0 + get-tsconfig: 4.13.0 + globby: 11.1.0 + mylas: 2.1.14 + normalize-path: 3.0.0 + plimit-lit: 1.6.1 + tsconfck@3.1.6(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index be30451965..0888ad4394 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,7 @@ packages: - plugins - web - .github + - packages/* ignoredBuiltDependencies: - '@nestjs/core' - '@parcel/watcher' diff --git a/server/Dockerfile b/server/Dockerfile index 9cc53c1095..9a8be2ab7b 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -13,12 +13,13 @@ FROM builder AS server WORKDIR /usr/src/app COPY ./server ./server/ +COPY ./packages/plugin-sdk ./packages/plugin-sdk/ RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \ --mount=type=bind,source=package.json,target=package.json \ --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ - SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile build && \ + SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --filter @immich/plugin-sdk --frozen-lockfile build && \ SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned FROM builder AS web @@ -55,21 +56,21 @@ ARG TARGETPLATFORM COPY --from=ghcr.io/jdx/mise:2026.1.1@sha256:a55c391f7582f34c58bce1a85090cd526596402ba77fc32b06c49b8404ef9c14 /usr/local/bin/mise /usr/local/bin/mise WORKDIR /usr/src/app -COPY ./plugins/mise.toml ./plugins/ -ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml +COPY ./packages/plugin-core/mise.toml ./packages/plugin-core/ +ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/packages/plugin-core/mise.toml ENV MISE_DATA_DIR=/buildcache/mise RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ - mise install --cd plugins + mise install --cd packages/plugin-core -COPY ./plugins ./plugins/ +COPY ./packages ./packages/ # Build plugins -RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \ +RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \ --mount=type=bind,source=package.json,target=package.json \ --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ - cd plugins && mise run build + cd packages/plugin-core && mise run build FROM ghcr.io/immich-app/base-server-prod:202603031112@sha256:bb8c8645ee61977140121e56ba09db7ae656a7506f9a6af1be8461b4d81fdf03 @@ -81,8 +82,8 @@ ENV NODE_ENV=production \ COPY --from=server /output/server-pruned ./server COPY --from=web /usr/src/app/web/build /build/www COPY --from=cli /output/cli-pruned ./cli -COPY --from=plugins /usr/src/app/plugins/dist /build/corePlugin/dist -COPY --from=plugins /usr/src/app/plugins/manifest.json /build/corePlugin/manifest.json +COPY --from=plugins /usr/src/app/packages/plugin-core/dist /build/corePlugin/dist +COPY --from=plugins /usr/src/app/packages/plugin-core/manifest.json /build/corePlugin/manifest.json RUN ln -s ../../cli/bin/immich server/bin/immich COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /LICENSE diff --git a/server/package.json b/server/package.json index 943f630687..30ae25fd55 100644 --- a/server/package.json +++ b/server/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@extism/extism": "2.0.0-rc13", + "@immich/plugin-sdk": "workspace:*", "@immich/sql-tools": "^0.3.2", "@nestjs/bullmq": "^11.0.1", "@nestjs/common": "^11.0.4", diff --git a/server/src/controllers/plugin.controller.spec.ts b/server/src/controllers/plugin.controller.spec.ts new file mode 100644 index 0000000000..f9a9af5897 --- /dev/null +++ b/server/src/controllers/plugin.controller.spec.ts @@ -0,0 +1,56 @@ +import { PluginController } from 'src/controllers/plugin.controller'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PluginService } from 'src/services/plugin.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { factory } from 'test/small.factory'; +import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(PluginController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(PluginService); + + beforeAll(async () => { + ctx = await controllerSetup(PluginController, [ + { provide: PluginService, useValue: service }, + { provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) }, + ]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('GET /plugins', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/plugins'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require id to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get(`/plugins`) + .query({ id: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')])); + }); + }); + + describe('GET /plugins/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/plugins/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require id to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get(`/plugins/invalid`) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')])); + }); + }); +}); diff --git a/server/src/controllers/plugin.controller.ts b/server/src/controllers/plugin.controller.ts index 52c833e93d..fb1bda287e 100644 --- a/server/src/controllers/plugin.controller.ts +++ b/server/src/controllers/plugin.controller.ts @@ -1,7 +1,12 @@ -import { Controller, Get, Param } from '@nestjs/common'; +import { Controller, Get, Param, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; -import { PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto'; +import { + PluginMethodResponseDto, + PluginMethodSearchDto, + PluginResponseDto, + PluginSearchDto, +} 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,26 +17,26 @@ 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({ summary: 'List all plugins', description: 'Retrieve a list of plugins available to the authenticated user.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) - getPlugins(): Promise { - return this.service.getAll(); + searchPlugins(@Query() dto: PluginSearchDto): Promise { + return this.service.search(dto); + } + + @Get('methods') + @Authenticated({ permission: Permission.PluginRead }) + @Endpoint({ + summary: 'Retrieve plugin methods', + description: 'Retrieve a list of plugin methods', + history: HistoryBuilder.v3(), + }) + searchPluginMethods(@Query() dto: PluginMethodSearchDto): Promise { + return this.service.searchMethods(dto); } @Get(':id') @@ -39,7 +44,7 @@ export class PluginController { @Endpoint({ summary: 'Retrieve a plugin', description: 'Retrieve information about a specific plugin by its ID.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) getPlugin(@Param() { id }: UUIDParamDto): Promise { return this.service.get(id); diff --git a/server/src/controllers/workflow.controller.spec.ts b/server/src/controllers/workflow.controller.spec.ts new file mode 100644 index 0000000000..8980696284 --- /dev/null +++ b/server/src/controllers/workflow.controller.spec.ts @@ -0,0 +1,113 @@ +import { WorkflowController } from 'src/controllers/workflow.controller'; +import { WorkflowTrigger } from 'src/enum'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { WorkflowService } from 'src/services/workflow.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { factory } from 'test/small.factory'; +import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(WorkflowController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(WorkflowService); + + beforeAll(async () => { + ctx = await controllerSetup(WorkflowController, [ + { provide: WorkflowService, useValue: service }, + { provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) }, + ]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('POST /workflows', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/workflows').send({}); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require a valid trigger`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post(`/workflows`) + .send({ trigger: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.badRequest( + expect.arrayContaining([expect.stringContaining('trigger must be one of the following values')]), + ), + ); + }); + + it(`should require a valid enabled value`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post(`/workflows`) + .send({ enabled: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.badRequest(expect.arrayContaining([expect.stringContaining('enabled must be a boolean')])), + ); + }); + + it(`should not require a name`, async () => { + const { status } = await request(ctx.getHttpServer()) + .post(`/workflows`) + .send({ trigger: WorkflowTrigger.AssetCreate }) + .set('Authorization', `Bearer token`); + expect(status).toBe(201); + expect(service.create).toHaveBeenCalled(); + }); + }); + + describe('GET /workflows', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/workflows'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require id to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get(`/workflows`) + .query({ id: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')])); + }); + }); + + describe('GET /workflows/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/workflows/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require id to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get(`/workflows/invalid`) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')])); + }); + }); + + describe('PUT /workflows/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/workflows/${factory.uuid()}`).send({}); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require id to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/workflows/invalid`) + .set('Authorization', `Bearer token`) + .send({}); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')])); + }); + }); +}); diff --git a/server/src/controllers/workflow.controller.ts b/server/src/controllers/workflow.controller.ts index e07b6443f4..59fa2eee27 100644 --- a/server/src/controllers/workflow.controller.ts +++ b/server/src/controllers/workflow.controller.ts @@ -1,8 +1,15 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { WorkflowCreateDto, WorkflowResponseDto, WorkflowUpdateDto } from 'src/dtos/workflow.dto'; +import { + WorkflowCreateDto, + WorkflowResponseDto, + WorkflowSearchDto, + WorkflowShareResponseDto, + WorkflowTriggerResponseDto, + WorkflowUpdateDto, +} from 'src/dtos/workflow.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { WorkflowService } from 'src/services/workflow.service'; @@ -18,7 +25,7 @@ export class WorkflowController { @Endpoint({ summary: 'Create a workflow', description: 'Create a new workflow, the workflow can also be created with empty filters and actions.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) createWorkflow(@Auth() auth: AuthDto, @Body() dto: WorkflowCreateDto): Promise { return this.service.create(auth, dto); @@ -29,10 +36,21 @@ export class WorkflowController { @Endpoint({ summary: 'List all workflows', description: 'Retrieve a list of workflows available to the authenticated user.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) - getWorkflows(@Auth() auth: AuthDto): Promise { - return this.service.getAll(auth); + searchWorkflows(@Auth() auth: AuthDto, @Query() dto: WorkflowSearchDto): Promise { + return this.service.search(auth, dto); + } + + @Get('triggers') + @Authenticated({ permission: false }) + @Endpoint({ + summary: 'List all workflow triggers', + description: 'Retrieve a list of all available workflow triggers.', + history: HistoryBuilder.v3(), + }) + getWorkflowTriggers(): WorkflowTriggerResponseDto[] { + return this.service.getTriggers(); } @Get(':id') @@ -40,19 +58,30 @@ export class WorkflowController { @Endpoint({ summary: 'Retrieve a workflow', description: 'Retrieve information about a specific workflow by its ID.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) getWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } + @Get(':id/share') + @Authenticated({ permission: Permission.WorkflowRead }) + @Endpoint({ + summary: 'Retrieve a workflow', + description: 'Retrieve a workflow details without ids, default values, etc.', + history: HistoryBuilder.v3(), + }) + getWorkflowForShare(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.share(auth, id); + } + @Put(':id') @Authenticated({ permission: Permission.WorkflowUpdate }) @Endpoint({ summary: 'Update a workflow', description: 'Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) updateWorkflow( @Auth() auth: AuthDto, @@ -68,7 +97,7 @@ export class WorkflowController { @Endpoint({ summary: 'Delete a workflow', description: 'Delete a workflow by its ID.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) deleteWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); diff --git a/server/src/database.ts b/server/src/database.ts index ec614df9e0..f1b4eed593 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -7,8 +7,6 @@ import { AssetVisibility, MemoryType, Permission, - PluginContext, - PluginTriggerType, SharedLinkType, SourceType, UserAvatarColor, @@ -16,10 +14,8 @@ import { } from 'src/enum'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; -import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table'; -import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; +import { PluginTable } from 'src/schema/tables/plugin.table'; import { UserMetadataItem } from 'src/types'; -import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types'; export type AuthUser = { id: string; @@ -278,43 +274,6 @@ export type AssetFace = { export type Plugin = Selectable; -export type PluginFilter = Selectable & { - methodName: string; - title: string; - description: string; - supportedContexts: PluginContext[]; - schema: JSONSchema | null; -}; - -export type PluginAction = Selectable & { - methodName: string; - title: string; - description: string; - supportedContexts: PluginContext[]; - schema: JSONSchema | null; -}; - -export type Workflow = Selectable & { - triggerType: PluginTriggerType; - name: string | null; - description: string; - enabled: boolean; -}; - -export type WorkflowFilter = Selectable & { - workflowId: string; - pluginFilterId: string; - filterConfig: FilterConfig | null; - order: number; -}; - -export type WorkflowAction = Selectable & { - workflowId: string; - pluginActionId: string; - actionConfig: ActionConfig | null; - order: number; -}; - const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const; const userWithPrefixColumns = [ 'user2.id', @@ -345,6 +304,32 @@ export const columns = { 'asset.width', 'asset.height', ], + workflowAssetV1: [ + 'asset.id', + 'asset.ownerId', + 'asset.stackId', + 'asset.livePhotoVideoId', + 'asset.libraryId', + 'asset.duplicateId', + 'asset.createdAt', + 'asset.updatedAt', + 'asset.deletedAt', + 'asset.fileCreatedAt', + 'asset.fileModifiedAt', + 'asset.localDateTime', + 'asset.type', + 'asset.status', + 'asset.visibility', + 'asset.duration', + 'asset.checksum', + 'asset.originalPath', + 'asset.originalFileName', + 'asset.isOffline', + 'asset.isFavorite', + 'asset.isExternal', + 'asset.isEdited', + 'asset.isFavorite', + ], assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'], assetFilesForThumbnail: [ 'asset_file.id', @@ -476,17 +461,6 @@ export const columns = { 'asset_exif.tags', 'asset_exif.timeZone', ], - plugin: [ - 'plugin.id as id', - 'plugin.name as name', - 'plugin.title as title', - 'plugin.description as description', - 'plugin.author as author', - 'plugin.version as version', - 'plugin.wasmPath as wasmPath', - 'plugin.createdAt as createdAt', - 'plugin.updatedAt as updatedAt', - ], } as const; export type LockableProperty = (typeof lockableProperties)[number]; diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 695adb4a36..84be9920d4 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -210,6 +210,10 @@ export class HistoryBuilder { private hasDeprecated = false; private items: HistoryEntry[] = []; + static v3() { + return new HistoryBuilder().added('v3.0.0'); + } + added(version: string, description?: string) { return this.push({ version, state: 'Added', description }); } diff --git a/server/src/dtos/plugin-manifest.dto.ts b/server/src/dtos/plugin-manifest.dto.ts index d5d1c52997..47bef825bc 100644 --- a/server/src/dtos/plugin-manifest.dto.ts +++ b/server/src/dtos/plugin-manifest.dto.ts @@ -1,9 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { ArrayMinSize, IsArray, - IsEnum, IsNotEmpty, IsObject, IsOptional, @@ -12,69 +11,35 @@ import { Matches, ValidateNested, } from 'class-validator'; -import { PluginContext } from 'src/enum'; -import { JSONSchema } from 'src/types/plugin-schema.types'; +import { WorkflowType } from 'src/enum'; +import { JSONSchema } from 'src/types'; import { ValidateEnum } from 'src/validation'; -class PluginManifestWasmDto { - @ApiProperty({ description: 'WASM file path' }) +class PluginManifestMethodDto { + @ApiProperty({ description: 'Method name' }) @IsString() @IsNotEmpty() - path!: string; -} + name!: string; -class PluginManifestFilterDto { - @ApiProperty({ description: 'Filter method name' }) - @IsString() - @IsNotEmpty() - methodName!: string; - - @ApiProperty({ description: 'Filter title' }) + @ApiProperty({ description: 'Method title' }) @IsString() @IsNotEmpty() title!: string; - @ApiProperty({ description: 'Filter description' }) - @IsString() - @IsNotEmpty() - description!: string; - - @ApiProperty({ description: 'Supported contexts', enum: PluginContext, isArray: true }) - @IsArray() - @ArrayMinSize(1) - @IsEnum(PluginContext, { each: true }) - supportedContexts!: PluginContext[]; - - @ApiPropertyOptional({ description: 'Filter schema' }) - @IsObject() - @IsOptional() - schema?: JSONSchema; -} - -class PluginManifestActionDto { - @ApiProperty({ description: 'Action method name' }) - @IsString() - @IsNotEmpty() - methodName!: string; - - @ApiProperty({ description: 'Action title' }) - @IsString() - @IsNotEmpty() - title!: string; - - @ApiProperty({ description: 'Action description' }) + @ApiProperty({ description: 'Method description' }) @IsString() @IsNotEmpty() description!: string; @ArrayMinSize(1) - @ValidateEnum({ enum: PluginContext, name: 'PluginContext', each: true, description: 'Supported contexts' }) - supportedContexts!: PluginContext[]; + @ValidateEnum({ enum: WorkflowType, name: 'WorkflowType', each: true, description: 'Workflow type' }) + types!: WorkflowType[]; - @ApiPropertyOptional({ description: 'Action schema' }) + @ApiPropertyOptional({ description: 'Method schema' }) @IsObject() @IsOptional() - schema?: JSONSchema; + @Transform(({ value }) => (value && Object.keys(value).length === 0 ? null : value)) + schema?: JSONSchema | null; } export class PluginManifestDto { @@ -102,27 +67,20 @@ export class PluginManifestDto { @IsNotEmpty() description!: string; + @ApiProperty({ description: 'WASM file path' }) + @IsString() + @IsNotEmpty() + wasmPath!: string; + @ApiProperty({ description: 'Plugin author' }) @IsString() @IsNotEmpty() author!: string; - @ApiProperty({ description: 'WASM configuration' }) - @ValidateNested() - @Type(() => PluginManifestWasmDto) - wasm!: PluginManifestWasmDto; - @ApiPropertyOptional({ description: 'Plugin filters' }) @IsArray() @ValidateNested({ each: true }) - @Type(() => PluginManifestFilterDto) + @Type(() => PluginManifestMethodDto) @IsOptional() - filters?: PluginManifestFilterDto[]; - - @ApiPropertyOptional({ description: 'Plugin actions' }) - @IsArray() - @ValidateNested({ each: true }) - @Type(() => PluginManifestActionDto) - @IsOptional() - actions?: PluginManifestActionDto[]; + methods!: PluginManifestMethodDto[]; } diff --git a/server/src/dtos/plugin.dto.ts b/server/src/dtos/plugin.dto.ts index de1f1b28d4..ada2462231 100644 --- a/server/src/dtos/plugin.dto.ts +++ b/server/src/dtos/plugin.dto.ts @@ -1,15 +1,28 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; -import { PluginAction, PluginFilter } from 'src/database'; -import { PluginContext as PluginContextType, PluginTriggerType } from 'src/enum'; -import type { JSONSchema } from 'src/types/plugin-schema.types'; -import { ValidateEnum } from 'src/validation'; +import { WorkflowTrigger, WorkflowType } from 'src/enum'; +import { JSONSchema } from 'src/types'; +import { asMethodString } from 'src/utils/workflow'; +import { ValidateBoolean, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation'; -export class PluginTriggerResponseDto { - @ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', description: 'Trigger type' }) - type!: PluginTriggerType; - @ValidateEnum({ enum: PluginContextType, name: 'PluginContextType', description: 'Context type' }) - contextType!: PluginContextType; +export class PluginSearchDto { + @ValidateUUID({ optional: true, description: 'Plugin ID' }) + id?: string; + + @ValidateBoolean({ optional: true, description: 'Whether the plugin is enabled' }) + enabled?: boolean; + + @ValidateString({ optional: true }) + name?: string; + + @ValidateString({ optional: true }) + version?: string; + + @ValidateString({ optional: true }) + title?: string; + + @ValidateString({ optional: true }) + description?: string; } export class PluginResponseDto { @@ -29,45 +42,56 @@ export class PluginResponseDto { createdAt!: string; @ApiProperty({ description: 'Last update date' }) updatedAt!: string; - @ApiProperty({ description: 'Plugin filters' }) - filters!: PluginFilterResponseDto[]; - @ApiProperty({ description: 'Plugin actions' }) - actions!: PluginActionResponseDto[]; + @ApiProperty({ description: 'Plugin methods' }) + methods!: PluginMethodResponseDto[]; } -export class PluginFilterResponseDto { - @ApiProperty({ description: 'Filter ID' }) - id!: string; - @ApiProperty({ description: 'Plugin ID' }) - pluginId!: string; - @ApiProperty({ description: 'Method name' }) - methodName!: string; - @ApiProperty({ description: 'Filter title' }) - title!: string; - @ApiProperty({ description: 'Filter description' }) - description!: string; +export class PluginMethodSearchDto { + @ValidateUUID({ optional: true, description: 'Plugin method ID' }) + id?: string; - @ValidateEnum({ enum: PluginContextType, name: 'PluginContextType', each: true, description: 'Supported contexts' }) - supportedContexts!: PluginContextType[]; - @ApiProperty({ description: 'Filter schema' }) - schema!: JSONSchema | null; + @ValidateBoolean({ optional: true, description: 'Whether the plugin method is enabled' }) + enabled?: boolean; + + @ValidateString({ optional: true }) + name?: string; + + @ValidateString({ optional: true }) + title?: string; + + @ValidateString({ optional: true }) + description?: string; + + @ValidateEnum({ optional: true, enum: WorkflowType, name: 'WorkflowType' }) + type?: WorkflowType; + + @ValidateEnum({ optional: true, enum: WorkflowTrigger, name: 'WorkflowTrigger' }) + trigger?: WorkflowTrigger; + + @ValidateString({ optional: true }) + pluginName?: string; + + @ValidateString({ optional: true }) + pluginVersion?: string; } -export class PluginActionResponseDto { - @ApiProperty({ description: 'Action ID' }) - id!: string; - @ApiProperty({ description: 'Plugin ID' }) - pluginId!: string; - @ApiProperty({ description: 'Method name' }) - methodName!: string; - @ApiProperty({ description: 'Action title' }) +export class PluginMethodResponseDto { + @ApiProperty({ description: 'Key' }) + key!: string; + + @ApiProperty({ description: 'Name' }) + name!: string; + + @ApiProperty({ description: 'Title' }) title!: string; - @ApiProperty({ description: 'Action description' }) + + @ApiProperty({ description: 'Description' }) description!: string; - @ValidateEnum({ enum: PluginContextType, name: 'PluginContextType', each: true, description: 'Supported contexts' }) - supportedContexts!: PluginContextType[]; - @ApiProperty({ description: 'Action schema' }) + @ValidateEnum({ name: 'WorkflowType', enum: WorkflowType, each: true, description: 'Workflow types' }) + types!: WorkflowType[]; + + @ApiProperty({ description: 'Schema' }) schema!: JSONSchema | null; } @@ -78,21 +102,28 @@ export class PluginInstallDto { manifestPath!: string; } -export type MapPlugin = { +type Plugin = { id: string; name: string; title: string; description: string; author: string; version: string; - wasmPath: string; createdAt: Date; updatedAt: Date; - filters: PluginFilter[]; - actions: PluginAction[]; + methods: PluginMethod[]; }; -export function mapPlugin(plugin: MapPlugin): PluginResponseDto { +type PluginMethod = { + pluginName: string; + name: string; + title: string; + description: string; + types: WorkflowType[]; + schema: JSONSchema | null; +}; + +export function mapPlugin(plugin: Plugin): PluginResponseDto { return { id: plugin.id, name: plugin.name, @@ -102,7 +133,17 @@ export function mapPlugin(plugin: MapPlugin): PluginResponseDto { version: plugin.version, createdAt: plugin.createdAt.toISOString(), updatedAt: plugin.updatedAt.toISOString(), - filters: plugin.filters, - actions: plugin.actions, + methods: plugin.methods.map((method) => mapMethod(method)), }; } + +export const mapMethod = (method: PluginMethod): PluginMethodResponseDto => { + return { + key: asMethodString({ pluginName: method.pluginName, methodName: method.name }), + name: method.name, + title: method.title, + description: method.description, + types: method.types, + schema: method.schema, + }; +}; diff --git a/server/src/dtos/workflow.dto.ts b/server/src/dtos/workflow.dto.ts index c4e5ac9c4c..0017655528 100644 --- a/server/src/dtos/workflow.dto.ts +++ b/server/src/dtos/workflow.dto.ts @@ -1,160 +1,169 @@ +import type { WorkflowStepConfig } from '@immich/plugin-sdk'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsNotEmpty, IsObject, IsString, IsUUID, ValidateNested } from 'class-validator'; -import { WorkflowAction, WorkflowFilter } from 'src/database'; -import { PluginTriggerType } from 'src/enum'; -import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types'; -import { Optional, ValidateBoolean, ValidateEnum } from 'src/validation'; +import { IsObject, ValidateNested } from 'class-validator'; +import { WorkflowTrigger, WorkflowType } from 'src/enum'; +import { Optional, ValidateBoolean, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation'; -export class WorkflowFilterItemDto { - @ApiProperty({ description: 'Plugin filter ID' }) - @IsUUID() - pluginFilterId!: string; - - @ApiPropertyOptional({ description: 'Filter configuration' }) - @IsObject() - @Optional() - filterConfig?: FilterConfig; +export class WorkflowTriggerResponseDto { + @ValidateEnum({ enum: WorkflowTrigger, name: 'WorkflowTrigger', description: 'Trigger type' }) + trigger!: WorkflowTrigger; + @ValidateEnum({ enum: WorkflowType, name: 'WorkflowType', description: 'Workflow types', each: true }) + types!: WorkflowType[]; } -export class WorkflowActionItemDto { - @ApiProperty({ description: 'Plugin action ID' }) - @IsUUID() - pluginActionId!: string; +export class WorkflowSearchDto { + @ValidateUUID({ optional: true, description: 'Workflow ID' }) + id?: string; - @ApiPropertyOptional({ description: 'Action configuration' }) - @IsObject() - @Optional() - actionConfig?: ActionConfig; -} + @ValidateEnum({ + optional: true, + enum: WorkflowTrigger, + name: 'WorkflowTrigger', + description: 'Workflow trigger type', + }) + trigger?: WorkflowTrigger; -export class WorkflowCreateDto { - @ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', description: 'Workflow trigger type' }) - triggerType!: PluginTriggerType; + @ValidateString({ optional: true, description: 'Workflow name' }) + name?: string; - @ApiProperty({ description: 'Workflow name' }) - @IsString() - @IsNotEmpty() - name!: string; - - @ApiPropertyOptional({ description: 'Workflow description' }) - @IsString() - @Optional() + @ValidateString({ optional: true, description: 'Workflow description' }) description?: string; @ValidateBoolean({ optional: true, description: 'Workflow enabled' }) enabled?: boolean; - - @ApiProperty({ description: 'Workflow filters' }) - @ValidateNested({ each: true }) - @Type(() => WorkflowFilterItemDto) - filters!: WorkflowFilterItemDto[]; - - @ApiProperty({ description: 'Workflow actions' }) - @ValidateNested({ each: true }) - @Type(() => WorkflowActionItemDto) - actions!: WorkflowActionItemDto[]; } -export class WorkflowUpdateDto { +class WorkflowBase { + @ValidateString({ optional: true, nullable: true, description: 'Workflow name' }) + name?: string | null; + + @ValidateString({ optional: true, nullable: true, description: 'Workflow description' }) + description?: string | null; + + @ValidateBoolean({ optional: true, description: 'Workflow enabled' }) + enabled?: boolean; + + @ValidateNested({ each: true }) + @Type(() => WorkflowStepDto) + @Optional() + steps?: WorkflowStepDto[]; +} + +export class WorkflowCreateDto extends WorkflowBase { + @ValidateEnum({ enum: WorkflowTrigger, name: 'WorkflowTrigger', description: 'Workflow trigger type' }) + trigger!: WorkflowTrigger; +} + +export class WorkflowUpdateDto extends WorkflowBase { @ValidateEnum({ - enum: PluginTriggerType, - name: 'PluginTriggerType', + enum: WorkflowTrigger, + name: 'WorkflowTrigger', optional: true, description: 'Workflow trigger type', }) - triggerType?: PluginTriggerType; - - @ApiPropertyOptional({ description: 'Workflow name' }) - @IsString() - @IsNotEmpty() - @Optional() - name?: string; - - @ApiPropertyOptional({ description: 'Workflow description' }) - @IsString() - @Optional() - description?: string; - - @ValidateBoolean({ optional: true, description: 'Workflow enabled' }) - enabled?: boolean; - - @ApiPropertyOptional({ description: 'Workflow filters' }) - @ValidateNested({ each: true }) - @Type(() => WorkflowFilterItemDto) - @Optional() - filters?: WorkflowFilterItemDto[]; - - @ApiPropertyOptional({ description: 'Workflow actions' }) - @ValidateNested({ each: true }) - @Type(() => WorkflowActionItemDto) - @Optional() - actions?: WorkflowActionItemDto[]; + trigger?: WorkflowTrigger; } -export class WorkflowResponseDto { - @ApiProperty({ description: 'Workflow ID' }) - id!: string; - @ApiProperty({ description: 'Owner user ID' }) - ownerId!: string; - @ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', description: 'Workflow trigger type' }) - triggerType!: PluginTriggerType; +class WorkflowResponseBase { + @ValidateEnum({ enum: WorkflowTrigger, name: 'WorkflowTrigger', description: 'Workflow trigger type' }) + trigger!: WorkflowTrigger; + @ApiProperty({ description: 'Workflow name' }) name!: string | null; + @ApiProperty({ description: 'Workflow description' }) - description!: string; + description!: string | null; +} + +export class WorkflowResponseDto extends WorkflowResponseBase { + @ApiProperty({ description: 'Workflow ID' }) + id!: string; + @ApiProperty({ description: 'Creation date' }) createdAt!: string; + + @ApiProperty({ description: 'Update date' }) + updatedAt!: string; + @ApiProperty({ description: 'Workflow enabled' }) enabled!: boolean; - @ApiProperty({ description: 'Workflow filters' }) - filters!: WorkflowFilterResponseDto[]; - @ApiProperty({ description: 'Workflow actions' }) - actions!: WorkflowActionResponseDto[]; + + @ApiProperty({ description: 'Workflow steps' }) + steps!: WorkflowStepDto[]; +} +class WorkflowStepBase { + @ValidateString({ description: 'Step plugin method' }) + method!: string; + + @ApiPropertyOptional({ description: 'Step configuration' }) + @IsObject() + @Optional({ nullable: true }) + config!: WorkflowStepConfig | null; } -export class WorkflowFilterResponseDto { - @ApiProperty({ description: 'Filter ID' }) - id!: string; - @ApiProperty({ description: 'Workflow ID' }) - workflowId!: string; - @ApiProperty({ description: 'Plugin filter ID' }) - pluginFilterId!: string; - @ApiProperty({ description: 'Filter configuration' }) - filterConfig!: FilterConfig | null; - @ApiProperty({ description: 'Filter order', type: 'number' }) - order!: number; +class WorkflowStepDto extends WorkflowStepBase { + @ValidateBoolean({ optional: true, description: 'Step is enabled' }) + enabled!: boolean; } -export class WorkflowActionResponseDto { - @ApiProperty({ description: 'Action ID' }) - id!: string; - @ApiProperty({ description: 'Workflow ID' }) - workflowId!: string; - @ApiProperty({ description: 'Plugin action ID' }) - pluginActionId!: string; - @ApiProperty({ description: 'Action configuration' }) - actionConfig!: ActionConfig | null; - @ApiProperty({ description: 'Action order', type: 'number' }) - order!: number; +class WorkflowShareStepDto extends WorkflowStepBase { + @ValidateBoolean({ optional: true, description: 'Step is enabled' }) + enabled?: boolean; } -export function mapWorkflowFilter(filter: WorkflowFilter): WorkflowFilterResponseDto { +export class WorkflowShareResponseDto extends WorkflowResponseBase { + @ApiProperty({ description: 'Workflow enabled' }) + enabled?: boolean; + + @ApiProperty({ description: 'Workflow steps' }) + steps!: WorkflowShareStepDto[]; +} + +type Workflow = { + id: string; + createdAt: Date; + updatedAt: Date; + trigger: WorkflowTrigger; + name: string | null; + description: string | null; + enabled: boolean; +}; + +type WorkflowStep = { + enabled: boolean; + methodName: string; + config: WorkflowStepConfig | null; + pluginName: string; +}; + +export const mapWorkflow = (workflow: Workflow & { steps: WorkflowStep[] }): WorkflowResponseDto => { return { - id: filter.id, - workflowId: filter.workflowId, - pluginFilterId: filter.pluginFilterId, - filterConfig: filter.filterConfig, - order: filter.order, + id: workflow.id, + enabled: workflow.enabled, + trigger: workflow.trigger, + name: workflow.name, + description: workflow.description, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + steps: workflow.steps.map((step) => ({ + method: `${step.pluginName}#${step.methodName}`, + config: step.config, + enabled: step.enabled, + })), }; -} +}; -export function mapWorkflowAction(action: WorkflowAction): WorkflowActionResponseDto { +export const mapWorkflowShare = (workflow: Workflow & { steps: WorkflowStep[] }): WorkflowShareResponseDto => { return { - id: action.id, - workflowId: action.workflowId, - pluginActionId: action.pluginActionId, - actionConfig: action.actionConfig, - order: action.order, + enabled: workflow.enabled ? undefined : false, + trigger: workflow.trigger, + name: workflow.name, + description: workflow.description, + steps: workflow.steps.map((step) => ({ + method: `${step.pluginName}#${step.methodName}`, + config: step.config, + enabled: step.enabled ? undefined : false, + })), }; -} +}; diff --git a/server/src/enum.ts b/server/src/enum.ts index 2aa9bd2aa6..c2873108c1 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -544,8 +544,11 @@ export enum BootstrapEventPriority { StorageService = -195, // Other services may need to queue jobs on bootstrap. JobService = -190, - // Initialise config after other bootstrap services, stop other services from using config on bootstrap + // Initialize config after other bootstrap services, stop other services from using config on bootstrap SystemConfig = 100, + PluginSync = 190, + // Load plugins into memory after sync + PluginLoad = 200, } export enum QueueName { @@ -655,7 +658,7 @@ export enum JobName { Ocr = 'Ocr', // Workflow - WorkflowRun = 'WorkflowRun', + WorkflowAssetCreate = 'WorkflowAssetCreate', } export enum QueueCommand { @@ -694,6 +697,7 @@ export enum DatabaseLock { CLIPDimSize = 512, Library = 1337, NightlyJobs = 600, + PluginImport = 666, MediaLocation = 700, GetSystemConfig = 69, BackupDatabase = 42, @@ -882,13 +886,12 @@ export enum ApiTag { Workflows = 'Workflows', } -export enum PluginContext { - Asset = 'asset', - Album = 'album', - Person = 'person', -} - -export enum PluginTriggerType { +export enum WorkflowTrigger { AssetCreate = 'AssetCreate', PersonRecognized = 'PersonRecognized', } + +export enum WorkflowType { + AssetV1 = 'AssetV1', + AssetPersonV1 = 'AssetPersonV1', +} diff --git a/server/src/plugins.ts b/server/src/plugins.ts deleted file mode 100644 index 77f35e79f6..0000000000 --- a/server/src/plugins.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { PluginContext, PluginTriggerType } from 'src/enum'; - -export type PluginTrigger = { - type: PluginTriggerType; - contextType: PluginContext; -}; - -export const pluginTriggers: PluginTrigger[] = [ - { - type: PluginTriggerType.AssetCreate, - contextType: PluginContext.Asset, - }, - { - type: PluginTriggerType.PersonRecognized, - contextType: PluginContext.Person, - }, -]; diff --git a/server/src/queries/plugin.repository.sql b/server/src/queries/plugin.repository.sql index 82c203dafd..ba81a5b0eb 100644 --- a/server/src/queries/plugin.repository.sql +++ b/server/src/queries/plugin.repository.sql @@ -1,48 +1,17 @@ -- NOTE: This file is auto generated by ./sql-generator --- PluginRepository.getPlugin +-- PluginRepository.getForLoad select - "plugin"."id" as "id", - "plugin"."name" as "name", - "plugin"."title" as "title", - "plugin"."description" as "description", - "plugin"."author" as "author", - "plugin"."version" as "version", - "plugin"."wasmPath" as "wasmPath", - "plugin"."createdAt" as "createdAt", - "plugin"."updatedAt" as "updatedAt", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - * - from - "plugin_filter" - where - "plugin_filter"."pluginId" = "plugin"."id" - ) as agg - ) as "filters", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - * - from - "plugin_action" - where - "plugin_action"."pluginId" = "plugin"."id" - ) as agg - ) as "actions" + "id", + "name", + "version", + "wasmBytes" from "plugin" where - "plugin"."id" = $1 + "enabled" = $1 --- PluginRepository.getPluginByName +-- PluginRepository.search select "plugin"."id" as "id", "plugin"."name" as "name", @@ -50,7 +19,6 @@ select "plugin"."description" as "description", "plugin"."author" as "author", "plugin"."version" as "version", - "plugin"."wasmPath" as "wasmPath", "plugin"."createdAt" as "createdAt", "plugin"."updatedAt" as "updatedAt", ( @@ -61,99 +29,68 @@ select select * from - "plugin_filter" + "plugin_method" where - "plugin_filter"."pluginId" = "plugin"."id" + "plugin_method"."pluginId" = "plugin"."id" ) as agg - ) as "filters", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - * - from - "plugin_action" - where - "plugin_action"."pluginId" = "plugin"."id" - ) as agg - ) as "actions" -from - "plugin" -where - "plugin"."name" = $1 - --- PluginRepository.getAllPlugins -select - "plugin"."id" as "id", - "plugin"."name" as "name", - "plugin"."title" as "title", - "plugin"."description" as "description", - "plugin"."author" as "author", - "plugin"."version" as "version", - "plugin"."wasmPath" as "wasmPath", - "plugin"."createdAt" as "createdAt", - "plugin"."updatedAt" as "updatedAt", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - * - from - "plugin_filter" - where - "plugin_filter"."pluginId" = "plugin"."id" - ) as agg - ) as "filters", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - * - from - "plugin_action" - where - "plugin_action"."pluginId" = "plugin"."id" - ) as agg - ) as "actions" + ) as "methods" from "plugin" order by "plugin"."name" --- PluginRepository.getFilter +-- PluginRepository.getByName select - * + "plugin"."id" as "id", + "plugin"."name" as "name", + "plugin"."title" as "title", + "plugin"."description" as "description", + "plugin"."author" as "author", + "plugin"."version" as "version", + "plugin"."createdAt" as "createdAt", + "plugin"."updatedAt" as "updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_method" + where + "plugin_method"."pluginId" = "plugin"."id" + ) as agg + ) as "methods" from - "plugin_filter" + "plugin" where - "id" = $1 + "plugin"."name" = $1 --- PluginRepository.getFiltersByPlugin +-- PluginRepository.get select - * + "plugin"."id" as "id", + "plugin"."name" as "name", + "plugin"."title" as "title", + "plugin"."description" as "description", + "plugin"."author" as "author", + "plugin"."version" as "version", + "plugin"."createdAt" as "createdAt", + "plugin"."updatedAt" as "updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + * + from + "plugin_method" + where + "plugin_method"."pluginId" = "plugin"."id" + ) as agg + ) as "methods" from - "plugin_filter" + "plugin" where - "pluginId" = $1 - --- PluginRepository.getAction -select - * -from - "plugin_action" -where - "id" = $1 - --- PluginRepository.getActionsByPlugin -select - * -from - "plugin_action" -where - "pluginId" = $1 + "plugin"."id" = $1 diff --git a/server/src/queries/workflow.repository.sql b/server/src/queries/workflow.repository.sql index 27dc21dffe..cdbdf07133 100644 --- a/server/src/queries/workflow.repository.sql +++ b/server/src/queries/workflow.repository.sql @@ -1,70 +1,96 @@ -- NOTE: This file is auto generated by ./sql-generator --- WorkflowRepository.getWorkflow +-- WorkflowRepository.search select - * + "workflow"."id", + "workflow"."name", + "workflow"."description", + "workflow"."trigger", + "workflow"."enabled", + "workflow"."createdAt", + "workflow"."updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "plugin"."name" as "pluginName", + "plugin_method"."name" as "methodName", + "workflow_step"."config", + "workflow_step"."enabled" + from + "workflow_step" + inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId" + inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId" + ) as agg + ) as "steps" +from + "workflow" +order by + "createdAt" desc + +-- WorkflowRepository.get +select + "workflow"."id", + "workflow"."name", + "workflow"."description", + "workflow"."trigger", + "workflow"."enabled", + "workflow"."createdAt", + "workflow"."updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "plugin"."name" as "pluginName", + "plugin_method"."name" as "methodName", + "workflow_step"."config", + "workflow_step"."enabled" + from + "workflow_step" + inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId" + inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId" + ) as agg + ) as "steps" from "workflow" where "id" = $1 -order by - "createdAt" desc --- WorkflowRepository.getWorkflowsByOwner +-- WorkflowRepository.getForWorkflowRun select - * + "workflow"."id", + "workflow"."name", + "workflow"."trigger", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "workflow_step"."id", + "workflow_step"."config", + "plugin_method"."pluginId" as "pluginId", + "plugin_method"."name" as "methodName", + "plugin_method"."types" as "types" + from + "workflow_step" + inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId" + where + "workflow_step"."workflowId" = "workflow"."id" + and "workflow_step"."enabled" = $1 + ) as agg + ) as "steps" from "workflow" where - "ownerId" = $1 -order by - "createdAt" desc - --- WorkflowRepository.getWorkflowsByTrigger -select - * -from - "workflow" -where - "triggerType" = $1 - and "enabled" = $2 - --- WorkflowRepository.getWorkflowByOwnerAndTrigger -select - * -from - "workflow" -where - "ownerId" = $1 - and "triggerType" = $2 + "id" = $2 and "enabled" = $3 --- WorkflowRepository.deleteWorkflow +-- WorkflowRepository.delete delete from "workflow" where "id" = $1 - --- WorkflowRepository.getFilters -select - * -from - "workflow_filter" -where - "workflowId" = $1 -order by - "order" asc - --- WorkflowRepository.deleteFiltersByWorkflow -delete from "workflow_filter" -where - "workflowId" = $1 - --- WorkflowRepository.getActions -select - * -from - "workflow_action" -where - "workflowId" = $1 -order by - "order" asc diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index 7e8082a582..7fe50d4039 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -320,7 +320,7 @@ const getEnv = (): EnvData => { root: folders.web, indexHtml: join(folders.web, 'index.html'), }, - corePlugin: join(buildFolder, 'corePlugin'), + corePlugin: join(buildFolder, 'plugins', 'immich-plugin-core'), }, setup: { diff --git a/server/src/repositories/logging.repository.ts b/server/src/repositories/logging.repository.ts index 39867b14d0..c1df648f09 100644 --- a/server/src/repositories/logging.repository.ts +++ b/server/src/repositories/logging.repository.ts @@ -119,6 +119,10 @@ export class LoggingRepository { logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : []; } + getLogLevel(): LogLevel { + return logLevels[0] || LogLevel.Fatal; + } + verbose(message: string, ...details: LogDetails) { this.handleMessage(LogLevel.Verbose, message, details); } diff --git a/server/src/repositories/plugin.repository.ts b/server/src/repositories/plugin.repository.ts index 6217237947..f9afba16e7 100644 --- a/server/src/repositories/plugin.repository.ts +++ b/server/src/repositories/plugin.repository.ts @@ -1,176 +1,236 @@ +import { CallContext, Plugin as ExtismPlugin, newPlugin } from '@extism/extism'; import { Injectable } from '@nestjs/common'; -import { Kysely } from 'kysely'; +import { Insertable, Kysely } from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; -import { readdir } from 'node:fs/promises'; -import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { PluginMethodSearchDto, PluginSearchDto } from 'src/dtos/plugin.dto'; +import { LogLevel, WorkflowType } from 'src/enum'; +import { LoggingRepository } from 'src/repositories/logging.repository'; import { DB } from 'src/schema'; +import { PluginMethodTable } from 'src/schema/tables/plugin-method.table'; +import { PluginTable } from 'src/schema/tables/plugin.table'; + +type PluginMethod = { pluginId: string; methodName: string }; +type PluginLoad = { id: string; name: string; version: string; wasmBytes: Buffer }; +type PluginMapItem = { plugin: ExtismPlugin; name: string; version: string }; +export type PluginHostFunction = (callContext: CallContext, input: bigint) => any; // TODO probably needs to be bigint return as well +export type PluginLoadOptions = { + functions: Record; +}; + +export type PluginMethodSearchResponse = { + id: string; + name: string; + pluginName: string; + types: WorkflowType[]; +}; + +const levels = { + [LogLevel.Verbose]: 'trace', + [LogLevel.Debug]: 'debug', + [LogLevel.Log]: 'info', + [LogLevel.Warn]: 'warn', + [LogLevel.Error]: 'error', + [LogLevel.Fatal]: 'error', +} as const; + +const asExtismLogLevel = (logLevel: LogLevel) => levels[logLevel] || 'info'; @Injectable() export class PluginRepository { - constructor(@InjectKysely() private db: Kysely) {} + private pluginMap: Map = new Map(); - /** - * Loads a plugin from a validated manifest file in a transaction. - * This ensures all plugin, filter, and action operations are atomic. - * @param manifest The validated plugin manifest - * @param basePath The base directory path where the plugin is located - */ - async loadPlugin(manifest: PluginManifestDto, basePath: string) { - return this.db.transaction().execute(async (tx) => { - // Upsert the plugin - const plugin = await tx - .insertInto('plugin') - .values({ - name: manifest.name, - title: manifest.title, - description: manifest.description, - author: manifest.author, - version: manifest.version, - wasmPath: `${basePath}/${manifest.wasm.path}`, - }) - .onConflict((oc) => - oc.column('name').doUpdateSet({ - title: manifest.title, - description: manifest.description, - author: manifest.author, - version: manifest.version, - wasmPath: `${basePath}/${manifest.wasm.path}`, - }), - ) - .returningAll() - .executeTakeFirstOrThrow(); - - const filters = manifest.filters - ? await tx - .insertInto('plugin_filter') - .values( - manifest.filters.map((filter) => ({ - pluginId: plugin.id, - methodName: filter.methodName, - title: filter.title, - description: filter.description, - supportedContexts: filter.supportedContexts, - schema: filter.schema, - })), - ) - .onConflict((oc) => - oc.column('methodName').doUpdateSet((eb) => ({ - pluginId: eb.ref('excluded.pluginId'), - title: eb.ref('excluded.title'), - description: eb.ref('excluded.description'), - supportedContexts: eb.ref('excluded.supportedContexts'), - schema: eb.ref('excluded.schema'), - })), - ) - .returningAll() - .execute() - : []; - - const actions = manifest.actions - ? await tx - .insertInto('plugin_action') - .values( - manifest.actions.map((action) => ({ - pluginId: plugin.id, - methodName: action.methodName, - title: action.title, - description: action.description, - supportedContexts: action.supportedContexts, - schema: action.schema, - })), - ) - .onConflict((oc) => - oc.column('methodName').doUpdateSet((eb) => ({ - pluginId: eb.ref('excluded.pluginId'), - title: eb.ref('excluded.title'), - description: eb.ref('excluded.description'), - supportedContexts: eb.ref('excluded.supportedContexts'), - schema: eb.ref('excluded.schema'), - })), - ) - .returningAll() - .execute() - : []; - - return { plugin, filters, actions }; - }); - } - - async readDirectory(path: string) { - return readdir(path, { withFileTypes: true }); - } - - @GenerateSql({ params: [DummyValue.UUID] }) - getPlugin(id: string) { - return this.db - .selectFrom('plugin') - .select((eb) => [ - ...columns.plugin, - jsonArrayFrom( - eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), - ).as('filters'), - jsonArrayFrom( - eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), - ).as('actions'), - ]) - .where('plugin.id', '=', id) - .executeTakeFirst(); - } - - @GenerateSql({ params: [DummyValue.STRING] }) - getPluginByName(name: string) { - return this.db - .selectFrom('plugin') - .select((eb) => [ - ...columns.plugin, - jsonArrayFrom( - eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), - ).as('filters'), - jsonArrayFrom( - eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), - ).as('actions'), - ]) - .where('plugin.name', '=', name) - .executeTakeFirst(); + constructor( + @InjectKysely() private db: Kysely, + private logger: LoggingRepository, + ) { + this.logger.setContext(PluginRepository.name); } @GenerateSql() - getAllPlugins() { + getForLoad() { + return this.db + .selectFrom('plugin') + .select(['id', 'name', 'version', 'wasmBytes']) + .where('enabled', '=', true) + .execute(); + } + + private queryBuilder() { return this.db .selectFrom('plugin') .select((eb) => [ - ...columns.plugin, + 'plugin.id', + 'plugin.name', + 'plugin.title', + 'plugin.description', + 'plugin.author', + 'plugin.version', + 'plugin.createdAt', + 'plugin.updatedAt', jsonArrayFrom( - eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), - ).as('filters'), - jsonArrayFrom( - eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), - ).as('actions'), - ]) + eb + .selectFrom('plugin_method') + .select([ + 'plugin_method.name', + 'plugin_method.title', + 'plugin_method.description', + 'plugin_method.types', + 'plugin_method.schema', + 'plugin.name as pluginName', + ]) + .whereRef('plugin_method.pluginId', '=', 'plugin.id'), + ).as('methods'), + ]); + } + + @GenerateSql() + search(dto: PluginSearchDto = {}) { + return this.queryBuilder() + .$if(!!dto.id, (qb) => qb.where('plugin.id', '=', dto.id!)) + .$if(!!dto.name, (qb) => qb.where('plugin.name', '=', dto.name!)) + .$if(!!dto.title, (qb) => qb.where('plugin.title', '=', dto.title!)) + .$if(!!dto.description, (qb) => qb.where('plugin.description', '=', dto.description!)) + .$if(!!dto.version, (qb) => qb.where('plugin.version', '=', dto.version!)) .orderBy('plugin.name') .execute(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getFilter(id: string) { - return this.db.selectFrom('plugin_filter').selectAll().where('id', '=', id).executeTakeFirst(); + @GenerateSql({ params: [DummyValue.STRING] }) + getByName(name: string) { + return this.queryBuilder().where('plugin.name', '=', name).executeTakeFirst(); } @GenerateSql({ params: [DummyValue.UUID] }) - getFiltersByPlugin(pluginId: string) { - return this.db.selectFrom('plugin_filter').selectAll().where('pluginId', '=', pluginId).execute(); + get(id: string) { + return this.queryBuilder().where('plugin.id', '=', id).executeTakeFirst(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getAction(id: string) { - return this.db.selectFrom('plugin_action').selectAll().where('id', '=', id).executeTakeFirst(); + @GenerateSql() + getForValidation(): Promise { + return this.db + .selectFrom('plugin_method') + .innerJoin('plugin', 'plugin_method.pluginId', 'plugin.id') + .select(['plugin_method.id', 'plugin_method.name', 'plugin.name as pluginName', 'plugin_method.types']) + .execute(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getActionsByPlugin(pluginId: string) { - return this.db.selectFrom('plugin_action').selectAll().where('pluginId', '=', pluginId).execute(); + @GenerateSql() + searchMethods(dto: PluginMethodSearchDto = {}) { + return this.db + .selectFrom('plugin_method') + .innerJoin('plugin', 'plugin.id', 'plugin_method.pluginId') + .select([ + 'plugin_method.id', + 'plugin_method.name', + 'plugin_method.title', + 'plugin_method.description', + 'plugin_method.pluginId', + 'plugin_method.types', + 'plugin_method.schema', + 'plugin.name as pluginName', + ]) + .$if(!!dto.id, (qb) => qb.where('plugin_method.id', '=', dto.id!)) + .$if(!!dto.name, (qb) => qb.where('plugin_method.name', '=', dto.name!)) + .$if(!!dto.title, (qb) => qb.where('plugin_method.title', '=', dto.title!)) + .$if(!!dto.type, (qb) => qb.where('plugin_method.types', '@>', [dto.type!])) + .$if(!!dto.description, (qb) => qb.where('plugin_method.description', '=', dto.description!)) + .$if(!!dto.pluginVersion, (qb) => qb.where('plugin.version', '=', dto.pluginVersion!)) + .$if(!!dto.pluginName, (qb) => qb.where('plugin.name', '=', dto.pluginName!)) + .orderBy('plugin_method.name') + .execute(); + } + + async create(dto: Insertable, initialMethods: Omit, 'pluginId'>[]) { + return this.db.transaction().execute(async (tx) => { + // Upsert the plugin + const plugin = await tx + .insertInto('plugin') + .values(dto) + .onConflict((oc) => + oc.columns(['name', 'version']).doUpdateSet((eb) => ({ + title: eb.ref('excluded.title'), + description: eb.ref('excluded.description'), + author: eb.ref('excluded.author'), + version: eb.ref('excluded.version'), + wasmBytes: eb.ref('excluded.wasmBytes'), + })), + ) + .returning(['id', 'name']) + .executeTakeFirstOrThrow(); + + // TODO: handle methods that were removed in a new version + const methods = + initialMethods.length > 0 + ? await tx + .insertInto('plugin_method') + .values(initialMethods.map((method) => ({ ...method, pluginId: plugin.id }))) + .onConflict((oc) => + oc.columns(['pluginId', 'name']).doUpdateSet((eb) => ({ + pluginId: eb.ref('excluded.pluginId'), + title: eb.ref('excluded.title'), + description: eb.ref('excluded.description'), + types: eb.ref('excluded.types'), + schema: eb.ref('excluded.schema'), + })), + ) + .returningAll() + .execute() + : []; + + return { ...plugin, methods }; + }); + } + + async load({ id, name, version, wasmBytes }: PluginLoad, { functions }: PluginLoadOptions) { + const data = new Uint8Array(wasmBytes.buffer, wasmBytes.byteOffset, wasmBytes.byteLength); + const pluginLabel = `${name}@${version}`; + + try { + const logger = LoggingRepository.create(`Plugin:${pluginLabel}`); + const plugin = await newPlugin( + { wasm: [{ data }] }, + { + useWasi: true, + runInWorker: true, + functions: { + 'extism:host/user': functions, + }, + logLevel: asExtismLogLevel(logger.getLogLevel()), + logger: { + trace: (message) => logger.verbose(message), + info: (message) => logger.log(message), + debug: (message) => logger.debug(message), + warn: (message) => logger.warn(message), + error: (message) => logger.error(message), + } as Console, + }, + ); + this.pluginMap.set(id, { plugin, name, version }); + } catch (error: Error | any) { + throw new Error(`Unable to instantiate plugin: ${pluginLabel}`, { cause: error }); + } + } + + async callMethod({ pluginId, methodName }: PluginMethod, input: unknown) { + const item = this.pluginMap.get(pluginId); + if (!item) { + throw new Error(`No loaded plugin found for ${pluginId}`); + } + + const { plugin, name, version } = item; + const methodLabel = `${name}@${version}#${methodName}`; + + try { + const result = await plugin.call(methodName, JSON.stringify(input)); + if (result) { + return result.json() as T; + } + + return result as T; + } catch (error: Error | any) { + throw new Error(`Plugin method call failed: ${methodLabel}`, { cause: error }); + } } } diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 5a1a936e77..0c06492036 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -2,7 +2,15 @@ import { Injectable } from '@nestjs/common'; import archiver from 'archiver'; import chokidar, { ChokidarOptions } from 'chokidar'; import { escapePath, glob, globStream } from 'fast-glob'; -import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, ReadOptionsWithBuffer } from 'node:fs'; +import { + constants, + createReadStream, + createWriteStream, + Dirent, + existsSync, + mkdirSync, + ReadOptionsWithBuffer, +} from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; import { PassThrough, Readable, Writable } from 'node:stream'; @@ -50,6 +58,10 @@ export class StorageRepository { return fs.readdir(folder); } + readdirWithTypes(folder: string): Promise { + return fs.readdir(folder, { withFileTypes: true }); + } + copyFile(source: string, target: string) { return fs.copyFile(source, target); } @@ -117,17 +129,24 @@ export class StorageRepository { } async readFile(filepath: string, options?: ReadOptionsWithBuffer): Promise { - const file = await fs.open(filepath); - try { - const { buffer } = await file.read(options); - return buffer as Buffer; - } finally { - await file.close(); + // read a slice + if (options) { + const file = await fs.open(filepath); + try { + const { buffer } = await file.read(options); + return buffer as Buffer; + } finally { + await file.close(); + } } + + // read everything + return fs.readFile(filepath); } - async readTextFile(filepath: string): Promise { - return fs.readFile(filepath, 'utf8'); + async readJsonFile(filepath: string): Promise { + const file = await fs.readFile(filepath, 'utf8'); + return JSON.parse(file) as T; } async checkFileExists(filepath: string, mode = constants.F_OK): Promise { diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts index deaf2aa2fc..35254f3136 100644 --- a/server/src/repositories/workflow.repository.ts +++ b/server/src/repositories/workflow.repository.ts @@ -1,149 +1,177 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Updateable } from 'kysely'; +import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { PluginTriggerType } from 'src/enum'; +import { WorkflowSearchDto } from 'src/dtos/workflow.dto'; import { DB } from 'src/schema'; -import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; +import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; +import { WorkflowTable } from 'src/schema/tables/workflow.table'; +export type WorkflowStepUpsert = Omit, 'workflowId' | 'order'>; @Injectable() export class WorkflowRepository { constructor(@InjectKysely() private db: Kysely) {} + private queryBuilder(db?: Kysely) { + return (db ?? this.db) + .selectFrom('workflow') + .select([ + 'workflow.id', + 'workflow.name', + 'workflow.description', + 'workflow.trigger', + 'workflow.enabled', + 'workflow.createdAt', + 'workflow.updatedAt', + ]) + .select((eb) => [ + jsonArrayFrom( + eb + .selectFrom('workflow_step') + .innerJoin('plugin_method', 'plugin_method.id', 'workflow_step.pluginMethodId') + .innerJoin('plugin', 'plugin.id', 'plugin_method.pluginId') + .select([ + 'plugin.name as pluginName', + 'plugin_method.name as methodName', + 'workflow_step.config', + 'workflow_step.enabled', + ]), + ).as('steps'), + ]); + } + @GenerateSql({ params: [DummyValue.UUID] }) - getWorkflow(id: string) { + search(dto: WorkflowSearchDto & { ownerId?: string }) { + return this.queryBuilder() + .$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!)) + .$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!)) + .$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!)) + .orderBy('createdAt', 'desc') + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + get(id: string) { + return this.queryBuilder().where('id', '=', id).executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getForWorkflowRun(id: string) { return this.db .selectFrom('workflow') - .selectAll() + .select(['workflow.id', 'workflow.name', 'workflow.trigger']) + .select((eb) => [ + jsonArrayFrom( + eb + .selectFrom('workflow_step') + .innerJoin('plugin_method', 'plugin_method.id', 'workflow_step.pluginMethodId') + .whereRef('workflow_step.workflowId', '=', 'workflow.id') + .where('workflow_step.enabled', '=', true) + .select([ + 'workflow_step.id', + 'workflow_step.config', + 'plugin_method.pluginId as pluginId', + 'plugin_method.name as methodName', + 'plugin_method.types as types', + ]), + ).as('steps'), + ]) .where('id', '=', id) - .orderBy('createdAt', 'desc') + .where('enabled', '=', true) .executeTakeFirst(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getWorkflowsByOwner(ownerId: string) { - return this.db - .selectFrom('workflow') - .selectAll() - .where('ownerId', '=', ownerId) - .orderBy('createdAt', 'desc') - .execute(); - } - - @GenerateSql({ params: [PluginTriggerType.AssetCreate] }) - getWorkflowsByTrigger(type: PluginTriggerType) { - return this.db - .selectFrom('workflow') - .selectAll() - .where('triggerType', '=', type) - .where('enabled', '=', true) - .execute(); - } - - @GenerateSql({ params: [DummyValue.UUID, PluginTriggerType.AssetCreate] }) - getWorkflowByOwnerAndTrigger(ownerId: string, type: PluginTriggerType) { - return this.db - .selectFrom('workflow') - .selectAll() - .where('ownerId', '=', ownerId) - .where('triggerType', '=', type) - .where('enabled', '=', true) - .execute(); - } - - async createWorkflow( - workflow: Insertable, - filters: Insertable[], - actions: Insertable[], - ) { - return await this.db.transaction().execute(async (tx) => { - const createdWorkflow = await tx.insertInto('workflow').values(workflow).returningAll().executeTakeFirstOrThrow(); - - if (filters.length > 0) { - const newFilters = filters.map((filter) => ({ - ...filter, - workflowId: createdWorkflow.id, - })); - - await tx.insertInto('workflow_filter').values(newFilters).execute(); - } - - if (actions.length > 0) { - const newActions = actions.map((action) => ({ - ...action, - workflowId: createdWorkflow.id, - })); - await tx.insertInto('workflow_action').values(newActions).execute(); - } - - return createdWorkflow; + create(dto: Insertable, steps?: WorkflowStepUpsert[]) { + return this.db.transaction().execute(async (tx) => { + const { id } = await tx.insertInto('workflow').values(dto).returning(['id']).executeTakeFirstOrThrow(); + return this.replaceAndReturn(tx, id, steps); }); } - async updateWorkflow( - id: string, - workflow: Updateable, - filters: Insertable[] | undefined, - actions: Insertable[] | undefined, - ) { - return await this.db.transaction().execute(async (trx) => { - if (Object.keys(workflow).length > 0) { - await trx.updateTable('workflow').set(workflow).where('id', '=', id).execute(); + update(id: string, dto: Updateable, steps?: WorkflowStepUpsert[]) { + return this.db.transaction().execute(async (tx) => { + if (Object.values(dto).some((prop) => prop !== undefined)) { + await tx.updateTable('workflow').set(dto).where('id', '=', id).executeTakeFirstOrThrow(); } - - if (filters !== undefined) { - await trx.deleteFrom('workflow_filter').where('workflowId', '=', id).execute(); - if (filters.length > 0) { - const filtersWithWorkflowId = filters.map((filter) => ({ - ...filter, - workflowId: id, - })); - await trx.insertInto('workflow_filter').values(filtersWithWorkflowId).execute(); - } - } - - if (actions !== undefined) { - await trx.deleteFrom('workflow_action').where('workflowId', '=', id).execute(); - if (actions.length > 0) { - const actionsWithWorkflowId = actions.map((action) => ({ - ...action, - workflowId: id, - })); - await trx.insertInto('workflow_action').values(actionsWithWorkflowId).execute(); - } - } - - return await trx.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirstOrThrow(); + return this.replaceAndReturn(tx, id, steps); }); } + private async replaceAndReturn(tx: Kysely, workflowId: string, steps?: WorkflowStepUpsert[]) { + if (steps) { + await tx.deleteFrom('workflow_step').where('workflowId', '=', workflowId).execute(); + if (steps.length > 0) { + await tx + .insertInto('workflow_step') + .values( + steps.map((step, i) => ({ + workflowId, + enabled: step.enabled ?? true, + pluginMethodId: step.pluginMethodId, + config: step.config, + order: i, + })), + ) + .returningAll() + .execute(); + } + } + + return this.queryBuilder(tx).where('id', '=', workflowId).executeTakeFirstOrThrow(); + } + @GenerateSql({ params: [DummyValue.UUID] }) - async deleteWorkflow(id: string) { + async delete(id: string) { await this.db.deleteFrom('workflow').where('id', '=', id).execute(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getFilters(workflowId: string) { + getForAssetV1(assetId: string) { return this.db - .selectFrom('workflow_filter') - .selectAll() - .where('workflowId', '=', workflowId) - .orderBy('order', 'asc') - .execute(); - } - - @GenerateSql({ params: [DummyValue.UUID] }) - async deleteFiltersByWorkflow(workflowId: string) { - await this.db.deleteFrom('workflow_filter').where('workflowId', '=', workflowId).execute(); - } - - @GenerateSql({ params: [DummyValue.UUID] }) - getActions(workflowId: string) { - return this.db - .selectFrom('workflow_action') - .selectAll() - .where('workflowId', '=', workflowId) - .orderBy('order', 'asc') - .execute(); + .selectFrom('asset') + .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') + .select((eb) => [ + ...columns.workflowAssetV1, + jsonObjectFrom( + eb + .selectFrom('asset_exif') + .select([ + 'asset_exif.make', + 'asset_exif.model', + 'asset_exif.orientation', + 'asset_exif.dateTimeOriginal', + 'asset_exif.modifyDate', + 'asset_exif.exifImageWidth', + 'asset_exif.exifImageHeight', + 'asset_exif.fileSizeInByte', + 'asset_exif.lensModel', + 'asset_exif.fNumber', + 'asset_exif.focalLength', + 'asset_exif.iso', + 'asset_exif.latitude', + 'asset_exif.longitude', + 'asset_exif.city', + 'asset_exif.state', + 'asset_exif.country', + 'asset_exif.description', + 'asset_exif.fps', + 'asset_exif.exposureTime', + 'asset_exif.livePhotoCID', + 'asset_exif.timeZone', + 'asset_exif.projectionType', + 'asset_exif.profileDescription', + 'asset_exif.colorspace', + 'asset_exif.bitsPerSample', + 'asset_exif.autoStackId', + 'asset_exif.rating', + 'asset_exif.tags', + 'asset_exif.updatedAt', + ]) + .whereRef('asset_exif.assetId', '=', 'asset.id'), + ).as('exifInfo'), + ]) + .where('id', '=', assetId) + .executeTakeFirstOrThrow(); } } diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 2426c2aab7..68d56ef48c 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -56,7 +56,8 @@ import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonAuditTable } from 'src/schema/tables/person-audit.table'; import { PersonTable } from 'src/schema/tables/person.table'; -import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table'; +import { PluginMethodTable } from 'src/schema/tables/plugin-method.table'; +import { PluginTable } from 'src/schema/tables/plugin.table'; import { SessionTable } from 'src/schema/tables/session.table'; import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; @@ -73,7 +74,8 @@ import { UserMetadataAuditTable } from 'src/schema/tables/user-metadata-audit.ta import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; import { UserTable } from 'src/schema/tables/user.table'; import { VersionHistoryTable } from 'src/schema/tables/version-history.table'; -import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; +import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; +import { WorkflowTable } from 'src/schema/tables/workflow.table'; @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) @Database({ name: 'immich' }) @@ -132,11 +134,9 @@ export class ImmichDatabase { UserTable, VersionHistoryTable, PluginTable, - PluginFilterTable, - PluginActionTable, + PluginMethodTable, WorkflowTable, - WorkflowFilterTable, - WorkflowActionTable, + WorkflowStepTable, ]; functions = [ @@ -249,10 +249,8 @@ export interface DB { version_history: VersionHistoryTable; plugin: PluginTable; - plugin_filter: PluginFilterTable; - plugin_action: PluginActionTable; + plugin_method: PluginMethodTable; workflow: WorkflowTable; - workflow_filter: WorkflowFilterTable; - workflow_action: WorkflowActionTable; + workflow_step: WorkflowStepTable; } diff --git a/server/src/schema/migrations/1772815612034-UpdateWorkflowTables.ts b/server/src/schema/migrations/1772815612034-UpdateWorkflowTables.ts new file mode 100644 index 0000000000..6b3e8b557f --- /dev/null +++ b/server/src/schema/migrations/1772815612034-UpdateWorkflowTables.ts @@ -0,0 +1,15 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // take #2... + await sql`DROP TABLE "workflow_action";`.execute(db); + await sql`DROP TABLE "workflow_filter";`.execute(db); + await sql`DROP TABLE "workflow";`.execute(db); + await sql`DROP TABLE "plugin_action";`.execute(db); + await sql`DROP TABLE "plugin_filter";`.execute(db); + await sql`DROP TABLE "plugin";`.execute(db); +} + +export async function down(): Promise { + // unsupported +} diff --git a/server/src/schema/migrations/1773170414665-UpdateWorkflowTables.ts b/server/src/schema/migrations/1773170414665-UpdateWorkflowTables.ts new file mode 100644 index 0000000000..50c4a7f65f --- /dev/null +++ b/server/src/schema/migrations/1773170414665-UpdateWorkflowTables.ts @@ -0,0 +1,72 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE TABLE "plugin" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "enabled" boolean NOT NULL DEFAULT true, + "name" character varying NOT NULL, + "version" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "author" character varying NOT NULL, + "wasmBytes" bytea NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "plugin_name_version_uq" UNIQUE ("name", "version"), + CONSTRAINT "plugin_name_uq" UNIQUE ("name"), + CONSTRAINT "plugin_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_name_idx" ON "plugin" ("name");`.execute(db); + await sql`CREATE TABLE "plugin_method" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "pluginId" uuid NOT NULL, + "name" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "types" character varying[] NOT NULL, + "schema" jsonb, + CONSTRAINT "plugin_method_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "plugin_method_pluginId_name_uq" UNIQUE ("pluginId", "name"), + CONSTRAINT "plugin_method_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_method_pluginId_idx" ON "plugin_method" ("pluginId");`.execute(db); + await sql`CREATE TABLE "workflow" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "ownerId" uuid NOT NULL, + "trigger" character varying NOT NULL, + "name" character varying, + "description" character varying, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "enabled" boolean NOT NULL DEFAULT true, + CONSTRAINT "workflow_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_ownerId_idx" ON "workflow" ("ownerId");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "workflow_updatedAt" + BEFORE UPDATE ON "workflow" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`CREATE TABLE "workflow_step" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "enabled" boolean NOT NULL DEFAULT true, + "workflowId" uuid NOT NULL, + "pluginMethodId" uuid NOT NULL, + "config" jsonb, + "order" integer NOT NULL, + CONSTRAINT "workflow_step_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_step_pluginMethodId_fkey" FOREIGN KEY ("pluginMethodId") REFERENCES "plugin_method" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_step_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_step_workflowId_idx" ON "workflow_step" ("workflowId");`.execute(db); + await sql`CREATE INDEX "workflow_step_pluginMethodId_idx" ON "workflow_step" ("pluginMethodId");`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_workflow_updatedAt', '{"type":"trigger","name":"workflow_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"workflow_updatedAt\\"\\n BEFORE UPDATE ON \\"workflow\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute( + db, + ); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_filter_supportedContexts_idx';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_action_supportedContexts_idx';`.execute(db); +} + +export async function down(): Promise { + // not supported +} diff --git a/server/src/schema/migrations/1773175313374-Test.ts b/server/src/schema/migrations/1773175313374-Test.ts new file mode 100644 index 0000000000..03479a760c --- /dev/null +++ b/server/src/schema/migrations/1773175313374-Test.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "workflow" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "workflow" DROP COLUMN "updateId";`.execute(db); +} diff --git a/server/src/schema/tables/asset-exif.table.ts b/server/src/schema/tables/asset-exif.table.ts index ae47ecfb10..d725189e6d 100644 --- a/server/src/schema/tables/asset-exif.table.ts +++ b/server/src/schema/tables/asset-exif.table.ts @@ -111,7 +111,7 @@ export class AssetExifTable { tags!: string[] | null; @UpdateDateColumn({ default: () => 'clock_timestamp()' }) - updatedAt!: Generated; + updatedAt!: Generated; @UpdateIdColumn({ index: true }) updateId!: Generated; diff --git a/server/src/schema/tables/plugin-method.table.ts b/server/src/schema/tables/plugin-method.table.ts new file mode 100644 index 0000000000..c406c9cbf7 --- /dev/null +++ b/server/src/schema/tables/plugin-method.table.ts @@ -0,0 +1,29 @@ +import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table, Unique } from '@immich/sql-tools'; +import { WorkflowType } from 'src/enum'; +import { PluginTable } from 'src/schema/tables/plugin.table'; +import { JSONSchema } from 'src/types'; + +@Unique({ columns: ['pluginId', 'name'] }) +@Table('plugin_method') +export class PluginMethodTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + pluginId!: string; + + @Column() + name!: string; + + @Column() + title!: string; + + @Column() + description!: string; + + @Column({ type: 'character varying', array: true }) + types!: Generated; + + @Column({ type: 'jsonb', nullable: true }) + schema!: JSONSchema | null; +} diff --git a/server/src/schema/tables/plugin.table.ts b/server/src/schema/tables/plugin.table.ts index 5f82807f23..99763a4d43 100644 --- a/server/src/schema/tables/plugin.table.ts +++ b/server/src/schema/tables/plugin.table.ts @@ -1,25 +1,29 @@ import { Column, CreateDateColumn, - ForeignKeyColumn, Generated, - Index, PrimaryGeneratedColumn, Table, Timestamp, + Unique, UpdateDateColumn, } from '@immich/sql-tools'; -import { PluginContext } from 'src/enum'; -import type { JSONSchema } from 'src/types/plugin-schema.types'; +@Unique({ columns: ['name', 'version'] }) @Table('plugin') export class PluginTable { @PrimaryGeneratedColumn('uuid') id!: Generated; + @Column({ type: 'boolean', default: true }) + enabled!: Generated; + @Column({ index: true, unique: true }) name!: string; + @Column() + version!: string; + @Column() title!: string; @@ -29,11 +33,8 @@ export class PluginTable { @Column() author!: string; - @Column() - version!: string; - - @Column() - wasmPath!: string; + @Column({ type: 'bytea' }) + wasmBytes!: Buffer; @CreateDateColumn() createdAt!: Generated; @@ -41,55 +42,3 @@ export class PluginTable { @UpdateDateColumn() updatedAt!: Generated; } - -@Index({ columns: ['supportedContexts'], using: 'gin' }) -@Table('plugin_filter') -export class PluginFilterTable { - @PrimaryGeneratedColumn('uuid') - id!: Generated; - - @ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - @Column({ index: true }) - pluginId!: string; - - @Column({ index: true, unique: true }) - methodName!: string; - - @Column() - title!: string; - - @Column() - description!: string; - - @Column({ type: 'character varying', array: true }) - supportedContexts!: Generated; - - @Column({ type: 'jsonb', nullable: true }) - schema!: JSONSchema | null; -} - -@Index({ columns: ['supportedContexts'], using: 'gin' }) -@Table('plugin_action') -export class PluginActionTable { - @PrimaryGeneratedColumn('uuid') - id!: Generated; - - @ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - @Column({ index: true }) - pluginId!: string; - - @Column({ index: true, unique: true }) - methodName!: string; - - @Column() - title!: string; - - @Column() - description!: string; - - @Column({ type: 'character varying', array: true }) - supportedContexts!: Generated; - - @Column({ type: 'jsonb', nullable: true }) - schema!: JSONSchema | null; -} diff --git a/server/src/schema/tables/workflow-step.table.ts b/server/src/schema/tables/workflow-step.table.ts new file mode 100644 index 0000000000..49cb58c731 --- /dev/null +++ b/server/src/schema/tables/workflow-step.table.ts @@ -0,0 +1,26 @@ +import { WorkflowStepConfig } from '@immich/plugin-sdk'; +import { Column, ForeignKeyColumn, PrimaryGeneratedColumn, Table } from '@immich/sql-tools'; +import { Generated } from 'kysely'; +import { PluginMethodTable } from 'src/schema/tables/plugin-method.table'; +import { WorkflowTable } from 'src/schema/tables/workflow.table'; + +@Table('workflow_step') +export class WorkflowStepTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @Column({ type: 'boolean', default: true }) + enabled!: boolean; + + @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + workflowId!: string; + + @ForeignKeyColumn(() => PluginMethodTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + pluginMethodId!: string; + + @Column({ type: 'jsonb', nullable: true }) + config!: WorkflowStepConfig | null; + + @Column({ type: 'integer' }) + order!: number; +} diff --git a/server/src/schema/tables/workflow.table.ts b/server/src/schema/tables/workflow.table.ts index 163518e039..8ac89d4b65 100644 --- a/server/src/schema/tables/workflow.table.ts +++ b/server/src/schema/tables/workflow.table.ts @@ -3,17 +3,17 @@ import { CreateDateColumn, ForeignKeyColumn, Generated, - Index, PrimaryGeneratedColumn, Table, Timestamp, + UpdateDateColumn, } from '@immich/sql-tools'; -import { PluginTriggerType } from 'src/enum'; -import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { WorkflowTrigger } from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; -import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types'; @Table('workflow') +@UpdatedAtTrigger('workflow_updatedAt') export class WorkflowTable { @PrimaryGeneratedColumn() id!: Generated; @@ -22,57 +22,23 @@ export class WorkflowTable { ownerId!: string; @Column() - triggerType!: PluginTriggerType; + trigger!: WorkflowTrigger; @Column({ nullable: true }) name!: string | null; - @Column() - description!: string; + @Column({ nullable: true }) + description!: string | null; @CreateDateColumn() createdAt!: Generated; + @UpdateDateColumn() + updatedAt!: Generated; + + @UpdateIdColumn() + updateId!: Generated; + @Column({ type: 'boolean', default: true }) - enabled!: boolean; -} - -@Index({ columns: ['workflowId', 'order'] }) -@Index({ columns: ['pluginFilterId'] }) -@Table('workflow_filter') -export class WorkflowFilterTable { - @PrimaryGeneratedColumn('uuid') - id!: Generated; - - @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - workflowId!: Generated; - - @ForeignKeyColumn(() => PluginFilterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - pluginFilterId!: string; - - @Column({ type: 'jsonb', nullable: true }) - filterConfig!: FilterConfig | null; - - @Column({ type: 'integer' }) - order!: number; -} - -@Index({ columns: ['workflowId', 'order'] }) -@Index({ columns: ['pluginActionId'] }) -@Table('workflow_action') -export class WorkflowActionTable { - @PrimaryGeneratedColumn('uuid') - id!: Generated; - - @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - workflowId!: Generated; - - @ForeignKeyColumn(() => PluginActionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - pluginActionId!: string; - - @Column({ type: 'jsonb', nullable: true }) - actionConfig!: ActionConfig | null; - - @Column({ type: 'integer' }) - order!: number; + enabled!: Generated; } diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index b3a50a07ae..5e15d65510 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { ClassConstructor } from 'class-transformer'; import { Insertable } from 'kysely'; import sanitize from 'sanitize-filename'; import { SystemConfig } from 'src/config'; @@ -187,6 +188,67 @@ export class BaseService { ); } + static create(Service: ClassConstructor, ctx: BaseService) { + const service = new Service( + LoggingRepository.create(), + ctx.accessRepository, + ctx.activityRepository, + ctx.albumRepository, + ctx.albumUserRepository, + ctx.apiKeyRepository, + ctx.appRepository, + ctx.assetRepository, + ctx.assetEditRepository, + ctx.assetJobRepository, + ctx.auditRepository, + ctx.configRepository, + ctx.cronRepository, + ctx.cryptoRepository, + ctx.databaseRepository, + ctx.downloadRepository, + ctx.duplicateRepository, + ctx.emailRepository, + ctx.eventRepository, + ctx.jobRepository, + ctx.libraryRepository, + ctx.machineLearningRepository, + ctx.mapRepository, + ctx.mediaRepository, + ctx.memoryRepository, + ctx.metadataRepository, + ctx.moveRepository, + ctx.notificationRepository, + ctx.oauthRepository, + ctx.ocrRepository, + ctx.partnerRepository, + ctx.personRepository, + ctx.pluginRepository, + ctx.processRepository, + ctx.searchRepository, + ctx.serverInfoRepository, + ctx.sessionRepository, + ctx.sharedLinkRepository, + ctx.sharedLinkAssetRepository, + ctx.stackRepository, + ctx.storageRepository, + ctx.syncRepository, + ctx.syncCheckpointRepository, + ctx.systemMetadataRepository, + ctx.tagRepository, + ctx.telemetryRepository, + ctx.trashRepository, + ctx.userRepository, + ctx.versionRepository, + ctx.viewRepository, + ctx.websocketRepository, + ctx.workflowRepository, + ); + + service.logger.setContext(this.name); + + return service as T; + } + get worker() { return this.configRepository.getWorker(); } diff --git a/server/src/services/index.ts b/server/src/services/index.ts index ba54474b71..233dcd3ba1 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -45,6 +45,7 @@ import { UserAdminService } from 'src/services/user-admin.service'; import { UserService } from 'src/services/user.service'; import { VersionService } from 'src/services/version.service'; import { ViewService } from 'src/services/view.service'; +import { WorkflowExecutionService } from 'src/services/workflow-execution.service'; import { WorkflowService } from 'src/services/workflow.service'; export const services = [ @@ -95,5 +96,6 @@ export const services = [ UserService, VersionService, ViewService, + WorkflowExecutionService, WorkflowService, ]; diff --git a/server/src/services/plugin-host.functions.ts b/server/src/services/plugin-host.functions.ts deleted file mode 100644 index 50b1052b54..0000000000 --- a/server/src/services/plugin-host.functions.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { CurrentPlugin } from '@extism/extism'; -import { UnauthorizedException } from '@nestjs/common'; -import { Updateable } from 'kysely'; -import { Permission } from 'src/enum'; -import { AccessRepository } from 'src/repositories/access.repository'; -import { AlbumRepository } from 'src/repositories/album.repository'; -import { AssetRepository } from 'src/repositories/asset.repository'; -import { CryptoRepository } from 'src/repositories/crypto.repository'; -import { LoggingRepository } from 'src/repositories/logging.repository'; -import { AssetTable } from 'src/schema/tables/asset.table'; -import { requireAccess } from 'src/utils/access'; - -/** - * Plugin host functions that are exposed to WASM plugins via Extism. - * These functions allow plugins to interact with the Immich system. - */ -export class PluginHostFunctions { - constructor( - private assetRepository: AssetRepository, - private albumRepository: AlbumRepository, - private accessRepository: AccessRepository, - private cryptoRepository: CryptoRepository, - private logger: LoggingRepository, - private pluginJwtSecret: string, - ) {} - - /** - * Creates Extism host function bindings for the plugin. - * These are the functions that WASM plugins can call. - */ - getHostFunctions() { - return { - 'extism:host/user': { - updateAsset: (cp: CurrentPlugin, offs: bigint) => this.handleUpdateAsset(cp, offs), - addAssetToAlbum: (cp: CurrentPlugin, offs: bigint) => this.handleAddAssetToAlbum(cp, offs), - }, - }; - } - - /** - * Host function wrapper for updateAsset. - * Reads the input from the plugin, parses it, and calls the actual update function. - */ - private async handleUpdateAsset(cp: CurrentPlugin, offs: bigint) { - const input = JSON.parse(cp.read(offs)!.text()); - await this.updateAsset(input); - } - - /** - * Host function wrapper for addAssetToAlbum. - * Reads the input from the plugin, parses it, and calls the actual add function. - */ - private async handleAddAssetToAlbum(cp: CurrentPlugin, offs: bigint) { - const input = JSON.parse(cp.read(offs)!.text()); - await this.addAssetToAlbum(input); - } - - /** - * Validates the JWT token and returns the auth context. - */ - private validateToken(authToken: string): { userId: string } { - try { - const auth = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.pluginJwtSecret); - if (!auth.userId) { - throw new UnauthorizedException('Invalid token: missing userId'); - } - return auth; - } catch (error) { - this.logger.error('Token validation failed:', error); - throw new UnauthorizedException('Invalid token'); - } - } - - /** - * Updates an asset with the given properties. - */ - async updateAsset(input: { authToken: string } & Updateable & { id: string }) { - const { authToken, id, ...assetData } = input; - - // Validate token - const auth = this.validateToken(authToken); - - // Check access to the asset - await requireAccess(this.accessRepository, { - auth: { user: { id: auth.userId } } as any, - permission: Permission.AssetUpdate, - ids: [id], - }); - - this.logger.log(`Updating asset ${id} -- ${JSON.stringify(assetData)}`); - await this.assetRepository.update({ id, ...assetData }); - } - - /** - * Adds an asset to an album. - */ - async addAssetToAlbum(input: { authToken: string; assetId: string; albumId: string }) { - const { authToken, assetId, albumId } = input; - - // Validate token - const auth = this.validateToken(authToken); - - // Check access to both the asset and the album - await requireAccess(this.accessRepository, { - auth: { user: { id: auth.userId } } as any, - permission: Permission.AssetRead, - ids: [assetId], - }); - - await requireAccess(this.accessRepository, { - auth: { user: { id: auth.userId } } as any, - permission: Permission.AlbumUpdate, - ids: [albumId], - }); - - this.logger.log(`Adding asset ${assetId} to album ${albumId}`); - await this.albumRepository.addAssetIds(albumId, [assetId]); - return 0; - } -} diff --git a/server/src/services/plugin.service.ts b/server/src/services/plugin.service.ts index d78b8940d3..62edf8438b 100644 --- a/server/src/services/plugin.service.ts +++ b/server/src/services/plugin.service.ts @@ -1,322 +1,34 @@ -import { Plugin as ExtismPlugin, newPlugin } from '@extism/extism'; import { BadRequestException, Injectable } from '@nestjs/common'; -import { plainToInstance } from 'class-transformer'; -import { validateOrReject } from 'class-validator'; -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, 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 { + mapMethod, + mapPlugin, + PluginMethodResponseDto, + PluginMethodSearchDto, + PluginResponseDto, + PluginSearchDto, +} from 'src/dtos/plugin.dto'; import { BaseService } from 'src/services/base.service'; -import { PluginHostFunctions } from 'src/services/plugin-host.functions'; -import { IWorkflowJob, JobItem, JobOf, WorkflowData } from 'src/types'; - -interface WorkflowContext { - authToken: string; - asset: Asset; -} - -interface PluginInput { - authToken: string; - config: T; - data: { - asset: Asset; - }; -} +import { isMethodCompatible } from 'src/utils/workflow'; @Injectable() export class PluginService extends BaseService { - private pluginJwtSecret!: string; - private loadedPlugins: Map = new Map(); - private hostFunctions!: PluginHostFunctions; - - @OnEvent({ name: 'AppBootstrap' }) - async onBootstrap() { - this.pluginJwtSecret = this.cryptoRepository.randomBytesAsText(32); - - await this.loadPluginsFromManifests(); - - this.hostFunctions = new PluginHostFunctions( - this.assetRepository, - this.albumRepository, - this.accessRepository, - this.cryptoRepository, - this.logger, - this.pluginJwtSecret, - ); - - await this.loadPlugins(); - } - - getTriggers(): PluginTriggerResponseDto[] { - return pluginTriggers; - } - - // - // CRUD operations for plugins - // - async getAll(): Promise { - const plugins = await this.pluginRepository.getAllPlugins(); + async search(dto: PluginSearchDto): Promise { + const plugins = await this.pluginRepository.search(dto); return plugins.map((plugin) => mapPlugin(plugin)); } async get(id: string): Promise { - const plugin = await this.pluginRepository.getPlugin(id); + const plugin = await this.pluginRepository.get(id); if (!plugin) { throw new BadRequestException('Plugin not found'); } return mapPlugin(plugin); } - /////////////////////////////////////////// - // Plugin Loader - ////////////////////////////////////////// - async loadPluginsFromManifests(): Promise { - // Load core plugin - const { resourcePaths, plugins } = this.configRepository.getEnv(); - const coreManifestPath = `${resourcePaths.corePlugin}/manifest.json`; - - const coreManifest = await this.readAndValidateManifest(coreManifestPath); - await this.loadPluginToDatabase(coreManifest, resourcePaths.corePlugin); - - this.logger.log(`Successfully processed core plugin: ${coreManifest.name} (version ${coreManifest.version})`); - - // Load external plugins - if (plugins.external.allow && plugins.external.installFolder) { - await this.loadExternalPlugins(plugins.external.installFolder); - } - } - - private async loadExternalPlugins(installFolder: string): Promise { - try { - const entries = await this.pluginRepository.readDirectory(installFolder); - - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - - const pluginFolder = join(installFolder, entry.name); - const manifestPath = join(pluginFolder, 'manifest.json'); - try { - const manifest = await this.readAndValidateManifest(manifestPath); - await this.loadPluginToDatabase(manifest, pluginFolder); - - this.logger.log(`Successfully processed external plugin: ${manifest.name} (version ${manifest.version})`); - } catch (error) { - this.logger.warn(`Failed to load external plugin from ${manifestPath}:`, error); - } - } - } catch (error) { - this.logger.error(`Failed to scan external plugins folder ${installFolder}:`, error); - } - } - - private async loadPluginToDatabase(manifest: PluginManifestDto, basePath: string): Promise { - const currentPlugin = await this.pluginRepository.getPluginByName(manifest.name); - if (currentPlugin != null && currentPlugin.version === manifest.version) { - this.logger.log(`Plugin ${manifest.name} is up to date (version ${manifest.version}). Skipping`); - return; - } - - const { plugin, filters, actions } = await this.pluginRepository.loadPlugin(manifest, basePath); - - this.logger.log(`Upserted plugin: ${plugin.name} (ID: ${plugin.id}, version: ${plugin.version})`); - - for (const filter of filters) { - this.logger.log(`Upserted plugin filter: ${filter.methodName} (ID: ${filter.id})`); - } - - for (const action of actions) { - this.logger.log(`Upserted plugin action: ${action.methodName} (ID: ${action.id})`); - } - } - - private async readAndValidateManifest(manifestPath: string): Promise { - const content = await this.storageRepository.readTextFile(manifestPath); - const manifestData = JSON.parse(content); - const manifest = plainToInstance(PluginManifestDto, manifestData); - - await validateOrReject(manifest, { - whitelist: true, - forbidNonWhitelisted: true, - }); - - return manifest; - } - - /////////////////////////////////////////// - // Plugin Execution - /////////////////////////////////////////// - private async loadPlugins() { - const plugins = await this.pluginRepository.getAllPlugins(); - for (const plugin of plugins) { - try { - this.logger.debug(`Loading plugin: ${plugin.name} from ${plugin.wasmPath}`); - - const extismPlugin = await newPlugin(plugin.wasmPath, { - useWasi: true, - functions: this.hostFunctions.getHostFunctions(), - }); - - this.loadedPlugins.set(plugin.id, extismPlugin); - this.logger.log(`Successfully loaded plugin: ${plugin.name}`); - } catch (error) { - this.logger.error(`Failed to load plugin ${plugin.name}:`, error); - } - } - } - - @OnEvent({ name: 'AssetCreate' }) - async handleAssetCreate({ asset }: ArgOf<'AssetCreate'>) { - await this.handleTrigger(PluginTriggerType.AssetCreate, { - ownerId: asset.ownerId, - event: { userId: asset.ownerId, asset }, - }); - } - - private async handleTrigger( - triggerType: T, - params: { ownerId: string; event: WorkflowData[T] }, - ): Promise { - const workflows = await this.workflowRepository.getWorkflowByOwnerAndTrigger(params.ownerId, triggerType); - if (workflows.length === 0) { - return; - } - - const jobs: JobItem[] = workflows.map((workflow) => ({ - name: JobName.WorkflowRun, - data: { - id: workflow.id, - type: triggerType, - event: params.event, - } as IWorkflowJob, - })); - - await this.jobRepository.queueAll(jobs); - this.logger.debug(`Queued ${jobs.length} workflow execution jobs for trigger ${triggerType}`); - } - - @OnJob({ name: JobName.WorkflowRun, queue: QueueName.Workflow }) - async handleWorkflowRun({ id: workflowId, type, event }: JobOf): Promise { - try { - const workflow = await this.workflowRepository.getWorkflow(workflowId); - if (!workflow) { - this.logger.error(`Workflow ${workflowId} not found`); - return JobStatus.Failed; - } - - const workflowFilters = await this.workflowRepository.getFilters(workflowId); - const workflowActions = await this.workflowRepository.getActions(workflowId); - - switch (type) { - case PluginTriggerType.AssetCreate: { - const data = event as WorkflowData[PluginTriggerType.AssetCreate]; - const asset = data.asset; - - const authToken = this.cryptoRepository.signJwt({ userId: data.userId }, this.pluginJwtSecret); - - const context = { - authToken, - asset, - }; - - const filtersPassed = await this.executeFilters(workflowFilters, context); - if (!filtersPassed) { - return JobStatus.Skipped; - } - - await this.executeActions(workflowActions, context); - this.logger.debug(`Workflow ${workflowId} executed successfully`); - return JobStatus.Success; - } - - case PluginTriggerType.PersonRecognized: { - this.logger.error('unimplemented'); - return JobStatus.Skipped; - } - - default: { - this.logger.error(`Unknown workflow trigger type: ${type}`); - return JobStatus.Failed; - } - } - } catch (error) { - this.logger.error(`Error executing workflow ${workflowId}:`, error); - return JobStatus.Failed; - } - } - - private async executeFilters(workflowFilters: WorkflowFilter[], context: WorkflowContext): Promise { - for (const workflowFilter of workflowFilters) { - const filter = await this.pluginRepository.getFilter(workflowFilter.pluginFilterId); - if (!filter) { - this.logger.error(`Filter ${workflowFilter.pluginFilterId} not found`); - return false; - } - - const pluginInstance = this.loadedPlugins.get(filter.pluginId); - if (!pluginInstance) { - this.logger.error(`Plugin ${filter.pluginId} not loaded`); - return false; - } - - const filterInput: PluginInput = { - authToken: context.authToken, - config: workflowFilter.filterConfig, - data: { - asset: context.asset, - }, - }; - - this.logger.debug(`Calling filter ${filter.methodName} with input: ${JSON.stringify(filterInput)}`); - - const filterResult = await pluginInstance.call( - filter.methodName, - new TextEncoder().encode(JSON.stringify(filterInput)), - ); - - if (!filterResult) { - this.logger.error(`Filter ${filter.methodName} returned null`); - return false; - } - - const result = JSON.parse(filterResult.text()); - if (result.passed === false) { - this.logger.debug(`Filter ${filter.methodName} returned false, stopping workflow execution`); - return false; - } - } - - return true; - } - - private async executeActions(workflowActions: WorkflowAction[], context: WorkflowContext): Promise { - for (const workflowAction of workflowActions) { - const action = await this.pluginRepository.getAction(workflowAction.pluginActionId); - if (!action) { - throw new Error(`Action ${workflowAction.pluginActionId} not found`); - } - - const pluginInstance = this.loadedPlugins.get(action.pluginId); - if (!pluginInstance) { - throw new Error(`Plugin ${action.pluginId} not loaded`); - } - - const actionInput: PluginInput = { - authToken: context.authToken, - config: workflowAction.actionConfig, - data: { - asset: context.asset, - }, - }; - - this.logger.debug(`Calling action ${action.methodName} with input: ${JSON.stringify(actionInput)}`); - - await pluginInstance.call(action.methodName, JSON.stringify(actionInput)); - } + async searchMethods(dto: PluginMethodSearchDto): Promise { + const methods = await this.pluginRepository.searchMethods(dto); + return methods + .filter((method) => !dto.trigger || isMethodCompatible(method, dto.trigger)) + .map((method) => mapMethod(method)); } } diff --git a/server/src/services/workflow-execution.service.ts b/server/src/services/workflow-execution.service.ts new file mode 100644 index 0000000000..e0b923e9c2 --- /dev/null +++ b/server/src/services/workflow-execution.service.ts @@ -0,0 +1,301 @@ +import { CurrentPlugin } from '@extism/extism'; +import { WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk'; +import { HttpException, UnauthorizedException } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import _ from 'lodash'; +import { join } from 'node:path'; +import { OnEvent, OnJob } from 'src/decorators'; +import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { + BootstrapEventPriority, + DatabaseLock, + ImmichWorker, + JobName, + JobStatus, + QueueName, + WorkflowTrigger, + WorkflowType, +} from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; +import { AlbumService } from 'src/services/album.service'; +import { BaseService } from 'src/services/base.service'; +import { JobOf } from 'src/types'; + +type ExecuteOptions = { + read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData }>; + write: (changes: Partial>) => Promise; +}; + +export class WorkflowExecutionService extends BaseService { + private jwtSecret!: string; + + @OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.PluginSync, workers: [ImmichWorker.Microservices] }) + async onPluginSync() { + await this.databaseRepository.withLock(DatabaseLock.PluginImport, async () => { + // TODO avoid importing plugins in each worker + // Can this use system metadata similar to geocoding? + + const { resourcePaths, plugins } = this.configRepository.getEnv(); + await this.importFolder(resourcePaths.corePlugin, { force: true }); + + if (plugins.external.allow && plugins.external.installFolder) { + await this.importFolders(plugins.external.installFolder); + } + }); + } + + @OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.PluginLoad, workers: [ImmichWorker.Microservices] }) + async onPluginLoad() { + this.jwtSecret = this.cryptoRepository.randomBytesAsText(32); + + const albumService = BaseService.create(AlbumService, this); + + const albumAddAssets = this.createFunction<[id: string, dto: BulkIdsDto]>(async (authDto, args) => + albumService.addAssets(authDto, ...args), + ); + + const plugins = await this.pluginRepository.getForLoad(); + for (const plugin of plugins) { + const pluginLabel = `${plugin.name}@${plugin.version}`; + + try { + await this.pluginRepository.load(plugin, { + functions: { + albumAddAssets, + }, + }); + + this.logger.log(`Loaded plugin: ${pluginLabel}`); + } catch (error) { + this.logger.error(`Unable to load plugin ${pluginLabel}`, error); + } + } + } + + private createFunction(fn: (authDto: AuthDto, args: T) => Promise) { + return async (plugin: CurrentPlugin, offset: bigint) => { + try { + const handle = plugin.read(offset); + if (!handle) { + return; + } + + const { authToken, args } = handle.json() as { authToken: string; args: T }; + if (!authToken) { + throw new Error('authToken is required'); + } + + const authDto = this.validate(authToken); + const response = await fn(authDto, args); + + return plugin.store(JSON.stringify({ success: true, response })); + } catch (error: Error | any) { + if (error instanceof HttpException) { + this.logger.error(`Plugin host exception: ${error}`); + return plugin.store( + JSON.stringify({ success: false, status: error.getStatus(), message: error.getResponse() }), + ); + } + + this.logger.error(`Plugin host exception: ${error}`, error?.stack); + + return plugin.store( + JSON.stringify({ + success: false, + status: 500, + message: `Internal server error: ${error}`, + }), + ); + } + }; + } + + private async importFolders(installFolder: string): Promise { + try { + const entries = await this.storageRepository.readdirWithTypes(installFolder); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + await this.importFolder(join(installFolder, entry.name)); + } + } catch (error) { + this.logger.error(`Failed to import plugins folder ${installFolder}:`, error); + } + } + + private async importFolder(folder: string, options?: { force?: boolean }) { + try { + const manifestPath = join(folder, 'manifest.json'); + const dto = await this.storageRepository.readJsonFile(manifestPath); + const manifest = plainToInstance(PluginManifestDto, dto); + const errors = await validate(manifest, { whitelist: true, forbidNonWhitelisted: true }); + if (errors.length > 0) { + this.logger.warn(`Invalid plugin manifest at ${manifestPath}:\n${errors.map((e) => e.toString()).join('\n')}`); + return; + } + + const existing = await this.pluginRepository.getByName(manifest.name); + if (existing && existing.version === manifest.version && options?.force !== true) { + return; + } + + const wasmPath = `${folder}/${manifest.wasmPath}`; + const wasmBytes = await this.storageRepository.readFile(wasmPath); + + const plugin = await this.pluginRepository.create( + { + enabled: true, + name: manifest.name, + title: manifest.title, + description: manifest.description, + author: manifest.author, + version: manifest.version, + wasmBytes, + }, + manifest.methods.map((method) => ({ + name: method.name, + title: method.title, + description: method.description, + types: method.types, + schema: method.schema, + })), + ); + + if (existing) { + this.logger.log( + `Upgraded plugin ${manifest.name} (${plugin.methods.length} methods) from ${existing.version} to ${manifest.version} `, + ); + } else { + this.logger.log( + `Imported plugin ${manifest.name}@${manifest.version} (${plugin.methods.length} methods) from ${folder}`, + ); + } + + return manifest; + } catch { + this.logger.warn(`Failed to import plugin from ${folder}:`); + } + } + + private validate(authToken: string): AuthDto { + try { + const jwt = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.jwtSecret); + if (!jwt.userId) { + throw new UnauthorizedException('Invalid token: missing userId'); + } + + return { + user: { + id: jwt.userId, + }, + } as AuthDto; + } catch (error) { + this.logger.error('Token validation failed:', error); + throw new UnauthorizedException('Invalid token'); + } + } + + private sign(userId: string) { + return this.cryptoRepository.signJwt({ userId }, this.jwtSecret); + } + + @OnEvent({ name: 'AssetCreate' }) + async onAssetCreate({ asset }: ArgOf<'AssetCreate'>) { + const dto = { ownerId: asset.ownerId, trigger: WorkflowTrigger.AssetCreate }; + const items = await this.workflowRepository.search(dto); + await this.jobRepository.queueAll( + items.map((workflow) => ({ + name: JobName.WorkflowAssetCreate, + data: { workflowId: workflow.id, assetId: asset.id }, + })), + ); + } + + @OnJob({ name: JobName.WorkflowAssetCreate, queue: QueueName.Workflow }) + async handleWorkflowAssetCreate({ workflowId, assetId }: JobOf) { + await this.execute(workflowId, (type: WorkflowType) => { + switch (type) { + case WorkflowType.AssetV1: { + return >{ + read: async () => { + const asset = await this.workflowRepository.getForAssetV1(assetId); + return { + data: { asset }, + authUserId: asset.ownerId, + }; + }, + write: async (changes) => { + if (changes.asset) { + await this.assetRepository.update({ + id: assetId, + ..._.omitBy( + { + isFavorite: changes.asset?.isFavorite, + visibility: changes.asset?.visibility, + }, + _.isUndefined, + ), + }); + } + }, + }; + } + } + }); + } + + private async execute(workflowId: string, getHandler: (type: WorkflowType) => ExecuteOptions | undefined) { + const workflow = await this.workflowRepository.getForWorkflowRun(workflowId); + if (!workflow) { + return; + } + + // TODO infer from steps + const type = 'AssetV1' as WorkflowType; + const handler = getHandler(type); + if (!handler) { + this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`); + return; + } + + try { + const { read, write } = handler; + const readResult = await read(type); + let data = readResult.data; + for (const step of workflow.steps) { + const payload: WorkflowEventPayload = { + trigger: workflow.trigger, + type, + config: step.config ?? {}, + workflow: { + id: workflowId, + authToken: this.sign(readResult.authUserId), + stepId: step.id, + }, + data, + }; + + const result = await this.pluginRepository.callMethod(step, payload); + if (result?.changes) { + await write(result.changes); + data = await read(type); + } + + const shouldContinue = result?.workflow?.continue ?? true; + if (!shouldContinue) { + break; + } + } + + this.logger.debug(`Workflow ${workflowId} executed successfully`); + } catch (error) { + this.logger.error(`Error executing workflow ${workflowId}:`, error); + return JobStatus.Failed; + } + } +} diff --git a/server/src/services/workflow.service.ts b/server/src/services/workflow.service.ts index 1a65182b1f..c0b9ea116e 100644 --- a/server/src/services/workflow.service.ts +++ b/server/src/services/workflow.service.ts @@ -1,159 +1,113 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { Workflow } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; import { - mapWorkflowAction, - mapWorkflowFilter, + mapWorkflow, + mapWorkflowShare, WorkflowCreateDto, WorkflowResponseDto, + WorkflowSearchDto, + WorkflowShareResponseDto, + WorkflowTriggerResponseDto, WorkflowUpdateDto, } from 'src/dtos/workflow.dto'; -import { Permission, PluginContext, PluginTriggerType } from 'src/enum'; -import { pluginTriggers } from 'src/plugins'; - +import { Permission, WorkflowTrigger } from 'src/enum'; +import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository'; import { BaseService } from 'src/services/base.service'; +import { getWorkflowTriggers, isMethodCompatible, resolveMethod } from 'src/utils/workflow'; @Injectable() export class WorkflowService extends BaseService { - async create(auth: AuthDto, dto: WorkflowCreateDto): Promise { - const context = this.getContextForTrigger(dto.triggerType); - - const filterInserts = await this.validateAndMapFilters(dto.filters, context); - const actionInserts = await this.validateAndMapActions(dto.actions, context); - - const workflow = await this.workflowRepository.createWorkflow( - { - ownerId: auth.user.id, - triggerType: dto.triggerType, - name: dto.name, - description: dto.description || '', - enabled: dto.enabled ?? true, - }, - filterInserts, - actionInserts, - ); - - return this.mapWorkflow(workflow); + getTriggers(): WorkflowTriggerResponseDto[] { + return getWorkflowTriggers(); } - async getAll(auth: AuthDto): Promise { - const workflows = await this.workflowRepository.getWorkflowsByOwner(auth.user.id); - - return Promise.all(workflows.map((workflow) => this.mapWorkflow(workflow))); + async search(auth: AuthDto, dto: WorkflowSearchDto): Promise { + const workflows = await this.workflowRepository.search({ ...dto, ownerId: auth.user.id }); + return workflows.map((workflow) => mapWorkflow(workflow)); } async get(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.WorkflowRead, ids: [id] }); const workflow = await this.findOrFail(id); - return this.mapWorkflow(workflow); + return mapWorkflow(workflow); + } + + async share(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.WorkflowRead, ids: [id] }); + const workflow = await this.findOrFail(id); + return mapWorkflowShare(workflow); + } + + async create(auth: AuthDto, dto: WorkflowCreateDto): Promise { + const { steps: stepsDto, ...workflowDto } = dto; + const steps = await this.resolveAndValidateSteps(stepsDto ?? [], workflowDto.trigger); + + const workflow = await this.workflowRepository.create( + { + ...workflowDto, + ownerId: auth.user.id, + }, + steps.map((step) => ({ + enabled: step.enabled ?? true, + config: step.config, + pluginMethodId: step.pluginMethod.id, + })), + ); + + return mapWorkflow({ ...workflow, steps: [] }); } async update(auth: AuthDto, id: string, dto: WorkflowUpdateDto): Promise { await this.requireAccess({ auth, permission: Permission.WorkflowUpdate, ids: [id] }); - if (Object.values(dto).filter((prop) => prop !== undefined).length === 0) { - throw new BadRequestException('No fields to update'); - } - - const workflow = await this.findOrFail(id); - const context = this.getContextForTrigger(dto.triggerType ?? workflow.triggerType); - - const { filters, actions, ...workflowUpdate } = dto; - const filterInserts = filters && (await this.validateAndMapFilters(filters, context)); - const actionInserts = actions && (await this.validateAndMapActions(actions, context)); - - const updatedWorkflow = await this.workflowRepository.updateWorkflow( + const { steps: stepsDto, ...workflowDto } = dto; + const current = await this.findOrFail(id); + const steps = stepsDto ? await this.resolveAndValidateSteps(stepsDto, dto.trigger ?? current.trigger) : undefined; + const workflow = await this.workflowRepository.update( id, - workflowUpdate, - filterInserts, - actionInserts, + workflowDto, + steps?.map((step) => ({ + enabled: step.enabled ?? true, + config: step.config, + pluginMethodId: step.pluginMethod.id, + })), ); - return this.mapWorkflow(updatedWorkflow); + return mapWorkflow(workflow); } async delete(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.WorkflowDelete, ids: [id] }); - await this.workflowRepository.deleteWorkflow(id); + await this.workflowRepository.delete(id); } - private async validateAndMapFilters( - filters: Array<{ pluginFilterId: string; filterConfig?: any }>, - requiredContext: PluginContext, - ) { - for (const dto of filters) { - const filter = await this.pluginRepository.getFilter(dto.pluginFilterId); - if (!filter) { - throw new BadRequestException(`Invalid filter ID: ${dto.pluginFilterId}`); + private async resolveAndValidateSteps(steps: T[], trigger: WorkflowTrigger) { + const methods = await this.pluginRepository.getForValidation(); + const results: Array = []; + + for (const step of steps) { + const pluginMethod = resolveMethod(methods, step.method); + if (!pluginMethod) { + throw new BadRequestException(`Unknown method ${step.method}`); } - if (!filter.supportedContexts.includes(requiredContext)) { - throw new BadRequestException( - `Filter "${filter.title}" does not support ${requiredContext} context. Supported contexts: ${filter.supportedContexts.join(', ')}`, - ); + if (!isMethodCompatible(pluginMethod, trigger)) { + throw new BadRequestException(`Method "${step.method}" is incompatible with workflow trigger: "${trigger}"`); } + + results.push({ ...step, pluginMethod }); } - return filters.map((dto, index) => ({ - pluginFilterId: dto.pluginFilterId, - filterConfig: dto.filterConfig || null, - order: index, - })); - } + // TODO make sure all steps can use a common WorkflowType - private async validateAndMapActions( - actions: Array<{ pluginActionId: string; actionConfig?: any }>, - requiredContext: PluginContext, - ) { - for (const dto of actions) { - const action = await this.pluginRepository.getAction(dto.pluginActionId); - if (!action) { - throw new BadRequestException(`Invalid action ID: ${dto.pluginActionId}`); - } - if (!action.supportedContexts.includes(requiredContext)) { - throw new BadRequestException( - `Action "${action.title}" does not support ${requiredContext} context. Supported contexts: ${action.supportedContexts.join(', ')}`, - ); - } - } - - return actions.map((dto, index) => ({ - pluginActionId: dto.pluginActionId, - actionConfig: dto.actionConfig || null, - order: index, - })); - } - - private getContextForTrigger(type: PluginTriggerType) { - const trigger = pluginTriggers.find((t) => t.type === type); - if (!trigger) { - throw new BadRequestException(`Invalid trigger type: ${type}`); - } - return trigger.contextType; + return results; } private async findOrFail(id: string) { - const workflow = await this.workflowRepository.getWorkflow(id); + const workflow = await this.workflowRepository.get(id); if (!workflow) { throw new BadRequestException('Workflow not found'); } return workflow; } - - private async mapWorkflow(workflow: Workflow): Promise { - const filters = await this.workflowRepository.getFilters(workflow.id); - const actions = await this.workflowRepository.getActions(workflow.id); - - return { - id: workflow.id, - ownerId: workflow.ownerId, - triggerType: workflow.triggerType, - name: workflow.name, - description: workflow.description, - createdAt: workflow.createdAt.toISOString(), - enabled: workflow.enabled, - filters: filters.map((f) => mapWorkflowFilter(f)), - actions: actions.map((a) => mapWorkflowAction(a)), - }; - } } diff --git a/server/src/types.ts b/server/src/types.ts index 8cf128f497..231e19d132 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,6 +1,6 @@ import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; -import { Asset, AssetFile } from 'src/database'; +import { AssetFile } from 'src/database'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; @@ -12,7 +12,6 @@ import { ImageFormat, JobName, MemoryType, - PluginTriggerType, QueueName, StorageFolder, SyncEntityType, @@ -20,6 +19,8 @@ import { TranscodeTarget, UserMetadataKey, VideoCodec, + WorkflowTrigger, + WorkflowType, } from 'src/enum'; export type DeepPartial = @@ -259,22 +260,11 @@ export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob { recipientId: string; } -export interface WorkflowData { - [PluginTriggerType.AssetCreate]: { - userId: string; - asset: Asset; - }; - [PluginTriggerType.PersonRecognized]: { - personId: string; - assetId: string; - }; -} - -export interface IWorkflowJob { +export type IWorkflowJob = { id: string; + trigger: WorkflowTrigger; type: T; - event: WorkflowData[T]; -} +}; export interface JobCounts { active: number; @@ -385,7 +375,7 @@ export type JobItem = | { name: JobName.Ocr; data: IEntityJob } // Workflow - | { name: JobName.WorkflowRun; data: IWorkflowJob } + | { name: JobName.WorkflowAssetCreate; data: { workflowId: string; assetId: string } } // Editor | { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob }; @@ -548,3 +538,23 @@ export interface UserMetadata extends Record; + required?: string[]; +}; + +export interface JSONSchema { + type: 'object'; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; + description?: string; +} diff --git a/server/src/types/plugin-schema.types.ts b/server/src/types/plugin-schema.types.ts deleted file mode 100644 index 793bb3c1ff..0000000000 --- a/server/src/types/plugin-schema.types.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * JSON Schema types for plugin configuration schemas - * Based on JSON Schema Draft 7 - */ - -export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null'; - -export interface JSONSchemaProperty { - type?: JSONSchemaType | JSONSchemaType[]; - description?: string; - default?: any; - enum?: any[]; - items?: JSONSchemaProperty; - properties?: Record; - required?: string[]; - additionalProperties?: boolean | JSONSchemaProperty; -} - -export interface JSONSchema { - type: 'object'; - properties?: Record; - required?: string[]; - additionalProperties?: boolean; - description?: string; -} - -export type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue }; - -export interface FilterConfig { - [key: string]: ConfigValue; -} - -export interface ActionConfig { - [key: string]: ConfigValue; -} diff --git a/server/src/utils/workflow.ts b/server/src/utils/workflow.ts new file mode 100644 index 0000000000..cc1c88877b --- /dev/null +++ b/server/src/utils/workflow.ts @@ -0,0 +1,60 @@ +import { WorkflowTrigger, WorkflowType } from 'src/enum'; +import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository'; + +export const triggerMap: Record = { + [WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1], + [WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetV1], +}; + +export const getWorkflowTriggers = () => + Object.entries(triggerMap).map(([trigger, types]) => ({ trigger: trigger as WorkflowTrigger, types })); + +/** some types extend other types and have implied compatibility */ +const inferredMap: Record = { + [WorkflowType.AssetV1]: [], + [WorkflowType.AssetPersonV1]: [WorkflowType.AssetV1], +}; + +const withImpliedItems = (type: WorkflowType): WorkflowType[] => [type, ...inferredMap[type]]; + +export const isMethodCompatible = (pluginMethod: { types: WorkflowType[] }, trigger: WorkflowTrigger) => { + const validTypes = triggerMap[trigger]; + const pluginCompatibility = pluginMethod.types.map((type) => withImpliedItems(type)); + for (const requested of validTypes) { + for (const pluginCompatibilityGroup of pluginCompatibility) { + if (pluginCompatibilityGroup.includes(requested)) { + return true; + } + } + } + + return false; +}; + +export const resolveMethod = (methods: PluginMethodSearchResponse[], method: string) => { + const result = parseMethodString(method); + if (!result) { + return; + } + + const { pluginName, methodName } = result; + + return methods.find((method) => method.pluginName === pluginName && method.name === methodName); +}; + +export const asMethodString = (method: { pluginName: string; methodName: string }) => { + return `${method.pluginName}#${method.methodName}`; +}; + +const METHOD_REGEX = /^(?[^@#\s]+)(?:@(?[^#\s]*))?#(?[^@#\s]+)$/; +export const parseMethodString = (method: string) => { + const matches = METHOD_REGEX.exec(method); + if (!matches) { + return; + } + + const pluginName = matches.groups?.name; + const version = matches.groups?.version; + const methodName = matches.groups?.method; + return { pluginName, version, methodName }; +}; diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 53bf78b5b8..c1d5f9a6a0 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -410,7 +410,6 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case OcrRepository: case PartnerRepository: case PersonRepository: - case PluginRepository: case SearchRepository: case SessionRepository: case SharedLinkRepository: @@ -442,6 +441,10 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { return new key(LoggingRepository.create()); } + case PluginRepository: { + return new key(db, LoggingRepository.create()); + } + case StorageRepository: { return new key(LoggingRepository.create()); } @@ -473,7 +476,6 @@ const newMockRepository = (key: ClassConstructor) => { case OcrRepository: case PartnerRepository: case PersonRepository: - case PluginRepository: case SessionRepository: case SyncRepository: case SyncCheckpointRepository: diff --git a/server/test/medium/specs/services/plugin.service.spec.ts b/server/test/medium/specs/services/plugin.service.spec.ts index b70e8e8d54..e651285ae4 100644 --- a/server/test/medium/specs/services/plugin.service.spec.ts +++ b/server/test/medium/specs/services/plugin.service.spec.ts @@ -1,5 +1,5 @@ import { Kysely } from 'kysely'; -import { PluginContext } from 'src/enum'; +import { WorkflowType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { PluginRepository } from 'src/repositories/plugin.repository'; @@ -9,7 +9,8 @@ import { newMediumService } from 'test/medium.factory'; import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; -let pluginRepo: PluginRepository; + +const wasmBytes = Buffer.from('some-wasm-binary-data'); const setup = (db?: Kysely) => { return newMediumService(PluginService, { @@ -21,7 +22,6 @@ const setup = (db?: Kysely) => { beforeAll(async () => { defaultDatabase = await getKyselyDB(); - pluginRepo = new PluginRepository(defaultDatabase); }); afterEach(async () => { @@ -32,214 +32,202 @@ describe(PluginService.name, () => { describe('getAll', () => { it('should return empty array when no plugins exist', async () => { const { sut } = setup(); - - const plugins = await sut.getAll(); - - expect(plugins).toEqual([]); + await expect(sut.search({})).resolves.toEqual([]); }); - it('should return plugin without filters and actions', async () => { - const { sut } = setup(); + it('should return plugin without methods', async () => { + const { ctx, sut } = setup(); - const result = await pluginRepo.loadPlugin( + const result = await ctx.get(PluginRepository).create( { + enabled: true, name: 'test-plugin', title: 'Test Plugin', description: 'A test plugin', author: 'Test Author', version: '1.0.0', - wasm: { path: '/path/to/test.wasm' }, + wasmBytes, }, - '/test/base/path', + [], ); - const plugins = await sut.getAll(); + const plugins = await sut.search({}); expect(plugins).toHaveLength(1); expect(plugins[0]).toMatchObject({ - id: result.plugin.id, + id: result.id, name: 'test-plugin', description: 'A test plugin', author: 'Test Author', version: '1.0.0', - filters: [], - actions: [], + methods: [], }); }); - it('should return plugin with filters and actions', async () => { - const { sut } = setup(); + it('should return plugin with multiple methods', async () => { + const { ctx, sut } = setup(); - const result = await pluginRepo.loadPlugin( + const result = await ctx.get(PluginRepository).create( { + enabled: true, name: 'full-plugin', title: 'Full Plugin', - description: 'A plugin with filters and actions', + description: 'A plugin with multiple methods', author: 'Test Author', version: '1.0.0', - wasm: { path: '/path/to/full.wasm' }, - filters: [ - { - methodName: 'test-filter', - title: 'Test Filter', - description: 'A test filter', - supportedContexts: [PluginContext.Asset], - schema: { type: 'object', properties: {} }, - }, - ], - actions: [ - { - methodName: 'test-action', - title: 'Test Action', - description: 'A test action', - supportedContexts: [PluginContext.Asset], - schema: { type: 'object', properties: {} }, - }, - ], + wasmBytes, }, - '/test/base/path', + [ + { + name: 'test-filter', + title: 'Test Filter', + description: 'A test filter', + types: [WorkflowType.AssetV1], + schema: { type: 'object', properties: {} }, + }, + { + name: 'test-action', + title: 'Test Action', + description: 'A test action', + types: [WorkflowType.AssetV1], + schema: { type: 'object', properties: {} }, + }, + ], ); - const plugins = await sut.getAll(); + const plugins = await sut.search({}); expect(plugins).toHaveLength(1); expect(plugins[0]).toMatchObject({ - id: result.plugin.id, + id: result.id, name: 'full-plugin', - filters: [ + methods: [ { - id: result.filters[0].id, - pluginId: result.plugin.id, - methodName: 'test-filter', + id: result.methods[0].id, + pluginId: result.id, + name: 'test-filter', title: 'Test Filter', description: 'A test filter', - supportedContexts: [PluginContext.Asset], + types: [WorkflowType.AssetV1], schema: { type: 'object', properties: {} }, }, - ], - actions: [ { - id: result.actions[0].id, - pluginId: result.plugin.id, - methodName: 'test-action', + id: result.methods[1].id, + pluginId: result.id, + name: 'test-action', title: 'Test Action', description: 'A test action', - supportedContexts: [PluginContext.Asset], + types: [WorkflowType.AssetV1], schema: { type: 'object', properties: {} }, }, ], }); }); - it('should return multiple plugins with their respective filters and actions', async () => { - const { sut } = setup(); + it('should return multiple plugins with their respective methods', async () => { + const { ctx, sut } = setup(); - await pluginRepo.loadPlugin( + await ctx.get(PluginRepository).create( { + enabled: true, name: 'plugin-1', title: 'Plugin 1', description: 'First plugin', author: 'Author 1', version: '1.0.0', - wasm: { path: '/path/to/plugin1.wasm' }, - filters: [ - { - methodName: 'filter-1', - title: 'Filter 1', - description: 'Filter for plugin 1', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - ], + wasmBytes, }, - '/test/base/path', + [ + { + name: 'filter-1', + title: 'Filter 1', + description: 'Filter for plugin 1', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + ], ); - await pluginRepo.loadPlugin( + await ctx.get(PluginRepository).create( { + enabled: true, name: 'plugin-2', title: 'Plugin 2', description: 'Second plugin', author: 'Author 2', version: '2.0.0', - wasm: { path: '/path/to/plugin2.wasm' }, - actions: [ - { - methodName: 'action-2', - title: 'Action 2', - description: 'Action for plugin 2', - supportedContexts: [PluginContext.Album], - schema: undefined, - }, - ], + wasmBytes, }, - '/test/base/path', + [ + { + name: 'action-2', + title: 'Action 2', + description: 'Action for plugin 2', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + ], ); - const plugins = await sut.getAll(); + const plugins = await sut.search({}); expect(plugins).toHaveLength(2); expect(plugins[0].name).toBe('plugin-1'); - expect(plugins[0].filters).toHaveLength(1); - expect(plugins[0].actions).toHaveLength(0); + expect(plugins[0].methods).toHaveLength(1); expect(plugins[1].name).toBe('plugin-2'); - expect(plugins[1].filters).toHaveLength(0); - expect(plugins[1].actions).toHaveLength(1); + expect(plugins[1].methods).toHaveLength(1); }); - it('should handle plugin with multiple filters and actions', async () => { - const { sut } = setup(); + it('should handle plugin with multiple methods', async () => { + const { ctx, sut } = setup(); - await pluginRepo.loadPlugin( + await ctx.get(PluginRepository).create( { + enabled: true, name: 'multi-plugin', title: 'Multi Plugin', - description: 'Plugin with multiple items', + description: 'Plugin with multiple methods', author: 'Test Author', version: '1.0.0', - wasm: { path: '/path/to/multi.wasm' }, - filters: [ - { - methodName: 'filter-a', - title: 'Filter A', - description: 'First filter', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - { - methodName: 'filter-b', - title: 'Filter B', - description: 'Second filter', - supportedContexts: [PluginContext.Album], - schema: undefined, - }, - ], - actions: [ - { - methodName: 'action-x', - title: 'Action X', - description: 'First action', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - { - methodName: 'action-y', - title: 'Action Y', - description: 'Second action', - supportedContexts: [PluginContext.Person], - schema: undefined, - }, - ], + wasmBytes, }, - '/test/base/path', + [ + { + name: 'filter-a', + title: 'Filter A', + description: 'First filter', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + { + name: 'filter-b', + title: 'Filter B', + description: 'Second filter', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + { + name: 'action-x', + title: 'Action X', + description: 'First action', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + { + name: 'action-y', + title: 'Action Y', + description: 'Second action', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + ], ); - const plugins = await sut.getAll(); + const plugins = await sut.search({}); expect(plugins).toHaveLength(1); - expect(plugins[0].filters).toHaveLength(2); - expect(plugins[0].actions).toHaveLength(2); + expect(plugins[0].methods).toHaveLength(4); }); }); @@ -250,55 +238,51 @@ describe(PluginService.name, () => { await expect(sut.get('00000000-0000-0000-0000-000000000000')).rejects.toThrow('Plugin not found'); }); - it('should return single plugin with filters and actions', async () => { - const { sut } = setup(); + it('should return single plugin with methods', async () => { + const { ctx, sut } = setup(); - const result = await pluginRepo.loadPlugin( + const result = await ctx.get(PluginRepository).create( { + enabled: true, name: 'single-plugin', title: 'Single Plugin', description: 'A single plugin', author: 'Test Author', version: '1.0.0', - wasm: { path: '/path/to/single.wasm' }, - filters: [ - { - methodName: 'single-filter', - title: 'Single Filter', - description: 'A single filter', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - ], - actions: [ - { - methodName: 'single-action', - title: 'Single Action', - description: 'A single action', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - ], + wasmBytes, }, - '/test/base/path', - ); - - const pluginResult = await sut.get(result.plugin.id); - - expect(pluginResult).toMatchObject({ - id: result.plugin.id, - name: 'single-plugin', - filters: [ + [ { - id: result.filters[0].id, - methodName: 'single-filter', + name: 'single-filter', title: 'Single Filter', + description: 'A single filter', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + { + name: 'single-action', + title: 'Single Action', + description: 'A single action', + types: [WorkflowType.AssetV1], + schema: undefined, }, ], - actions: [ + ); + + const pluginResult = await sut.get(result.id); + + expect(pluginResult).toMatchObject({ + id: result.id, + name: 'single-plugin', + methods: [ { - id: result.actions[0].id, - methodName: 'single-action', + id: result.methods[0].id, + name: 'single-filter', + title: 'Single Filter', + }, + { + id: result.methods[1].id, + name: 'single-action', title: 'Single Action', }, ], diff --git a/server/test/medium/specs/services/workflow.service.spec.ts b/server/test/medium/specs/services/workflow.service.spec.ts index 229737c531..8d79557bdd 100644 --- a/server/test/medium/specs/services/workflow.service.spec.ts +++ b/server/test/medium/specs/services/workflow.service.spec.ts @@ -1,5 +1,5 @@ import { Kysely } from 'kysely'; -import { PluginContext, PluginTriggerType } from 'src/enum'; +import { WorkflowTrigger, WorkflowType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { PluginRepository } from 'src/repositories/plugin.repository'; @@ -20,53 +20,48 @@ const setup = (db?: Kysely) => { }); }; +const wasmBytes = Buffer.from('random-wasm-bytes'); + beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); describe(WorkflowService.name, () => { let testPluginId: string; - let testFilterId: string; - let testActionId: string; beforeAll(async () => { - // Create a test plugin with filters and actions once for all tests - const pluginRepo = new PluginRepository(defaultDatabase); - const result = await pluginRepo.loadPlugin( + const { ctx } = setup(); + // Create a test plugin with methods and actions once for all tests + const pluginRepo = ctx.get(PluginRepository); + const result = await pluginRepo.create( { + enabled: true, name: 'test-core-plugin', title: 'Test Core Plugin', description: 'A test core plugin for workflow tests', author: 'Test Author', version: '1.0.0', - wasm: { - path: '/test/path.wasm', - }, - filters: [ - { - methodName: 'test-filter', - title: 'Test Filter', - description: 'A test filter', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - ], - actions: [ - { - methodName: 'test-action', - title: 'Test Action', - description: 'A test action', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - ], + wasmBytes, }, - '/plugins/test-core-plugin', + [ + { + name: 'test-filter', + title: 'Test Filter', + description: 'A test filter', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + { + name: 'test-action', + title: 'Test Action', + description: 'A test action', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + ], ); - testPluginId = result.plugin.id; - testFilterId = result.filters[0].id; - testActionId = result.actions[0].id; + testPluginId = result.id; }); afterAll(async () => { @@ -74,229 +69,48 @@ describe(WorkflowService.name, () => { }); describe('create', () => { - it('should create a workflow without filters or actions', async () => { + it('should create a workflow without methods or actions', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const auth = factory.auth({ user }); const workflow = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'test-workflow', description: 'A test workflow', enabled: true, - filters: [], - actions: [], }); expect(workflow).toMatchObject({ id: expect.any(String), ownerId: user.id, - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'test-workflow', description: 'A test workflow', enabled: true, - filters: [], - actions: [], }); }); - it('should create a workflow with filters and actions', async () => { + it('should create a workflow with methods and actions', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const auth = factory.auth({ user }); const workflow = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'test-workflow-with-relations', - description: 'A test workflow with filters and actions', + description: 'A test workflow with methods and actions', enabled: true, - filters: [ - { - pluginFilterId: testFilterId, - filterConfig: { key: 'value' }, - }, - ], - actions: [ - { - pluginActionId: testActionId, - actionConfig: { action: 'test' }, - }, - ], }); expect(workflow).toMatchObject({ id: expect.any(String), ownerId: user.id, - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'test-workflow-with-relations', enabled: true, }); - - expect(workflow.filters).toHaveLength(1); - expect(workflow.filters[0]).toMatchObject({ - id: expect.any(String), - workflowId: workflow.id, - pluginFilterId: testFilterId, - filterConfig: { key: 'value' }, - order: 0, - }); - - expect(workflow.actions).toHaveLength(1); - expect(workflow.actions[0]).toMatchObject({ - id: expect.any(String), - workflowId: workflow.id, - pluginActionId: testActionId, - actionConfig: { action: 'test' }, - order: 0, - }); - }); - - it('should throw error when creating workflow with invalid filter', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - await expect( - sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'invalid-workflow', - description: 'A workflow with invalid filter', - enabled: true, - filters: [{ pluginFilterId: factory.uuid(), filterConfig: { key: 'value' } }], - actions: [], - }), - ).rejects.toThrow('Invalid filter ID'); - }); - - it('should throw error when creating workflow with invalid action', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - await expect( - sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'invalid-workflow', - description: 'A workflow with invalid action', - enabled: true, - filters: [], - actions: [{ pluginActionId: factory.uuid(), actionConfig: { action: 'test' } }], - }), - ).rejects.toThrow('Invalid action ID'); - }); - - it('should throw error when filter does not support trigger context', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - // Create a plugin with a filter that only supports Album context - const pluginRepo = new PluginRepository(defaultDatabase); - const result = await pluginRepo.loadPlugin( - { - name: 'album-only-plugin', - title: 'Album Only Plugin', - description: 'Plugin with album-only filter', - author: 'Test Author', - version: '1.0.0', - wasm: { path: '/test/album-plugin.wasm' }, - filters: [ - { - methodName: 'album-filter', - title: 'Album Filter', - description: 'A filter that only works with albums', - supportedContexts: [PluginContext.Album], - schema: undefined, - }, - ], - }, - '/plugins/test-core-plugin', - ); - - await expect( - sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'invalid-context-workflow', - description: 'A workflow with context mismatch', - enabled: true, - filters: [{ pluginFilterId: result.filters[0].id }], - actions: [], - }), - ).rejects.toThrow('does not support asset context'); - }); - - it('should throw error when action does not support trigger context', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - // Create a plugin with an action that only supports Person context - const pluginRepo = new PluginRepository(defaultDatabase); - const result = await pluginRepo.loadPlugin( - { - name: 'person-only-plugin', - title: 'Person Only Plugin', - description: 'Plugin with person-only action', - author: 'Test Author', - version: '1.0.0', - wasm: { path: '/test/person-plugin.wasm' }, - actions: [ - { - methodName: 'person-action', - title: 'Person Action', - description: 'An action that only works with persons', - supportedContexts: [PluginContext.Person], - schema: undefined, - }, - ], - }, - '/plugins/test-core-plugin', - ); - - await expect( - sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'invalid-context-workflow', - description: 'A workflow with context mismatch', - enabled: true, - filters: [], - actions: [{ pluginActionId: result.actions[0].id }], - }), - ).rejects.toThrow('does not support asset context'); - }); - - it('should create workflow with multiple filters and actions in correct order', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const workflow = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'multi-step-workflow', - description: 'A workflow with multiple filters and actions', - enabled: true, - filters: [ - { pluginFilterId: testFilterId, filterConfig: { step: 1 } }, - { pluginFilterId: testFilterId, filterConfig: { step: 2 } }, - ], - actions: [ - { pluginActionId: testActionId, actionConfig: { step: 1 } }, - { pluginActionId: testActionId, actionConfig: { step: 2 } }, - { pluginActionId: testActionId, actionConfig: { step: 3 } }, - ], - }); - - expect(workflow.filters).toHaveLength(2); - expect(workflow.filters[0].order).toBe(0); - expect(workflow.filters[0].filterConfig).toEqual({ step: 1 }); - expect(workflow.filters[1].order).toBe(1); - expect(workflow.filters[1].filterConfig).toEqual({ step: 2 }); - - expect(workflow.actions).toHaveLength(3); - expect(workflow.actions[0].order).toBe(0); - expect(workflow.actions[1].order).toBe(1); - expect(workflow.actions[2].order).toBe(2); }); }); @@ -307,24 +121,20 @@ describe(WorkflowService.name, () => { const auth = factory.auth({ user }); const workflow1 = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'workflow-1', description: 'First workflow', enabled: true, - filters: [], - actions: [], }); const workflow2 = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'workflow-2', description: 'Second workflow', enabled: false, - filters: [], - actions: [], }); - const workflows = await sut.getAll(auth); + const workflows = await sut.search(auth, {}); expect(workflows).toHaveLength(2); expect(workflows).toEqual( @@ -340,7 +150,7 @@ describe(WorkflowService.name, () => { const { user } = await ctx.newUser(); const auth = factory.auth({ user }); - const workflows = await sut.getAll(auth); + const workflows = await sut.search(auth, {}); expect(workflows).toEqual([]); }); @@ -353,424 +163,15 @@ describe(WorkflowService.name, () => { const auth2 = factory.auth({ user: user2 }); await sut.create(auth1, { - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'user1-workflow', description: 'User 1 workflow', enabled: true, - filters: [], - actions: [], }); - const user2Workflows = await sut.getAll(auth2); + const user2Workflows = await sut.search(auth2, {}); expect(user2Workflows).toEqual([]); }); }); - - describe('get', () => { - it('should return a specific workflow by id', 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: 'A test workflow', - enabled: true, - filters: [{ pluginFilterId: testFilterId, filterConfig: { key: 'value' } }], - actions: [{ pluginActionId: testActionId, actionConfig: { action: 'test' } }], - }); - - const workflow = await sut.get(auth, created.id); - - expect(workflow).toMatchObject({ - id: created.id, - name: 'test-workflow', - description: 'A test workflow', - enabled: true, - }); - expect(workflow.filters).toHaveLength(1); - expect(workflow.actions).toHaveLength(1); - }); - - it('should throw error when workflow does not exist', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - await expect(sut.get(auth, '66da82df-e424-4bf4-b6f3-5d8e71620dae')).rejects.toThrow(); - }); - - it('should throw error when user does not have access to workflow', async () => { - const { sut, ctx } = setup(); - const { user: user1 } = await ctx.newUser(); - const { user: user2 } = await ctx.newUser(); - const auth1 = factory.auth({ user: user1 }); - const auth2 = factory.auth({ user: user2 }); - - const workflow = await sut.create(auth1, { - triggerType: PluginTriggerType.AssetCreate, - name: 'private-workflow', - description: 'Private workflow', - enabled: true, - filters: [], - actions: [], - }); - - await expect(sut.get(auth2, workflow.id)).rejects.toThrow(); - }); - }); - - describe('update', () => { - it('should update workflow basic fields', 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: 'original-workflow', - description: 'Original description', - enabled: true, - filters: [], - actions: [], - }); - - const updated = await sut.update(auth, created.id, { - name: 'updated-workflow', - description: 'Updated description', - enabled: false, - }); - - expect(updated).toMatchObject({ - id: created.id, - name: 'updated-workflow', - description: 'Updated description', - enabled: false, - }); - }); - - it('should update workflow 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: { old: 'config' } }], - actions: [], - }); - - const updated = await sut.update(auth, created.id, { - filters: [ - { pluginFilterId: testFilterId, filterConfig: { new: 'config' } }, - { pluginFilterId: testFilterId, filterConfig: { second: 'filter' } }, - ], - }); - - expect(updated.filters).toHaveLength(2); - expect(updated.filters[0].filterConfig).toEqual({ new: 'config' }); - expect(updated.filters[1].filterConfig).toEqual({ second: 'filter' }); - }); - - it('should update workflow actions', 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: [{ pluginActionId: testActionId, actionConfig: { old: 'config' } }], - }); - - const updated = await sut.update(auth, created.id, { - actions: [ - { pluginActionId: testActionId, actionConfig: { new: 'config' } }, - { pluginActionId: testActionId, actionConfig: { second: 'action' } }, - ], - }); - - expect(updated.actions).toHaveLength(2); - expect(updated.actions[0].actionConfig).toEqual({ new: 'config' }); - expect(updated.actions[1].actionConfig).toEqual({ second: 'action' }); - }); - - it('should clear filters when updated with empty array', 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: { key: 'value' } }], - actions: [], - }); - - const updated = await sut.update(auth, created.id, { - filters: [], - }); - - expect(updated.filters).toHaveLength(0); - }); - - it('should throw error when no fields to update', 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 expect(sut.update(auth, created.id, {})).rejects.toThrow('No fields to update'); - }); - - it('should throw error when updating non-existent workflow', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - await expect(sut.update(auth, factory.uuid(), { name: 'updated-name' })).rejects.toThrow(); - }); - - it('should throw error when user does not have access to update workflow', async () => { - const { sut, ctx } = setup(); - const { user: user1 } = await ctx.newUser(); - const { user: user2 } = await ctx.newUser(); - const auth1 = factory.auth({ user: user1 }); - const auth2 = factory.auth({ user: user2 }); - - const workflow = await sut.create(auth1, { - triggerType: PluginTriggerType.AssetCreate, - name: 'private-workflow', - description: 'Private', - enabled: true, - filters: [], - actions: [], - }); - - await expect( - sut.update(auth2, workflow.id, { - name: 'hacked-workflow', - }), - ).rejects.toThrow(); - }); - - it('should throw error when updating with invalid filter', 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 expect( - sut.update(auth, created.id, { - filters: [{ pluginFilterId: factory.uuid(), filterConfig: {} }], - }), - ).rejects.toThrow(); - }); - - it('should throw error when updating with invalid action', 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 expect( - 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', () => { - it('should delete a workflow', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const workflow = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [], - actions: [], - }); - - await sut.delete(auth, workflow.id); - - await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access'); - }); - - it('should delete workflow with filters and actions', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const workflow = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [{ pluginFilterId: testFilterId, filterConfig: {} }], - actions: [{ pluginActionId: testActionId, actionConfig: {} }], - }); - - await sut.delete(auth, workflow.id); - - await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access'); - }); - - it('should throw error when deleting non-existent workflow', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - await expect(sut.delete(auth, factory.uuid())).rejects.toThrow(); - }); - - it('should throw error when user does not have access to delete workflow', async () => { - const { sut, ctx } = setup(); - const { user: user1 } = await ctx.newUser(); - const { user: user2 } = await ctx.newUser(); - const auth1 = factory.auth({ user: user1 }); - const auth2 = factory.auth({ user: user2 }); - - const workflow = await sut.create(auth1, { - triggerType: PluginTriggerType.AssetCreate, - name: 'private-workflow', - description: 'Private', - enabled: true, - filters: [], - actions: [], - }); - - await expect(sut.delete(auth2, workflow.id)).rejects.toThrow(); - }); - }); }); diff --git a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts new file mode 100644 index 0000000000..44a369e2dd --- /dev/null +++ b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts @@ -0,0 +1,213 @@ +import { WorkflowStepConfig } from '@immich/plugin-sdk'; +import { Kysely } from 'kysely'; +import { AssetVisibility, WorkflowTrigger } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { DatabaseRepository } from 'src/repositories/database.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; +import { DB } from 'src/schema'; +import { WorkflowExecutionService } from 'src/services/workflow-execution.service'; +import { resolveMethod } from 'src/utils/workflow'; +import { MediumTestContext } from 'test/medium.factory'; +import { mockEnvData } from 'test/repositories/config.repository.mock'; +import { getKyselyDB } from 'test/utils'; + +let initialized = false; + +class WorkflowTestContext extends MediumTestContext { + constructor(database: Kysely) { + super(WorkflowExecutionService, { + database, + real: [ + AccessRepository, + AlbumRepository, + AssetRepository, + CryptoRepository, + DatabaseRepository, + LoggingRepository, + StorageRepository, + PluginRepository, + WorkflowRepository, + ], + mock: [ConfigRepository], + }); + } + + async init() { + if (initialized) { + return; + } + + const mockData = mockEnvData({}); + mockData.resourcePaths.corePlugin = '../packages/plugin-core'; + mockData.plugins.external.allow = false; + this.getMock(ConfigRepository).getEnv.mockReturnValue(mockData); + + await this.sut.onPluginSync(); + await this.sut.onPluginLoad(); + + initialized = true; + } +} + +type WorkflowTemplate = { + ownerId: string; + trigger: WorkflowTrigger; + steps: WorkflowTemplateStep[]; +}; + +type WorkflowTemplateStep = { + method: string; + config?: WorkflowStepConfig; +}; + +const createWorkflow = async (template: WorkflowTemplate) => { + const workflowRepo = ctx.get(WorkflowRepository); + const pluginRepo = ctx.get(PluginRepository); + + const methods = await pluginRepo.getForValidation(); + const steps = template.steps.map((step) => { + const pluginMethod = resolveMethod(methods, step.method); + if (!pluginMethod) { + throw new Error(`Plugin method not found: ${step.method}`); + } + + return { ...step, pluginMethod }; + }); + + return workflowRepo.create( + { + enabled: true, + name: 'Test workflow', + description: 'A workflow to test the core plugin', + ownerId: template.ownerId, + trigger: template.trigger, + }, + steps.map((step) => ({ + enabled: true, + pluginMethodId: step.pluginMethod.id, + config: step.config, + })), + ); +}; + +let ctx: WorkflowTestContext; + +beforeAll(async () => { + const db = await getKyselyDB(); + ctx = new WorkflowTestContext(db); + await ctx.init(); +}); + +describe('core plugin', () => { + describe('assetArchive', () => { + it('should archive an asset', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ method: 'immich-plugin-core#assetArchive' }], + }); + + await ctx.sut.handleWorkflowAssetCreate({ workflowId: workflow.id, assetId: asset.id }); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ + visibility: AssetVisibility.Archive, + }); + }); + + it('should unarchive an asset', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Archive }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ method: 'immich-plugin-core#assetArchive', config: { inverse: true } }], + }); + + await ctx.sut.handleWorkflowAssetCreate({ workflowId: workflow.id, assetId: asset.id }); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ + visibility: AssetVisibility.Timeline, + }); + }); + }); + + describe('assetFavoriteAsset', () => { + it('should favorite an asset', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ method: 'immich-plugin-core#assetFavorite' }], + }); + + await ctx.sut.handleWorkflowAssetCreate({ workflowId: workflow.id, assetId: asset.id }); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true }); + }); + + it('should unfavorite an asset', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ method: 'immich-plugin-core#assetFavorite', config: { inverse: true } }], + }); + + await ctx.sut.handleWorkflowAssetCreate({ workflowId: workflow.id, assetId: asset.id }); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false }); + }); + }); + + describe('albumAddAssets', () => { + it('should add an asset to an album', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true }); + const { album } = await ctx.newAlbum({ ownerId: user.id }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ method: 'immich-plugin-core#albumAddAssets', config: { albumId: album.id } }], + }); + + await ctx.sut.handleWorkflowAssetCreate({ workflowId: workflow.id, assetId: asset.id }); + + const assetIds = await ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id]); + expect([...assetIds]).toEqual([asset.id]); + }); + + it('should require album access', async () => { + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user1.id, isFavorite: true }); + const { album } = await ctx.newAlbum({ ownerId: user2.id }); + + const workflow = await createWorkflow({ + ownerId: user1.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ method: 'immich-plugin-core#albumAddAssets', config: { albumId: album.id } }], + }); + + await ctx.sut.handleWorkflowAssetCreate({ workflowId: workflow.id, assetId: asset.id }); + + const assetIds = await ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id]); + expect([...assetIds]).not.toContain(asset.id); + }); + }); +}); diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index 62e498372e..de32a8c6da 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -73,7 +73,7 @@ const envData: EnvData = { root: '/build/www', indexHtml: '/build/www/index.html', }, - corePlugin: '/build/corePlugin', + corePlugin: '/build/plugins/immich-plugin-core', }, setup: { diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 85c72b6c10..334d7d0d53 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -53,7 +53,8 @@ export const newStorageRepositoryMock = (): Mocked['readJsonFile'], + readdirWithTypes: vitest.fn(), createFile: vitest.fn(), createWriteStream: vitest.fn(), createOrOverwriteFile: vitest.fn(), diff --git a/server/test/utils.ts b/server/test/utils.ts index b3e47b2b7e..988b5d23c0 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -322,7 +322,7 @@ export const getMocks = () => { oauth: automock(OAuthRepository, { args: [loggerMock] }), partner: automock(PartnerRepository, { strict: false }), person: automock(PersonRepository, { strict: false }), - plugin: automock(PluginRepository, { strict: true }), + plugin: automock(PluginRepository, { strict: true, args: [databaseMock, loggerMock] }), process: automock(ProcessRepository), search: automock(SearchRepository, { strict: false }), // eslint-disable-next-line no-sparse-arrays diff --git a/web/package.json b/web/package.json index 5bec4e786b..31f273c191 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "workspace:*", - "@immich/ui": "^0.64.0", + "@immich/ui": "^0.65.2", "@mapbox/mapbox-gl-rtl-text": "0.3.0", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", diff --git a/web/src/lib/components/HeaderActionButton.svelte b/web/src/lib/components/HeaderActionButton.svelte index 542c22ba43..e68a4a920d 100644 --- a/web/src/lib/components/HeaderActionButton.svelte +++ b/web/src/lib/components/HeaderActionButton.svelte @@ -1,18 +1,19 @@ {#if action.$if?.() ?? true} + + {/if} + {:else} + {@const albumId = getString()} + {#if albumId} + + + + {:else} +
+ + {#if description} + {description} + {/if} + +
+ {/if} + {/if} +{:else if schema.enum && schema.array} + + + +{:else if schema.enum} + + getValue(), setValue} /> + +{:else} + Unknown schema + +{/if} diff --git a/web/src/lib/components/utilities-page/utilities-menu.svelte b/web/src/lib/components/utilities-page/utilities-menu.svelte index 10028c2c92..2495481b65 100644 --- a/web/src/lib/components/utilities-page/utilities-menu.svelte +++ b/web/src/lib/components/utilities-page/utilities-menu.svelte @@ -9,6 +9,7 @@ mdiCrosshairsGps, mdiImageSizeSelectLarge, mdiLinkEdit, + mdiStateMachine, } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -16,7 +17,7 @@ { href: Route.duplicatesUtility(), icon: mdiContentDuplicate, label: $t('review_duplicates') }, { href: Route.largeFileUtility(), icon: mdiImageSizeSelectLarge, label: $t('review_large_files') }, { href: Route.geolocationUtility(), icon: mdiCrosshairsGps, label: $t('manage_geolocation') }, - // { href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') }, + { href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') }, ]; diff --git a/web/src/lib/components/workflows/SchemaFormFields.svelte b/web/src/lib/components/workflows/SchemaFormFields.svelte deleted file mode 100644 index fbdb1fa0a5..0000000000 --- a/web/src/lib/components/workflows/SchemaFormFields.svelte +++ /dev/null @@ -1,161 +0,0 @@ - - -{#if components} -
- {#each Object.entries(components) as [key, component] (key)} - {@const label = component.title || component.label || key} - -
- - {#if component.type === 'select'} - {#if isPickerField(component.subType)} - updateConfig(key, value)} - /> - {:else} - {@const options = component.options?.map((opt) => { - return { label: opt.label, value: String(opt.value) }; - }) || [{ label: 'N/A', value: '' }]} - - updateConfig(key, e.currentTarget.value)} - required={component.required} - /> - - {/if} -
- {/each} -
-{:else} - No configuration required -{/if} diff --git a/web/src/lib/components/workflows/WorkflowCardConnector.svelte b/web/src/lib/components/workflows/WorkflowCardConnector.svelte deleted file mode 100644 index 9666311980..0000000000 --- a/web/src/lib/components/workflows/WorkflowCardConnector.svelte +++ /dev/null @@ -1,42 +0,0 @@ - - -
-
-
- {#if animated} -
- {/if} -
-
-
-
-
-
-
-
- - diff --git a/web/src/lib/components/workflows/WorkflowJsonEditor.svelte b/web/src/lib/components/workflows/WorkflowJsonEditor.svelte index 5401eeb5f0..5f585bedbd 100644 --- a/web/src/lib/components/workflows/WorkflowJsonEditor.svelte +++ b/web/src/lib/components/workflows/WorkflowJsonEditor.svelte @@ -1,14 +1,14 @@ - - -
- {#if pickerMetadata && !Array.isArray(pickerMetadata)} - - {:else if pickerMetadata && Array.isArray(pickerMetadata) && pickerMetadata.length > 0} -
- {#each pickerMetadata as item (item.id)} - removeItemFromSelection(item.id)} /> - {/each} -
- {/if} - -
-
diff --git a/web/src/lib/components/workflows/WorkflowPickerItemCard.svelte b/web/src/lib/components/workflows/WorkflowPickerItemCard.svelte deleted file mode 100644 index 63f9654783..0000000000 --- a/web/src/lib/components/workflows/WorkflowPickerItemCard.svelte +++ /dev/null @@ -1,57 +0,0 @@ - - - - -
- {#if isAlbum && 'albumThumbnailAssetId' in item} - {#if item.albumThumbnailAssetId} - {item.albumName} - {:else} -
- {/if} - {:else if !isAlbum && 'name' in item} - {item.name} - {/if} -
-
- - {isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''} - - {#if isAlbum && 'assetCount' in item} - - {$t('items_count', { values: { count: item.assetCount } })} - - {/if} -
- - -
-
diff --git a/web/src/lib/components/workflows/WorkflowSummary.svelte b/web/src/lib/components/workflows/WorkflowSummary.svelte index 6057b5e0cc..56d23ecf5b 100644 --- a/web/src/lib/components/workflows/WorkflowSummary.svelte +++ b/web/src/lib/components/workflows/WorkflowSummary.svelte @@ -1,35 +1,16 @@ - - diff --git a/web/src/lib/managers/plugin-manager.svelte.ts b/web/src/lib/managers/plugin-manager.svelte.ts new file mode 100644 index 0000000000..44d8a2869d --- /dev/null +++ b/web/src/lib/managers/plugin-manager.svelte.ts @@ -0,0 +1,80 @@ +import { eventManager } from '$lib/managers/event-manager.svelte'; +import { user } from '$lib/stores/user.store'; +import { + getWorkflowTriggers, + searchPluginMethods, + WorkflowTrigger, + type PluginMethodResponseDto, + type WorkflowTriggerResponseDto, +} from '@immich/sdk'; +import { t } from 'svelte-i18n'; +import { SvelteMap } from 'svelte/reactivity'; +import { get } from 'svelte/store'; + +class PluginManager { + #loading: Promise | undefined; + #methodMap = new SvelteMap(); + #methods = $state([]); + #triggers = $state([]); + + constructor() { + eventManager.on({ + AuthLogout: () => this.clearCache(), + AuthUserLoaded: () => this.initialize(), + }); + + // loaded event might have already happened + if (get(user)) { + void this.initialize(); + } + } + + get triggers() { + return this.#triggers; + } + + ready() { + return this.initialize(); + } + + getMethodLabel(key: string) { + const method = this.#methodMap.get(key); + return method?.title ?? get(t)('unknown'); + } + + getTrigger(trigger: WorkflowTrigger) { + const result = this.#triggers.find((t) => t.trigger === trigger); + + if (!result) { + throw new Error(`Unknown trigger type: ${trigger}`); + } + + return result; + } + + private clearCache() { + this.#loading = undefined; + this.#methodMap = new SvelteMap(); + } + + private initialize() { + if (!this.#loading) { + this.#loading = this.load(); + } + + return this.#loading; + } + + private async load() { + const [methods, triggers] = await Promise.all([searchPluginMethods({}), getWorkflowTriggers()]); + + this.#methods = methods; + for (const method of this.#methods) { + this.#methodMap.set(method.key, method); + } + + this.#triggers = triggers; + } +} + +export const pluginManager = new PluginManager(); diff --git a/web/src/lib/modals/AddWorkflowStepModal.svelte b/web/src/lib/modals/AddWorkflowStepModal.svelte deleted file mode 100644 index e99058f98a..0000000000 --- a/web/src/lib/modals/AddWorkflowStepModal.svelte +++ /dev/null @@ -1,80 +0,0 @@ - - -{#snippet stepButton(title: string, description?: string, onclick?: () => void)} - -{/snippet} - - onClose()}> - -
- - {#if filters.length > 0 && (!type || type === 'filter')} -
- {#each filters as filter (filter.id)} - {@render stepButton(filter.title, filter.description, () => handleSelect('filter', filter))} - {/each} -
- {/if} - - - {#if actions.length > 0 && (!type || type === 'action')} -
- {#each actions as action (action.id)} - {@render stepButton(action.title, action.description, () => handleSelect('action', action))} - {/each} -
- {/if} -
-
-
diff --git a/web/src/lib/modals/AlbumPickerModal.svelte b/web/src/lib/modals/AlbumPickerModal.svelte index b2420215bc..15ce62f810 100644 --- a/web/src/lib/modals/AlbumPickerModal.svelte +++ b/web/src/lib/modals/AlbumPickerModal.svelte @@ -20,9 +20,9 @@ let search = $state(''); let selectedRowIndex: number = $state(-1); - interface Props { + type Props = { onClose: (albums?: AlbumResponseDto[]) => void; - } + }; let { onClose }: Props = $props(); diff --git a/web/src/lib/modals/PluginMethodPicker.svelte b/web/src/lib/modals/PluginMethodPicker.svelte new file mode 100644 index 0000000000..4c42b1007b --- /dev/null +++ b/web/src/lib/modals/PluginMethodPicker.svelte @@ -0,0 +1,43 @@ + + + + {#if loading} +
+ +
+ {:else} + + {#each methods as method (method.key)} + onClose(method)}> +
+ {method.title} + {#if method.description} + {method.description} + {/if} +
+
+ {/each} +
+ {/if} +
diff --git a/web/src/lib/modals/WorkflowAddStepModal.svelte b/web/src/lib/modals/WorkflowAddStepModal.svelte new file mode 100644 index 0000000000..dbe5e6e977 --- /dev/null +++ b/web/src/lib/modals/WorkflowAddStepModal.svelte @@ -0,0 +1,74 @@ + + +{#if method} + +
+
+ {method.title} + {#if method.description} + {method.description} + {/if} +
+ +
+ + {#if method.schema} +
+ + + + {#if debug} + +