diff --git a/i18n/en.json b/i18n/en.json index ce999793d4..f023a57f97 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -993,10 +993,12 @@ "unable_to_complete_oauth_login": "Unable to complete OAuth login", "unable_to_connect": "Unable to connect", "unable_to_copy_to_clipboard": "Cannot copy to clipboard, make sure you are accessing the page through https", + "unable_to_create": "Unable to create workflow", "unable_to_create_admin_account": "Unable to create admin account", "unable_to_create_api_key": "Unable to create a new API Key", "unable_to_create_library": "Unable to create library", "unable_to_create_user": "Unable to create user", + "unable_to_delete": "Unable to delete workflow", "unable_to_delete_album": "Unable to delete album", "unable_to_delete_asset": "Unable to delete asset", "unable_to_delete_assets": "Error deleting assets", @@ -1801,7 +1803,16 @@ "second": "Second", "see_all_people": "See all people", "select": "Select", + "select_album": "Select album", "select_album_cover": "Select album cover", + "select_albums": "Select albums", + "album_selected": "Album selected", + "albums_selected": "{count, plural, one {# album selected} other {# albums selected}}", + "select_person": "Select person", + "select_people": "Select people", + "person_selected": "Person selected", + "people_selected": "{count, plural, one {# person selected} other {# people selected}}", + "select_count": "{count, plural, one {Select #} other {Select #}}", "select_all": "Select all", "select_all_duplicates": "Select all duplicates", "select_all_in": "Select all in {group}", @@ -2182,6 +2193,11 @@ "welcome_to_immich": "Welcome to Immich", "wifi_name": "Wi-Fi Name", "workflow": "Workflow", + "workflow_deleted": "Workflow deleted", + "workflow_json": "Workflow JSON", + "workflow_json_help": "Edit the workflow configuration in JSON format. Changes will sync to the visual builder.", + "workflow_updated": "Workflow updated", + "workflows_help_text": "Workflows automate actions on your assets based on triggers and filters.", "wrong_pin_code": "Wrong PIN code", "year": "Year", "years_ago": "{years, plural, one {# year} other {# years}} ago", @@ -2189,5 +2205,30 @@ "you_dont_have_any_shared_links": "You don't have any shared links", "your_wifi_name": "Your Wi-Fi name", "zoom_image": "Zoom Image", - "zoom_to_bounds": "Zoom to bounds" + "zoom_to_bounds": "Zoom to bounds", + "add_action": "Add action", + "add_filter": "Add filter", + "array_field_not_fully_supported": "Array fields require manual JSON editing", + "asset_created": "Asset created", + "create_first_workflow": "Create first workflow", + "create_workflow": "Create workflow", + "edit_workflow": "Edit workflow", + "editor_mode": "Editor mode", + "hide_json": "Hide JSON", + "json_editor": "JSON editor", + "json_error": "JSON error", + "move_down": "Move down", + "move_up": "Move up", + "name_required": "Name is required", + "no_actions_added": "No actions added yet", + "no_configuration_needed": "No configuration needed", + "no_filters_added": "No filters added yet", + "no_workflows_yet": "No workflows yet", + "person_recognized": "Person recognized", + "show_json": "Show JSON", + "trigger_type": "Trigger type", + "unsupported_field_type": "Unsupported field type", + "untitled_workflow": "Untitled workflow", + "validation_error": "Validation error", + "visual_builder": "Visual builder" } diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4e34f66a81..42003ff578 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -196,6 +196,7 @@ Class | Method | HTTP request | Description *PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person *PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin *PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins +*PluginsApi* | [**getTriggers**](doc//PluginsApi.md#gettriggers) | **GET** /plugins/triggers | List all plugin triggers *SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | Retrieve assets by city *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | Retrieve explore data *SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | Retrieve search suggestions @@ -455,6 +456,7 @@ Class | Method | HTTP request | Description - [PluginContext](doc//PluginContext.md) - [PluginFilterResponseDto](doc//PluginFilterResponseDto.md) - [PluginResponseDto](doc//PluginResponseDto.md) + - [PluginTriggerResponseDto](doc//PluginTriggerResponseDto.md) - [PluginTriggerType](doc//PluginTriggerType.md) - [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseUpdate](doc//PurchaseUpdate.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index f3db370c92..fc2a0929a4 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -214,6 +214,7 @@ part 'model/plugin_action_response_dto.dart'; part 'model/plugin_context.dart'; part 'model/plugin_filter_response_dto.dart'; part 'model/plugin_response_dto.dart'; +part 'model/plugin_trigger_response_dto.dart'; part 'model/plugin_trigger_type.dart'; part 'model/purchase_response.dart'; part 'model/purchase_update.dart'; diff --git a/mobile/openapi/lib/api/plugins_api.dart b/mobile/openapi/lib/api/plugins_api.dart index 264d3049e8..1660bd8509 100644 --- a/mobile/openapi/lib/api/plugins_api.dart +++ b/mobile/openapi/lib/api/plugins_api.dart @@ -123,4 +123,55 @@ class PluginsApi { } return null; } + + /// List all plugin triggers + /// + /// Retrieve a list of all available plugin triggers. + /// + /// Note: This method returns the HTTP [Response]. + Future getTriggersWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/plugins/triggers'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// List all plugin triggers + /// + /// Retrieve a list of all available plugin triggers. + Future?> getTriggers() async { + final response = await getTriggersWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 91dc670d12..529fa2ceb8 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -480,6 +480,8 @@ class ApiClient { return PluginFilterResponseDto.fromJson(value); case 'PluginResponseDto': return PluginResponseDto.fromJson(value); + case 'PluginTriggerResponseDto': + return PluginTriggerResponseDto.fromJson(value); case 'PluginTriggerType': return PluginTriggerTypeTypeTransformer().decode(value); case 'PurchaseResponse': diff --git a/mobile/openapi/lib/model/plugin_trigger_response_dto.dart b/mobile/openapi/lib/model/plugin_trigger_response_dto.dart new file mode 100644 index 0000000000..4e155e1748 --- /dev/null +++ b/mobile/openapi/lib/model/plugin_trigger_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 PluginTriggerResponseDto { + /// Returns a new [PluginTriggerResponseDto] instance. + PluginTriggerResponseDto({ + required this.context, + required this.description, + required this.name, + required this.schema, + required this.triggerType, + }); + + PluginContext context; + + String description; + + String name; + + Object? schema; + + PluginTriggerType triggerType; + + @override + bool operator ==(Object other) => identical(this, other) || other is PluginTriggerResponseDto && + other.context == context && + other.description == description && + other.name == name && + other.schema == schema && + other.triggerType == triggerType; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (context.hashCode) + + (description.hashCode) + + (name.hashCode) + + (schema == null ? 0 : schema!.hashCode) + + (triggerType.hashCode); + + @override + String toString() => 'PluginTriggerResponseDto[context=$context, description=$description, name=$name, schema=$schema, triggerType=$triggerType]'; + + Map toJson() { + final json = {}; + json[r'context'] = this.context; + json[r'description'] = this.description; + json[r'name'] = this.name; + if (this.schema != null) { + json[r'schema'] = this.schema; + } else { + // json[r'schema'] = null; + } + json[r'triggerType'] = this.triggerType; + return json; + } + + /// Returns a new [PluginTriggerResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PluginTriggerResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PluginTriggerResponseDto"); + if (value is Map) { + final json = value.cast(); + + return PluginTriggerResponseDto( + context: PluginContext.fromJson(json[r'context'])!, + description: mapValueOfType(json, r'description')!, + name: mapValueOfType(json, r'name')!, + schema: mapValueOfType(json, r'schema'), + 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 = PluginTriggerResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PluginTriggerResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PluginTriggerResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PluginTriggerResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'context', + 'description', + 'name', + 'schema', + 'triggerType', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_response_dto.dart b/mobile/openapi/lib/model/workflow_response_dto.dart index 5132e7cb73..1ad36f300b 100644 --- a/mobile/openapi/lib/model/workflow_response_dto.dart +++ b/mobile/openapi/lib/model/workflow_response_dto.dart @@ -40,7 +40,7 @@ class WorkflowResponseDto { String ownerId; - WorkflowResponseDtoTriggerTypeEnum triggerType; + PluginTriggerType triggerType; @override bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto && @@ -105,7 +105,7 @@ class WorkflowResponseDto { id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name'), ownerId: mapValueOfType(json, r'ownerId')!, - triggerType: WorkflowResponseDtoTriggerTypeEnum.fromJson(json[r'triggerType'])!, + triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!, ); } return null; @@ -165,77 +165,3 @@ class WorkflowResponseDto { }; } - -class WorkflowResponseDtoTriggerTypeEnum { - /// Instantiate a new enum with the provided [value]. - const WorkflowResponseDtoTriggerTypeEnum._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const assetCreate = WorkflowResponseDtoTriggerTypeEnum._(r'AssetCreate'); - static const personRecognized = WorkflowResponseDtoTriggerTypeEnum._(r'PersonRecognized'); - - /// List of all possible values in this [enum][WorkflowResponseDtoTriggerTypeEnum]. - static const values = [ - 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/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d42aa0baa1..73ca4715da 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7914,6 +7914,55 @@ "x-immich-state": "Alpha" } }, + "/plugins/triggers": { + "get": { + "description": "Retrieve a list of all available plugin triggers.", + "operationId": "getTriggers", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PluginTriggerResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List all plugin triggers", + "tags": [ + "Plugins" + ], + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "plugin.read", + "x-immich-state": "Alpha" + } + }, "/plugins/{id}": { "get": { "description": "Retrieve information about a specific plugin by its ID.", @@ -17897,6 +17946,42 @@ ], "type": "object" }, + "PluginTriggerResponseDto": { + "properties": { + "context": { + "allOf": [ + { + "$ref": "#/components/schemas/PluginContext" + } + ] + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "schema": { + "nullable": true, + "type": "object" + }, + "triggerType": { + "allOf": [ + { + "$ref": "#/components/schemas/PluginTriggerType" + } + ] + } + }, + "required": [ + "context", + "description", + "name", + "schema", + "triggerType" + ], + "type": "object" + }, "PluginTriggerType": { "enum": [ "AssetCreate", @@ -22705,11 +22790,11 @@ "type": "string" }, "triggerType": { - "enum": [ - "AssetCreate", - "PersonRecognized" - ], - "type": "string" + "allOf": [ + { + "$ref": "#/components/schemas/PluginTriggerType" + } + ] } }, "required": [ diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 0664d26995..232e6aaf7a 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -957,6 +957,13 @@ export type PluginResponseDto = { updatedAt: string; version: string; }; +export type PluginTriggerResponseDto = { + context: PluginContext; + description: string; + name: string; + schema: object | null; + triggerType: PluginTriggerType; +}; export type SearchExploreItem = { data: AssetResponseDto; value: string; @@ -1722,7 +1729,7 @@ export type WorkflowResponseDto = { id: string; name: string | null; ownerId: string; - triggerType: TriggerType; + triggerType: PluginTriggerType; }; export type WorkflowActionItemDto = { actionConfig?: object; @@ -3601,6 +3608,17 @@ export function getPlugins(opts?: Oazapfts.RequestOpts) { ...opts })); } +/** + * List all plugin triggers + */ +export function getTriggers(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: PluginTriggerResponseDto[]; + }>("/plugins/triggers", { + ...opts + })); +} /** * Retrieve a plugin */ @@ -5288,6 +5306,10 @@ export enum PluginContext { Album = "album", Person = "person" } +export enum PluginTriggerType { + AssetCreate = "AssetCreate", + PersonRecognized = "PersonRecognized" +} export enum SearchSuggestionType { Country = "country", State = "state", @@ -5439,11 +5461,3 @@ export enum OAuthTokenEndpointAuthMethod { ClientSecretPost = "client_secret_post", ClientSecretBasic = "client_secret_basic" } -export enum TriggerType { - AssetCreate = "AssetCreate", - PersonRecognized = "PersonRecognized" -} -export enum PluginTriggerType { - AssetCreate = "AssetCreate", - PersonRecognized = "PersonRecognized" -} diff --git a/plugins/manifest.json b/plugins/manifest.json index 1172530c1e..4d2de275ca 100644 --- a/plugins/manifest.json +++ b/plugins/manifest.json @@ -1,30 +1,36 @@ { "name": "immich-core", - "version": "2.0.0", + "version": "2.0.1", "title": "Immich Core", "description": "Core workflow capabilities for Immich", "author": "Immich Team", - "wasm": { "path": "dist/plugin.wasm" }, - "filters": [ { "methodName": "filterFileName", "title": "Filter by filename", "description": "Filter assets by filename pattern using text matching or regular expressions", - "supportedContexts": ["asset"], + "supportedContexts": [ + "asset" + ], "schema": { "type": "object", "properties": { "pattern": { "type": "string", + "title": "Filename pattern", "description": "Text or regex pattern to match against filename" }, "matchType": { "type": "string", - "enum": ["contains", "regex", "exact"], + "title": "Match type", + "enum": [ + "contains", + "regex", + "exact" + ], "default": "contains", "description": "Type of pattern matching to perform" }, @@ -34,43 +40,57 @@ "description": "Whether matching should be case-sensitive" } }, - "required": ["pattern"] + "required": [ + "pattern" + ] } }, { "methodName": "filterFileType", "title": "Filter by file type", "description": "Filter assets by file type", - "supportedContexts": ["asset"], + "supportedContexts": [ + "asset" + ], "schema": { "type": "object", "properties": { "fileTypes": { "type": "array", + "title": "File types", "items": { "type": "string", - "enum": ["IMAGE", "VIDEO"] + "enum": [ + "image", + "video" + ] }, "description": "Allowed file types" } }, - "required": ["fileTypes"] + "required": [ + "fileTypes" + ] } }, { "methodName": "filterPerson", "title": "Filter by person", "description": "Filter by detected person", - "supportedContexts": ["person"], + "supportedContexts": [ + "person" + ], "schema": { "type": "object", "properties": { "personIds": { "type": "array", + "title": "Person IDs", "items": { "type": "string" }, - "description": "List of person to match" + "description": "List of person to match", + "subType": "people-picker" }, "matchAny": { "type": "boolean", @@ -78,24 +98,29 @@ "description": "Match any name (true) or require all names (false)" } }, - "required": ["personIds"] + "required": [ + "personIds" + ] } } ], - "actions": [ { "methodName": "actionArchive", "title": "Archive", "description": "Move the asset to archive", - "supportedContexts": ["asset"], + "supportedContexts": [ + "asset" + ], "schema": {} }, { "methodName": "actionFavorite", "title": "Favorite", "description": "Mark the asset as favorite or unfavorite", - "supportedContexts": ["asset"], + "supportedContexts": [ + "asset" + ], "schema": { "type": "object", "properties": { @@ -111,16 +136,23 @@ "methodName": "actionAddToAlbum", "title": "Add to Album", "description": "Add the item to a specified album", - "supportedContexts": ["asset", "person"], + "supportedContexts": [ + "asset", + "person" + ], "schema": { "type": "object", "properties": { "albumId": { "type": "string", - "description": "Target album ID" + "title": "Album ID", + "description": "Target album ID", + "subType": "album-picker" } }, - "required": ["albumId"] + "required": [ + "albumId" + ] } } ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0e4b5ea78..52eda1ff63 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -708,8 +708,8 @@ importers: specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.43.0 - version: 0.43.0(@internationalized/date@3.8.2)(svelte@5.43.0) + specifier: ^0.44.0 + version: 0.44.0(@internationalized/date@3.8.2)(svelte@5.43.0) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -2811,8 +2811,8 @@ packages: peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.43.0': - resolution: {integrity: sha512-dwWIURsGghsbeFnqxCqUyWslyRU2vQjih7uewNr0nsW68bJ5/esl+V/Kiw2opiNiwI4Q3HEcuTRY57k4Hq+X3Q==} + '@immich/ui@0.44.0': + resolution: {integrity: sha512-APlSvNdaHNyD20hncJH5d0TDS1eGMhn0XeTr01UdR02FJvlfBBIEHCug0LG57p3pWZecbow2JjtU2GBhNDQb6g==} peerDependencies: svelte: ^5.0.0 @@ -14429,7 +14429,7 @@ snapshots: dependencies: svelte: 5.43.0 - '@immich/ui@0.43.0(@internationalized/date@3.8.2)(svelte@5.43.0)': + '@immich/ui@0.44.0(@internationalized/date@3.8.2)(svelte@5.43.0)': dependencies: '@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.43.0) '@mdi/js': 7.4.47 diff --git a/server/src/controllers/plugin.controller.ts b/server/src/controllers/plugin.controller.ts index a0a4d14b0b..b1aebbbbf4 100644 --- a/server/src/controllers/plugin.controller.ts +++ b/server/src/controllers/plugin.controller.ts @@ -1,7 +1,7 @@ import { Controller, Get, Param } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; -import { PluginResponseDto } from 'src/dtos/plugin.dto'; +import { PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto'; import { Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { PluginService } from 'src/services/plugin.service'; @@ -12,6 +12,17 @@ import { UUIDParamDto } from 'src/validation'; export class PluginController { constructor(private service: PluginService) {} + @Get('triggers') + @Authenticated({ permission: Permission.PluginRead }) + @Endpoint({ + summary: 'List all plugin triggers', + description: 'Retrieve a list of all available plugin triggers.', + history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + }) + getTriggers(): PluginTriggerResponseDto[] { + return this.service.getTriggers(); + } + @Get() @Authenticated({ permission: Permission.PluginRead }) @Endpoint({ diff --git a/server/src/dtos/plugin.dto.ts b/server/src/dtos/plugin.dto.ts index ce80eccd65..bb9e91e5b5 100644 --- a/server/src/dtos/plugin.dto.ts +++ b/server/src/dtos/plugin.dto.ts @@ -1,9 +1,19 @@ import { IsNotEmpty, IsString } from 'class-validator'; import { PluginAction, PluginFilter } from 'src/database'; -import { PluginContext } from 'src/enum'; +import { PluginContext, PluginTriggerType } from 'src/enum'; import type { JSONSchema } from 'src/types/plugin-schema.types'; import { ValidateEnum } from 'src/validation'; +export class PluginTriggerResponseDto { + name!: string; + @ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' }) + triggerType!: PluginTriggerType; + description!: string; + @ValidateEnum({ enum: PluginContext, name: 'PluginContext' }) + context!: PluginContext; + schema!: JSONSchema | null; +} + export class PluginResponseDto { id!: string; name!: string; diff --git a/server/src/dtos/workflow.dto.ts b/server/src/dtos/workflow.dto.ts index 307440945d..20cceec997 100644 --- a/server/src/dtos/workflow.dto.ts +++ b/server/src/dtos/workflow.dto.ts @@ -74,6 +74,7 @@ export class WorkflowUpdateDto { export class WorkflowResponseDto { id!: string; ownerId!: string; + @ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' }) triggerType!: PluginTriggerType; name!: string | null; description!: string; diff --git a/server/src/plugins.ts b/server/src/plugins.ts index 0c69483696..fa5783ac9c 100644 --- a/server/src/plugins.ts +++ b/server/src/plugins.ts @@ -3,7 +3,7 @@ import { JSONSchema } from 'src/types/plugin-schema.types'; export type PluginTrigger = { name: string; - type: PluginTriggerType; + triggerType: PluginTriggerType; description: string; context: PluginContext; schema: JSONSchema | null; @@ -12,25 +12,15 @@ export type PluginTrigger = { export const pluginTriggers: PluginTrigger[] = [ { name: 'Asset Uploaded', - type: PluginTriggerType.AssetCreate, + triggerType: PluginTriggerType.AssetCreate, description: 'Triggered when a new asset is uploaded', context: PluginContext.Asset, - schema: { - type: 'object', - properties: { - assetType: { - type: 'string', - description: 'Type of the asset', - default: 'ALL', - enum: ['Image', 'Video', 'All'], - }, - }, - }, + schema: null, }, { name: 'Person Recognized', - type: PluginTriggerType.PersonRecognized, - description: 'Triggered when a person is detected in an asset', + triggerType: PluginTriggerType.PersonRecognized, + description: 'Triggered when a person is detected', context: PluginContext.Person, schema: null, }, diff --git a/server/src/services/plugin.service.ts b/server/src/services/plugin.service.ts index 28d1ac56ca..cd11b0779e 100644 --- a/server/src/services/plugin.service.ts +++ b/server/src/services/plugin.service.ts @@ -6,8 +6,9 @@ import { join } from 'node:path'; import { Asset, WorkflowAction, WorkflowFilter } from 'src/database'; import { OnEvent, OnJob } from 'src/decorators'; import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; -import { mapPlugin, PluginResponseDto } from 'src/dtos/plugin.dto'; +import { mapPlugin, PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto'; import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum'; +import { pluginTriggers } from 'src/plugins'; import { ArgOf } from 'src/repositories/event.repository'; import { BaseService } from 'src/services/base.service'; import { PluginHostFunctions } from 'src/services/plugin-host.functions'; @@ -50,6 +51,10 @@ export class PluginService extends BaseService { await this.loadPlugins(); } + getTriggers(): PluginTriggerResponseDto[] { + return pluginTriggers; + } + // // CRUD operations for plugins // @@ -111,12 +116,12 @@ export class PluginService extends BaseService { } private async loadPluginToDatabase(manifest: PluginManifestDto, basePath: string): Promise { - const currentPlugin = await this.pluginRepository.getPluginByName(manifest.name); - if (currentPlugin != null && currentPlugin.version === manifest.version) { - this.logger.log(`Plugin ${manifest.name} is up to date (version ${manifest.version}). Skipping`); - return; - } - + // const currentPlugin = await this.pluginRepository.getPluginByName(manifest.name); + // if (currentPlugin != null && currentPlugin.version === manifest.version) { + // this.logger.log(`Plugin ${manifest.name} is up to date (version ${manifest.version}). Skipping`); + // return; + // } + // const { plugin, filters, actions } = await this.pluginRepository.loadPlugin(manifest, basePath); this.logger.log(`Upserted plugin: ${plugin.name} (ID: ${plugin.id}, version: ${plugin.version})`); diff --git a/server/src/services/workflow.service.ts b/server/src/services/workflow.service.ts index ae72187d7d..afc51dcca3 100644 --- a/server/src/services/workflow.service.ts +++ b/server/src/services/workflow.service.ts @@ -125,7 +125,7 @@ export class WorkflowService extends BaseService { } private getTriggerOrFail(triggerType: PluginTriggerType) { - const trigger = pluginTriggers.find((t) => t.type === triggerType); + const trigger = pluginTriggers.find((t) => t.triggerType === triggerType); if (!trigger) { throw new BadRequestException(`Invalid trigger type: ${triggerType}`); } diff --git a/web/package.json b/web/package.json index 2a93230c24..6b4e59b909 100644 --- a/web/package.json +++ b/web/package.json @@ -28,7 +28,7 @@ "@formatjs/icu-messageformat-parser": "^2.9.8", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.43.0", + "@immich/ui": "^0.44.0", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.11.5", diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 7f40bf7a6d..2a58f84d28 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -60,12 +60,12 @@ {/if}
-
+
{@render children?.()}
{#if title || buttons} -
+
{#if title}
{title}
diff --git a/web/src/lib/components/utilities-page/utilities-menu.svelte b/web/src/lib/components/utilities-page/utilities-menu.svelte index bf7090e310..d8c9cf3880 100644 --- a/web/src/lib/components/utilities-page/utilities-menu.svelte +++ b/web/src/lib/components/utilities-page/utilities-menu.svelte @@ -4,6 +4,7 @@ import ObtainiumConfigModal from '$lib/modals/ObtainiumConfigModal.svelte'; import { Icon, modalManager } from '@immich/ui'; import { + mdiAutoFix, mdiCellphoneArrowDownVariant, mdiContentDuplicate, mdiCrosshairsGps, @@ -16,6 +17,7 @@ { href: AppRoute.DUPLICATES, icon: mdiContentDuplicate, label: $t('review_duplicates') }, { href: AppRoute.LARGE_FILES, icon: mdiImageSizeSelectLarge, label: $t('review_large_files') }, { href: AppRoute.GEOLOCATION, icon: mdiCrosshairsGps, label: $t('manage_geolocation') }, + { href: AppRoute.WORKFLOWS, icon: mdiAutoFix, label: $t('workflow') }, ]; diff --git a/web/src/lib/components/workflow/ActionBuilder.svelte b/web/src/lib/components/workflow/ActionBuilder.svelte new file mode 100644 index 0000000000..36e45f1bc1 --- /dev/null +++ b/web/src/lib/components/workflow/ActionBuilder.svelte @@ -0,0 +1,142 @@ + + +{#if actions.length === 0} +
+ {$t('no_actions_added')} +
+{:else} +
+ {#each actions as action, index (index)} + {@const actionDef = getActionById(action.actionId)} +
+
+
+ + + +
+
+ moveUp(index)} + disabled={index === 0} + size="small" + /> + moveDown(index)} + disabled={index === actions.length - 1} + size="small" + /> + removeAction(index)} + size="small" + /> +
+
+ + {#if actionDef} +
+ {actionDef.description} +
+ {#if actionDef.schema} + + {/if} + {/if} +
+ {/each} +
+{/if} + + diff --git a/web/src/lib/components/workflow/FilterBuilder.svelte b/web/src/lib/components/workflow/FilterBuilder.svelte new file mode 100644 index 0000000000..8748674557 --- /dev/null +++ b/web/src/lib/components/workflow/FilterBuilder.svelte @@ -0,0 +1,142 @@ + + +{#if filters.length === 0} +
+ {$t('no_filters_added')} +
+{:else} +
+ {#each filters as filter, index (index)} + {@const filterDef = getFilterById(filter.filterId)} +
+
+
+ + + +
+
+ moveUp(index)} + disabled={index === 0} + size="small" + /> + moveDown(index)} + disabled={index === filters.length - 1} + size="small" + /> + removeFilter(index)} + size="small" + /> +
+
+ + {#if filterDef} +
+ {filterDef.description} +
+ {#if filterDef.schema} + + {/if} + {/if} +
+ {/each} +
+{/if} + + diff --git a/web/src/lib/components/workflow/schema-form/SchemaFormFields.svelte b/web/src/lib/components/workflow/schema-form/SchemaFormFields.svelte new file mode 100644 index 0000000000..7d7c6d480e --- /dev/null +++ b/web/src/lib/components/workflow/schema-form/SchemaFormFields.svelte @@ -0,0 +1,326 @@ + + +{#snippet pickerItemCard( + item: AlbumResponseDto | PersonResponseDto, + isAlbum: boolean, + size: 'large' | 'small', + onRemove: () => void, +)} + {@const sizeClass = size === 'large' ? 'h-16 w-16' : 'h-12 w-12'} + {@const textSizeClass = size === 'large' ? 'font-medium' : 'font-medium text-sm'} + {@const iconSizeClass = size === 'large' ? 'h-5 w-5' : 'h-4 w-4'} + {@const countSizeClass = size === 'large' ? 'text-sm' : 'text-xs'} + +
+
+ {#if isAlbum && 'albumThumbnailAssetId' in item} + {#if item.albumThumbnailAssetId} + {item.albumName} + {:else} +
+ {/if} + {:else if !isAlbum && 'name' in item} + {item.name} + {/if} +
+
+

+ {isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''} +

+ {#if isAlbum && 'assetCount' in item} +

+ {$t('items_count', { values: { count: item.assetCount } })} +

+ {/if} +
+ +
+{/snippet} + +{#snippet pickerField( + subType: string, + key: string, + label: string, + component: { required?: boolean; description?: string }, + multiple: boolean, +)} + {@const picker = renderPicker(subType as 'album-picker' | 'people-picker', multiple)} + {@const metadata = pickerMetadata[key]} + {@const isAlbum = subType === 'album-picker'} + + +
+ {#if metadata && !Array.isArray(metadata)} + {@render pickerItemCard(metadata, isAlbum, 'large', () => removeSelection(key))} + {:else if metadata && Array.isArray(metadata) && metadata.length > 0} +
+ {#each metadata as item (item.id)} + {@render pickerItemCard(item, isAlbum, 'small', () => removeItemFromSelection(key, item.id))} + {/each} +
+ {/if} + +
+
+{/snippet} + +{#if components} +
+ {#each Object.entries(components) as [key, component] (key)} + {@const label = component.title || component.label || key} + +
+ + {#if component.type === 'select'} + {#if component.subType === 'album-picker' || component.subType === 'people-picker'} + {@render pickerField(component.subType, key, label, component, false)} + {:else} + {@const options = component.options?.map((opt) => { + return { label: opt.label, value: String(opt.value) }; + }) || [{ label: 'N/A', value: '' }]} + + + updateConfig(key, e.currentTarget.value)} + required={component.required} + /> + + {/if} +
+ {/each} +
+{:else} +

No configuration required

+{/if} diff --git a/web/src/lib/components/workflows/workflow-card-connector.svelte b/web/src/lib/components/workflows/workflow-card-connector.svelte new file mode 100644 index 0000000000..5ba116c8e3 --- /dev/null +++ b/web/src/lib/components/workflows/workflow-card-connector.svelte @@ -0,0 +1,43 @@ + + +
+
+
+ {#if animated} +
+ {/if} + +
+
+
+
+
+
+
+
+ + diff --git a/web/src/lib/components/workflows/workflow-trigger-card.svelte b/web/src/lib/components/workflows/workflow-trigger-card.svelte new file mode 100644 index 0000000000..76c3bde53a --- /dev/null +++ b/web/src/lib/components/workflows/workflow-trigger-card.svelte @@ -0,0 +1,51 @@ + + + diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 2075696b1a..807577e31e 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -55,6 +55,8 @@ export enum AppRoute { DUPLICATES = '/utilities/duplicates', LARGE_FILES = '/utilities/large-files', GEOLOCATION = '/utilities/geolocation', + WORKFLOWS = '/utilities/workflows', + WORKFLOWS_EDIT = '/utilities/workflows/edit', FOLDERS = '/folders', TAGS = '/tags', diff --git a/web/src/lib/constants/workflow-templates.ts b/web/src/lib/constants/workflow-templates.ts new file mode 100644 index 0000000000..e78e351bf5 --- /dev/null +++ b/web/src/lib/constants/workflow-templates.ts @@ -0,0 +1,52 @@ +// Preset workflow templates for common use cases +export interface WorkflowTemplate { + name: string; + description: string; + triggerType: 'AssetCreate' | 'PersonRecognized'; + enabled: boolean; + filters: Array<{ filterId: string; filterConfig?: object }>; + actions: Array<{ actionId: string; actionConfig?: object }>; +} + +// Note: These templates use placeholder filter/action IDs that need to be resolved +// from the actual plugin data at runtime +export const workflowTemplates = { + archiveOldPhotos: { + name: 'Archive Old Photos', + description: 'Automatically archive photos matching certain criteria', + triggerType: 'AssetCreate' as const, + enabled: false, + filters: [ + // This will need to be populated with actual filter IDs from plugins + ], + actions: [ + // This will need to be populated with actual action IDs from plugins + ], + }, + favoritePhotos: { + name: 'Auto-Favorite Photos', + description: 'Automatically mark photos as favorites based on criteria', + triggerType: 'AssetCreate' as const, + enabled: false, + filters: [], + actions: [], + }, + addToAlbum: { + name: 'Add to Album', + description: 'Automatically add assets to a specific album', + triggerType: 'AssetCreate' as const, + enabled: false, + filters: [], + actions: [], + }, + blank: { + name: 'New Workflow', + description: '', + triggerType: 'AssetCreate' as const, + enabled: true, + filters: [], + actions: [], + }, +}; + +export type WorkflowTemplateName = keyof typeof workflowTemplates; diff --git a/web/src/lib/modals/PeoplePickerModal.svelte b/web/src/lib/modals/PeoplePickerModal.svelte new file mode 100644 index 0000000000..d687fe7be5 --- /dev/null +++ b/web/src/lib/modals/PeoplePickerModal.svelte @@ -0,0 +1,99 @@ + + + + +
+ + +
+ {#if loading} +
+ +
+ {:else if filteredPeople.length > 0} +
+ {#each filteredPeople as person (person.id)} + {@const isSelected = selectedPeople.some((p) => p.id === person.id)} + + {/each} +
+ {:else} +

{$t('no_people_found')}

+ {/if} +
+
+
+ + {#if multiple && selectedPeople.length > 0} + + + + + + + {/if} +
diff --git a/web/src/lib/modals/WorkflowEditorModal.svelte b/web/src/lib/modals/WorkflowEditorModal.svelte new file mode 100644 index 0000000000..4e9584794b --- /dev/null +++ b/web/src/lib/modals/WorkflowEditorModal.svelte @@ -0,0 +1,217 @@ + + + + +
+ handleModeChange(mode as 'visual' | 'json')} + /> +
+ + {#if editorMode === 'visual'} +
{ + e.preventDefault(); + void handleSubmit(); + }} + class="mt-4 flex flex-col gap-4" + > + + + + + + + +

+ {$t('workflow_json_help')} +

+
+ {/if} + + + + + + + + + diff --git a/web/src/lib/utils/workflow.ts b/web/src/lib/utils/workflow.ts new file mode 100644 index 0000000000..ad379b5c82 --- /dev/null +++ b/web/src/lib/utils/workflow.ts @@ -0,0 +1,112 @@ +export type ComponentType = 'select' | 'multiselect' | 'text' | 'switch' | 'checkbox'; + +export interface ComponentConfig { + type: ComponentType; + label?: string; + description?: string; + defaultValue?: unknown; + required?: boolean; + options?: Array<{ label: string; value: string | number | boolean }>; + placeholder?: string; + subType?: string; + title?: string; +} + +interface JSONSchemaProperty { + type?: string; + description?: string; + default?: unknown; + enum?: unknown[]; + items?: JSONSchemaProperty; + subType?: string; + title?: string; +} + +interface JSONSchema { + type?: string; + properties?: Record; + required?: string[]; +} + +export const getComponentFromSchema = (schema: object | null): Record | null => { + if (!schema || !isJSONSchema(schema) || !schema.properties) { + return null; + } + + const components: Record = {}; + const requiredFields = schema.required || []; + + for (const [propertyName, property] of Object.entries(schema.properties)) { + const config = getComponentForProperty(property, propertyName); + if (config) { + config.required = requiredFields.includes(propertyName); + components[propertyName] = config; + } + } + + return Object.keys(components).length > 0 ? components : null; +}; + +function isJSONSchema(obj: object): obj is JSONSchema { + return 'properties' in obj || 'type' in obj; +} + +function getComponentForProperty(property: JSONSchemaProperty, propertyName: string): ComponentConfig | null { + const { type, title, enum: enumValues, description, default: defaultValue, items } = property; + + const config: ComponentConfig = { + type: 'text', + label: formatLabel(propertyName), + description, + defaultValue, + title, + }; + + if (enumValues && enumValues.length > 0) { + config.type = 'select'; + config.options = enumValues.map((value: unknown) => ({ + label: formatLabel(String(value)), + value: value as string | number | boolean, + })); + return config; + } + + if (type === 'array' && items?.enum && items.enum.length > 0) { + config.type = 'multiselect'; + config.subType = items.subType; + config.options = items.enum.map((value: unknown) => ({ + label: formatLabel(String(value)), + value: value as string | number | boolean, + })); + + return config; + } + + if (type === 'boolean') { + config.type = 'switch'; + return config; + } + + if (type === 'string') { + config.type = 'text'; + config.subType = property.subType; + config.placeholder = description; + return config; + } + + if (type === 'array') { + config.type = 'multiselect'; + config.subType = property.subType; + return config; + } + + return config; +} + +export function formatLabel(propertyName: string): string { + return propertyName + .replaceAll(/([A-Z])/g, ' $1') + .replaceAll('_', ' ') + .replace(/^./, (str) => str.toUpperCase()) + .trim(); +} diff --git a/web/src/routes/(user)/utilities/workflows/+page.svelte b/web/src/routes/(user)/utilities/workflows/+page.svelte new file mode 100644 index 0000000000..7e6d7424bc --- /dev/null +++ b/web/src/routes/(user)/utilities/workflows/+page.svelte @@ -0,0 +1,226 @@ + + + + {#snippet buttons()} + + + + {/snippet} + +
+
+ {#if workflows.length === 0} +
+ +

{$t('no_workflows_yet')}

+

+ {$t('workflows_help_text')} +

+ +
+ {:else} +
+ {#each workflows as workflow (workflow.id)} + + +
+
+
+ {workflow.name || $t('untitled_workflow')} + {#if workflow.description} +

{workflow.description}

+ {/if} +
+ + handleToggleEnabled(workflow)} + class={workflow.enabled ? 'text-green-500' : 'text-gray-400'} + /> + handleCopyWorkflow(workflow)} + /> + handleEditWorkflow(workflow)} + /> + handleDeleteWorkflow(workflow)} + /> + +
+ +
+ + {workflow.enabled ? $t('enabled') : $t('disabled')} + + + {getTriggerLabel(workflow.triggerType)} + + + {workflow.filters.length} + {workflow.filters.length === 1 ? $t('filter') : $t('filter')} + + + {workflow.actions.length} + {workflow.actions.length === 1 ? $t('action') : $t('actions')} + +
+ + + + {#if expandedWorkflows.has(workflow.id)} +
+
{JSON.stringify(
+                          workflow,
+                          null,
+                          2,
+                        )}
+
+ {/if} +
+
+
+ {/each} +
+ {/if} +
+
+
diff --git a/web/src/routes/(user)/utilities/workflows/+page.ts b/web/src/routes/(user)/utilities/workflows/+page.ts new file mode 100644 index 0000000000..88b402ceda --- /dev/null +++ b/web/src/routes/(user)/utilities/workflows/+page.ts @@ -0,0 +1,18 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getPlugins, getWorkflows } from '@immich/sdk'; +import type { PageLoad } from '../$types'; + +export const load = (async ({ url }) => { + await authenticate(url); + const [workflows, plugins] = await Promise.all([getWorkflows(), getPlugins()]); + const $t = await getFormatter(); + + return { + workflows, + plugins, + meta: { + title: $t('workflow'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/(user)/utilities/workflows/edit/[workflowId]/+page.svelte b/web/src/routes/(user)/utilities/workflows/edit/[workflowId]/+page.svelte new file mode 100644 index 0000000000..2b1ad7fe6b --- /dev/null +++ b/web/src/routes/(user)/utilities/workflows/edit/[workflowId]/+page.svelte @@ -0,0 +1,196 @@ + + + + {#snippet buttons()} + + + {editWorkflow.enabled ? 'ON' : 'OFF'} + + + + + {/snippet} + + + + + +
+ +
+ Basic information + Describing the workflow +
+
+
+ + + + + + + +