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 <jason@rasm.me>
This commit is contained in:
Alex
2026-05-26 15:47:05 -05:00
committed by GitHub
parent dc66892ca1
commit 8682be4774
29 changed files with 794 additions and 20 deletions
+3
View File
@@ -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",
@@ -334,7 +334,9 @@ class PhotoViewCoreState extends State<PhotoViewCore>
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;
+3
View File
@@ -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)
+2
View File
@@ -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';
+51
View File
@@ -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<Response> searchPluginTemplatesWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/plugins/templates';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Retrieve workflow templates
///
/// Retrieve workflow templates provided by installed plugins
Future<List<PluginTemplateResponseDto>?> 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<PluginTemplateResponseDto>') as List)
.cast<PluginTemplateResponseDto>()
.toList(growable: false);
}
return null;
}
/// List all plugins
///
/// Retrieve a list of plugins available to the authenticated user.
+4
View File
@@ -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':
+135
View File
@@ -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<PluginTemplateStepResponseDto> 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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return PluginTemplateResponseDto(
description: mapValueOfType<String>(json, r'description')!,
key: mapValueOfType<String>(json, r'key')!,
steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']),
title: mapValueOfType<String>(json, r'title')!,
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
);
}
return null;
}
static List<PluginTemplateResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginTemplateResponseDto>[];
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<String, PluginTemplateResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginTemplateResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<PluginTemplateResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginTemplateResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'description',
'key',
'steps',
'title',
'trigger',
};
}
@@ -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<String, Object>? 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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return PluginTemplateStepResponseDto(
config: mapCastOfType<String, Object>(json, r'config'),
enabled: mapValueOfType<bool>(json, r'enabled'),
method: mapValueOfType<String>(json, r'method')!,
);
}
return null;
}
static List<PluginTemplateStepResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginTemplateStepResponseDto>[];
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<String, PluginTemplateStepResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginTemplateStepResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<PluginTemplateStepResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginTemplateStepResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'config',
'method',
};
}
+102
View File
@@ -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": {
+30
View File
@@ -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",
+5
View File
@@ -100,6 +100,11 @@ export const assetTrash = () => {
export const assetAddToAlbums = () => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[] }>(({ config, data, functions }) => {
if (config.albumIds.length === 0) {
// noop
return {};
}
if (config.albumIds.length === 1) {
functions.albumAddAssets(config.albumIds[0], [data.asset.id]);
return {};
+33
View File
@@ -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
*/
@@ -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<PluginTemplateResponseDto[]> {
return this.service.searchTemplates();
}
@Get(':id')
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
+27 -1
View File
@@ -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' });
+48 -3
View File
@@ -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<string, unknown> | 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,
+39
View File
@@ -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), '[]')
@@ -274,6 +274,7 @@ export class DatabaseRepository {
columns: { ignoreExtra: true },
functions: { ignoreExtra: false },
parameters: { ignoreExtra: true },
extensions: { ignoreExtra: true },
});
return drift;
@@ -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'])
@@ -0,0 +1,13 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
await sql`ALTER TABLE "plugin" DROP COLUMN "templates";`.execute(db);
await sql`ALTER TABLE "plugin" DROP COLUMN "sha256hash";`.execute(db);
}
+7
View File
@@ -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<Timestamp>;
+7
View File
@@ -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<PluginTemplateResponseDto[]> {
const plugins = await this.pluginRepository.search();
return plugins.flatMap((plugin) => plugin.templates.map((template) => mapTemplate(plugin, template)));
}
}
@@ -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,
);
+2 -2
View File
@@ -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 = /^(?<name>[^@#\s]+)(?:@(?<version>[^#\s]*))?#(?<method>[^@#\s]+)$/;
@@ -11,6 +11,7 @@ import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const wasmBytes = Buffer.from('some-wasm-binary-data');
const sha256hash = Buffer.from('some-manifest-hash');
const setup = (db?: Kysely<DB>) => {
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,
},
[
@@ -21,6 +21,7 @@ const setup = (db?: Kysely<DB>) => {
};
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,
},
[
{
+13 -1
View File
@@ -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<string, PluginMethodResponseDto>();
#methods = $state<PluginMethodResponseDto[]>([]);
#triggers = $state<WorkflowTriggerResponseDto[]>([]);
#templates = $state<PluginTemplateResponseDto[]>([]);
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;
}
}
@@ -0,0 +1,66 @@
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { handleCreateWorkflow } from '$lib/services/workflow.service';
import { type PluginTemplateResponseDto } from '@immich/sdk';
import { FormModal, Icon, ListButton, Text } from '@immich/ui';
import { mdiFlashOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
onClose: () => void;
};
const { onClose }: Props = $props();
let selected = $state<PluginTemplateResponseDto>();
const onSubmit = async () => {
if (!selected) {
return;
}
const success = await handleCreateWorkflow({
trigger: selected.trigger,
steps: selected.steps,
name: selected.title,
description: selected.description,
enabled: false,
});
if (success) {
onClose();
}
};
const isSelected = (template: PluginTemplateResponseDto) => selected?.key === template.key;
</script>
<FormModal
title={$t('workflow_templates')}
{onClose}
{onSubmit}
disabled={!selected}
size="medium"
submitText={$t('use_template')}
>
<div class="flex flex-col gap-2">
{#each pluginManager.templates as template (template.key)}
<ListButton
selected={isSelected(template)}
onclick={() => (selected = isSelected(template) ? undefined : template)}
>
<div class="flex w-full items-center gap-3 text-start">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-immich-primary/10 text-immich-primary dark:bg-immich-dark-primary/15 dark:text-immich-dark-primary"
>
<Icon icon={mdiFlashOutline} size="18" />
</div>
<div class="min-w-0 grow">
<Text fontWeight="medium">{template.title}</Text>
<Text size="tiny" color="muted">{template.description}</Text>
</div>
</div>
</ListButton>
{/each}
</div>
</FormModal>
+12 -3
View File
@@ -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;
}
};
+2 -2
View File
@@ -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 @@
<OnEvents {onWorkflowCreate} {onWorkflowUpdate} {onWorkflowDelete} />
<UserPageLayout title={data.meta.title} actions={[Create]} scrollbar={false}>
<UserPageLayout title={data.meta.title} actions={[UseTemplate, Create]} scrollbar={false}>
<section class="flex place-content-center sm:mx-4">
<Container center size="large" class="pb-28">
{#if workflows.length === 0}