From 8682be477410b2346f09759f8ca592eb479b0433 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 26 May 2026 15:47:05 -0500 Subject: [PATCH] feat: workflow template (#28553) * wip: confirm before existing and disable/enable save button condition * fix: get correct workflow detail * wip: add back workflow summary * wip: add back json editor * wip: step property badge * wip: redesign card flow * wip: redesign card flow * redesign workflow summary * wworkflow summary styling * wip * drag and drop * list redesign * refactor * refactor * remove deadcode * refactor * insert steps * push down when dropped * feat: workflow template * simplify * move template to manifest * feat: hash manifest file * fix: template column * fix: migration * fix: workflow lookup * chore: clean up --------- Co-authored-by: Jason Rasmussen --- i18n/en.json | 3 + .../photo_view/src/core/photo_view_core.dart | 4 +- mobile/openapi/README.md | 3 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api/plugins_api.dart | 51 +++++++ mobile/openapi/lib/api_client.dart | 4 + .../model/plugin_template_response_dto.dart | 135 ++++++++++++++++++ .../plugin_template_step_response_dto.dart | 131 +++++++++++++++++ open-api/immich-openapi-specs.json | 102 +++++++++++++ packages/plugin-core/manifest.json | 30 ++++ packages/plugin-core/src/index.ts | 5 + packages/sdk/src/fetch-client.ts | 33 +++++ server/src/controllers/plugin.controller.ts | 12 ++ server/src/dtos/plugin-manifest.dto.ts | 28 +++- server/src/dtos/plugin.dto.ts | 51 ++++++- server/src/queries/plugin.repository.sql | 39 +++++ .../src/repositories/database.repository.ts | 1 + server/src/repositories/plugin.repository.ts | 8 ++ .../1779806699547-AddPluginTemplates.ts | 13 ++ server/src/schema/tables/plugin.table.ts | 7 + server/src/services/plugin.service.ts | 7 + .../services/workflow-execution.service.ts | 26 +++- server/src/utils/workflow.ts | 4 +- .../specs/services/plugin.service.spec.ts | 13 ++ .../specs/services/workflow.service.spec.ts | 3 + web/src/lib/managers/plugin-manager.svelte.ts | 14 +- .../lib/modals/WorkflowTemplatePicker.svelte | 66 +++++++++ web/src/lib/services/workflow.service.ts | 15 +- web/src/routes/(user)/workflows/+page.svelte | 4 +- 29 files changed, 794 insertions(+), 20 deletions(-) create mode 100644 mobile/openapi/lib/model/plugin_template_response_dto.dart create mode 100644 mobile/openapi/lib/model/plugin_template_step_response_dto.dart create mode 100644 server/src/schema/migrations/1779806699547-AddPluginTemplates.ts create mode 100644 web/src/lib/modals/WorkflowTemplatePicker.svelte diff --git a/i18n/en.json b/i18n/en.json index e73ac13e60..cb8cc9c3f9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -698,6 +698,7 @@ "birthdate_saved": "Date of birth saved successfully", "birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.", "blurred_background": "Blurred background", + "browse_templates": "Browse templates", "bugs_and_feature_requests": "Bugs & Feature Requests", "build": "Build", "build_image": "Build Image", @@ -2417,6 +2418,7 @@ "use_browser_locale_description": "Format dates, times, and numbers based on your browser locale", "use_current_connection": "Use current connection", "use_custom_date_range": "Use custom date range instead", + "use_template": "Use template", "user": "User", "user_has_been_deleted": "This user has been deleted.", "user_id": "User ID", @@ -2491,6 +2493,7 @@ "workflow_name": "Workflow name", "workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?", "workflow_summary": "Workflow summary", + "workflow_templates": "Workflow templates", "workflow_update_success": "Workflow updated successfully", "workflow_updated": "Workflow updated", "workflows": "Workflows", diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart index 5009ee5333..265feb756e 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart @@ -334,7 +334,9 @@ class PhotoViewCoreState extends State void _updateScaleBoundaries() { final prev = controller.scaleBoundaries; - if (prev == widget.scaleBoundaries) return; + if (prev == widget.scaleBoundaries) { + return; + } if (prev != null && controller.scale != null && prev.initialScale > 0) { final ratio = widget.scaleBoundaries.initialScale / prev.initialScale; diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index ecc75dd945..23987073dd 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -206,6 +206,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* | [**searchPluginMethods**](doc//PluginsApi.md#searchpluginmethods) | **GET** /plugins/methods | Retrieve plugin methods +*PluginsApi* | [**searchPluginTemplates**](doc//PluginsApi.md#searchplugintemplates) | **GET** /plugins/templates | Retrieve workflow templates *PluginsApi* | [**searchPlugins**](doc//PluginsApi.md#searchplugins) | **GET** /plugins | List all plugins *QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue *QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue @@ -491,6 +492,8 @@ Class | Method | HTTP request | Description - [PlacesResponseDto](doc//PlacesResponseDto.md) - [PluginMethodResponseDto](doc//PluginMethodResponseDto.md) - [PluginResponseDto](doc//PluginResponseDto.md) + - [PluginTemplateResponseDto](doc//PluginTemplateResponseDto.md) + - [PluginTemplateStepResponseDto](doc//PluginTemplateStepResponseDto.md) - [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseUpdate](doc//PurchaseUpdate.md) - [QueueCommand](doc//QueueCommand.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 1769c8af75..d5a6f483dc 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -237,6 +237,8 @@ part 'model/pin_code_setup_dto.dart'; part 'model/places_response_dto.dart'; part 'model/plugin_method_response_dto.dart'; part 'model/plugin_response_dto.dart'; +part 'model/plugin_template_response_dto.dart'; +part 'model/plugin_template_step_response_dto.dart'; part 'model/purchase_response.dart'; part 'model/purchase_update.dart'; part 'model/queue_command.dart'; diff --git a/mobile/openapi/lib/api/plugins_api.dart b/mobile/openapi/lib/api/plugins_api.dart index d2a1d386d9..a29f597dc4 100644 --- a/mobile/openapi/lib/api/plugins_api.dart +++ b/mobile/openapi/lib/api/plugins_api.dart @@ -204,6 +204,57 @@ class PluginsApi { return null; } + /// Retrieve workflow templates + /// + /// Retrieve workflow templates provided by installed plugins + /// + /// Note: This method returns the HTTP [Response]. + Future searchPluginTemplatesWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/plugins/templates'; + + // 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 workflow templates + /// + /// Retrieve workflow templates provided by installed plugins + Future?> searchPluginTemplates() async { + final response = await searchPluginTemplatesWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// List all plugins /// /// Retrieve a list of plugins available to the authenticated user. diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 103a5db5f4..a3c2369c1d 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -520,6 +520,10 @@ class ApiClient { return PluginMethodResponseDto.fromJson(value); case 'PluginResponseDto': return PluginResponseDto.fromJson(value); + case 'PluginTemplateResponseDto': + return PluginTemplateResponseDto.fromJson(value); + case 'PluginTemplateStepResponseDto': + return PluginTemplateStepResponseDto.fromJson(value); case 'PurchaseResponse': return PurchaseResponse.fromJson(value); case 'PurchaseUpdate': diff --git a/mobile/openapi/lib/model/plugin_template_response_dto.dart b/mobile/openapi/lib/model/plugin_template_response_dto.dart new file mode 100644 index 0000000000..4625da37d3 --- /dev/null +++ b/mobile/openapi/lib/model/plugin_template_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 PluginTemplateResponseDto { + /// Returns a new [PluginTemplateResponseDto] instance. + PluginTemplateResponseDto({ + required this.description, + required this.key, + this.steps = const [], + required this.title, + required this.trigger, + }); + + /// Template description + String description; + + /// Template key (unique across all templates) + String key; + + /// Workflow steps + List steps; + + /// Template title + String title; + + WorkflowTrigger trigger; + + @override + bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto && + other.description == description && + other.key == key && + _deepEquality.equals(other.steps, steps) && + other.title == title && + other.trigger == trigger; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (description.hashCode) + + (key.hashCode) + + (steps.hashCode) + + (title.hashCode) + + (trigger.hashCode); + + @override + String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger]'; + + Map toJson() { + final json = {}; + json[r'description'] = this.description; + json[r'key'] = this.key; + json[r'steps'] = this.steps; + json[r'title'] = this.title; + json[r'trigger'] = this.trigger; + return json; + } + + /// Returns a new [PluginTemplateResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PluginTemplateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PluginTemplateResponseDto"); + if (value is Map) { + final json = value.cast(); + + return PluginTemplateResponseDto( + description: mapValueOfType(json, r'description')!, + key: mapValueOfType(json, r'key')!, + steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']), + title: mapValueOfType(json, r'title')!, + trigger: WorkflowTrigger.fromJson(json[r'trigger'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PluginTemplateResponseDto.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 = PluginTemplateResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PluginTemplateResponseDto-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] = PluginTemplateResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'description', + 'key', + 'steps', + 'title', + 'trigger', + }; +} + diff --git a/mobile/openapi/lib/model/plugin_template_step_response_dto.dart b/mobile/openapi/lib/model/plugin_template_step_response_dto.dart new file mode 100644 index 0000000000..b58884d1cd --- /dev/null +++ b/mobile/openapi/lib/model/plugin_template_step_response_dto.dart @@ -0,0 +1,131 @@ +// +// 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 PluginTemplateStepResponseDto { + /// Returns a new [PluginTemplateStepResponseDto] instance. + PluginTemplateStepResponseDto({ + this.config = const {}, + this.enabled, + required this.method, + }); + + /// Step configuration + Map? config; + + /// Whether the step is enabled + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? enabled; + + /// Step plugin method + String method; + + @override + bool operator ==(Object other) => identical(this, other) || other is PluginTemplateStepResponseDto && + _deepEquality.equals(other.config, config) && + other.enabled == enabled && + other.method == method; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (config == null ? 0 : config!.hashCode) + + (enabled == null ? 0 : enabled!.hashCode) + + (method.hashCode); + + @override + String toString() => 'PluginTemplateStepResponseDto[config=$config, enabled=$enabled, method=$method]'; + + Map toJson() { + final json = {}; + if (this.config != null) { + json[r'config'] = this.config; + } else { + // json[r'config'] = null; + } + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + json[r'method'] = this.method; + return json; + } + + /// Returns a new [PluginTemplateStepResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PluginTemplateStepResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PluginTemplateStepResponseDto"); + if (value is Map) { + final json = value.cast(); + + return PluginTemplateStepResponseDto( + config: mapCastOfType(json, r'config'), + enabled: mapValueOfType(json, r'enabled'), + method: mapValueOfType(json, r'method')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PluginTemplateStepResponseDto.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 = PluginTemplateStepResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PluginTemplateStepResponseDto-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] = PluginTemplateStepResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'config', + 'method', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 66d85ad373..357d76824a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8818,6 +8818,50 @@ "x-immich-permission": "plugin.read" } }, + "/plugins/templates": { + "get": { + "description": "Retrieve workflow templates provided by installed plugins", + "operationId": "searchPluginTemplates", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PluginTemplateResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve workflow templates", + "tags": [ + "Plugins" + ], + "x-immich-history": [ + { + "version": "v3.0.0", + "state": "Added" + } + ], + "x-immich-permission": "plugin.read" + } + }, "/plugins/{id}": { "get": { "description": "Retrieve information about a specific plugin by its ID.", @@ -20131,6 +20175,64 @@ ], "type": "object" }, + "PluginTemplateResponseDto": { + "properties": { + "description": { + "description": "Template description", + "type": "string" + }, + "key": { + "description": "Template key (unique across all templates)", + "type": "string" + }, + "steps": { + "description": "Workflow steps", + "items": { + "$ref": "#/components/schemas/PluginTemplateStepResponseDto" + }, + "type": "array" + }, + "title": { + "description": "Template title", + "type": "string" + }, + "trigger": { + "$ref": "#/components/schemas/WorkflowTrigger", + "description": "Workflow trigger" + } + }, + "required": [ + "description", + "key", + "steps", + "title", + "trigger" + ], + "type": "object" + }, + "PluginTemplateStepResponseDto": { + "properties": { + "config": { + "additionalProperties": {}, + "description": "Step configuration", + "nullable": true, + "type": "object" + }, + "enabled": { + "description": "Whether the step is enabled", + "type": "boolean" + }, + "method": { + "description": "Step plugin method", + "type": "string" + } + }, + "required": [ + "config", + "method" + ], + "type": "object" + }, "PurchaseResponse": { "properties": { "hideBuyButtonUntil": { diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json index 1dc6d21409..3111678862 100644 --- a/packages/plugin-core/manifest.json +++ b/packages/plugin-core/manifest.json @@ -5,6 +5,36 @@ "description": "Core workflow capabilities for Immich", "author": "Immich Team", "wasmPath": "dist/plugin.wasm", + "templates": [ + { + "name": "auto-archive-screenshots", + "title": "Auto-archive screenshots", + "description": "Archive uploads with \"screenshot\" in the filename and optionally add them to an album", + "trigger": "AssetCreate", + "steps": [ + { + "method": "immich-plugin-core#assetFileFilter", + "config": { + "pattern": "screenshot", + "matchType": "contains", + "caseSensitive": false + } + }, + { + "method": "immich-plugin-core#assetAddToAlbums", + "config": { + "albumIds": [] + } + }, + { + "method": "immich-plugin-core#assetArchive", + "config": { + "inverse": false + } + } + ] + } + ], "methods": [ { "name": "assetFileFilter", diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index de54e52bb0..85a4a449e7 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -100,6 +100,11 @@ export const assetTrash = () => { export const assetAddToAlbums = () => { return wrapper(({ config, data, functions }) => { + if (config.albumIds.length === 0) { + // noop + return {}; + } + if (config.albumIds.length === 1) { functions.albumAddAssets(config.albumIds[0], [data.asset.id]); return {}; diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index e82074d02c..22fa8100d1 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -1514,6 +1514,28 @@ export type PluginResponseDto = { /** Plugin version */ version: string; }; +export type PluginTemplateStepResponseDto = { + /** Step configuration */ + config: { + [key: string]: any; + } | null; + /** Whether the step is enabled */ + enabled?: boolean; + /** Step plugin method */ + method: string; +}; +export type PluginTemplateResponseDto = { + /** Template description */ + description: string; + /** Template key (unique across all templates) */ + key: string; + /** Workflow steps */ + steps: PluginTemplateStepResponseDto[]; + /** Template title */ + title: string; + /** Workflow trigger */ + trigger: WorkflowTrigger; +}; export type QueueResponseDto = { /** Whether the queue is paused */ isPaused: boolean; @@ -5242,6 +5264,17 @@ export function searchPluginMethods({ description, enabled, id, name, pluginName ...opts })); } +/** + * Retrieve workflow templates + */ +export function searchPluginTemplates(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: PluginTemplateResponseDto[]; + }>("/plugins/templates", { + ...opts + })); +} /** * Retrieve a plugin */ diff --git a/server/src/controllers/plugin.controller.ts b/server/src/controllers/plugin.controller.ts index fb1bda287e..10eb4ba514 100644 --- a/server/src/controllers/plugin.controller.ts +++ b/server/src/controllers/plugin.controller.ts @@ -6,6 +6,7 @@ import { PluginMethodSearchDto, PluginResponseDto, PluginSearchDto, + PluginTemplateResponseDto, } from 'src/dtos/plugin.dto'; import { Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; @@ -39,6 +40,17 @@ export class PluginController { return this.service.searchMethods(dto); } + @Get('templates') + @Authenticated({ permission: Permission.PluginRead }) + @Endpoint({ + summary: 'Retrieve workflow templates', + description: 'Retrieve workflow templates provided by installed plugins', + history: HistoryBuilder.v3(), + }) + searchPluginTemplates(): Promise { + return this.service.searchTemplates(); + } + @Get(':id') @Authenticated({ permission: Permission.PluginRead }) @Endpoint({ diff --git a/server/src/dtos/plugin-manifest.dto.ts b/server/src/dtos/plugin-manifest.dto.ts index 4d004a375d..c8f043fde1 100644 --- a/server/src/dtos/plugin-manifest.dto.ts +++ b/server/src/dtos/plugin-manifest.dto.ts @@ -1,6 +1,6 @@ import { createZodDto } from 'nestjs-zod'; import { JsonSchemaSchema } from 'src/dtos/json-schema.dto'; -import { WorkflowTypeSchema } from 'src/enum'; +import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum'; import z from 'zod'; const pluginNameRegex = /^[a-z0-9-]+[a-z0-9]$/; @@ -23,6 +23,24 @@ const PluginManifestMethodSchema = z }) .meta({ id: 'PluginManifestMethodDto' }); +const PluginManifestTemplateStepSchema = z + .object({ + method: z.string().min(1).describe('Step plugin method (pluginName#methodName)'), + config: z.record(z.string(), z.unknown()).nullable().optional().describe('Step configuration'), + enabled: z.boolean().optional().describe('Whether the step is enabled'), + }) + .meta({ id: 'PluginManifestTemplateStepDto' }); + +const PluginManifestTemplateSchema = z + .object({ + name: z.string().min(1).describe('Template name (must be unique within the manifest)'), + title: z.string().min(1).describe('Template title'), + description: z.string().min(1).describe('Template description'), + trigger: WorkflowTriggerSchema.describe('Workflow trigger'), + steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'), + }) + .meta({ id: 'PluginManifestTemplateDto' }); + const PluginManifestSchema = z .object({ name: z @@ -39,6 +57,14 @@ const PluginManifestSchema = z wasmPath: z.string().min(1).describe('WASM file path'), author: z.string().min(1).describe('Plugin author'), methods: z.array(PluginManifestMethodSchema).optional().default([]).describe('Plugin methods'), + templates: z + .array(PluginManifestTemplateSchema) + .optional() + .default([]) + .refine((templates) => new Set(templates.map((t) => t.name)).size === templates.length, { + error: 'Template names must be unique within the manifest', + }) + .describe('Workflow templates'), }) .meta({ id: 'PluginManifestDto' }); diff --git a/server/src/dtos/plugin.dto.ts b/server/src/dtos/plugin.dto.ts index 410ada4b53..074321bb44 100644 --- a/server/src/dtos/plugin.dto.ts +++ b/server/src/dtos/plugin.dto.ts @@ -1,7 +1,7 @@ import { createZodDto } from 'nestjs-zod'; import { JsonSchemaDto } from 'src/dtos/json-schema.dto'; -import { WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum'; -import { asMethodString } from 'src/utils/workflow'; +import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum'; +import { asPluginKey } from 'src/utils/workflow'; import z from 'zod'; const PluginSearchSchema = z @@ -43,6 +43,24 @@ const PluginResponseSchema = z }) .meta({ id: 'PluginResponseDto' }); +const PluginTemplateStepResponseSchema = z + .object({ + method: z.string().describe('Step plugin method'), + config: z.record(z.string(), z.unknown()).nullable().describe('Step configuration'), + enabled: z.boolean().optional().describe('Whether the step is enabled'), + }) + .meta({ id: 'PluginTemplateStepResponseDto' }); + +const PluginTemplateResponseSchema = z + .object({ + key: z.string().describe('Template key (unique across all templates)'), + title: z.string().describe('Template title'), + description: z.string().describe('Template description'), + trigger: WorkflowTriggerSchema.describe('Workflow trigger'), + steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'), + }) + .meta({ id: 'PluginTemplateResponseDto' }); + const PluginMethodSearchSchema = z .object({ id: z.uuidv4().optional().describe('Plugin method ID'), @@ -61,6 +79,33 @@ export class PluginSearchDto extends createZodDto(PluginSearchSchema) {} export class PluginResponseDto extends createZodDto(PluginResponseSchema) {} export class PluginMethodSearchDto extends createZodDto(PluginMethodSearchSchema) {} export class PluginMethodResponseDto extends createZodDto(PluginMethodResponseSchema) {} +export class PluginTemplateResponseDto extends createZodDto(PluginTemplateResponseSchema) {} + +export type PluginTemplate = { + name: string; + title: string; + description: string; + trigger: WorkflowTrigger; + steps: Array<{ + method: string; + config?: Record | null; + enabled?: boolean; + }>; +}; + +export const mapTemplate = (plugin: { name: string }, template: PluginTemplate): PluginTemplateResponseDto => { + return { + key: asPluginKey({ pluginName: plugin.name, name: template.name }), + title: template.title, + description: template.description, + trigger: template.trigger, + steps: template.steps.map((step) => ({ + method: step.method, + config: step.config ?? null, + enabled: step.enabled, + })), + }; +}; type Plugin = { id: string; @@ -101,7 +146,7 @@ export function mapPlugin(plugin: Plugin): PluginResponseDto { export const mapMethod = (method: PluginMethod): PluginMethodResponseDto => { return { - key: asMethodString({ pluginName: method.pluginName, methodName: method.name }), + key: asPluginKey({ pluginName: method.pluginName, name: method.name }), name: method.name, title: method.title, hostFunctions: method.hostFunctions, diff --git a/server/src/queries/plugin.repository.sql b/server/src/queries/plugin.repository.sql index 2c920b7ab0..824602ba86 100644 --- a/server/src/queries/plugin.repository.sql +++ b/server/src/queries/plugin.repository.sql @@ -35,6 +35,7 @@ select "plugin"."version", "plugin"."createdAt", "plugin"."updatedAt", + "plugin"."templates", ( select coalesce(json_agg(agg), '[]') @@ -60,6 +61,42 @@ from order by "plugin"."name" +-- PluginRepository.getByHash +select + "plugin"."id", + "plugin"."name", + "plugin"."title", + "plugin"."description", + "plugin"."author", + "plugin"."version", + "plugin"."createdAt", + "plugin"."updatedAt", + "plugin"."templates", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "plugin_method"."name", + "plugin_method"."title", + "plugin_method"."description", + "plugin_method"."types", + "plugin_method"."schema", + "plugin_method"."hostFunctions", + "plugin_method"."uiHints", + "plugin"."name" as "pluginName" + from + "plugin_method" + where + "plugin_method"."pluginId" = "plugin"."id" + ) as agg + ) as "methods" +from + "plugin" +where + "plugin"."sha256hash" = $1 + -- PluginRepository.getByName select "plugin"."id", @@ -70,6 +107,7 @@ select "plugin"."version", "plugin"."createdAt", "plugin"."updatedAt", + "plugin"."templates", ( select coalesce(json_agg(agg), '[]') @@ -105,6 +143,7 @@ select "plugin"."version", "plugin"."createdAt", "plugin"."updatedAt", + "plugin"."templates", ( select coalesce(json_agg(agg), '[]') diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index a86e929ef4..df69e85d84 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -274,6 +274,7 @@ export class DatabaseRepository { columns: { ignoreExtra: true }, functions: { ignoreExtra: false }, parameters: { ignoreExtra: true }, + extensions: { ignoreExtra: true }, }); return drift; diff --git a/server/src/repositories/plugin.repository.ts b/server/src/repositories/plugin.repository.ts index a07f433541..43006c6aa6 100644 --- a/server/src/repositories/plugin.repository.ts +++ b/server/src/repositories/plugin.repository.ts @@ -81,6 +81,7 @@ export class PluginRepository { 'plugin.version', 'plugin.createdAt', 'plugin.updatedAt', + 'plugin.templates', jsonArrayFrom( eb .selectFrom('plugin_method') @@ -102,6 +103,11 @@ export class PluginRepository { .execute(); } + @GenerateSql({ params: [DummyValue.STRING] }) + getByHash(hash: Buffer) { + return this.queryBuilder().where('plugin.sha256hash', '=', hash).executeTakeFirst(); + } + @GenerateSql({ params: [DummyValue.STRING] }) getByName(name: string) { return this.queryBuilder().where('plugin.name', '=', name).executeTakeFirst(); @@ -151,6 +157,8 @@ export class PluginRepository { author: eb.ref('excluded.author'), version: eb.ref('excluded.version'), wasmBytes: eb.ref('excluded.wasmBytes'), + templates: eb.ref('excluded.templates'), + sha256hash: eb.ref('excluded.sha256hash'), })), ) .returning(['id', 'name']) diff --git a/server/src/schema/migrations/1779806699547-AddPluginTemplates.ts b/server/src/schema/migrations/1779806699547-AddPluginTemplates.ts new file mode 100644 index 0000000000..9e0e75a443 --- /dev/null +++ b/server/src/schema/migrations/1779806699547-AddPluginTemplates.ts @@ -0,0 +1,13 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "plugin" ADD "templates" jsonb NOT NULL DEFAULT '[]';`.execute(db); + await sql`ALTER TABLE "plugin" ADD "sha256hash" bytea NOT NULL DEFAULT decode('20464b37ad726d03d878d38d873c40a52d1fdfb754feda956ebb464afd689e2f', 'hex');`.execute(db); + await sql`ALTER TABLE "plugin" ALTER COLUMN "sha256hash" DROP DEFAULT;`.execute(db); + await sql`ALTER TABLE "plugin" ALTER COLUMN "templates" DROP DEFAULT;`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "plugin" DROP COLUMN "templates";`.execute(db); + await sql`ALTER TABLE "plugin" DROP COLUMN "sha256hash";`.execute(db); +} diff --git a/server/src/schema/tables/plugin.table.ts b/server/src/schema/tables/plugin.table.ts index 99763a4d43..5de5a329d1 100644 --- a/server/src/schema/tables/plugin.table.ts +++ b/server/src/schema/tables/plugin.table.ts @@ -8,6 +8,7 @@ import { Unique, UpdateDateColumn, } from '@immich/sql-tools'; +import { PluginTemplate } from 'src/dtos/plugin.dto'; @Unique({ columns: ['name', 'version'] }) @Table('plugin') @@ -36,6 +37,12 @@ export class PluginTable { @Column({ type: 'bytea' }) wasmBytes!: Buffer; + @Column({ type: 'jsonb' }) + templates!: PluginTemplate[]; + + @Column({ type: 'bytea' }) + sha256hash!: Buffer; + @CreateDateColumn() createdAt!: Generated; diff --git a/server/src/services/plugin.service.ts b/server/src/services/plugin.service.ts index 62edf8438b..f9c4d0e2b8 100644 --- a/server/src/services/plugin.service.ts +++ b/server/src/services/plugin.service.ts @@ -2,10 +2,12 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { mapMethod, mapPlugin, + mapTemplate, PluginMethodResponseDto, PluginMethodSearchDto, PluginResponseDto, PluginSearchDto, + PluginTemplateResponseDto, } from 'src/dtos/plugin.dto'; import { BaseService } from 'src/services/base.service'; import { isMethodCompatible } from 'src/utils/workflow'; @@ -31,4 +33,9 @@ export class PluginService extends BaseService { .filter((method) => !dto.trigger || isMethodCompatible(method, dto.trigger)) .map((method) => mapMethod(method)); } + + async searchTemplates(): Promise { + const plugins = await this.pluginRepository.search(); + return plugins.flatMap((plugin) => plugin.templates.map((template) => mapTemplate(plugin, template))); + } } diff --git a/server/src/services/workflow-execution.service.ts b/server/src/services/workflow-execution.service.ts index b2e106a251..a1ecf4526d 100644 --- a/server/src/services/workflow-execution.service.ts +++ b/server/src/services/workflow-execution.service.ts @@ -11,6 +11,7 @@ import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; import { BootstrapEventPriority, DatabaseLock, + ImmichEnvironment, ImmichWorker, JobName, JobStatus, @@ -43,8 +44,8 @@ export class WorkflowExecutionService extends BaseService { // TODO avoid importing plugins in each worker // Can this use system metadata similar to geocoding? - const { resourcePaths, plugins } = this.configRepository.getEnv(); - await this.importFolder(resourcePaths.corePlugin, { force: true }); + const { environment, resourcePaths, plugins } = this.configRepository.getEnv(); + await this.importFolder(resourcePaths.corePlugin, { force: environment === ImmichEnvironment.Development }); if (plugins.external.allow && plugins.external.installFolder) { await this.importFolders(plugins.external.installFolder); @@ -166,7 +167,19 @@ export class WorkflowExecutionService extends BaseService { private async importFolder(folder: string, options?: { force?: boolean }) { try { const manifestPath = join(folder, 'manifest.json'); - const dto = await this.storageRepository.readJsonFile(manifestPath); + const bytes = await this.storageRepository.readFile(manifestPath); + const contents = bytes.toString('utf8'); + const sha256hash = this.cryptoRepository.hashSha256(contents) as Buffer; + + if (!options?.force) { + const match = await this.pluginRepository.getByHash(sha256hash); + if (match) { + this.logger.log(`Plugin up to date (name=${match.name}@${match.version}, hash=${sha256hash.toString('hex')}`); + return; + } + } + + const dto = JSON.parse(contents); const result = PluginManifestDto.schema.safeParse(dto); if (!result.success) { const issues = result.error.issues.map((issue) => ` - [${issue.path.join('.')}] ${issue.message}`).join('\n'); @@ -176,22 +189,21 @@ export class WorkflowExecutionService extends BaseService { const manifest = result.data; const existing = await this.pluginRepository.getByName(manifest.name); - if (existing && existing.version === manifest.version && options?.force !== true) { - return; - } - const wasmPath = `${folder}/${manifest.wasmPath}`; const wasmBytes = await this.storageRepository.readFile(wasmPath); const plugin = await this.pluginRepository.upsert( { + // NOTE: new properties here need to be added to the on conflict clause in the repository enabled: true, name: manifest.name, title: manifest.title, description: manifest.description, author: manifest.author, version: manifest.version, + templates: manifest.templates, wasmBytes, + sha256hash, }, manifest.methods, ); diff --git a/server/src/utils/workflow.ts b/server/src/utils/workflow.ts index 5803fca342..879fe4c608 100644 --- a/server/src/utils/workflow.ts +++ b/server/src/utils/workflow.ts @@ -50,8 +50,8 @@ export const resolveMethod = (methods: PluginMethodSearchResponse[], method: str return methods.find((method) => method.pluginName === pluginName && method.name === methodName); }; -export const asMethodString = (method: { pluginName: string; methodName: string }) => { - return `${method.pluginName}#${method.methodName}`; +export const asPluginKey = (method: { pluginName: string; name: string }) => { + return `${method.pluginName}#${method.name}`; }; const METHOD_REGEX = /^(?[^@#\s]+)(?:@(?[^#\s]*))?#(?[^@#\s]+)$/; diff --git a/server/test/medium/specs/services/plugin.service.spec.ts b/server/test/medium/specs/services/plugin.service.spec.ts index 88254db17e..de4882a575 100644 --- a/server/test/medium/specs/services/plugin.service.spec.ts +++ b/server/test/medium/specs/services/plugin.service.spec.ts @@ -11,6 +11,7 @@ import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; const wasmBytes = Buffer.from('some-wasm-binary-data'); +const sha256hash = Buffer.from('some-manifest-hash'); const setup = (db?: Kysely) => { return newMediumService(PluginService, { @@ -46,7 +47,9 @@ describe(PluginService.name, () => { description: 'A test plugin', author: 'Test Author', version: '1.0.0', + templates: [], wasmBytes, + sha256hash, }, [], ); @@ -75,7 +78,9 @@ describe(PluginService.name, () => { description: 'A plugin with multiple methods', author: 'Test Author', version: '1.0.0', + templates: [], wasmBytes, + sha256hash, }, [ { @@ -130,7 +135,9 @@ describe(PluginService.name, () => { description: 'First plugin', author: 'Author 1', version: '1.0.0', + templates: [], wasmBytes, + sha256hash, }, [ { @@ -150,7 +157,9 @@ describe(PluginService.name, () => { description: 'Second plugin', author: 'Author 2', version: '2.0.0', + templates: [], wasmBytes, + sha256hash, }, [ { @@ -183,7 +192,9 @@ describe(PluginService.name, () => { description: 'Plugin with multiple methods', author: 'Test Author', version: '1.0.0', + templates: [], wasmBytes, + sha256hash, }, [ { @@ -242,6 +253,8 @@ describe(PluginService.name, () => { description: 'A single plugin', author: 'Test Author', version: '1.0.0', + templates: [], + sha256hash, wasmBytes, }, [ diff --git a/server/test/medium/specs/services/workflow.service.spec.ts b/server/test/medium/specs/services/workflow.service.spec.ts index b16b359b85..07301581cb 100644 --- a/server/test/medium/specs/services/workflow.service.spec.ts +++ b/server/test/medium/specs/services/workflow.service.spec.ts @@ -21,6 +21,7 @@ const setup = (db?: Kysely) => { }; const wasmBytes = Buffer.from('random-wasm-bytes'); +const sha256hash = Buffer.from('some-manifest-hash'); beforeAll(async () => { defaultDatabase = await getKyselyDB(); @@ -41,7 +42,9 @@ describe(WorkflowService.name, () => { description: 'A test core plugin for workflow tests', author: 'Test Author', version: '1.0.0', + templates: [], wasmBytes, + sha256hash, }, [ { diff --git a/web/src/lib/managers/plugin-manager.svelte.ts b/web/src/lib/managers/plugin-manager.svelte.ts index 57ca8fded5..5f9ee315a5 100644 --- a/web/src/lib/managers/plugin-manager.svelte.ts +++ b/web/src/lib/managers/plugin-manager.svelte.ts @@ -1,8 +1,10 @@ import { getWorkflowTriggers, searchPluginMethods, + searchPluginTemplates, WorkflowTrigger, type PluginMethodResponseDto, + type PluginTemplateResponseDto, type WorkflowTriggerResponseDto, } from '@immich/sdk'; import { t } from 'svelte-i18n'; @@ -16,6 +18,7 @@ class PluginManager { #methodMap = new SvelteMap(); #methods = $state([]); #triggers = $state([]); + #templates = $state([]); constructor() { eventManager.on({ @@ -33,6 +36,10 @@ class PluginManager { return this.#triggers; } + get templates() { + return this.#templates; + } + ready() { return this.initialize(); } @@ -70,7 +77,11 @@ class PluginManager { } private async load() { - const [methods, triggers] = await Promise.all([searchPluginMethods({}), getWorkflowTriggers()]); + const [methods, triggers, templates] = await Promise.all([ + searchPluginMethods({}), + getWorkflowTriggers(), + searchPluginTemplates(), + ]); this.#methods = methods; for (const method of this.#methods) { @@ -78,6 +89,7 @@ class PluginManager { } this.#triggers = triggers; + this.#templates = templates; } } diff --git a/web/src/lib/modals/WorkflowTemplatePicker.svelte b/web/src/lib/modals/WorkflowTemplatePicker.svelte new file mode 100644 index 0000000000..05306671fc --- /dev/null +++ b/web/src/lib/modals/WorkflowTemplatePicker.svelte @@ -0,0 +1,66 @@ + + + +
+ {#each pluginManager.templates as template (template.key)} + (selected = isSelected(template) ? undefined : template)} + > +
+
+ +
+
+ {template.title} + {template.description} +
+
+
+ {/each} +
+
diff --git a/web/src/lib/services/workflow.service.ts b/web/src/lib/services/workflow.service.ts index 98e0383d2c..55b263ec83 100644 --- a/web/src/lib/services/workflow.service.ts +++ b/web/src/lib/services/workflow.service.ts @@ -10,10 +10,11 @@ import { type WorkflowUpdateDto, } from '@immich/sdk'; import { modalManager, toastManager, type ActionItem } from '@immich/ui'; -import { mdiCodeJson, mdiDelete, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js'; +import { mdiCodeJson, mdiDelete, mdiFileDocumentMultipleOutline, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js'; import type { MessageFormatter } from 'svelte-i18n'; import { goto } from '$app/navigation'; import { eventManager } from '$lib/managers/event-manager.svelte'; +import WorkflowTemplatePicker from '$lib/modals/WorkflowTemplatePicker.svelte'; import { Route } from '$lib/route'; import { handleError } from '$lib/utils/handle-error'; import { getFormatter } from '$lib/utils/i18n'; @@ -33,7 +34,13 @@ export const getWorkflowsActions = ($t: MessageFormatter) => { }), }; - return { Create }; + const UseTemplate: ActionItem = { + title: $t('browse_templates'), + icon: mdiFileDocumentMultipleOutline, + onAction: () => modalManager.show(WorkflowTemplatePicker, {}), + }; + + return { Create, UseTemplate }; }; export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowResponseDto) => { @@ -72,14 +79,16 @@ export const getWorkflowShowSchemaAction = ( onAction: onToggle, }); -const handleCreateWorkflow = async (dto: WorkflowCreateDto) => { +export const handleCreateWorkflow = async (dto: WorkflowCreateDto) => { const $t = await getFormatter(); try { const response = await createWorkflow({ workflowCreateDto: dto }); eventManager.emit('WorkflowCreate', response); + return true; } catch (error) { handleError(error, $t('errors.unable_to_create')); + return false; } }; diff --git a/web/src/routes/(user)/workflows/+page.svelte b/web/src/routes/(user)/workflows/+page.svelte index f4d4b67a33..49addc83d1 100644 --- a/web/src/routes/(user)/workflows/+page.svelte +++ b/web/src/routes/(user)/workflows/+page.svelte @@ -59,7 +59,7 @@ }); }; - const { Create } = $derived(getWorkflowsActions($t)); + const { Create, UseTemplate } = $derived(getWorkflowsActions($t)); const onWorkflowCreate = async (response: WorkflowResponseDto) => { await goto(Route.viewWorkflow(response)); @@ -76,7 +76,7 @@ - +
{#if workflows.length === 0}