From 4dcc04946584b7822faba82644cf8644d4d7da8d Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 14 Nov 2025 14:05:05 -0600 Subject: [PATCH] feat: workflow foundation (#23621) * feat: plugins * feat: table definition * feat: type and migration * feat: add repositories * feat: validate manifest with class-validator and load manifest info to database * feat: workflow/plugin controller/service layer * feat: implement workflow logic * feat: make trigger static * feat: dynamical instantiate plugin instances * fix: access control and helper script * feat: it works * chore: simplify * refactor: refactor and use queue for workflow execution * refactor: remove unsused property in plugin-schema * build wasm in prod * feat: plugin loader in transaction * fix: docker build arm64 * generated files * shell check * fix tests * fix: waiting for migration to finish before loading plugin * remove context reassignment * feat: use mise to manage extism tools (#23760) * pr feedback * refactor: create workflow now including create filters and actions * feat: workflow medium tests * fix: broken medium test * feat: medium tests * chore: unify workflow job * sign user id with jwt * chore: query plugin with filters and action * chore: read manifest in repository * chore: load manifest from server configs * merge main * feat: endpoint documentation * pr feedback * load plugin from absolute path * refactor:handle trigger * throw error and return early * pr feedback * unify plugin services * fix: plugins code * clean up * remove triggerConfig * clean up * displayName and methodName --------- Co-authored-by: Jason Rasmussen Co-authored-by: bo0tzz --- docker/docker-compose.dev.yml | 1 + i18n/en.json | 1 + mobile/openapi/README.md | 19 + mobile/openapi/lib/api.dart | 14 + mobile/openapi/lib/api/plugins_api.dart | 126 +++ mobile/openapi/lib/api/workflows_api.dart | 292 +++++++ mobile/openapi/lib/api_client.dart | 24 + mobile/openapi/lib/api_helper.dart | 6 + mobile/openapi/lib/model/permission.dart | 24 + .../lib/model/plugin_action_response_dto.dart | 151 ++++ mobile/openapi/lib/model/plugin_context.dart | 88 ++ .../lib/model/plugin_filter_response_dto.dart | 151 ++++ .../lib/model/plugin_response_dto.dart | 171 ++++ .../lib/model/plugin_trigger_type.dart | 85 ++ mobile/openapi/lib/model/queue_name.dart | 3 + .../lib/model/queues_response_dto.dart | 14 +- .../lib/model/system_config_job_dto.dart | 14 +- .../lib/model/workflow_action_item_dto.dart | 116 +++ .../model/workflow_action_response_dto.dart | 135 ++++ .../lib/model/workflow_create_dto.dart | 157 ++++ .../lib/model/workflow_filter_item_dto.dart | 116 +++ .../model/workflow_filter_response_dto.dart | 135 ++++ .../lib/model/workflow_response_dto.dart | 241 ++++++ .../lib/model/workflow_update_dto.dart | 156 ++++ open-api/immich-openapi-specs.json | 757 +++++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 194 ++++- plugins/.gitignore | 2 + plugins/LICENSE | 26 + plugins/esbuild.js | 12 + plugins/manifest.json | 127 +++ plugins/mise.toml | 11 + plugins/package-lock.json | 443 ++++++++++ plugins/package.json | 19 + plugins/src/index.d.ts | 12 + plugins/src/index.ts | 71 ++ plugins/tsconfig.json | 24 + pnpm-lock.yaml | 130 +++ pnpm-workspace.yaml | 1 + server/Dockerfile | 20 + server/package.json | 4 + server/src/config.ts | 1 + server/src/constants.ts | 4 + server/src/controllers/index.ts | 4 + server/src/controllers/plugin.controller.ts | 36 + server/src/controllers/workflow.controller.ts | 76 ++ server/src/database.ts | 55 ++ server/src/dtos/env.dto.ts | 7 + server/src/dtos/plugin-manifest.dto.ts | 110 +++ server/src/dtos/plugin.dto.ts | 77 ++ server/src/dtos/queue.dto.ts | 3 + server/src/dtos/system-config.dto.ts | 6 + server/src/dtos/workflow.dto.ts | 120 +++ server/src/enum.ts | 27 + server/src/plugins.ts | 37 + server/src/queries/access.repository.sql | 9 + server/src/queries/plugin.repository.sql | 159 ++++ server/src/queries/workflow.repository.sql | 68 ++ server/src/repositories/access.repository.ts | 22 + server/src/repositories/config.repository.ts | 12 + server/src/repositories/crypto.repository.ts | 9 + server/src/repositories/event.repository.ts | 2 + server/src/repositories/index.ts | 4 + server/src/repositories/plugin.repository.ts | 176 ++++ server/src/repositories/storage.repository.ts | 4 + .../src/repositories/workflow.repository.ts | 139 ++++ server/src/schema/index.ts | 16 + ...762297277677-AddPluginAndWorkflowTables.ts | 113 +++ server/src/schema/tables/plugin.table.ts | 95 +++ server/src/schema/tables/workflow.table.ts | 78 ++ server/src/services/asset-media.service.ts | 3 + server/src/services/base.service.ts | 7 + server/src/services/index.ts | 4 + server/src/services/plugin-host.functions.ts | 120 +++ server/src/services/plugin.service.ts | 317 ++++++++ server/src/services/queue.service.spec.ts | 3 +- .../services/system-config.service.spec.ts | 1 + server/src/services/workflow.service.ts | 159 ++++ server/src/types.ts | 24 +- server/src/types/plugin-schema.types.ts | 35 + server/src/utils/access.ts | 6 + server/test/medium.factory.ts | 10 +- .../specs/services/plugin.service.spec.ts | 308 +++++++ .../specs/services/workflow.service.spec.ts | 697 ++++++++++++++++ .../repositories/access.repository.mock.ts | 4 + .../repositories/config.repository.mock.ts | 6 + .../repositories/crypto.repository.mock.ts | 2 + .../repositories/storage.repository.mock.ts | 1 + server/test/utils.ts | 8 + web/src/lib/utils.ts | 1 + 89 files changed, 7264 insertions(+), 14 deletions(-) create mode 100644 mobile/openapi/lib/api/plugins_api.dart create mode 100644 mobile/openapi/lib/api/workflows_api.dart create mode 100644 mobile/openapi/lib/model/plugin_action_response_dto.dart create mode 100644 mobile/openapi/lib/model/plugin_context.dart create mode 100644 mobile/openapi/lib/model/plugin_filter_response_dto.dart create mode 100644 mobile/openapi/lib/model/plugin_response_dto.dart create mode 100644 mobile/openapi/lib/model/plugin_trigger_type.dart create mode 100644 mobile/openapi/lib/model/workflow_action_item_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_action_response_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_create_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_filter_item_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_filter_response_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_response_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_update_dto.dart create mode 100644 plugins/.gitignore create mode 100644 plugins/LICENSE create mode 100644 plugins/esbuild.js create mode 100644 plugins/manifest.json create mode 100644 plugins/mise.toml create mode 100644 plugins/package-lock.json create mode 100644 plugins/package.json create mode 100644 plugins/src/index.d.ts create mode 100644 plugins/src/index.ts create mode 100644 plugins/tsconfig.json create mode 100644 server/src/controllers/plugin.controller.ts create mode 100644 server/src/controllers/workflow.controller.ts create mode 100644 server/src/dtos/plugin-manifest.dto.ts create mode 100644 server/src/dtos/plugin.dto.ts create mode 100644 server/src/dtos/workflow.dto.ts create mode 100644 server/src/plugins.ts create mode 100644 server/src/queries/plugin.repository.sql create mode 100644 server/src/queries/workflow.repository.sql create mode 100644 server/src/repositories/plugin.repository.ts create mode 100644 server/src/repositories/workflow.repository.ts create mode 100644 server/src/schema/migrations/1762297277677-AddPluginAndWorkflowTables.ts create mode 100644 server/src/schema/tables/plugin.table.ts create mode 100644 server/src/schema/tables/workflow.table.ts create mode 100644 server/src/services/plugin-host.functions.ts create mode 100644 server/src/services/plugin.service.ts create mode 100644 server/src/services/workflow.service.ts create mode 100644 server/src/types/plugin-schema.types.ts create mode 100644 server/test/medium/specs/services/plugin.service.spec.ts create mode 100644 server/test/medium/specs/services/workflow.service.spec.ts diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 5968c5bb3a..e2fb8fbc30 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -41,6 +41,7 @@ services: - app-node_modules:/usr/src/app/node_modules - sveltekit:/usr/src/app/web/.svelte-kit - coverage:/usr/src/app/web/coverage + - ../plugins:/build/corePlugin env_file: - .env environment: diff --git a/i18n/en.json b/i18n/en.json index f0b10d2ac1..ce999793d4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2181,6 +2181,7 @@ "welcome": "Welcome", "welcome_to_immich": "Welcome to Immich", "wifi_name": "Wi-Fi Name", + "workflow": "Workflow", "wrong_pin_code": "Wrong PIN code", "year": "Year", "years_ago": "{years, plural, one {# year} other {# years}} ago", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 5e93c571bd..4e34f66a81 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -194,6 +194,8 @@ Class | Method | HTTP request | Description *PeopleApi* | [**reassignFaces**](doc//PeopleApi.md#reassignfaces) | **PUT** /people/{id}/reassign | Reassign faces *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* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins *SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | Retrieve assets by city *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | Retrieve explore data *SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | Retrieve search suggestions @@ -295,6 +297,11 @@ Class | Method | HTTP request | Description *UsersAdminApi* | [**updateUserPreferencesAdmin**](doc//UsersAdminApi.md#updateuserpreferencesadmin) | **PUT** /admin/users/{id}/preferences | Update user preferences *ViewsApi* | [**getAssetsByOriginalPath**](doc//ViewsApi.md#getassetsbyoriginalpath) | **GET** /view/folder | Retrieve assets by original path *ViewsApi* | [**getUniqueOriginalPaths**](doc//ViewsApi.md#getuniqueoriginalpaths) | **GET** /view/folder/unique-paths | Retrieve unique paths +*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* | [**updateWorkflow**](doc//WorkflowsApi.md#updateworkflow) | **PUT** /workflows/{id} | Update a workflow ## Documentation For Models @@ -444,6 +451,11 @@ Class | Method | HTTP request | Description - [PinCodeResetDto](doc//PinCodeResetDto.md) - [PinCodeSetupDto](doc//PinCodeSetupDto.md) - [PlacesResponseDto](doc//PlacesResponseDto.md) + - [PluginActionResponseDto](doc//PluginActionResponseDto.md) + - [PluginContext](doc//PluginContext.md) + - [PluginFilterResponseDto](doc//PluginFilterResponseDto.md) + - [PluginResponseDto](doc//PluginResponseDto.md) + - [PluginTriggerType](doc//PluginTriggerType.md) - [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseUpdate](doc//PurchaseUpdate.md) - [QueueCommand](doc//QueueCommand.md) @@ -603,6 +615,13 @@ 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) + - [WorkflowUpdateDto](doc//WorkflowUpdateDto.md) ## Documentation For Authorization diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index c64295837c..f3db370c92 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -48,6 +48,7 @@ part 'api/notifications_api.dart'; part 'api/notifications_admin_api.dart'; part 'api/partners_api.dart'; part 'api/people_api.dart'; +part 'api/plugins_api.dart'; part 'api/search_api.dart'; part 'api/server_api.dart'; part 'api/sessions_api.dart'; @@ -62,6 +63,7 @@ part 'api/trash_api.dart'; part 'api/users_api.dart'; part 'api/users_admin_api.dart'; part 'api/views_api.dart'; +part 'api/workflows_api.dart'; part 'model/api_key_create_dto.dart'; part 'model/api_key_create_response_dto.dart'; @@ -208,6 +210,11 @@ 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.dart'; +part 'model/plugin_filter_response_dto.dart'; +part 'model/plugin_response_dto.dart'; +part 'model/plugin_trigger_type.dart'; part 'model/purchase_response.dart'; part 'model/purchase_update.dart'; part 'model/queue_command.dart'; @@ -367,6 +374,13 @@ 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_update_dto.dart'; /// An [ApiClient] instance that uses the default values obtained from diff --git a/mobile/openapi/lib/api/plugins_api.dart b/mobile/openapi/lib/api/plugins_api.dart new file mode 100644 index 0000000000..264d3049e8 --- /dev/null +++ b/mobile/openapi/lib/api/plugins_api.dart @@ -0,0 +1,126 @@ +// +// 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 PluginsApi { + PluginsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Retrieve a plugin + /// + /// Retrieve information about a specific plugin by its ID. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getPluginWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/plugins/{id}' + .replaceAll('{id}', id); + + // 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, + ); + } + + /// Retrieve a plugin + /// + /// Retrieve information about a specific plugin by its ID. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getPlugin(String id,) async { + final response = await getPluginWithHttpInfo(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), 'PluginResponseDto',) as PluginResponseDto; + + } + return null; + } + + /// List all plugins + /// + /// Retrieve a list of plugins available to the authenticated user. + /// + /// Note: This method returns the HTTP [Response]. + Future getPluginsWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/plugins'; + + // 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 plugins + /// + /// Retrieve a list of plugins available to the authenticated user. + Future?> getPlugins() async { + final response = await getPluginsWithHttpInfo(); + 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; + } +} diff --git a/mobile/openapi/lib/api/workflows_api.dart b/mobile/openapi/lib/api/workflows_api.dart new file mode 100644 index 0000000000..c589ec9823 --- /dev/null +++ b/mobile/openapi/lib/api/workflows_api.dart @@ -0,0 +1,292 @@ +// +// 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 WorkflowsApi { + WorkflowsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Create a workflow + /// + /// Create a new workflow, the workflow can also be created with empty filters and actions. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [WorkflowCreateDto] workflowCreateDto (required): + Future createWorkflowWithHttpInfo(WorkflowCreateDto workflowCreateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/workflows'; + + // ignore: prefer_final_locals + Object? postBody = workflowCreateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Create a workflow + /// + /// Create a new workflow, the workflow can also be created with empty filters and actions. + /// + /// Parameters: + /// + /// * [WorkflowCreateDto] workflowCreateDto (required): + Future createWorkflow(WorkflowCreateDto workflowCreateDto,) async { + final response = await createWorkflowWithHttpInfo(workflowCreateDto,); + 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), 'WorkflowResponseDto',) as WorkflowResponseDto; + + } + return null; + } + + /// Delete a workflow + /// + /// Delete a workflow by its ID. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future deleteWorkflowWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/workflows/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Delete a workflow + /// + /// Delete a workflow by its ID. + /// + /// Parameters: + /// + /// * [String] id (required): + Future deleteWorkflow(String id,) async { + final response = await deleteWorkflowWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Retrieve a workflow + /// + /// Retrieve information about a specific workflow by its ID. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getWorkflowWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/workflows/{id}' + .replaceAll('{id}', id); + + // 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, + ); + } + + /// Retrieve a workflow + /// + /// Retrieve information about a specific workflow by its ID. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getWorkflow(String id,) async { + final response = await getWorkflowWithHttpInfo(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), 'WorkflowResponseDto',) as WorkflowResponseDto; + + } + return null; + } + + /// List all workflows + /// + /// Retrieve a list of workflows available to the authenticated user. + /// + /// Note: This method returns the HTTP [Response]. + Future getWorkflowsWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/workflows'; + + // 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 workflows + /// + /// Retrieve a list of workflows available to the authenticated user. + Future?> getWorkflows() async { + final response = await getWorkflowsWithHttpInfo(); + 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; + } + + /// Update a workflow + /// + /// 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. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [WorkflowUpdateDto] workflowUpdateDto (required): + Future updateWorkflowWithHttpInfo(String id, WorkflowUpdateDto workflowUpdateDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/workflows/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = workflowUpdateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Update a workflow + /// + /// 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. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [WorkflowUpdateDto] workflowUpdateDto (required): + Future updateWorkflow(String id, WorkflowUpdateDto workflowUpdateDto,) async { + final response = await updateWorkflowWithHttpInfo(id, workflowUpdateDto,); + 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), 'WorkflowResponseDto',) as WorkflowResponseDto; + + } + return null; + } +} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 373a4b9d8b..91dc670d12 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -472,6 +472,16 @@ class ApiClient { return PinCodeSetupDto.fromJson(value); case 'PlacesResponseDto': return PlacesResponseDto.fromJson(value); + case 'PluginActionResponseDto': + return PluginActionResponseDto.fromJson(value); + case 'PluginContext': + return PluginContextTypeTransformer().decode(value); + case 'PluginFilterResponseDto': + return PluginFilterResponseDto.fromJson(value); + case 'PluginResponseDto': + return PluginResponseDto.fromJson(value); + case 'PluginTriggerType': + return PluginTriggerTypeTypeTransformer().decode(value); case 'PurchaseResponse': return PurchaseResponse.fromJson(value); case 'PurchaseUpdate': @@ -790,6 +800,20 @@ 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 'WorkflowUpdateDto': + return WorkflowUpdateDto.fromJson(value); default: dynamic match; if (value is List && (match = _regList.firstMatch(targetType)?.group(1)) != null) { diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 5c21009a0b..4b33a07214 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -121,6 +121,12 @@ String parameterToString(dynamic value) { if (value is Permission) { return PermissionTypeTransformer().encode(value).toString(); } + if (value is PluginContext) { + return PluginContextTypeTransformer().encode(value).toString(); + } + if (value is PluginTriggerType) { + return PluginTriggerTypeTypeTransformer().encode(value).toString(); + } if (value is QueueCommand) { return QueueCommandTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index e05c3e84bc..8b05de523b 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -98,6 +98,10 @@ class Permission { static const pinCodePeriodCreate = Permission._(r'pinCode.create'); static const pinCodePeriodUpdate = Permission._(r'pinCode.update'); static const pinCodePeriodDelete = Permission._(r'pinCode.delete'); + static const pluginPeriodCreate = Permission._(r'plugin.create'); + static const pluginPeriodRead = Permission._(r'plugin.read'); + static const pluginPeriodUpdate = Permission._(r'plugin.update'); + static const pluginPeriodDelete = Permission._(r'plugin.delete'); static const serverPeriodAbout = Permission._(r'server.about'); static const serverPeriodApkLinks = Permission._(r'server.apkLinks'); static const serverPeriodStorage = Permission._(r'server.storage'); @@ -147,6 +151,10 @@ class Permission { static const userProfileImagePeriodRead = Permission._(r'userProfileImage.read'); static const userProfileImagePeriodUpdate = Permission._(r'userProfileImage.update'); static const userProfileImagePeriodDelete = Permission._(r'userProfileImage.delete'); + static const workflowPeriodCreate = Permission._(r'workflow.create'); + static const workflowPeriodRead = Permission._(r'workflow.read'); + static const workflowPeriodUpdate = Permission._(r'workflow.update'); + static const workflowPeriodDelete = Permission._(r'workflow.delete'); static const adminUserPeriodCreate = Permission._(r'adminUser.create'); static const adminUserPeriodRead = Permission._(r'adminUser.read'); static const adminUserPeriodUpdate = Permission._(r'adminUser.update'); @@ -231,6 +239,10 @@ class Permission { pinCodePeriodCreate, pinCodePeriodUpdate, pinCodePeriodDelete, + pluginPeriodCreate, + pluginPeriodRead, + pluginPeriodUpdate, + pluginPeriodDelete, serverPeriodAbout, serverPeriodApkLinks, serverPeriodStorage, @@ -280,6 +292,10 @@ class Permission { userProfileImagePeriodRead, userProfileImagePeriodUpdate, userProfileImagePeriodDelete, + workflowPeriodCreate, + workflowPeriodRead, + workflowPeriodUpdate, + workflowPeriodDelete, adminUserPeriodCreate, adminUserPeriodRead, adminUserPeriodUpdate, @@ -399,6 +415,10 @@ class PermissionTypeTransformer { case r'pinCode.create': return Permission.pinCodePeriodCreate; case r'pinCode.update': return Permission.pinCodePeriodUpdate; case r'pinCode.delete': return Permission.pinCodePeriodDelete; + case r'plugin.create': return Permission.pluginPeriodCreate; + case r'plugin.read': return Permission.pluginPeriodRead; + case r'plugin.update': return Permission.pluginPeriodUpdate; + case r'plugin.delete': return Permission.pluginPeriodDelete; case r'server.about': return Permission.serverPeriodAbout; case r'server.apkLinks': return Permission.serverPeriodApkLinks; case r'server.storage': return Permission.serverPeriodStorage; @@ -448,6 +468,10 @@ class PermissionTypeTransformer { case r'userProfileImage.read': return Permission.userProfileImagePeriodRead; case r'userProfileImage.update': return Permission.userProfileImagePeriodUpdate; case r'userProfileImage.delete': return Permission.userProfileImagePeriodDelete; + case r'workflow.create': return Permission.workflowPeriodCreate; + case r'workflow.read': return Permission.workflowPeriodRead; + case r'workflow.update': return Permission.workflowPeriodUpdate; + case r'workflow.delete': return Permission.workflowPeriodDelete; case r'adminUser.create': return Permission.adminUserPeriodCreate; case r'adminUser.read': return Permission.adminUserPeriodRead; case r'adminUser.update': return Permission.adminUserPeriodUpdate; diff --git a/mobile/openapi/lib/model/plugin_action_response_dto.dart b/mobile/openapi/lib/model/plugin_action_response_dto.dart new file mode 100644 index 0000000000..75b23fc8a4 --- /dev/null +++ b/mobile/openapi/lib/model/plugin_action_response_dto.dart @@ -0,0 +1,151 @@ +// +// 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 PluginActionResponseDto { + /// Returns a new [PluginActionResponseDto] instance. + PluginActionResponseDto({ + required this.description, + required this.id, + required this.methodName, + required this.pluginId, + required this.schema, + this.supportedContexts = const [], + required this.title, + }); + + String description; + + String id; + + String methodName; + + String pluginId; + + Object? schema; + + List supportedContexts; + + String title; + + @override + bool operator ==(Object other) => identical(this, other) || other is PluginActionResponseDto && + 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() => 'PluginActionResponseDto[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 [PluginActionResponseDto] 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"); + if (value is Map) { + final json = value.cast(); + + return PluginActionResponseDto( + 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: PluginContext.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 = PluginActionResponseDto.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 = PluginActionResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + 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 = >{}; + 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,); + } + } + 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_context.dart b/mobile/openapi/lib/model/plugin_context.dart new file mode 100644 index 0000000000..efb701c7d0 --- /dev/null +++ b/mobile/openapi/lib/model/plugin_context.dart @@ -0,0 +1,88 @@ +// +// 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 PluginContext { + /// Instantiate a new enum with the provided [value]. + const PluginContext._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const asset = PluginContext._(r'asset'); + static const album = PluginContext._(r'album'); + static const person = PluginContext._(r'person'); + + /// List of all possible values in this [enum][PluginContext]. + static const values = [ + asset, + album, + person, + ]; + + static PluginContext? fromJson(dynamic value) => PluginContextTypeTransformer().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 = PluginContext.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [PluginContext] to String, +/// and [decode] dynamic data back to [PluginContext]. +class PluginContextTypeTransformer { + factory PluginContextTypeTransformer() => _instance ??= const PluginContextTypeTransformer._(); + + const PluginContextTypeTransformer._(); + + String encode(PluginContext data) => data.value; + + /// Decodes a [dynamic value][data] to a PluginContext. + /// + /// 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. + PluginContext? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'asset': return PluginContext.asset; + case r'album': return PluginContext.album; + case r'person': return PluginContext.person; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [PluginContextTypeTransformer] instance. + static PluginContextTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/plugin_filter_response_dto.dart b/mobile/openapi/lib/model/plugin_filter_response_dto.dart new file mode 100644 index 0000000000..8ed6acec78 --- /dev/null +++ b/mobile/openapi/lib/model/plugin_filter_response_dto.dart @@ -0,0 +1,151 @@ +// +// 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, + }); + + String description; + + String id; + + String methodName; + + String pluginId; + + Object? schema; + + List supportedContexts; + + 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: PluginContext.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_response_dto.dart b/mobile/openapi/lib/model/plugin_response_dto.dart new file mode 100644 index 0000000000..afa6f3e1ab --- /dev/null +++ b/mobile/openapi/lib/model/plugin_response_dto.dart @@ -0,0 +1,171 @@ +// +// 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 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, + required this.name, + required this.title, + required this.updatedAt, + required this.version, + }); + + List actions; + + String author; + + String createdAt; + + String description; + + List filters; + + String id; + + String name; + + String title; + + String updatedAt; + + String version; + + @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 && + other.name == name && + other.title == title && + other.updatedAt == updatedAt && + other.version == version; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (actions.hashCode) + + (author.hashCode) + + (createdAt.hashCode) + + (description.hashCode) + + (filters.hashCode) + + (id.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]'; + + 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'name'] = this.name; + json[r'title'] = this.title; + json[r'updatedAt'] = this.updatedAt; + json[r'version'] = this.version; + return json; + } + + /// Returns a new [PluginResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PluginResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PluginResponseDto"); + if (value is Map) { + 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')!, + name: mapValueOfType(json, r'name')!, + title: mapValueOfType(json, r'title')!, + updatedAt: mapValueOfType(json, r'updatedAt')!, + version: mapValueOfType(json, r'version')!, + ); + } + 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 = PluginResponseDto.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 = PluginResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PluginResponseDto-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] = PluginResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'actions', + 'author', + 'createdAt', + 'description', + 'filters', + 'id', + 'name', + 'title', + 'updatedAt', + 'version', + }; +} + diff --git a/mobile/openapi/lib/model/plugin_trigger_type.dart b/mobile/openapi/lib/model/plugin_trigger_type.dart new file mode 100644 index 0000000000..b200f1b9e6 --- /dev/null +++ b/mobile/openapi/lib/model/plugin_trigger_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; + + +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/queue_name.dart b/mobile/openapi/lib/model/queue_name.dart index 7b8214e202..bcc4159fce 100644 --- a/mobile/openapi/lib/model/queue_name.dart +++ b/mobile/openapi/lib/model/queue_name.dart @@ -39,6 +39,7 @@ class QueueName { static const notifications = QueueName._(r'notifications'); static const backupDatabase = QueueName._(r'backupDatabase'); static const ocr = QueueName._(r'ocr'); + static const workflow = QueueName._(r'workflow'); /// List of all possible values in this [enum][QueueName]. static const values = [ @@ -58,6 +59,7 @@ class QueueName { notifications, backupDatabase, ocr, + workflow, ]; static QueueName? fromJson(dynamic value) => QueueNameTypeTransformer().decode(value); @@ -112,6 +114,7 @@ class QueueNameTypeTransformer { case r'notifications': return QueueName.notifications; case r'backupDatabase': return QueueName.backupDatabase; case r'ocr': return QueueName.ocr; + case r'workflow': return QueueName.workflow; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/queues_response_dto.dart b/mobile/openapi/lib/model/queues_response_dto.dart index b20f8c3c09..be40a56fb1 100644 --- a/mobile/openapi/lib/model/queues_response_dto.dart +++ b/mobile/openapi/lib/model/queues_response_dto.dart @@ -29,6 +29,7 @@ class QueuesResponseDto { required this.storageTemplateMigration, required this.thumbnailGeneration, required this.videoConversion, + required this.workflow, }); QueueResponseDto backgroundTask; @@ -63,6 +64,8 @@ class QueuesResponseDto { QueueResponseDto videoConversion; + QueueResponseDto workflow; + @override bool operator ==(Object other) => identical(this, other) || other is QueuesResponseDto && other.backgroundTask == backgroundTask && @@ -80,7 +83,8 @@ class QueuesResponseDto { other.smartSearch == smartSearch && other.storageTemplateMigration == storageTemplateMigration && other.thumbnailGeneration == thumbnailGeneration && - other.videoConversion == videoConversion; + other.videoConversion == videoConversion && + other.workflow == workflow; @override int get hashCode => @@ -100,10 +104,11 @@ class QueuesResponseDto { (smartSearch.hashCode) + (storageTemplateMigration.hashCode) + (thumbnailGeneration.hashCode) + - (videoConversion.hashCode); + (videoConversion.hashCode) + + (workflow.hashCode); @override - String toString() => 'QueuesResponseDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; + String toString() => 'QueuesResponseDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]'; Map toJson() { final json = {}; @@ -123,6 +128,7 @@ class QueuesResponseDto { json[r'storageTemplateMigration'] = this.storageTemplateMigration; json[r'thumbnailGeneration'] = this.thumbnailGeneration; json[r'videoConversion'] = this.videoConversion; + json[r'workflow'] = this.workflow; return json; } @@ -151,6 +157,7 @@ class QueuesResponseDto { storageTemplateMigration: QueueResponseDto.fromJson(json[r'storageTemplateMigration'])!, thumbnailGeneration: QueueResponseDto.fromJson(json[r'thumbnailGeneration'])!, videoConversion: QueueResponseDto.fromJson(json[r'videoConversion'])!, + workflow: QueueResponseDto.fromJson(json[r'workflow'])!, ); } return null; @@ -214,6 +221,7 @@ class QueuesResponseDto { 'storageTemplateMigration', 'thumbnailGeneration', 'videoConversion', + 'workflow', }; } diff --git a/mobile/openapi/lib/model/system_config_job_dto.dart b/mobile/openapi/lib/model/system_config_job_dto.dart index 3eeb9c7d3b..461420b3e3 100644 --- a/mobile/openapi/lib/model/system_config_job_dto.dart +++ b/mobile/openapi/lib/model/system_config_job_dto.dart @@ -25,6 +25,7 @@ class SystemConfigJobDto { required this.smartSearch, required this.thumbnailGeneration, required this.videoConversion, + required this.workflow, }); JobSettingsDto backgroundTask; @@ -51,6 +52,8 @@ class SystemConfigJobDto { JobSettingsDto videoConversion; + JobSettingsDto workflow; + @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigJobDto && other.backgroundTask == backgroundTask && @@ -64,7 +67,8 @@ class SystemConfigJobDto { other.sidecar == sidecar && other.smartSearch == smartSearch && other.thumbnailGeneration == thumbnailGeneration && - other.videoConversion == videoConversion; + other.videoConversion == videoConversion && + other.workflow == workflow; @override int get hashCode => @@ -80,10 +84,11 @@ class SystemConfigJobDto { (sidecar.hashCode) + (smartSearch.hashCode) + (thumbnailGeneration.hashCode) + - (videoConversion.hashCode); + (videoConversion.hashCode) + + (workflow.hashCode); @override - String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; + String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, ocr=$ocr, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion, workflow=$workflow]'; Map toJson() { final json = {}; @@ -99,6 +104,7 @@ class SystemConfigJobDto { json[r'smartSearch'] = this.smartSearch; json[r'thumbnailGeneration'] = this.thumbnailGeneration; json[r'videoConversion'] = this.videoConversion; + json[r'workflow'] = this.workflow; return json; } @@ -123,6 +129,7 @@ class SystemConfigJobDto { smartSearch: JobSettingsDto.fromJson(json[r'smartSearch'])!, thumbnailGeneration: JobSettingsDto.fromJson(json[r'thumbnailGeneration'])!, videoConversion: JobSettingsDto.fromJson(json[r'videoConversion'])!, + workflow: JobSettingsDto.fromJson(json[r'workflow'])!, ); } return null; @@ -182,6 +189,7 @@ class SystemConfigJobDto { 'smartSearch', 'thumbnailGeneration', 'videoConversion', + 'workflow', }; } diff --git a/mobile/openapi/lib/model/workflow_action_item_dto.dart b/mobile/openapi/lib/model/workflow_action_item_dto.dart new file mode 100644 index 0000000000..ee0b30216d --- /dev/null +++ b/mobile/openapi/lib/model/workflow_action_item_dto.dart @@ -0,0 +1,116 @@ +// +// 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.actionId, + }); + + /// + /// 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; + + String actionId; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowActionItemDto && + other.actionConfig == actionConfig && + other.actionId == actionId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (actionConfig == null ? 0 : actionConfig!.hashCode) + + (actionId.hashCode); + + @override + String toString() => 'WorkflowActionItemDto[actionConfig=$actionConfig, actionId=$actionId]'; + + Map toJson() { + final json = {}; + if (this.actionConfig != null) { + json[r'actionConfig'] = this.actionConfig; + } else { + // json[r'actionConfig'] = null; + } + json[r'actionId'] = this.actionId; + 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'), + actionId: mapValueOfType(json, r'actionId')!, + ); + } + 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 = { + 'actionId', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_action_response_dto.dart b/mobile/openapi/lib/model/workflow_action_response_dto.dart new file mode 100644 index 0000000000..6528f018c9 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_action_response_dto.dart @@ -0,0 +1,135 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowActionResponseDto { + /// Returns a new [WorkflowActionResponseDto] instance. + WorkflowActionResponseDto({ + required this.actionConfig, + required this.actionId, + required this.id, + required this.order, + required this.workflowId, + }); + + Object? actionConfig; + + String actionId; + + String id; + + num order; + + String workflowId; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowActionResponseDto && + other.actionConfig == actionConfig && + other.actionId == actionId && + other.id == id && + other.order == order && + other.workflowId == workflowId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (actionConfig == null ? 0 : actionConfig!.hashCode) + + (actionId.hashCode) + + (id.hashCode) + + (order.hashCode) + + (workflowId.hashCode); + + @override + String toString() => 'WorkflowActionResponseDto[actionConfig=$actionConfig, actionId=$actionId, id=$id, order=$order, workflowId=$workflowId]'; + + Map toJson() { + final json = {}; + if (this.actionConfig != null) { + json[r'actionConfig'] = this.actionConfig; + } else { + // json[r'actionConfig'] = null; + } + json[r'actionId'] = this.actionId; + json[r'id'] = this.id; + json[r'order'] = this.order; + 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'), + actionId: mapValueOfType(json, r'actionId')!, + id: mapValueOfType(json, r'id')!, + order: num.parse('${json[r'order']}'), + 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', + 'actionId', + 'id', + 'order', + 'workflowId', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_create_dto.dart b/mobile/openapi/lib/model/workflow_create_dto.dart new file mode 100644 index 0000000000..c6e44743ac --- /dev/null +++ b/mobile/openapi/lib/model/workflow_create_dto.dart @@ -0,0 +1,157 @@ +// +// 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 WorkflowCreateDto { + /// Returns a new [WorkflowCreateDto] instance. + WorkflowCreateDto({ + this.actions = const [], + this.description, + this.enabled, + this.filters = const [], + required this.name, + required this.triggerType, + }); + + List actions; + + /// + /// 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; + + /// + /// 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; + + List filters; + + String name; + + PluginTriggerType triggerType; + + @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; + + @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); + + @override + String toString() => 'WorkflowCreateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]'; + + Map toJson() { + final json = {}; + json[r'actions'] = this.actions; + 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; + } + json[r'filters'] = this.filters; + json[r'name'] = this.name; + json[r'triggerType'] = this.triggerType; + return json; + } + + /// Returns a new [WorkflowCreateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowCreateDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowCreateDto"); + if (value is Map) { + 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'])!, + ); + } + 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 = WorkflowCreateDto.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 = WorkflowCreateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowCreateDto-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] = WorkflowCreateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'actions', + 'filters', + 'name', + 'triggerType', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_filter_item_dto.dart b/mobile/openapi/lib/model/workflow_filter_item_dto.dart new file mode 100644 index 0000000000..5b78585c3d --- /dev/null +++ b/mobile/openapi/lib/model/workflow_filter_item_dto.dart @@ -0,0 +1,116 @@ +// +// 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.filterId, + }); + + /// + /// 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; + + String filterId; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterItemDto && + other.filterConfig == filterConfig && + other.filterId == filterId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (filterConfig == null ? 0 : filterConfig!.hashCode) + + (filterId.hashCode); + + @override + String toString() => 'WorkflowFilterItemDto[filterConfig=$filterConfig, filterId=$filterId]'; + + Map toJson() { + final json = {}; + if (this.filterConfig != null) { + json[r'filterConfig'] = this.filterConfig; + } else { + // json[r'filterConfig'] = null; + } + json[r'filterId'] = this.filterId; + 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'), + filterId: mapValueOfType(json, r'filterId')!, + ); + } + 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 = { + 'filterId', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_filter_response_dto.dart b/mobile/openapi/lib/model/workflow_filter_response_dto.dart new file mode 100644 index 0000000000..5257c92b80 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_filter_response_dto.dart @@ -0,0 +1,135 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowFilterResponseDto { + /// Returns a new [WorkflowFilterResponseDto] instance. + WorkflowFilterResponseDto({ + required this.filterConfig, + required this.filterId, + required this.id, + required this.order, + required this.workflowId, + }); + + Object? filterConfig; + + String filterId; + + String id; + + num order; + + String workflowId; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterResponseDto && + other.filterConfig == filterConfig && + other.filterId == filterId && + other.id == id && + other.order == order && + other.workflowId == workflowId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (filterConfig == null ? 0 : filterConfig!.hashCode) + + (filterId.hashCode) + + (id.hashCode) + + (order.hashCode) + + (workflowId.hashCode); + + @override + String toString() => 'WorkflowFilterResponseDto[filterConfig=$filterConfig, filterId=$filterId, id=$id, order=$order, workflowId=$workflowId]'; + + Map toJson() { + final json = {}; + if (this.filterConfig != null) { + json[r'filterConfig'] = this.filterConfig; + } else { + // json[r'filterConfig'] = null; + } + json[r'filterId'] = this.filterId; + json[r'id'] = this.id; + json[r'order'] = this.order; + 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'), + filterId: mapValueOfType(json, r'filterId')!, + id: mapValueOfType(json, r'id')!, + order: num.parse('${json[r'order']}'), + 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', + 'filterId', + 'id', + 'order', + 'workflowId', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_response_dto.dart b/mobile/openapi/lib/model/workflow_response_dto.dart new file mode 100644 index 0000000000..5132e7cb73 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_response_dto.dart @@ -0,0 +1,241 @@ +// +// 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 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, + }); + + List actions; + + String createdAt; + + String description; + + bool enabled; + + List filters; + + String id; + + String? name; + + String ownerId; + + WorkflowResponseDtoTriggerTypeEnum triggerType; + + @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; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (actions.hashCode) + + (createdAt.hashCode) + + (description.hashCode) + + (enabled.hashCode) + + (filters.hashCode) + + (id.hashCode) + + (name == null ? 0 : name!.hashCode) + + (ownerId.hashCode) + + (triggerType.hashCode); + + @override + String toString() => 'WorkflowResponseDto[actions=$actions, createdAt=$createdAt, description=$description, enabled=$enabled, filters=$filters, id=$id, name=$name, ownerId=$ownerId, triggerType=$triggerType]'; + + Map toJson() { + final json = {}; + json[r'actions'] = this.actions; + json[r'createdAt'] = this.createdAt; + json[r'description'] = this.description; + 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; + return json; + } + + /// Returns a new [WorkflowResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowResponseDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowResponseDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowResponseDto( + actions: WorkflowActionResponseDto.listFromJson(json[r'actions']), + createdAt: mapValueOfType(json, r'createdAt')!, + 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: WorkflowResponseDtoTriggerTypeEnum.fromJson(json[r'triggerType'])!, + ); + } + 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 = WorkflowResponseDto.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 = WorkflowResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowResponseDto-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] = WorkflowResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'actions', + 'createdAt', + 'description', + 'enabled', + 'filters', + 'id', + 'name', + 'ownerId', + 'triggerType', + }; +} + + +class WorkflowResponseDtoTriggerTypeEnum { + /// Instantiate a new enum with the provided [value]. + const WorkflowResponseDtoTriggerTypeEnum._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const assetCreate = WorkflowResponseDtoTriggerTypeEnum._(r'AssetCreate'); + static const personRecognized = WorkflowResponseDtoTriggerTypeEnum._(r'PersonRecognized'); + + /// List of all possible values in this [enum][WorkflowResponseDtoTriggerTypeEnum]. + static const values = [ + assetCreate, + personRecognized, + ]; + + static WorkflowResponseDtoTriggerTypeEnum? fromJson(dynamic value) => WorkflowResponseDtoTriggerTypeEnumTypeTransformer().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 = WorkflowResponseDtoTriggerTypeEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [WorkflowResponseDtoTriggerTypeEnum] to String, +/// and [decode] dynamic data back to [WorkflowResponseDtoTriggerTypeEnum]. +class WorkflowResponseDtoTriggerTypeEnumTypeTransformer { + factory WorkflowResponseDtoTriggerTypeEnumTypeTransformer() => _instance ??= const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._(); + + const WorkflowResponseDtoTriggerTypeEnumTypeTransformer._(); + + String encode(WorkflowResponseDtoTriggerTypeEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a WorkflowResponseDtoTriggerTypeEnum. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + WorkflowResponseDtoTriggerTypeEnum? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'AssetCreate': return WorkflowResponseDtoTriggerTypeEnum.assetCreate; + case r'PersonRecognized': return WorkflowResponseDtoTriggerTypeEnum.personRecognized; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [WorkflowResponseDtoTriggerTypeEnumTypeTransformer] instance. + static WorkflowResponseDtoTriggerTypeEnumTypeTransformer? _instance; +} + + diff --git a/mobile/openapi/lib/model/workflow_update_dto.dart b/mobile/openapi/lib/model/workflow_update_dto.dart new file mode 100644 index 0000000000..b36a396dc6 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_update_dto.dart @@ -0,0 +1,156 @@ +// +// 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 WorkflowUpdateDto { + /// Returns a new [WorkflowUpdateDto] instance. + WorkflowUpdateDto({ + this.actions = const [], + this.description, + this.enabled, + this.filters = const [], + this.name, + }); + + List actions; + + /// + /// 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; + + /// + /// 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; + + List filters; + + /// + /// 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; + + @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; + + @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); + + @override + String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name]'; + + Map toJson() { + final json = {}; + json[r'actions'] = this.actions; + 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; + } + json[r'filters'] = this.filters; + if (this.name != null) { + json[r'name'] = this.name; + } else { + // json[r'name'] = null; + } + return json; + } + + /// Returns a new [WorkflowUpdateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowUpdateDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowUpdateDto"); + if (value is Map) { + 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'), + ); + } + 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 = WorkflowUpdateDto.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 = WorkflowUpdateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowUpdateDto-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] = WorkflowUpdateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7ae48eaf8b..d42aa0baa1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7865,6 +7865,111 @@ "x-immich-state": "Stable" } }, + "/plugins": { + "get": { + "description": "Retrieve a list of plugins available to the authenticated user.", + "operationId": "getPlugins", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PluginResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List all plugins", + "tags": [ + "Plugins" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "plugin.read", + "x-immich-state": "Alpha" + } + }, + "/plugins/{id}": { + "get": { + "description": "Retrieve information about a specific plugin by its ID.", + "operationId": "getPlugin", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PluginResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve a plugin", + "tags": [ + "Plugins" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "plugin.read", + "x-immich-state": "Alpha" + } + }, "/search/cities": { "get": { "description": "Retrieve a list of assets with each asset belonging to a different city. This endpoint is used on the places pages to show a single thumbnail for each city the user has assets in.", @@ -13485,6 +13590,276 @@ ], "x-immich-state": "Stable" } + }, + "/workflows": { + "get": { + "description": "Retrieve a list of workflows available to the authenticated user.", + "operationId": "getWorkflows", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/WorkflowResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List all workflows", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "workflow.read", + "x-immich-state": "Alpha" + }, + "post": { + "description": "Create a new workflow, the workflow can also be created with empty filters and actions.", + "operationId": "createWorkflow", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowCreateDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Create a workflow", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "workflow.create", + "x-immich-state": "Alpha" + } + }, + "/workflows/{id}": { + "delete": { + "description": "Delete a workflow by its ID.", + "operationId": "deleteWorkflow", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Delete a workflow", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "workflow.delete", + "x-immich-state": "Alpha" + }, + "get": { + "description": "Retrieve information about a specific workflow by its ID.", + "operationId": "getWorkflow", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve a workflow", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "workflow.read", + "x-immich-state": "Alpha" + }, + "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.", + "operationId": "updateWorkflow", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Update a workflow", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "workflow.update", + "x-immich-state": "Alpha" + } } }, "info": { @@ -13566,6 +13941,10 @@ "name": "People", "description": "A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job." }, + { + "name": "Plugins", + "description": "A plugin is an installed module that makes filters and actions available for the workflow feature." + }, { "name": "Search", "description": "Endpoints related to searching assets via text, smart search, optical character recognition (OCR), and other filters like person, album, and other metadata. Search endpoints usually support pagination and sorting." @@ -13621,6 +14000,10 @@ { "name": "Views", "description": "Endpoints for specialized views, such as the folder view." + }, + { + "name": "Workflows", + "description": "A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution." } ], "servers": [ @@ -17022,6 +17405,10 @@ "pinCode.create", "pinCode.update", "pinCode.delete", + "plugin.create", + "plugin.read", + "plugin.update", + "plugin.delete", "server.about", "server.apkLinks", "server.storage", @@ -17071,6 +17458,10 @@ "userProfileImage.read", "userProfileImage.update", "userProfileImage.delete", + "workflow.create", + "workflow.read", + "workflow.update", + "workflow.delete", "adminUser.create", "adminUser.read", "adminUser.update", @@ -17367,6 +17758,152 @@ ], "type": "object" }, + "PluginActionResponseDto": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "methodName": { + "type": "string" + }, + "pluginId": { + "type": "string" + }, + "schema": { + "nullable": true, + "type": "object" + }, + "supportedContexts": { + "items": { + "$ref": "#/components/schemas/PluginContext" + }, + "type": "array" + }, + "title": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "methodName", + "pluginId", + "schema", + "supportedContexts", + "title" + ], + "type": "object" + }, + "PluginContext": { + "enum": [ + "asset", + "album", + "person" + ], + "type": "string" + }, + "PluginFilterResponseDto": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "methodName": { + "type": "string" + }, + "pluginId": { + "type": "string" + }, + "schema": { + "nullable": true, + "type": "object" + }, + "supportedContexts": { + "items": { + "$ref": "#/components/schemas/PluginContext" + }, + "type": "array" + }, + "title": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "methodName", + "pluginId", + "schema", + "supportedContexts", + "title" + ], + "type": "object" + }, + "PluginResponseDto": { + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/PluginActionResponseDto" + }, + "type": "array" + }, + "author": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "filters": { + "items": { + "$ref": "#/components/schemas/PluginFilterResponseDto" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "actions", + "author", + "createdAt", + "description", + "filters", + "id", + "name", + "title", + "updatedAt", + "version" + ], + "type": "object" + }, + "PluginTriggerType": { + "enum": [ + "AssetCreate", + "PersonRecognized" + ], + "type": "string" + }, "PurchaseResponse": { "properties": { "hideBuyButtonUntil": { @@ -17438,7 +17975,8 @@ "library", "notifications", "backupDatabase", - "ocr" + "ocr", + "workflow" ], "type": "string" }, @@ -17552,6 +18090,9 @@ }, "videoConversion": { "$ref": "#/components/schemas/QueueResponseDto" + }, + "workflow": { + "$ref": "#/components/schemas/QueueResponseDto" } }, "required": [ @@ -17570,7 +18111,8 @@ "smartSearch", "storageTemplateMigration", "thumbnailGeneration", - "videoConversion" + "videoConversion", + "workflow" ], "type": "object" }, @@ -20420,6 +20962,9 @@ }, "videoConversion": { "$ref": "#/components/schemas/JobSettingsDto" + }, + "workflow": { + "$ref": "#/components/schemas/JobSettingsDto" } }, "required": [ @@ -20434,7 +20979,8 @@ "sidecar", "smartSearch", "thumbnailGeneration", - "videoConversion" + "videoConversion", + "workflow" ], "type": "object" }, @@ -21999,6 +22545,211 @@ "webm" ], "type": "string" + }, + "WorkflowActionItemDto": { + "properties": { + "actionConfig": { + "type": "object" + }, + "actionId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "actionId" + ], + "type": "object" + }, + "WorkflowActionResponseDto": { + "properties": { + "actionConfig": { + "nullable": true, + "type": "object" + }, + "actionId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "order": { + "type": "number" + }, + "workflowId": { + "type": "string" + } + }, + "required": [ + "actionConfig", + "actionId", + "id", + "order", + "workflowId" + ], + "type": "object" + }, + "WorkflowCreateDto": { + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/WorkflowActionItemDto" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "filters": { + "items": { + "$ref": "#/components/schemas/WorkflowFilterItemDto" + }, + "type": "array" + }, + "name": { + "type": "string" + }, + "triggerType": { + "allOf": [ + { + "$ref": "#/components/schemas/PluginTriggerType" + } + ] + } + }, + "required": [ + "actions", + "filters", + "name", + "triggerType" + ], + "type": "object" + }, + "WorkflowFilterItemDto": { + "properties": { + "filterConfig": { + "type": "object" + }, + "filterId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "filterId" + ], + "type": "object" + }, + "WorkflowFilterResponseDto": { + "properties": { + "filterConfig": { + "nullable": true, + "type": "object" + }, + "filterId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "order": { + "type": "number" + }, + "workflowId": { + "type": "string" + } + }, + "required": [ + "filterConfig", + "filterId", + "id", + "order", + "workflowId" + ], + "type": "object" + }, + "WorkflowResponseDto": { + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/WorkflowActionResponseDto" + }, + "type": "array" + }, + "createdAt": { + "type": "string" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "filters": { + "items": { + "$ref": "#/components/schemas/WorkflowFilterResponseDto" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "name": { + "nullable": true, + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "triggerType": { + "enum": [ + "AssetCreate", + "PersonRecognized" + ], + "type": "string" + } + }, + "required": [ + "actions", + "createdAt", + "description", + "enabled", + "filters", + "id", + "name", + "ownerId", + "triggerType" + ], + "type": "object" + }, + "WorkflowUpdateDto": { + "properties": { + "actions": { + "items": { + "$ref": "#/components/schemas/WorkflowActionItemDto" + }, + "type": "array" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "filters": { + "items": { + "$ref": "#/components/schemas/WorkflowFilterItemDto" + }, + "type": "array" + }, + "name": { + "type": "string" + } + }, + "type": "object" } } } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 00a6eea954..0664d26995 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -732,6 +732,7 @@ export type QueuesResponseDto = { storageTemplateMigration: QueueResponseDto; thumbnailGeneration: QueueResponseDto; videoConversion: QueueResponseDto; + workflow: QueueResponseDto; }; export type JobCreateDto = { name: ManualJobName; @@ -926,6 +927,36 @@ export type AssetFaceUpdateDto = { export type PersonStatisticsResponseDto = { assets: number; }; +export type PluginActionResponseDto = { + description: string; + id: string; + methodName: string; + pluginId: string; + schema: object | null; + supportedContexts: PluginContext[]; + title: string; +}; +export type PluginFilterResponseDto = { + description: string; + id: string; + methodName: string; + pluginId: string; + schema: object | null; + supportedContexts: PluginContext[]; + title: string; +}; +export type PluginResponseDto = { + actions: PluginActionResponseDto[]; + author: string; + createdAt: string; + description: string; + filters: PluginFilterResponseDto[]; + id: string; + name: string; + title: string; + updatedAt: string; + version: string; +}; export type SearchExploreItem = { data: AssetResponseDto; value: string; @@ -1411,6 +1442,7 @@ export type SystemConfigJobDto = { smartSearch: JobSettingsDto; thumbnailGeneration: JobSettingsDto; videoConversion: JobSettingsDto; + workflow: JobSettingsDto; }; export type SystemConfigLibraryScanDto = { cronExpression: string; @@ -1667,6 +1699,54 @@ export type CreateProfileImageResponseDto = { profileImagePath: string; userId: string; }; +export type WorkflowActionResponseDto = { + actionConfig: object | null; + actionId: string; + id: string; + order: number; + workflowId: string; +}; +export type WorkflowFilterResponseDto = { + filterConfig: object | null; + filterId: string; + id: string; + order: number; + workflowId: string; +}; +export type WorkflowResponseDto = { + actions: WorkflowActionResponseDto[]; + createdAt: string; + description: string; + enabled: boolean; + filters: WorkflowFilterResponseDto[]; + id: string; + name: string | null; + ownerId: string; + triggerType: TriggerType; +}; +export type WorkflowActionItemDto = { + actionConfig?: object; + actionId: string; +}; +export type WorkflowFilterItemDto = { + filterConfig?: object; + filterId: string; +}; +export type WorkflowCreateDto = { + actions: WorkflowActionItemDto[]; + description?: string; + enabled?: boolean; + filters: WorkflowFilterItemDto[]; + name: string; + triggerType: PluginTriggerType; +}; +export type WorkflowUpdateDto = { + actions?: WorkflowActionItemDto[]; + description?: string; + enabled?: boolean; + filters?: WorkflowFilterItemDto[]; + name?: string; +}; /** * List all activities */ @@ -3510,6 +3590,30 @@ export function getPersonThumbnail({ id }: { ...opts })); } +/** + * List all plugins + */ +export function getPlugins(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: PluginResponseDto[]; + }>("/plugins", { + ...opts + })); +} +/** + * Retrieve a plugin + */ +export function getPlugin({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: PluginResponseDto; + }>(`/plugins/${encodeURIComponent(id)}`, { + ...opts + })); +} /** * Retrieve assets by city */ @@ -4824,6 +4928,72 @@ export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) { ...opts })); } +/** + * List all workflows + */ +export function getWorkflows(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowResponseDto[]; + }>("/workflows", { + ...opts + })); +} +/** + * Create a workflow + */ +export function createWorkflow({ workflowCreateDto }: { + workflowCreateDto: WorkflowCreateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: WorkflowResponseDto; + }>("/workflows", oazapfts.json({ + ...opts, + method: "POST", + body: workflowCreateDto + }))); +} +/** + * Delete a workflow + */ +export function deleteWorkflow({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/workflows/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * Retrieve a workflow + */ +export function getWorkflow({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowResponseDto; + }>(`/workflows/${encodeURIComponent(id)}`, { + ...opts + })); +} +/** + * Update a workflow + */ +export function updateWorkflow({ id, workflowUpdateDto }: { + id: string; + workflowUpdateDto: WorkflowUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowResponseDto; + }>(`/workflows/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PUT", + body: workflowUpdateDto + }))); +} export enum ReactionLevel { Album = "album", Asset = "asset" @@ -4976,6 +5146,10 @@ export enum Permission { PinCodeCreate = "pinCode.create", PinCodeUpdate = "pinCode.update", PinCodeDelete = "pinCode.delete", + PluginCreate = "plugin.create", + PluginRead = "plugin.read", + PluginUpdate = "plugin.update", + PluginDelete = "plugin.delete", ServerAbout = "server.about", ServerApkLinks = "server.apkLinks", ServerStorage = "server.storage", @@ -5025,6 +5199,10 @@ export enum Permission { UserProfileImageRead = "userProfileImage.read", UserProfileImageUpdate = "userProfileImage.update", UserProfileImageDelete = "userProfileImage.delete", + WorkflowCreate = "workflow.create", + WorkflowRead = "workflow.read", + WorkflowUpdate = "workflow.update", + WorkflowDelete = "workflow.delete", AdminUserCreate = "adminUser.create", AdminUserRead = "adminUser.read", AdminUserUpdate = "adminUser.update", @@ -5083,7 +5261,8 @@ export enum QueueName { Library = "library", Notifications = "notifications", BackupDatabase = "backupDatabase", - Ocr = "ocr" + Ocr = "ocr", + Workflow = "workflow" } export enum QueueCommand { Start = "start", @@ -5104,6 +5283,11 @@ export enum PartnerDirection { SharedBy = "shared-by", SharedWith = "shared-with" } +export enum PluginContext { + Asset = "asset", + Album = "album", + Person = "person" +} export enum SearchSuggestionType { Country = "country", State = "state", @@ -5255,3 +5439,11 @@ export enum OAuthTokenEndpointAuthMethod { ClientSecretPost = "client_secret_post", ClientSecretBasic = "client_secret_basic" } +export enum TriggerType { + AssetCreate = "AssetCreate", + PersonRecognized = "PersonRecognized" +} +export enum PluginTriggerType { + AssetCreate = "AssetCreate", + PersonRecognized = "PersonRecognized" +} diff --git a/plugins/.gitignore b/plugins/.gitignore new file mode 100644 index 0000000000..76add878f8 --- /dev/null +++ b/plugins/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/plugins/LICENSE b/plugins/LICENSE new file mode 100644 index 0000000000..53f0fa6953 --- /dev/null +++ b/plugins/LICENSE @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000000..04cb6e85aa --- /dev/null +++ b/plugins/esbuild.js @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000000..1172530c1e --- /dev/null +++ b/plugins/manifest.json @@ -0,0 +1,127 @@ +{ + "name": "immich-core", + "version": "2.0.0", + "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", + "description": "Text or regex pattern to match against filename" + }, + "matchType": { + "type": "string", + "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", + "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", + "items": { + "type": "string" + }, + "description": "List of person to match" + }, + "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", + "description": "Target album ID" + } + }, + "required": ["albumId"] + } + } + ] +} diff --git a/plugins/mise.toml b/plugins/mise.toml new file mode 100644 index 0000000000..c1001e574b --- /dev/null +++ b/plugins/mise.toml @@ -0,0 +1,11 @@ +[tools] +"github:extism/cli" = "1.6.3" +"github:webassembly/binaryen" = "version_124" +"github:extism/js-pdk" = "1.5.1" + +[tasks.install] +run = "pnpm install --frozen-lockfile" + +[tasks.build] +depends = ["install"] +run = "pnpm run build" diff --git a/plugins/package-lock.json b/plugins/package-lock.json new file mode 100644 index 0000000000..3b0f0b34cb --- /dev/null +++ b/plugins/package-lock.json @@ -0,0 +1,443 @@ +{ + "name": "js-pdk-template", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "js-pdk-template", + "version": "1.0.0", + "license": "BSD-3-Clause", + "devDependencies": { + "@extism/js-pdk": "^1.0.1", + "esbuild": "^0.19.6", + "typescript": "^5.3.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@extism/js-pdk": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@extism/js-pdk/-/js-pdk-1.0.1.tgz", + "integrity": "sha512-YJWfHGeOuJnQw4V8NPNHvbSr6S8iDd2Ga6VEukwlRP7tu62ozTxIgokYw8i+rajD/16zz/gK0KYARBpm2qPAmQ==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, + "node_modules/typescript": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz", + "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/plugins/package.json b/plugins/package.json new file mode 100644 index 0000000000..ab6b2f8435 --- /dev/null +++ b/plugins/package.json @@ -0,0 +1,19 @@ +{ + "name": "plugins", + "version": "1.0.0", + "description": "", + "main": "src/index.ts", + "scripts": { + "build": "pnpm build:tsc && pnpm build:wasm", + "build:tsc": "tsc --noEmit && node esbuild.js", + "build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm" + }, + "keywords": [], + "author": "", + "license": "AGPL-3.0", + "devDependencies": { + "@extism/js-pdk": "^1.0.1", + "esbuild": "^0.19.6", + "typescript": "^5.3.2" + } +} diff --git a/plugins/src/index.d.ts b/plugins/src/index.d.ts new file mode 100644 index 0000000000..7f805aafe6 --- /dev/null +++ b/plugins/src/index.d.ts @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000000..9566c02cd8 --- /dev/null +++ b/plugins/src/index.ts @@ -0,0 +1,71 @@ +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/plugins/tsconfig.json b/plugins/tsconfig.json new file mode 100644 index 0000000000..86c9e766bf --- /dev/null +++ b/plugins/tsconfig.json @@ -0,0 +1,24 @@ +{ + "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 + "strict": true, // Enable all strict type-checking options + "esModuleInterop": true, // Enables compatibility with Babel-style module imports + "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) + }, + "include": [ + "src/**/*.ts" // Include all TypeScript files in src directory + ], + "exclude": [ + "node_modules" // Exclude the node_modules directory + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81ad7fcd3c..c0e4b5ea78 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -299,8 +299,23 @@ importers: specifier: ^5.3.3 version: 5.9.3 + plugins: + devDependencies: + '@extism/js-pdk': + specifier: ^1.0.1 + version: 1.1.1 + esbuild: + specifier: ^0.19.6 + version: 0.19.12 + typescript: + specifier: ^5.3.2 + version: 5.9.3 + server: dependencies: + '@extism/extism': + specifier: 2.0.0-rc13 + version: 2.0.0-rc13 '@nestjs/bullmq': specifier: ^11.0.1 version: 11.0.4(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(bullmq@5.62.1) @@ -367,6 +382,9 @@ importers: '@socket.io/redis-adapter': specifier: ^8.3.0 version: 8.3.0(socket.io-adapter@2.5.5) + ajv: + specifier: ^8.17.1 + version: 8.17.1 archiver: specifier: ^7.0.0 version: 7.0.1 @@ -430,6 +448,9 @@ importers: js-yaml: specifier: ^4.1.0 version: 4.1.0 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 kysely: specifier: 0.28.2 version: 0.28.2 @@ -569,6 +590,9 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/lodash': specifier: ^4.14.197 version: 4.17.20 @@ -2572,6 +2596,12 @@ packages: resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@extism/extism@2.0.0-rc13': + resolution: {integrity: sha512-iQ3mrPKOC0WMZ94fuJrKbJmMyz4LQ9Abf8gd4F5ShxKWa+cRKcVzk0EqRQsp5xXsQ2dO3zJTiA6eTc4Ihf7k+A==} + + '@extism/js-pdk@1.1.1': + resolution: {integrity: sha512-VZLn/dX0ttA1uKk2PZeR/FL3N+nA1S5Vc7E5gdjkR60LuUIwCZT9cYON245V4HowHlBA7YOegh0TLjkx+wNbrA==} + '@faker-js/faker@10.1.0': resolution: {integrity: sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==} engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'} @@ -4590,6 +4620,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/justified-layout@4.1.4': resolution: {integrity: sha512-q2ybP0u0NVj87oMnGZOGxY2iUN8ddr48zPOBHBdbOLpsMTA/keGj+93ou+OMCnJk0xewzlNIaVEkxM6VBD3E2w==} @@ -5364,6 +5397,9 @@ packages: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -6294,6 +6330,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -7717,12 +7756,22 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + just-compare@2.3.0: resolution: {integrity: sha512-6shoR7HDT+fzfL3gBahx1jZG3hWLrhPAf+l7nCwahDdT9XDtosB9kIF0ZrzUp5QY8dJWfQVr5rnsPqsbvflDzg==} justified-layout@4.1.0: resolution: {integrity: sha512-M5FimNMXgiOYerVRGsXZ2YK9YNCaTtwtYp7Hb2308U1Q9TXXHx5G0p08mcVR5O53qf8bWY4NJcPBxE6zuayXSg==} + jwa@1.4.2: + resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + kdbush@3.0.0: resolution: {integrity: sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==} @@ -7922,15 +7971,36 @@ packages: lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.uniq@4.5.0: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} @@ -11043,6 +11113,9 @@ packages: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} + urlpattern-polyfill@8.0.2: + resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==} + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -14170,6 +14243,12 @@ snapshots: '@eslint/core': 0.16.0 levn: 0.4.1 + '@extism/extism@2.0.0-rc13': {} + + '@extism/js-pdk@1.1.1': + dependencies: + urlpattern-polyfill: 8.0.2 + '@faker-js/faker@10.1.0': {} '@fig/complete-commander@3.2.0(commander@11.1.0)': @@ -16414,6 +16493,11 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.19.0 + '@types/justified-layout@4.1.4': {} '@types/keygrip@1.0.6': {} @@ -17389,6 +17473,8 @@ snapshots: buffer-crc32@1.0.0: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -18341,6 +18427,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.243: {} @@ -20155,10 +20245,34 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.7.3 + just-compare@2.3.0: {} justified-layout@4.1.0: {} + jwa@1.4.2: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.2 + safe-buffer: 5.2.1 + kdbush@3.0.0: {} kdbush@4.0.2: {} @@ -20323,12 +20437,26 @@ snapshots: lodash.defaults@4.2.0: {} + lodash.includes@4.3.0: {} + lodash.isarguments@3.1.0: {} + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + lodash.memoize@4.1.2: {} lodash.merge@4.6.2: {} + lodash.once@4.1.1: {} + lodash.uniq@4.5.0: {} lodash@4.17.21: {} @@ -24145,6 +24273,8 @@ snapshots: punycode: 1.4.1 qs: 6.14.0 + urlpattern-polyfill@8.0.2: {} + use-sync-external-store@1.6.0(react@18.3.1): dependencies: react: 18.3.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index db33e87a0e..d5629d2323 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: - e2e - open-api/typescript-sdk - server + - plugins - web - .github ignoredBuiltDependencies: diff --git a/server/Dockerfile b/server/Dockerfile index 0fc4126926..0bb7fc6be5 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -48,6 +48,24 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \ pnpm --filter @immich/sdk --filter @immich/cli build && \ pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned +FROM builder AS plugins + +COPY --from=ghcr.io/jdx/mise:2025.11.3 /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 +RUN mise install --cd plugins + +COPY ./plugins ./plugins/ +# Build plugins +RUN --mount=type=cache,id=pnpm-plugins,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 \ + cd plugins && mise run build + FROM ghcr.io/immich-app/base-server-prod:202511041104@sha256:57c0379977fd5521d83cdf661aecd1497c83a9a661ebafe0a5243a09fc1064cb WORKDIR /usr/src/app @@ -58,6 +76,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 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 aa6ba671a5..a252a53b8a 100644 --- a/server/package.json +++ b/server/package.json @@ -34,6 +34,7 @@ "email:dev": "email dev -p 3050 --dir src/emails" }, "dependencies": { + "@extism/extism": "2.0.0-rc13", "@nestjs/bullmq": "^11.0.1", "@nestjs/common": "^11.0.4", "@nestjs/core": "^11.0.4", @@ -56,6 +57,7 @@ "@react-email/components": "^0.5.0", "@react-email/render": "^1.1.2", "@socket.io/redis-adapter": "^8.3.0", + "ajv": "^8.17.1", "archiver": "^7.0.0", "async-lock": "^1.4.0", "bcrypt": "^6.0.0", @@ -77,6 +79,7 @@ "i18n-iso-countries": "^7.6.0", "ioredis": "^5.8.2", "js-yaml": "^4.1.0", + "jsonwebtoken": "^9.0.2", "kysely": "0.28.2", "kysely-postgres-js": "^3.0.0", "lodash": "^4.17.21", @@ -124,6 +127,7 @@ "@types/cookie-parser": "^1.4.8", "@types/express": "^5.0.0", "@types/fluent-ffmpeg": "^2.1.21", + "@types/jsonwebtoken": "^9.0.10", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.14.197", "@types/luxon": "^3.6.2", diff --git a/server/src/config.ts b/server/src/config.ts index e81ad49621..c18acd79f8 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -235,6 +235,7 @@ export const defaults = Object.freeze({ [QueueName.VideoConversion]: { concurrency: 1 }, [QueueName.Notification]: { concurrency: 5 }, [QueueName.Ocr]: { concurrency: 1 }, + [QueueName.Workflow]: { concurrency: 5 }, }, logging: { enabled: true, diff --git a/server/src/constants.ts b/server/src/constants.ts index ddf8bc91d1..d624557c54 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -160,6 +160,8 @@ export const endpointTags: Record = { [ApiTag.Partners]: 'A partner is a link with another user that allows sharing of assets between two users.', [ApiTag.People]: 'A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job.', + [ApiTag.Plugins]: + 'A plugin is an installed module that makes filters and actions available for the workflow feature.', [ApiTag.Search]: 'Endpoints related to searching assets via text, smart search, optical character recognition (OCR), and other filters like person, album, and other metadata. Search endpoints usually support pagination and sorting.', [ApiTag.Server]: @@ -185,4 +187,6 @@ export const endpointTags: Record = { [ApiTag.Users]: 'Endpoints for viewing and updating the current users, including product key information, profile picture data, onboarding progress, and more.', [ApiTag.Views]: 'Endpoints for specialized views, such as the folder view.', + [ApiTag.Workflows]: + 'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.', }; diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index e3661ec794..c0c0461fb3 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -18,6 +18,7 @@ import { NotificationController } from 'src/controllers/notification.controller' import { OAuthController } from 'src/controllers/oauth.controller'; import { PartnerController } from 'src/controllers/partner.controller'; import { PersonController } from 'src/controllers/person.controller'; +import { PluginController } from 'src/controllers/plugin.controller'; import { SearchController } from 'src/controllers/search.controller'; import { ServerController } from 'src/controllers/server.controller'; import { SessionController } from 'src/controllers/session.controller'; @@ -32,6 +33,7 @@ import { TrashController } from 'src/controllers/trash.controller'; import { UserAdminController } from 'src/controllers/user-admin.controller'; import { UserController } from 'src/controllers/user.controller'; import { ViewController } from 'src/controllers/view.controller'; +import { WorkflowController } from 'src/controllers/workflow.controller'; export const controllers = [ ApiKeyController, @@ -54,6 +56,7 @@ export const controllers = [ OAuthController, PartnerController, PersonController, + PluginController, SearchController, ServerController, SessionController, @@ -68,4 +71,5 @@ export const controllers = [ UserAdminController, UserController, ViewController, + WorkflowController, ]; diff --git a/server/src/controllers/plugin.controller.ts b/server/src/controllers/plugin.controller.ts new file mode 100644 index 0000000000..a0a4d14b0b --- /dev/null +++ b/server/src/controllers/plugin.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { PluginResponseDto } from 'src/dtos/plugin.dto'; +import { Permission } from 'src/enum'; +import { Authenticated } from 'src/middleware/auth.guard'; +import { PluginService } from 'src/services/plugin.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Plugins') +@Controller('plugins') +export class PluginController { + constructor(private service: PluginService) {} + + @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'), + }) + getPlugins(): Promise { + return this.service.getAll(); + } + + @Get(':id') + @Authenticated({ permission: Permission.PluginRead }) + @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'), + }) + getPlugin(@Param() { id }: UUIDParamDto): Promise { + return this.service.get(id); + } +} diff --git a/server/src/controllers/workflow.controller.ts b/server/src/controllers/workflow.controller.ts new file mode 100644 index 0000000000..e07b6443f4 --- /dev/null +++ b/server/src/controllers/workflow.controller.ts @@ -0,0 +1,76 @@ +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } 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 { Permission } from 'src/enum'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { WorkflowService } from 'src/services/workflow.service'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags('Workflows') +@Controller('workflows') +export class WorkflowController { + constructor(private service: WorkflowService) {} + + @Post() + @Authenticated({ permission: Permission.WorkflowCreate }) + @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'), + }) + createWorkflow(@Auth() auth: AuthDto, @Body() dto: WorkflowCreateDto): Promise { + return this.service.create(auth, dto); + } + + @Get() + @Authenticated({ permission: Permission.WorkflowRead }) + @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'), + }) + getWorkflows(@Auth() auth: AuthDto): Promise { + return this.service.getAll(auth); + } + + @Get(':id') + @Authenticated({ permission: Permission.WorkflowRead }) + @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'), + }) + getWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(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'), + }) + updateWorkflow( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: WorkflowUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @Authenticated({ permission: Permission.WorkflowDelete }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete a workflow', + description: 'Delete a workflow by its ID.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + 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 b62cb70347..4aa69127ff 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -7,6 +7,8 @@ import { AssetVisibility, MemoryType, Permission, + PluginContext, + PluginTriggerType, SharedLinkType, SourceType, UserAvatarColor, @@ -14,7 +16,10 @@ 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 { UserMetadataItem } from 'src/types'; +import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types'; export type AuthUser = { id: string; @@ -277,6 +282,45 @@ export type AssetFace = { updateId: string; }; +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; + filterId: string; + filterConfig: FilterConfig | null; + order: number; +}; + +export type WorkflowAction = Selectable & { + workflowId: string; + actionId: string; + actionConfig: ActionConfig | null; + order: number; +}; + const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const; const userWithPrefixColumns = [ 'user2.id', @@ -418,4 +462,15 @@ export const columns = { 'asset_exif.state', '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; diff --git a/server/src/dtos/env.dto.ts b/server/src/dtos/env.dto.ts index 3543d8dae9..2a9dd8b662 100644 --- a/server/src/dtos/env.dto.ts +++ b/server/src/dtos/env.dto.ts @@ -57,6 +57,13 @@ export class EnvDto { @Type(() => Number) IMMICH_MICROSERVICES_METRICS_PORT?: number; + @ValidateBoolean({ optional: true }) + IMMICH_PLUGINS_ENABLED?: boolean; + + @Optional() + @Matches(/^\//, { message: 'IMMICH_PLUGINS_INSTALL_FOLDER must be an absolute path' }) + IMMICH_PLUGINS_INSTALL_FOLDER?: string; + @IsInt() @Optional() @Type(() => Number) diff --git a/server/src/dtos/plugin-manifest.dto.ts b/server/src/dtos/plugin-manifest.dto.ts new file mode 100644 index 0000000000..fcb3ad4a22 --- /dev/null +++ b/server/src/dtos/plugin-manifest.dto.ts @@ -0,0 +1,110 @@ +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsEnum, + IsNotEmpty, + IsObject, + IsOptional, + IsSemVer, + IsString, + Matches, + ValidateNested, +} from 'class-validator'; +import { PluginContext } from 'src/enum'; +import { JSONSchema } from 'src/types/plugin-schema.types'; +import { ValidateEnum } from 'src/validation'; + +class PluginManifestWasmDto { + @IsString() + @IsNotEmpty() + path!: string; +} + +class PluginManifestFilterDto { + @IsString() + @IsNotEmpty() + methodName!: string; + + @IsString() + @IsNotEmpty() + title!: string; + + @IsString() + @IsNotEmpty() + description!: string; + + @IsArray() + @ArrayMinSize(1) + @IsEnum(PluginContext, { each: true }) + supportedContexts!: PluginContext[]; + + @IsObject() + @IsOptional() + schema?: JSONSchema; +} + +class PluginManifestActionDto { + @IsString() + @IsNotEmpty() + methodName!: string; + + @IsString() + @IsNotEmpty() + title!: string; + + @IsString() + @IsNotEmpty() + description!: string; + + @IsArray() + @ArrayMinSize(1) + @ValidateEnum({ enum: PluginContext, name: 'PluginContext', each: true }) + supportedContexts!: PluginContext[]; + + @IsObject() + @IsOptional() + schema?: JSONSchema; +} + +export class PluginManifestDto { + @IsString() + @IsNotEmpty() + @Matches(/^[a-z0-9-]+[a-z0-9]$/, { + message: 'Plugin name must contain only lowercase letters, numbers, and hyphens, and cannot end with a hyphen', + }) + name!: string; + + @IsString() + @IsNotEmpty() + @IsSemVer() + version!: string; + + @IsString() + @IsNotEmpty() + title!: string; + + @IsString() + @IsNotEmpty() + description!: string; + + @IsString() + @IsNotEmpty() + author!: string; + + @ValidateNested() + @Type(() => PluginManifestWasmDto) + wasm!: PluginManifestWasmDto; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PluginManifestFilterDto) + @IsOptional() + filters?: PluginManifestFilterDto[]; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PluginManifestActionDto) + @IsOptional() + actions?: PluginManifestActionDto[]; +} diff --git a/server/src/dtos/plugin.dto.ts b/server/src/dtos/plugin.dto.ts new file mode 100644 index 0000000000..ce80eccd65 --- /dev/null +++ b/server/src/dtos/plugin.dto.ts @@ -0,0 +1,77 @@ +import { IsNotEmpty, IsString } from 'class-validator'; +import { PluginAction, PluginFilter } from 'src/database'; +import { PluginContext } from 'src/enum'; +import type { JSONSchema } from 'src/types/plugin-schema.types'; +import { ValidateEnum } from 'src/validation'; + +export class PluginResponseDto { + id!: string; + name!: string; + title!: string; + description!: string; + author!: string; + version!: string; + createdAt!: string; + updatedAt!: string; + filters!: PluginFilterResponseDto[]; + actions!: PluginActionResponseDto[]; +} + +export class PluginFilterResponseDto { + id!: string; + pluginId!: string; + methodName!: string; + title!: string; + description!: string; + + @ValidateEnum({ enum: PluginContext, name: 'PluginContext' }) + supportedContexts!: PluginContext[]; + schema!: JSONSchema | null; +} + +export class PluginActionResponseDto { + id!: string; + pluginId!: string; + methodName!: string; + title!: string; + description!: string; + + @ValidateEnum({ enum: PluginContext, name: 'PluginContext' }) + supportedContexts!: PluginContext[]; + schema!: JSONSchema | null; +} + +export class PluginInstallDto { + @IsString() + @IsNotEmpty() + manifestPath!: string; +} + +export type MapPlugin = { + id: string; + name: string; + title: string; + description: string; + author: string; + version: string; + wasmPath: string; + createdAt: Date; + updatedAt: Date; + filters: PluginFilter[]; + actions: PluginAction[]; +}; + +export function mapPlugin(plugin: MapPlugin): PluginResponseDto { + return { + id: plugin.id, + name: plugin.name, + title: plugin.title, + description: plugin.description, + author: plugin.author, + version: plugin.version, + createdAt: plugin.createdAt.toISOString(), + updatedAt: plugin.updatedAt.toISOString(), + filters: plugin.filters, + actions: plugin.actions, + }; +} diff --git a/server/src/dtos/queue.dto.ts b/server/src/dtos/queue.dto.ts index 1492e014d9..df00c5cfc2 100644 --- a/server/src/dtos/queue.dto.ts +++ b/server/src/dtos/queue.dto.ts @@ -91,4 +91,7 @@ export class QueuesResponseDto implements Record { @ApiProperty({ type: QueueResponseDto }) [QueueName.Ocr]!: QueueResponseDto; + + @ApiProperty({ type: QueueResponseDto }) + [QueueName.Workflow]!: QueueResponseDto; } diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 6d36e2cc8a..c835073c31 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -224,6 +224,12 @@ class SystemConfigJobDto implements Record @IsObject() @Type(() => JobSettingsDto) [QueueName.Notification]!: JobSettingsDto; + + @ApiProperty({ type: JobSettingsDto }) + @ValidateNested() + @IsObject() + @Type(() => JobSettingsDto) + [QueueName.Workflow]!: JobSettingsDto; } class SystemConfigLibraryScanDto { diff --git a/server/src/dtos/workflow.dto.ts b/server/src/dtos/workflow.dto.ts new file mode 100644 index 0000000000..307440945d --- /dev/null +++ b/server/src/dtos/workflow.dto.ts @@ -0,0 +1,120 @@ +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'; + +export class WorkflowFilterItemDto { + @IsUUID() + filterId!: string; + + @IsObject() + @Optional() + filterConfig?: FilterConfig; +} + +export class WorkflowActionItemDto { + @IsUUID() + actionId!: string; + + @IsObject() + @Optional() + actionConfig?: ActionConfig; +} + +export class WorkflowCreateDto { + @ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' }) + triggerType!: PluginTriggerType; + + @IsString() + @IsNotEmpty() + name!: string; + + @IsString() + @Optional() + description?: string; + + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateNested({ each: true }) + @Type(() => WorkflowFilterItemDto) + filters!: WorkflowFilterItemDto[]; + + @ValidateNested({ each: true }) + @Type(() => WorkflowActionItemDto) + actions!: WorkflowActionItemDto[]; +} + +export class WorkflowUpdateDto { + @IsString() + @IsNotEmpty() + @Optional() + name?: string; + + @IsString() + @Optional() + description?: string; + + @ValidateBoolean({ optional: true }) + enabled?: boolean; + + @ValidateNested({ each: true }) + @Type(() => WorkflowFilterItemDto) + @Optional() + filters?: WorkflowFilterItemDto[]; + + @ValidateNested({ each: true }) + @Type(() => WorkflowActionItemDto) + @Optional() + actions?: WorkflowActionItemDto[]; +} + +export class WorkflowResponseDto { + id!: string; + ownerId!: string; + triggerType!: PluginTriggerType; + name!: string | null; + description!: string; + createdAt!: string; + enabled!: boolean; + filters!: WorkflowFilterResponseDto[]; + actions!: WorkflowActionResponseDto[]; +} + +export class WorkflowFilterResponseDto { + id!: string; + workflowId!: string; + filterId!: string; + filterConfig!: FilterConfig | null; + order!: number; +} + +export class WorkflowActionResponseDto { + id!: string; + workflowId!: string; + actionId!: string; + actionConfig!: ActionConfig | null; + order!: number; +} + +export function mapWorkflowFilter(filter: WorkflowFilter): WorkflowFilterResponseDto { + return { + id: filter.id, + workflowId: filter.workflowId, + filterId: filter.filterId, + filterConfig: filter.filterConfig, + order: filter.order, + }; +} + +export function mapWorkflowAction(action: WorkflowAction): WorkflowActionResponseDto { + return { + id: action.id, + workflowId: action.workflowId, + actionId: action.actionId, + actionConfig: action.actionConfig, + order: action.order, + }; +} diff --git a/server/src/enum.ts b/server/src/enum.ts index f3814863b1..6055ee85bf 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -177,6 +177,11 @@ export enum Permission { PinCodeUpdate = 'pinCode.update', PinCodeDelete = 'pinCode.delete', + PluginCreate = 'plugin.create', + PluginRead = 'plugin.read', + PluginUpdate = 'plugin.update', + PluginDelete = 'plugin.delete', + ServerAbout = 'server.about', ServerApkLinks = 'server.apkLinks', ServerStorage = 'server.storage', @@ -240,6 +245,11 @@ export enum Permission { UserProfileImageUpdate = 'userProfileImage.update', UserProfileImageDelete = 'userProfileImage.delete', + WorkflowCreate = 'workflow.create', + WorkflowRead = 'workflow.read', + WorkflowUpdate = 'workflow.update', + WorkflowDelete = 'workflow.delete', + AdminUserCreate = 'adminUser.create', AdminUserRead = 'adminUser.read', AdminUserUpdate = 'adminUser.update', @@ -525,6 +535,7 @@ export enum QueueName { Notification = 'notifications', BackupDatabase = 'backupDatabase', Ocr = 'ocr', + Workflow = 'workflow', } export enum JobName { @@ -601,6 +612,9 @@ export enum JobName { // OCR OcrQueueAll = 'OcrQueueAll', Ocr = 'Ocr', + + // Workflow + WorkflowRun = 'WorkflowRun', } export enum QueueCommand { @@ -793,6 +807,7 @@ export enum ApiTag { NotificationsAdmin = 'Notifications (admin)', Partners = 'Partners', People = 'People', + Plugins = 'Plugins', Search = 'Search', Server = 'Server', Sessions = 'Sessions', @@ -807,4 +822,16 @@ export enum ApiTag { UsersAdmin = 'Users (admin)', Users = 'Users', Views = 'Views', + Workflows = 'Workflows', +} + +export enum PluginContext { + Asset = 'asset', + Album = 'album', + Person = 'person', +} + +export enum PluginTriggerType { + AssetCreate = 'AssetCreate', + PersonRecognized = 'PersonRecognized', } diff --git a/server/src/plugins.ts b/server/src/plugins.ts new file mode 100644 index 0000000000..0c69483696 --- /dev/null +++ b/server/src/plugins.ts @@ -0,0 +1,37 @@ +import { PluginContext, PluginTriggerType } from 'src/enum'; +import { JSONSchema } from 'src/types/plugin-schema.types'; + +export type PluginTrigger = { + name: string; + type: PluginTriggerType; + description: string; + context: PluginContext; + schema: JSONSchema | null; +}; + +export const pluginTriggers: PluginTrigger[] = [ + { + name: 'Asset Uploaded', + type: PluginTriggerType.AssetCreate, + description: 'Triggered when a new asset is uploaded', + context: PluginContext.Asset, + schema: { + type: 'object', + properties: { + assetType: { + type: 'string', + description: 'Type of the asset', + default: 'ALL', + enum: ['Image', 'Video', 'All'], + }, + }, + }, + }, + { + name: 'Person Recognized', + type: PluginTriggerType.PersonRecognized, + description: 'Triggered when a person is detected in an asset', + context: PluginContext.Person, + schema: null, + }, +]; diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 9ce6d845da..1239260dce 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -243,3 +243,12 @@ from where "partner"."sharedById" in ($1) and "partner"."sharedWithId" = $2 + +-- AccessRepository.workflow.checkOwnerAccess +select + "workflow"."id" +from + "workflow" +where + "workflow"."id" in ($1) + and "workflow"."ownerId" = $2 diff --git a/server/src/queries/plugin.repository.sql b/server/src/queries/plugin.repository.sql new file mode 100644 index 0000000000..82c203dafd --- /dev/null +++ b/server/src/queries/plugin.repository.sql @@ -0,0 +1,159 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- PluginRepository.getPlugin +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" +from + "plugin" +where + "plugin"."id" = $1 + +-- PluginRepository.getPluginByName +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" +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" +from + "plugin" +order by + "plugin"."name" + +-- PluginRepository.getFilter +select + * +from + "plugin_filter" +where + "id" = $1 + +-- PluginRepository.getFiltersByPlugin +select + * +from + "plugin_filter" +where + "pluginId" = $1 + +-- PluginRepository.getAction +select + * +from + "plugin_action" +where + "id" = $1 + +-- PluginRepository.getActionsByPlugin +select + * +from + "plugin_action" +where + "pluginId" = $1 diff --git a/server/src/queries/workflow.repository.sql b/server/src/queries/workflow.repository.sql new file mode 100644 index 0000000000..3797c5bb06 --- /dev/null +++ b/server/src/queries/workflow.repository.sql @@ -0,0 +1,68 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- WorkflowRepository.getWorkflow +select + * +from + "workflow" +where + "id" = $1 + +-- WorkflowRepository.getWorkflowsByOwner +select + * +from + "workflow" +where + "ownerId" = $1 +order by + "name" + +-- WorkflowRepository.getWorkflowsByTrigger +select + * +from + "workflow" +where + "triggerType" = $1 + and "enabled" = $2 + +-- WorkflowRepository.getWorkflowByOwnerAndTrigger +select + * +from + "workflow" +where + "ownerId" = $1 + and "triggerType" = $2 + and "enabled" = $3 + +-- WorkflowRepository.deleteWorkflow +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/access.repository.ts b/server/src/repositories/access.repository.ts index a801c046aa..533e74a311 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -462,6 +462,26 @@ class TagAccess { } } +class WorkflowAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, workflowIds: Set) { + if (workflowIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('workflow') + .select('workflow.id') + .where('workflow.id', 'in', [...workflowIds]) + .where('workflow.ownerId', '=', userId) + .execute() + .then((workflows) => new Set(workflows.map((workflow) => workflow.id))); + } +} + @Injectable() export class AccessRepository { activity: ActivityAccess; @@ -476,6 +496,7 @@ export class AccessRepository { stack: StackAccess; tag: TagAccess; timeline: TimelineAccess; + workflow: WorkflowAccess; constructor(@InjectKysely() db: Kysely) { this.activity = new ActivityAccess(db); @@ -490,5 +511,6 @@ export class AccessRepository { this.stack = new StackAccess(db); this.tag = new TagAccess(db); this.timeline = new TimelineAccess(db); + this.workflow = new WorkflowAccess(db); } } diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index d5c279099c..05d4bd2ac3 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -85,6 +85,7 @@ export interface EnvData { root: string; indexHtml: string; }; + corePlugin: string; }; redis: RedisOptions; @@ -102,6 +103,11 @@ export interface EnvData { workers: ImmichWorker[]; + plugins: { + enabled: boolean; + installFolder?: string; + }; + noColor: boolean; nodeVersion?: string; } @@ -304,6 +310,7 @@ const getEnv = (): EnvData => { root: folders.web, indexHtml: join(folders.web, 'index.html'), }, + corePlugin: join(buildFolder, 'corePlugin'), }, storage: { @@ -319,6 +326,11 @@ const getEnv = (): EnvData => { workers, + plugins: { + enabled: !!dto.IMMICH_PLUGINS_ENABLED, + installFolder: dto.IMMICH_PLUGINS_INSTALL_FOLDER, + }, + noColor: !!dto.NO_COLOR, }; }; diff --git a/server/src/repositories/crypto.repository.ts b/server/src/repositories/crypto.repository.ts index c3136db456..bcd791ade2 100644 --- a/server/src/repositories/crypto.repository.ts +++ b/server/src/repositories/crypto.repository.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { compareSync, hash } from 'bcrypt'; +import jwt from 'jsonwebtoken'; import { createHash, createPublicKey, createVerify, randomBytes, randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; @@ -57,4 +58,12 @@ export class CryptoRepository { randomBytesAsText(bytes: number) { return randomBytes(bytes).toString('base64').replaceAll(/\W/g, ''); } + + signJwt(payload: string | object | Buffer, secret: string, options?: jwt.SignOptions): string { + return jwt.sign(payload, secret, { algorithm: 'HS256', ...options }); + } + + verifyJwt(token: string, secret: string): T { + return jwt.verify(token, secret, { algorithms: ['HS256'] }) as T; + } } diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index c3e6cd20cf..80d411c5ae 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -4,6 +4,7 @@ import { ClassConstructor } from 'class-transformer'; import _ from 'lodash'; import { Socket } from 'socket.io'; import { SystemConfig } from 'src/config'; +import { Asset } from 'src/database'; import { EventConfig } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum'; @@ -41,6 +42,7 @@ type EventMap = { AlbumInvite: [{ id: string; userId: string }]; // asset events + AssetCreate: [{ asset: Asset }]; AssetTag: [{ assetId: string }]; AssetUntag: [{ assetId: string }]; AssetHide: [{ assetId: string; userId: string }]; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index cf65cfcb2a..c69536a327 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -28,6 +28,7 @@ import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; @@ -46,6 +47,7 @@ import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; import { WebsocketRepository } from 'src/repositories/websocket.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; export const repositories = [ AccessRepository, @@ -78,6 +80,7 @@ export const repositories = [ OcrRepository, PartnerRepository, PersonRepository, + PluginRepository, ProcessRepository, SearchRepository, SessionRepository, @@ -96,4 +99,5 @@ export const repositories = [ ViewRepository, VersionHistoryRepository, WebsocketRepository, + WorkflowRepository, ]; diff --git a/server/src/repositories/plugin.repository.ts b/server/src/repositories/plugin.repository.ts new file mode 100644 index 0000000000..6217237947 --- /dev/null +++ b/server/src/repositories/plugin.repository.ts @@ -0,0 +1,176 @@ +import { Injectable } from '@nestjs/common'; +import { 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 { DB } from 'src/schema'; + +@Injectable() +export class PluginRepository { + constructor(@InjectKysely() private db: Kysely) {} + + /** + * 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(); + } + + @GenerateSql() + getAllPlugins() { + 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'), + ]) + .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.UUID] }) + getFiltersByPlugin(pluginId: string) { + return this.db.selectFrom('plugin_filter').selectAll().where('pluginId', '=', pluginId).execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getAction(id: string) { + return this.db.selectFrom('plugin_action').selectAll().where('id', '=', id).executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getActionsByPlugin(pluginId: string) { + return this.db.selectFrom('plugin_action').selectAll().where('pluginId', '=', pluginId).execute(); + } +} diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 50f44d9f67..e901273b57 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -113,6 +113,10 @@ export class StorageRepository { } } + async readTextFile(filepath: string): Promise { + return fs.readFile(filepath, 'utf8'); + } + async checkFileExists(filepath: string, mode = constants.F_OK): Promise { try { await fs.access(filepath, mode); diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts new file mode 100644 index 0000000000..4ae657cfbf --- /dev/null +++ b/server/src/repositories/workflow.repository.ts @@ -0,0 +1,139 @@ +import { Injectable } from '@nestjs/common'; +import { Insertable, Kysely, Updateable } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { DummyValue, GenerateSql } from 'src/decorators'; +import { PluginTriggerType } from 'src/enum'; +import { DB } from 'src/schema'; +import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; + +@Injectable() +export class WorkflowRepository { + constructor(@InjectKysely() private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID] }) + getWorkflow(id: string) { + return this.db.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getWorkflowsByOwner(ownerId: string) { + return this.db.selectFrom('workflow').selectAll().where('ownerId', '=', ownerId).orderBy('name').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; + }); + } + + 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(); + } + + 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(); + }); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async deleteWorkflow(id: string) { + await this.db.deleteFrom('workflow').where('id', '=', id).execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getFilters(workflowId: 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(); + } +} diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 7f4bdbeed3..9e206826e6 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -53,6 +53,7 @@ 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 { 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'; @@ -69,6 +70,7 @@ 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 { Database, Extensions, Generated, Int8 } from 'src/sql-tools'; @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) @@ -125,6 +127,12 @@ export class ImmichDatabase { UserMetadataAuditTable, UserTable, VersionHistoryTable, + PluginTable, + PluginFilterTable, + PluginActionTable, + WorkflowTable, + WorkflowFilterTable, + WorkflowActionTable, ]; functions = [ @@ -231,4 +239,12 @@ export interface DB { user_metadata_audit: UserMetadataAuditTable; version_history: VersionHistoryTable; + + plugin: PluginTable; + plugin_filter: PluginFilterTable; + plugin_action: PluginActionTable; + + workflow: WorkflowTable; + workflow_filter: WorkflowFilterTable; + workflow_action: WorkflowActionTable; } diff --git a/server/src/schema/migrations/1762297277677-AddPluginAndWorkflowTables.ts b/server/src/schema/migrations/1762297277677-AddPluginAndWorkflowTables.ts new file mode 100644 index 0000000000..6dacc1056b --- /dev/null +++ b/server/src/schema/migrations/1762297277677-AddPluginAndWorkflowTables.ts @@ -0,0 +1,113 @@ +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(), + "name" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "author" character varying NOT NULL, + "version" character varying NOT NULL, + "wasmPath" character varying NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + 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_filter" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "pluginId" uuid NOT NULL, + "methodName" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "supportedContexts" character varying[] NOT NULL, + "schema" jsonb, + CONSTRAINT "plugin_filter_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "plugin_filter_methodName_uq" UNIQUE ("methodName"), + CONSTRAINT "plugin_filter_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_filter_supportedContexts_idx" ON "plugin_filter" USING gin ("supportedContexts");`.execute( + db, + ); + await sql`CREATE INDEX "plugin_filter_pluginId_idx" ON "plugin_filter" ("pluginId");`.execute(db); + await sql`CREATE INDEX "plugin_filter_methodName_idx" ON "plugin_filter" ("methodName");`.execute(db); + await sql`CREATE TABLE "plugin_action" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "pluginId" uuid NOT NULL, + "methodName" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "supportedContexts" character varying[] NOT NULL, + "schema" jsonb, + CONSTRAINT "plugin_action_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "plugin_action_methodName_uq" UNIQUE ("methodName"), + CONSTRAINT "plugin_action_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_action_supportedContexts_idx" ON "plugin_action" USING gin ("supportedContexts");`.execute( + db, + ); + await sql`CREATE INDEX "plugin_action_pluginId_idx" ON "plugin_action" ("pluginId");`.execute(db); + await sql`CREATE INDEX "plugin_action_methodName_idx" ON "plugin_action" ("methodName");`.execute(db); + await sql`CREATE TABLE "workflow" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "ownerId" uuid NOT NULL, + "triggerType" character varying NOT NULL, + "name" character varying, + "description" character varying NOT NULL, + "createdAt" 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 TABLE "workflow_filter" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "workflowId" uuid NOT NULL, + "filterId" uuid NOT NULL, + "filterConfig" jsonb, + "order" integer NOT NULL, + CONSTRAINT "workflow_filter_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_filter_filterId_fkey" FOREIGN KEY ("filterId") REFERENCES "plugin_filter" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_filter_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_filter_filterId_idx" ON "workflow_filter" ("filterId");`.execute(db); + await sql`CREATE INDEX "workflow_filter_workflowId_order_idx" ON "workflow_filter" ("workflowId", "order");`.execute( + db, + ); + await sql`CREATE INDEX "workflow_filter_workflowId_idx" ON "workflow_filter" ("workflowId");`.execute(db); + await sql`CREATE TABLE "workflow_action" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "workflowId" uuid NOT NULL, + "actionId" uuid NOT NULL, + "actionConfig" jsonb, + "order" integer NOT NULL, + CONSTRAINT "workflow_action_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_action_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "plugin_action" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_action_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_action_actionId_idx" ON "workflow_action" ("actionId");`.execute(db); + await sql`CREATE INDEX "workflow_action_workflowId_order_idx" ON "workflow_action" ("workflowId", "order");`.execute( + db, + ); + await sql`CREATE INDEX "workflow_action_workflowId_idx" ON "workflow_action" ("workflowId");`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_plugin_filter_supportedContexts_idx', '{"type":"index","name":"plugin_filter_supportedContexts_idx","sql":"CREATE INDEX \\"plugin_filter_supportedContexts_idx\\" ON \\"plugin_filter\\" (\\"supportedContexts\\") USING gin;"}'::jsonb);`.execute( + db, + ); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_plugin_action_supportedContexts_idx', '{"type":"index","name":"plugin_action_supportedContexts_idx","sql":"CREATE INDEX \\"plugin_action_supportedContexts_idx\\" ON \\"plugin_action\\" (\\"supportedContexts\\") USING gin;"}'::jsonb);`.execute( + db, + ); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TABLE "workflow";`.execute(db); + await sql`DROP TABLE "workflow_filter";`.execute(db); + await sql`DROP TABLE "workflow_action";`.execute(db); + + await sql`DROP TABLE "plugin";`.execute(db); + await sql`DROP TABLE "plugin_filter";`.execute(db); + await sql`DROP TABLE "plugin_action";`.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); +} diff --git a/server/src/schema/tables/plugin.table.ts b/server/src/schema/tables/plugin.table.ts new file mode 100644 index 0000000000..3de7ca63c9 --- /dev/null +++ b/server/src/schema/tables/plugin.table.ts @@ -0,0 +1,95 @@ +import { PluginContext } from 'src/enum'; +import { + Column, + CreateDateColumn, + ForeignKeyColumn, + Generated, + Index, + PrimaryGeneratedColumn, + Table, + Timestamp, + UpdateDateColumn, +} from 'src/sql-tools'; +import type { JSONSchema } from 'src/types/plugin-schema.types'; + +@Table('plugin') +export class PluginTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @Column({ index: true, unique: true }) + name!: string; + + @Column() + title!: string; + + @Column() + description!: string; + + @Column() + author!: string; + + @Column() + version!: string; + + @Column() + wasmPath!: string; + + @CreateDateColumn() + createdAt!: Generated; + + @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.table.ts b/server/src/schema/tables/workflow.table.ts new file mode 100644 index 0000000000..8f7c9adb0d --- /dev/null +++ b/server/src/schema/tables/workflow.table.ts @@ -0,0 +1,78 @@ +import { PluginTriggerType } from 'src/enum'; +import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table'; +import { UserTable } from 'src/schema/tables/user.table'; +import { + Column, + CreateDateColumn, + ForeignKeyColumn, + Generated, + Index, + PrimaryGeneratedColumn, + Table, + Timestamp, +} from 'src/sql-tools'; +import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types'; + +@Table('workflow') +export class WorkflowTable { + @PrimaryGeneratedColumn() + id!: Generated; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) + ownerId!: string; + + @Column() + triggerType!: PluginTriggerType; + + @Column({ nullable: true }) + name!: string | null; + + @Column() + description!: string; + + @CreateDateColumn() + createdAt!: Generated; + + @Column({ type: 'boolean', default: true }) + enabled!: boolean; +} + +@Index({ columns: ['workflowId', 'order'] }) +@Index({ columns: ['filterId'] }) +@Table('workflow_filter') +export class WorkflowFilterTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + workflowId!: Generated; + + @ForeignKeyColumn(() => PluginFilterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + filterId!: string; + + @Column({ type: 'jsonb', nullable: true }) + filterConfig!: FilterConfig | null; + + @Column({ type: 'integer' }) + order!: number; +} + +@Index({ columns: ['workflowId', 'order'] }) +@Index({ columns: ['actionId'] }) +@Table('workflow_action') +export class WorkflowActionTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + workflowId!: Generated; + + @ForeignKeyColumn(() => PluginActionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + actionId!: string; + + @Column({ type: 'jsonb', nullable: true }) + actionConfig!: ActionConfig | null; + + @Column({ type: 'integer' }) + order!: number; +} diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 8cf1ef331d..4db60c349f 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -426,6 +426,9 @@ export class AssetMediaService extends BaseService { } await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size }); + + await this.eventRepository.emit('AssetCreate', { asset }); + await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } }); return asset; diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 51041c1b1a..2c6d07b635 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -35,6 +35,7 @@ import { OAuthRepository } from 'src/repositories/oauth.repository'; import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; import { ProcessRepository } from 'src/repositories/process.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; @@ -53,6 +54,7 @@ import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; import { ViewRepository } from 'src/repositories/view-repository'; import { WebsocketRepository } from 'src/repositories/websocket.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { UserTable } from 'src/schema/tables/user.table'; import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access'; import { getConfig, updateConfig } from 'src/utils/config'; @@ -88,6 +90,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ OcrRepository, PartnerRepository, PersonRepository, + PluginRepository, ProcessRepository, SearchRepository, ServerInfoRepository, @@ -105,6 +108,8 @@ export const BASE_SERVICE_DEPENDENCIES = [ UserRepository, VersionHistoryRepository, ViewRepository, + WebsocketRepository, + WorkflowRepository, ]; @Injectable() @@ -142,6 +147,7 @@ export class BaseService { protected ocrRepository: OcrRepository, protected partnerRepository: PartnerRepository, protected personRepository: PersonRepository, + protected pluginRepository: PluginRepository, protected processRepository: ProcessRepository, protected searchRepository: SearchRepository, protected serverInfoRepository: ServerInfoRepository, @@ -160,6 +166,7 @@ export class BaseService { protected versionRepository: VersionHistoryRepository, protected viewRepository: ViewRepository, protected websocketRepository: WebsocketRepository, + protected workflowRepository: WorkflowRepository, ) { this.logger.setContext(this.constructor.name); this.storageCore = StorageCore.create( diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 8862a5b37e..9d09bdaa53 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -23,6 +23,7 @@ import { NotificationService } from 'src/services/notification.service'; import { OcrService } from 'src/services/ocr.service'; import { PartnerService } from 'src/services/partner.service'; import { PersonService } from 'src/services/person.service'; +import { PluginService } from 'src/services/plugin.service'; import { QueueService } from 'src/services/queue.service'; import { SearchService } from 'src/services/search.service'; import { ServerService } from 'src/services/server.service'; @@ -43,6 +44,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 { WorkflowService } from 'src/services/workflow.service'; export const services = [ ApiKeyService, @@ -70,6 +72,7 @@ export const services = [ OcrService, PartnerService, PersonService, + PluginService, QueueService, SearchService, ServerService, @@ -90,4 +93,5 @@ export const services = [ UserService, VersionService, ViewService, + WorkflowService, ]; diff --git a/server/src/services/plugin-host.functions.ts b/server/src/services/plugin-host.functions.ts new file mode 100644 index 0000000000..50b1052b54 --- /dev/null +++ b/server/src/services/plugin-host.functions.ts @@ -0,0 +1,120 @@ +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 new file mode 100644 index 0000000000..28d1ac56ca --- /dev/null +++ b/server/src/services/plugin.service.ts @@ -0,0 +1,317 @@ +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 } from 'src/dtos/plugin.dto'; +import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; +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; + }; +} + +@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(); + } + + // + // CRUD operations for plugins + // + async getAll(): Promise { + const plugins = await this.pluginRepository.getAllPlugins(); + return plugins.map((plugin) => mapPlugin(plugin)); + } + + async get(id: string): Promise { + const plugin = await this.pluginRepository.getPlugin(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.enabled && plugins.installFolder) { + await this.loadExternalPlugins(plugins.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.filterId); + if (!filter) { + this.logger.error(`Filter ${workflowFilter.filterId} 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.actionId); + if (!action) { + throw new Error(`Action ${workflowAction.actionId} 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)); + } + } +} diff --git a/server/src/services/queue.service.spec.ts b/server/src/services/queue.service.spec.ts index 1cc53df644..5dce9476e2 100644 --- a/server/src/services/queue.service.spec.ts +++ b/server/src/services/queue.service.spec.ts @@ -22,7 +22,7 @@ describe(QueueService.name, () => { it('should update concurrency', () => { sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig }); - expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(16); + expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(17); expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FacialRecognition, 1); expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DuplicateDetection, 1); expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BackgroundTask, 5); @@ -97,6 +97,7 @@ describe(QueueService.name, () => { [QueueName.Notification]: expectedJobStatus, [QueueName.BackupDatabase]: expectedJobStatus, [QueueName.Ocr]: expectedJobStatus, + [QueueName.Workflow]: expectedJobStatus, }); }); }); diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index b9a38e4b06..fbdd655bbc 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -40,6 +40,7 @@ const updatedConfig = Object.freeze({ [QueueName.VideoConversion]: { concurrency: 1 }, [QueueName.Notification]: { concurrency: 5 }, [QueueName.Ocr]: { concurrency: 1 }, + [QueueName.Workflow]: { concurrency: 5 }, }, backup: { database: { diff --git a/server/src/services/workflow.service.ts b/server/src/services/workflow.service.ts new file mode 100644 index 0000000000..ae72187d7d --- /dev/null +++ b/server/src/services/workflow.service.ts @@ -0,0 +1,159 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { Workflow } from 'src/database'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + mapWorkflowAction, + mapWorkflowFilter, + WorkflowCreateDto, + WorkflowResponseDto, + WorkflowUpdateDto, +} from 'src/dtos/workflow.dto'; +import { Permission, PluginContext, PluginTriggerType } from 'src/enum'; +import { pluginTriggers } from 'src/plugins'; + +import { BaseService } from 'src/services/base.service'; + +@Injectable() +export class WorkflowService extends BaseService { + async create(auth: AuthDto, dto: WorkflowCreateDto): Promise { + const trigger = this.getTriggerOrFail(dto.triggerType); + + const filterInserts = await this.validateAndMapFilters(dto.filters, trigger.context); + const actionInserts = await this.validateAndMapActions(dto.actions, trigger.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); + } + + async getAll(auth: AuthDto): Promise { + const workflows = await this.workflowRepository.getWorkflowsByOwner(auth.user.id); + + return Promise.all(workflows.map((workflow) => this.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); + } + + 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 trigger = this.getTriggerOrFail(workflow.triggerType); + + const { filters, actions, ...workflowUpdate } = dto; + const filterInserts = filters && (await this.validateAndMapFilters(filters, trigger.context)); + const actionInserts = actions && (await this.validateAndMapActions(actions, trigger.context)); + + const updatedWorkflow = await this.workflowRepository.updateWorkflow( + id, + workflowUpdate, + filterInserts, + actionInserts, + ); + + return this.mapWorkflow(updatedWorkflow); + } + + async delete(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.WorkflowDelete, ids: [id] }); + await this.workflowRepository.deleteWorkflow(id); + } + + private async validateAndMapFilters( + filters: Array<{ filterId: string; filterConfig?: any }>, + requiredContext: PluginContext, + ) { + for (const dto of filters) { + const filter = await this.pluginRepository.getFilter(dto.filterId); + if (!filter) { + throw new BadRequestException(`Invalid filter ID: ${dto.filterId}`); + } + + if (!filter.supportedContexts.includes(requiredContext)) { + throw new BadRequestException( + `Filter "${filter.title}" does not support ${requiredContext} context. Supported contexts: ${filter.supportedContexts.join(', ')}`, + ); + } + } + + return filters.map((dto, index) => ({ + filterId: dto.filterId, + filterConfig: dto.filterConfig || null, + order: index, + })); + } + + private async validateAndMapActions( + actions: Array<{ actionId: string; actionConfig?: any }>, + requiredContext: PluginContext, + ) { + for (const dto of actions) { + const action = await this.pluginRepository.getAction(dto.actionId); + if (!action) { + throw new BadRequestException(`Invalid action ID: ${dto.actionId}`); + } + 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) => ({ + actionId: dto.actionId, + actionConfig: dto.actionConfig || null, + order: index, + })); + } + + private getTriggerOrFail(triggerType: PluginTriggerType) { + const trigger = pluginTriggers.find((t) => t.type === triggerType); + if (!trigger) { + throw new BadRequestException(`Invalid trigger type: ${triggerType}`); + } + return trigger; + } + + private async findOrFail(id: string) { + const workflow = await this.workflowRepository.getWorkflow(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 afc72480e8..ad947e3774 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,5 +1,6 @@ import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; +import { Asset } from 'src/database'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { @@ -11,6 +12,7 @@ import { ImageFormat, JobName, MemoryType, + PluginTriggerType, QueueName, StorageFolder, SyncEntityType, @@ -263,6 +265,23 @@ 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 { + id: string; + type: T; + event: WorkflowData[T]; +} + export interface JobCounts { active: number; completed: number; @@ -374,7 +393,10 @@ export type JobItem = // OCR | { name: JobName.OcrQueueAll; data: IBaseJob } - | { name: JobName.Ocr; data: IEntityJob }; + | { name: JobName.Ocr; data: IEntityJob } + + // Workflow + | { name: JobName.WorkflowRun; data: IWorkflowJob }; export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number]; diff --git a/server/src/types/plugin-schema.types.ts b/server/src/types/plugin-schema.types.ts new file mode 100644 index 0000000000..793bb3c1ff --- /dev/null +++ b/server/src/types/plugin-schema.types.ts @@ -0,0 +1,35 @@ +/** + * 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/access.ts b/server/src/utils/access.ts index 7a0f701f74..f8d5f0ca08 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -298,6 +298,12 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return access.stack.checkOwnerAccess(auth.user.id, ids); } + case Permission.WorkflowRead: + case Permission.WorkflowUpdate: + case Permission.WorkflowDelete: { + return access.workflow.checkOwnerAccess(auth.user.id, ids); + } + default: { return new Set(); } diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 6167b6a6ff..efcdc59793 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -36,6 +36,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository import { OcrRepository } from 'src/repositories/ocr.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { SessionRepository } from 'src/repositories/session.repository'; import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository'; @@ -49,6 +50,7 @@ import { TagRepository } from 'src/repositories/tag.repository'; import { TelemetryRepository } from 'src/repositories/telemetry.repository'; import { UserRepository } from 'src/repositories/user.repository'; import { VersionHistoryRepository } from 'src/repositories/version-history.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { DB } from 'src/schema'; import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; @@ -380,6 +382,7 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case OcrRepository: case PartnerRepository: case PersonRepository: + case PluginRepository: case SearchRepository: case SessionRepository: case SharedLinkRepository: @@ -389,7 +392,8 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case SyncCheckpointRepository: case SystemMetadataRepository: case UserRepository: - case VersionHistoryRepository: { + case VersionHistoryRepository: + case WorkflowRepository: { return new key(db); } @@ -441,13 +445,15 @@ const newMockRepository = (key: ClassConstructor) => { case OcrRepository: case PartnerRepository: case PersonRepository: + case PluginRepository: case SessionRepository: case SyncRepository: case SyncCheckpointRepository: case SystemMetadataRepository: case UserRepository: case VersionHistoryRepository: - case TagRepository: { + case TagRepository: + case WorkflowRepository: { return automock(key); } diff --git a/server/test/medium/specs/services/plugin.service.spec.ts b/server/test/medium/specs/services/plugin.service.spec.ts new file mode 100644 index 0000000000..b70e8e8d54 --- /dev/null +++ b/server/test/medium/specs/services/plugin.service.spec.ts @@ -0,0 +1,308 @@ +import { Kysely } from 'kysely'; +import { PluginContext } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; +import { DB } from 'src/schema'; +import { PluginService } from 'src/services/plugin.service'; +import { newMediumService } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; +let pluginRepo: PluginRepository; + +const setup = (db?: Kysely) => { + return newMediumService(PluginService, { + database: db || defaultDatabase, + real: [PluginRepository, AccessRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); + pluginRepo = new PluginRepository(defaultDatabase); +}); + +afterEach(async () => { + await defaultDatabase.deleteFrom('plugin').execute(); +}); + +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([]); + }); + + it('should return plugin without filters and actions', async () => { + const { sut } = setup(); + + const result = await pluginRepo.loadPlugin( + { + name: 'test-plugin', + title: 'Test Plugin', + description: 'A test plugin', + author: 'Test Author', + version: '1.0.0', + wasm: { path: '/path/to/test.wasm' }, + }, + '/test/base/path', + ); + + const plugins = await sut.getAll(); + + expect(plugins).toHaveLength(1); + expect(plugins[0]).toMatchObject({ + id: result.plugin.id, + name: 'test-plugin', + description: 'A test plugin', + author: 'Test Author', + version: '1.0.0', + filters: [], + actions: [], + }); + }); + + it('should return plugin with filters and actions', async () => { + const { sut } = setup(); + + const result = await pluginRepo.loadPlugin( + { + name: 'full-plugin', + title: 'Full Plugin', + description: 'A plugin with filters and actions', + 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: {} }, + }, + ], + }, + '/test/base/path', + ); + + const plugins = await sut.getAll(); + + expect(plugins).toHaveLength(1); + expect(plugins[0]).toMatchObject({ + id: result.plugin.id, + name: 'full-plugin', + filters: [ + { + id: result.filters[0].id, + pluginId: result.plugin.id, + methodName: 'test-filter', + title: 'Test Filter', + description: 'A test filter', + supportedContexts: [PluginContext.Asset], + schema: { type: 'object', properties: {} }, + }, + ], + actions: [ + { + id: result.actions[0].id, + pluginId: result.plugin.id, + methodName: 'test-action', + title: 'Test Action', + description: 'A test action', + supportedContexts: [PluginContext.Asset], + schema: { type: 'object', properties: {} }, + }, + ], + }); + }); + + it('should return multiple plugins with their respective filters and actions', async () => { + const { sut } = setup(); + + await pluginRepo.loadPlugin( + { + 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, + }, + ], + }, + '/test/base/path', + ); + + await pluginRepo.loadPlugin( + { + 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, + }, + ], + }, + '/test/base/path', + ); + + const plugins = await sut.getAll(); + + 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[1].name).toBe('plugin-2'); + expect(plugins[1].filters).toHaveLength(0); + expect(plugins[1].actions).toHaveLength(1); + }); + + it('should handle plugin with multiple filters and actions', async () => { + const { sut } = setup(); + + await pluginRepo.loadPlugin( + { + name: 'multi-plugin', + title: 'Multi Plugin', + description: 'Plugin with multiple items', + 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, + }, + ], + }, + '/test/base/path', + ); + + const plugins = await sut.getAll(); + + expect(plugins).toHaveLength(1); + expect(plugins[0].filters).toHaveLength(2); + expect(plugins[0].actions).toHaveLength(2); + }); + }); + + describe('get', () => { + it('should throw error when plugin does not exist', async () => { + const { sut } = setup(); + + 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(); + + const result = await pluginRepo.loadPlugin( + { + 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, + }, + ], + }, + '/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', + title: 'Single Filter', + }, + ], + actions: [ + { + id: result.actions[0].id, + methodName: '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 new file mode 100644 index 0000000000..af12019ef6 --- /dev/null +++ b/server/test/medium/specs/services/workflow.service.spec.ts @@ -0,0 +1,697 @@ +import { Kysely } from 'kysely'; +import { PluginContext, PluginTriggerType } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; +import { DB } from 'src/schema'; +import { WorkflowService } from 'src/services/workflow.service'; +import { newMediumService } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(WorkflowService, { + database: db || defaultDatabase, + real: [WorkflowRepository, PluginRepository, AccessRepository], + mock: [LoggingRepository], + }); +}; + +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( + { + 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, + }, + ], + }, + '/plugins/test-core-plugin', + ); + + testPluginId = result.plugin.id; + testFilterId = result.filters[0].id; + testActionId = result.actions[0].id; + }); + + afterAll(async () => { + await defaultDatabase.deleteFrom('plugin').where('id', '=', testPluginId).execute(); + }); + + describe('create', () => { + it('should create a workflow without filters or actions', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.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, + name: 'test-workflow', + description: 'A test workflow', + enabled: true, + filters: [], + actions: [], + }); + }); + + it('should create a workflow with filters and actions', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow-with-relations', + description: 'A test workflow with filters and actions', + enabled: true, + filters: [ + { + filterId: testFilterId, + filterConfig: { key: 'value' }, + }, + ], + actions: [ + { + actionId: testActionId, + actionConfig: { action: 'test' }, + }, + ], + }); + + expect(workflow).toMatchObject({ + id: expect.any(String), + ownerId: user.id, + triggerType: PluginTriggerType.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, + filterId: testFilterId, + filterConfig: { key: 'value' }, + order: 0, + }); + + expect(workflow.actions).toHaveLength(1); + expect(workflow.actions[0]).toMatchObject({ + id: expect.any(String), + workflowId: workflow.id, + actionId: 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 = { user: { id: user.id } } as any; + + await expect( + sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'invalid-workflow', + description: 'A workflow with invalid filter', + enabled: true, + filters: [ + { + filterId: '66da82df-e424-4bf4-b6f3-5d8e71620dae', + 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 = { user: { id: user.id } } as any; + + await expect( + sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'invalid-workflow', + description: 'A workflow with invalid action', + enabled: true, + filters: [], + actions: [ + { + actionId: '66da82df-e424-4bf4-b6f3-5d8e71620dae', + 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 = { user: { id: user.id } } as any; + + // 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: [{ filterId: 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 = { user: { id: user.id } } as any; + + // 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: [{ actionId: 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 = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'multi-step-workflow', + description: 'A workflow with multiple filters and actions', + enabled: true, + filters: [ + { filterId: testFilterId, filterConfig: { step: 1 } }, + { filterId: testFilterId, filterConfig: { step: 2 } }, + ], + actions: [ + { actionId: testActionId, actionConfig: { step: 1 } }, + { actionId: testActionId, actionConfig: { step: 2 } }, + { actionId: 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); + }); + }); + + describe('getAll', () => { + it('should return all workflows for a user', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflow1 = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'workflow-1', + description: 'First workflow', + enabled: true, + filters: [], + actions: [], + }); + + const workflow2 = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'workflow-2', + description: 'Second workflow', + enabled: false, + filters: [], + actions: [], + }); + + const workflows = await sut.getAll(auth); + + expect(workflows).toHaveLength(2); + expect(workflows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: workflow1.id, name: 'workflow-1' }), + expect.objectContaining({ id: workflow2.id, name: 'workflow-2' }), + ]), + ); + }); + + it('should return empty array when user has no workflows', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + const workflows = await sut.getAll(auth); + + expect(workflows).toEqual([]); + }); + + it('should not return workflows from other users', async () => { + const { sut, ctx } = setup(); + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const auth1 = { user: { id: user1.id } } as any; + const auth2 = { user: { id: user2.id } } as any; + + await sut.create(auth1, { + triggerType: PluginTriggerType.AssetCreate, + name: 'user1-workflow', + description: 'User 1 workflow', + enabled: true, + filters: [], + actions: [], + }); + + const user2Workflows = await sut.getAll(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 = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'A test workflow', + enabled: true, + filters: [{ filterId: testFilterId, filterConfig: { key: 'value' } }], + actions: [{ actionId: 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 = { user: { id: user.id } } as any; + + 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 = { user: { id: user1.id } } as any; + const auth2 = { user: { id: user2.id } } as any; + + 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 = { user: { id: user.id } } as any; + + 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 = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [{ filterId: testFilterId, filterConfig: { old: 'config' } }], + actions: [], + }); + + const updated = await sut.update(auth, created.id, { + filters: [ + { filterId: testFilterId, filterConfig: { new: 'config' } }, + { filterId: 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 = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [], + actions: [{ actionId: testActionId, actionConfig: { old: 'config' } }], + }); + + const updated = await sut.update(auth, created.id, { + actions: [ + { actionId: testActionId, actionConfig: { new: 'config' } }, + { actionId: 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 = { user: { id: user.id } } as any; + + const created = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [{ filterId: 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 = { user: { id: user.id } } as any; + + 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 = { user: { id: user.id } } as any; + + await expect( + sut.update(auth, 'non-existent-id', { + 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 = { user: { id: user1.id } } as any; + const auth2 = { user: { id: user2.id } } as any; + + 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 = { user: { id: user.id } } as any; + + 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: [{ filterId: 'invalid-filter-id', filterConfig: {} }], + }), + ).rejects.toThrow(); + }); + + it('should throw error when updating with invalid action', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + 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: [{ actionId: 'invalid-action-id', actionConfig: {} }], + }), + ).rejects.toThrow(); + }); + }); + + describe('delete', () => { + it('should delete a workflow', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = { user: { id: user.id } } as any; + + 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 = { user: { id: user.id } } as any; + + const workflow = await sut.create(auth, { + triggerType: PluginTriggerType.AssetCreate, + name: 'test-workflow', + description: 'Test', + enabled: true, + filters: [{ filterId: testFilterId, filterConfig: {} }], + actions: [{ actionId: 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 = { user: { id: user.id } } as any; + + await expect(sut.delete(auth, 'non-existent-id')).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 = { user: { id: user1.id } } as any; + const auth2 = { user: { id: user2.id } } as any; + + 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/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 50db983cba..208b09c120 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -65,5 +65,9 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { tag: { checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), }, + + workflow: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, }; }; diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index e31e1a3348..656027fab5 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -72,6 +72,7 @@ const envData: EnvData = { root: '/build/www', indexHtml: '/build/www/index.html', }, + corePlugin: '/build/corePlugin', }, storage: { @@ -86,6 +87,11 @@ const envData: EnvData = { workers: [ImmichWorker.Api, ImmichWorker.Microservices], + plugins: { + enabled: true, + installFolder: '/app/data/plugins', + }, + noColor: false, }; diff --git a/server/test/repositories/crypto.repository.mock.ts b/server/test/repositories/crypto.repository.mock.ts index 1167923c0c..773891206e 100644 --- a/server/test/repositories/crypto.repository.mock.ts +++ b/server/test/repositories/crypto.repository.mock.ts @@ -13,5 +13,7 @@ export const newCryptoRepositoryMock = (): Mocked Buffer.from(`${input.toString()} (hashed)`)), hashFile: vitest.fn().mockImplementation((input) => `${input} (file-hashed)`), randomBytesAsText: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')), + signJwt: vitest.fn().mockReturnValue('mock-jwt-token'), + verifyJwt: vitest.fn().mockImplementation((token) => ({ verified: true, token })), }; }; diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 9752a39441..31451da82f 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -49,6 +49,7 @@ export const newStorageRepositoryMock = (): Mocked = T extends RepositoryInterface ? U : never; @@ -308,6 +312,7 @@ export const newTestService = ( oauth: automock(OAuthRepository, { args: [loggerMock] }), partner: automock(PartnerRepository, { strict: false }), person: automock(PersonRepository, { strict: false }), + plugin: automock(PluginRepository, { strict: true }), process: automock(ProcessRepository), search: automock(SearchRepository, { strict: false }), // eslint-disable-next-line no-sparse-arrays @@ -330,6 +335,7 @@ export const newTestService = ( view: automock(ViewRepository), // eslint-disable-next-line no-sparse-arrays websocket: automock(WebsocketRepository, { args: [, loggerMock], strict: false }), + workflow: automock(WorkflowRepository, { strict: true }), }; const sut = new Service( @@ -363,6 +369,7 @@ export const newTestService = ( overrides.ocr || (mocks.ocr as As), overrides.partner || (mocks.partner as As), overrides.person || (mocks.person as As), + overrides.plugin || (mocks.plugin as As), overrides.process || (mocks.process as As), overrides.search || (mocks.search as As), overrides.serverInfo || (mocks.serverInfo as As), @@ -381,6 +388,7 @@ export const newTestService = ( overrides.versionHistory || (mocks.versionHistory as As), overrides.view || (mocks.view as As), overrides.websocket || (mocks.websocket as As), + overrides.workflow || (mocks.workflow as As), ); return { diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 87f0d7e7bf..100f807273 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -162,6 +162,7 @@ export const getQueueName = derived(t, ($t) => { [QueueName.Notifications]: $t('notifications'), [QueueName.BackupDatabase]: $t('admin.backup_database'), [QueueName.Ocr]: $t('admin.machine_learning_ocr'), + [QueueName.Workflow]: $t('workflow'), }; return names[name];