mirror of
https://github.com/immich-app/immich.git
synced 2026-05-26 17:42:32 -04:00
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:
@@ -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;
|
||||
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
Generated
+2
@@ -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';
|
||||
|
||||
Generated
+51
@@ -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.
|
||||
|
||||
Generated
+4
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
[
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user