Compare commits

...

1 Commits

Author SHA1 Message Date
Jason Rasmussen
8e04007f8b WIP 2026-03-11 00:09:54 -04:00
124 changed files with 4894 additions and 6388 deletions

View File

@@ -16,7 +16,7 @@ services:
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- /etc/localtime:/etc/localtime:ro
- pnpm_store_server:/buildcache/pnpm-store
- ../plugins:/build/corePlugin
- ../packages/plugin-core:/build/plugins/immich-plugin-core
immich-web:
env_file: !reset []
immich-machine-learning:

View File

@@ -90,6 +90,8 @@ jobs:
- name: Run formatter
run: pnpm format
if: ${{ !cancelled() }}
- name: Build app
run: pnpm run --filter immich --filter @immich/plugin-sdk build
- name: Run tsc
run: pnpm check
if: ${{ !cancelled() }}
@@ -394,6 +396,8 @@ jobs:
cache-dependency-path: '**/pnpm-lock.yaml'
- name: Run pnpm install
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Build plugin
run: pnpm run --filter @immich/plugin-sdk --filter @immich/plugin-core build
- name: Run medium tests
run: pnpm test:medium
if: ${{ !cancelled() }}
@@ -722,7 +726,7 @@ jobs:
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
- name: Build the app
run: pnpm --filter immich build
run: pnpm --filter immich --filter @immich/plugin-sdk build
- name: Run API generation
run: ./bin/generate-open-api.sh
working-directory: open-api
@@ -784,7 +788,7 @@ jobs:
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
- name: Build the app
run: pnpm build
run: pnpm run --filter immich --filter @immich/plugin-sdk build
- name: Run existing migrations
run: pnpm migrations:run
- name: Test npm run schema:reset command works

View File

@@ -73,7 +73,7 @@ services:
- ${UPLOAD_LOCATION}/photos:/data
- /etc/localtime:/etc/localtime:ro
- pnpm_store_server:/buildcache/pnpm-store
- ../plugins:/build/corePlugin
- ../packages/plugin-core:/build/plugins/immich-plugin-core
env_file:
- .env
environment:

View File

@@ -22,13 +22,12 @@
"add_birthday": "Add a birthday",
"add_endpoint": "Add endpoint",
"add_exclusion_pattern": "Add exclusion pattern",
"add_filter": "Add filter",
"add_filter_description": "Click to add a filter condition",
"add_location": "Add location",
"add_more_users": "Add more users",
"add_partner": "Add partner",
"add_path": "Add path",
"add_photos": "Add photos",
"add_step": "Add step",
"add_tag": "Add tag",
"add_to": "Add to…",
"add_to_album": "Add to album",
@@ -42,7 +41,6 @@
"add_to_shared_album": "Add to shared album",
"add_upload_to_stack": "Add upload to stack",
"add_url": "Add URL",
"add_workflow_step": "Add workflow step",
"added_to_archive": "Added to archive",
"added_to_favorites": "Added to favorites",
"added_to_favorites_count": "Added {count, number} to favorites",
@@ -805,6 +803,7 @@
"comments_are_disabled": "Comments are disabled",
"common_create_new_album": "Create new album",
"completed": "Completed",
"configuration": "Configuration",
"confirm": "Confirm",
"confirm_admin_password": "Confirm Admin Password",
"confirm_delete_face": "Are you sure you want to delete {name} face from the asset?",
@@ -1583,7 +1582,6 @@
"next": "Next",
"next_memory": "Next memory",
"no": "No",
"no_actions_added": "No actions added yet",
"no_albums_found": "No albums found",
"no_albums_message": "Create an album to organize your photos and videos",
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
@@ -1600,7 +1598,6 @@
"no_exif_info_available": "No exif info available",
"no_explore_results_message": "Upload more photos to explore your collection.",
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_filters_added": "No filters added yet",
"no_libraries_message": "Create an external library to view your photos and videos",
"no_local_assets_found": "No local assets found with this checksum",
"no_location_set": "No location set",
@@ -1613,6 +1610,7 @@
"no_results": "No results",
"no_results_description": "Try a synonym or more general keyword",
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
"no_steps": "No steps added yet",
"no_uploads_in_progress": "No uploads in progress",
"none": "None",
"not_allowed": "Not allowed",
@@ -2181,6 +2179,7 @@
"start_date_before_end_date": "Start date must be before end date",
"state": "State",
"status": "Status",
"steps": "Steps",
"stop_casting": "Stop casting",
"stop_motion_photo": "Stop Motion Photo",
"stop_photo_sharing": "Stop sharing your photos?",
@@ -2311,7 +2310,6 @@
"unsupported_field_type": "Unsupported field type",
"unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.",
"untagged": "Untagged",
"untitled_workflow": "Untitled workflow",
"up_next": "Up next",
"update_location_action_prompt": "Update the location of {count} selected assets with:",
"updated_at": "Updated",
@@ -2402,6 +2400,7 @@
"welcome_to_immich": "Welcome to Immich",
"width": "Width",
"wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
"workflow_delete_prompt": "Are you sure you want to delete this workflow?",
"workflow_deleted": "Workflow deleted",
"workflow_description": "Workflow description",

View File

@@ -2,7 +2,7 @@ experimental_monorepo_root = true
[monorepo]
config_roots = [
"plugins",
"packages/plugin-core",
"server",
"cli",
"deployment",

View File

@@ -211,8 +211,8 @@ Class | Method | HTTP request | Description
*PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
*PluginsApi* | [**getPluginTriggers**](doc//PluginsApi.md#getplugintriggers) | **GET** /plugins/triggers | List all plugin triggers
*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins
*PluginsApi* | [**searchPluginMethods**](doc//PluginsApi.md#searchpluginmethods) | **GET** /plugins/methods | Retrieve plugin methods
*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
*QueuesApi* | [**getQueueJobs**](doc//QueuesApi.md#getqueuejobs) | **GET** /queues/{name}/jobs | Retrieve queue jobs
@@ -323,7 +323,8 @@ Class | Method | HTTP request | Description
*WorkflowsApi* | [**createWorkflow**](doc//WorkflowsApi.md#createworkflow) | **POST** /workflows | Create a workflow
*WorkflowsApi* | [**deleteWorkflow**](doc//WorkflowsApi.md#deleteworkflow) | **DELETE** /workflows/{id} | Delete a workflow
*WorkflowsApi* | [**getWorkflow**](doc//WorkflowsApi.md#getworkflow) | **GET** /workflows/{id} | Retrieve a workflow
*WorkflowsApi* | [**getWorkflows**](doc//WorkflowsApi.md#getworkflows) | **GET** /workflows | List all workflows
*WorkflowsApi* | [**getWorkflowTriggers**](doc//WorkflowsApi.md#getworkflowtriggers) | **GET** /workflows/triggers | List all workflow triggers
*WorkflowsApi* | [**searchWorkflows**](doc//WorkflowsApi.md#searchworkflows) | **GET** /workflows | List all workflows
*WorkflowsApi* | [**updateWorkflow**](doc//WorkflowsApi.md#updateworkflow) | **PUT** /workflows/{id} | Update a workflow
@@ -498,12 +499,8 @@ Class | Method | HTTP request | Description
- [PinCodeResetDto](doc//PinCodeResetDto.md)
- [PinCodeSetupDto](doc//PinCodeSetupDto.md)
- [PlacesResponseDto](doc//PlacesResponseDto.md)
- [PluginActionResponseDto](doc//PluginActionResponseDto.md)
- [PluginContextType](doc//PluginContextType.md)
- [PluginFilterResponseDto](doc//PluginFilterResponseDto.md)
- [PluginMethodResponseDto](doc//PluginMethodResponseDto.md)
- [PluginResponseDto](doc//PluginResponseDto.md)
- [PluginTriggerResponseDto](doc//PluginTriggerResponseDto.md)
- [PluginTriggerType](doc//PluginTriggerType.md)
- [PurchaseResponse](doc//PurchaseResponse.md)
- [PurchaseUpdate](doc//PurchaseUpdate.md)
- [QueueCommand](doc//QueueCommand.md)
@@ -675,12 +672,13 @@ Class | Method | HTTP request | Description
- [VersionCheckStateResponseDto](doc//VersionCheckStateResponseDto.md)
- [VideoCodec](doc//VideoCodec.md)
- [VideoContainer](doc//VideoContainer.md)
- [WorkflowActionItemDto](doc//WorkflowActionItemDto.md)
- [WorkflowActionResponseDto](doc//WorkflowActionResponseDto.md)
- [WorkflowCreateDto](doc//WorkflowCreateDto.md)
- [WorkflowFilterItemDto](doc//WorkflowFilterItemDto.md)
- [WorkflowFilterResponseDto](doc//WorkflowFilterResponseDto.md)
- [WorkflowResponseDto](doc//WorkflowResponseDto.md)
- [WorkflowStepDto](doc//WorkflowStepDto.md)
- [WorkflowStepResponseDto](doc//WorkflowStepResponseDto.md)
- [WorkflowTrigger](doc//WorkflowTrigger.md)
- [WorkflowTriggerResponseDto](doc//WorkflowTriggerResponseDto.md)
- [WorkflowType](doc//WorkflowType.md)
- [WorkflowUpdateDto](doc//WorkflowUpdateDto.md)

View File

@@ -237,12 +237,8 @@ part 'model/pin_code_change_dto.dart';
part 'model/pin_code_reset_dto.dart';
part 'model/pin_code_setup_dto.dart';
part 'model/places_response_dto.dart';
part 'model/plugin_action_response_dto.dart';
part 'model/plugin_context_type.dart';
part 'model/plugin_filter_response_dto.dart';
part 'model/plugin_method_response_dto.dart';
part 'model/plugin_response_dto.dart';
part 'model/plugin_trigger_response_dto.dart';
part 'model/plugin_trigger_type.dart';
part 'model/purchase_response.dart';
part 'model/purchase_update.dart';
part 'model/queue_command.dart';
@@ -414,12 +410,13 @@ part 'model/validate_library_response_dto.dart';
part 'model/version_check_state_response_dto.dart';
part 'model/video_codec.dart';
part 'model/video_container.dart';
part 'model/workflow_action_item_dto.dart';
part 'model/workflow_action_response_dto.dart';
part 'model/workflow_create_dto.dart';
part 'model/workflow_filter_item_dto.dart';
part 'model/workflow_filter_response_dto.dart';
part 'model/workflow_response_dto.dart';
part 'model/workflow_step_dto.dart';
part 'model/workflow_step_response_dto.dart';
part 'model/workflow_trigger.dart';
part 'model/workflow_trigger_response_dto.dart';
part 'model/workflow_type.dart';
part 'model/workflow_update_dto.dart';

View File

@@ -73,14 +73,36 @@ class PluginsApi {
return null;
}
/// List all plugin triggers
/// Retrieve plugin methods
///
/// Retrieve a list of all available plugin triggers.
/// Retrieve a list of plugin methods
///
/// Note: This method returns the HTTP [Response].
Future<Response> getPluginTriggersWithHttpInfo() async {
///
/// Parameters:
///
/// * [String] description:
///
/// * [bool] enabled:
/// Whether the plugin method is enabled
///
/// * [String] id:
/// Plugin method ID
///
/// * [String] name:
///
/// * [String] pluginName:
///
/// * [String] pluginVersion:
///
/// * [String] title:
///
/// * [WorkflowTrigger] trigger:
///
/// * [WorkflowType] type:
Future<Response> searchPluginMethodsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, String? pluginName, String? pluginVersion, String? title, WorkflowTrigger? trigger, WorkflowType? type, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/plugins/triggers';
final apiPath = r'/plugins/methods';
// ignore: prefer_final_locals
Object? postBody;
@@ -89,6 +111,34 @@ class PluginsApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (description != null) {
queryParams.addAll(_queryParams('', 'description', description));
}
if (enabled != null) {
queryParams.addAll(_queryParams('', 'enabled', enabled));
}
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
if (name != null) {
queryParams.addAll(_queryParams('', 'name', name));
}
if (pluginName != null) {
queryParams.addAll(_queryParams('', 'pluginName', pluginName));
}
if (pluginVersion != null) {
queryParams.addAll(_queryParams('', 'pluginVersion', pluginVersion));
}
if (title != null) {
queryParams.addAll(_queryParams('', 'title', title));
}
if (trigger != null) {
queryParams.addAll(_queryParams('', 'trigger', trigger));
}
if (type != null) {
queryParams.addAll(_queryParams('', 'type', type));
}
const contentTypes = <String>[];
@@ -103,11 +153,33 @@ class PluginsApi {
);
}
/// List all plugin triggers
/// Retrieve plugin methods
///
/// Retrieve a list of all available plugin triggers.
Future<List<PluginTriggerResponseDto>?> getPluginTriggers() async {
final response = await getPluginTriggersWithHttpInfo();
/// Retrieve a list of plugin methods
///
/// Parameters:
///
/// * [String] description:
///
/// * [bool] enabled:
/// Whether the plugin method is enabled
///
/// * [String] id:
/// Plugin method ID
///
/// * [String] name:
///
/// * [String] pluginName:
///
/// * [String] pluginVersion:
///
/// * [String] title:
///
/// * [WorkflowTrigger] trigger:
///
/// * [WorkflowType] type:
Future<List<PluginMethodResponseDto>?> searchPluginMethods({ String? description, bool? enabled, String? id, String? name, String? pluginName, String? pluginVersion, String? title, WorkflowTrigger? trigger, WorkflowType? type, }) async {
final response = await searchPluginMethodsWithHttpInfo( description: description, enabled: enabled, id: id, name: name, pluginName: pluginName, pluginVersion: pluginVersion, title: title, trigger: trigger, type: type, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@@ -116,8 +188,8 @@ class PluginsApi {
// 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<PluginTriggerResponseDto>') as List)
.cast<PluginTriggerResponseDto>()
return (await apiClient.deserializeAsync(responseBody, 'List<PluginMethodResponseDto>') as List)
.cast<PluginMethodResponseDto>()
.toList(growable: false);
}
@@ -129,7 +201,23 @@ class PluginsApi {
/// Retrieve a list of plugins available to the authenticated user.
///
/// Note: This method returns the HTTP [Response].
Future<Response> getPluginsWithHttpInfo() async {
///
/// Parameters:
///
/// * [String] description:
///
/// * [bool] enabled:
/// Whether the plugin is enabled
///
/// * [String] id:
/// Plugin ID
///
/// * [String] name:
///
/// * [String] title:
///
/// * [String] version:
Future<Response> searchPluginsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, String? title, String? version, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/plugins';
@@ -140,6 +228,25 @@ class PluginsApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (description != null) {
queryParams.addAll(_queryParams('', 'description', description));
}
if (enabled != null) {
queryParams.addAll(_queryParams('', 'enabled', enabled));
}
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
if (name != null) {
queryParams.addAll(_queryParams('', 'name', name));
}
if (title != null) {
queryParams.addAll(_queryParams('', 'title', title));
}
if (version != null) {
queryParams.addAll(_queryParams('', 'version', version));
}
const contentTypes = <String>[];
@@ -157,8 +264,24 @@ class PluginsApi {
/// List all plugins
///
/// Retrieve a list of plugins available to the authenticated user.
Future<List<PluginResponseDto>?> getPlugins() async {
final response = await getPluginsWithHttpInfo();
///
/// Parameters:
///
/// * [String] description:
///
/// * [bool] enabled:
/// Whether the plugin is enabled
///
/// * [String] id:
/// Plugin ID
///
/// * [String] name:
///
/// * [String] title:
///
/// * [String] version:
Future<List<PluginResponseDto>?> searchPlugins({ String? description, bool? enabled, String? id, String? name, String? title, String? version, }) async {
final response = await searchPluginsWithHttpInfo( description: description, enabled: enabled, id: id, name: name, title: title, version: version, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -178,14 +178,14 @@ class WorkflowsApi {
return null;
}
/// List all workflows
/// List all workflow triggers
///
/// Retrieve a list of workflows available to the authenticated user.
/// Retrieve a list of all available workflow triggers.
///
/// Note: This method returns the HTTP [Response].
Future<Response> getWorkflowsWithHttpInfo() async {
Future<Response> getWorkflowTriggersWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/workflows';
final apiPath = r'/workflows/triggers';
// ignore: prefer_final_locals
Object? postBody;
@@ -208,11 +208,112 @@ class WorkflowsApi {
);
}
/// List all workflow triggers
///
/// Retrieve a list of all available workflow triggers.
Future<List<WorkflowTriggerResponseDto>?> getWorkflowTriggers() async {
final response = await getWorkflowTriggersWithHttpInfo();
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<WorkflowTriggerResponseDto>') as List)
.cast<WorkflowTriggerResponseDto>()
.toList(growable: false);
}
return null;
}
/// List all workflows
///
/// Retrieve a list of workflows available to the authenticated user.
Future<List<WorkflowResponseDto>?> getWorkflows() async {
final response = await getWorkflowsWithHttpInfo();
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] description:
/// Workflow description
///
/// * [bool] enabled:
/// Workflow enabled
///
/// * [String] id:
/// Workflow ID
///
/// * [String] name:
/// Workflow name
///
/// * [WorkflowTrigger] trigger:
/// Workflow trigger type
Future<Response> searchWorkflowsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, WorkflowTrigger? trigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/workflows';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (description != null) {
queryParams.addAll(_queryParams('', 'description', description));
}
if (enabled != null) {
queryParams.addAll(_queryParams('', 'enabled', enabled));
}
if (id != null) {
queryParams.addAll(_queryParams('', 'id', id));
}
if (name != null) {
queryParams.addAll(_queryParams('', 'name', name));
}
if (trigger != null) {
queryParams.addAll(_queryParams('', 'trigger', trigger));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// List all workflows
///
/// Retrieve a list of workflows available to the authenticated user.
///
/// Parameters:
///
/// * [String] description:
/// Workflow description
///
/// * [bool] enabled:
/// Workflow enabled
///
/// * [String] id:
/// Workflow ID
///
/// * [String] name:
/// Workflow name
///
/// * [WorkflowTrigger] trigger:
/// Workflow trigger type
Future<List<WorkflowResponseDto>?> searchWorkflows({ String? description, bool? enabled, String? id, String? name, WorkflowTrigger? trigger, }) async {
final response = await searchWorkflowsWithHttpInfo( description: description, enabled: enabled, id: id, name: name, trigger: trigger, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -520,18 +520,10 @@ class ApiClient {
return PinCodeSetupDto.fromJson(value);
case 'PlacesResponseDto':
return PlacesResponseDto.fromJson(value);
case 'PluginActionResponseDto':
return PluginActionResponseDto.fromJson(value);
case 'PluginContextType':
return PluginContextTypeTypeTransformer().decode(value);
case 'PluginFilterResponseDto':
return PluginFilterResponseDto.fromJson(value);
case 'PluginMethodResponseDto':
return PluginMethodResponseDto.fromJson(value);
case 'PluginResponseDto':
return PluginResponseDto.fromJson(value);
case 'PluginTriggerResponseDto':
return PluginTriggerResponseDto.fromJson(value);
case 'PluginTriggerType':
return PluginTriggerTypeTypeTransformer().decode(value);
case 'PurchaseResponse':
return PurchaseResponse.fromJson(value);
case 'PurchaseUpdate':
@@ -874,18 +866,20 @@ class ApiClient {
return VideoCodecTypeTransformer().decode(value);
case 'VideoContainer':
return VideoContainerTypeTransformer().decode(value);
case 'WorkflowActionItemDto':
return WorkflowActionItemDto.fromJson(value);
case 'WorkflowActionResponseDto':
return WorkflowActionResponseDto.fromJson(value);
case 'WorkflowCreateDto':
return WorkflowCreateDto.fromJson(value);
case 'WorkflowFilterItemDto':
return WorkflowFilterItemDto.fromJson(value);
case 'WorkflowFilterResponseDto':
return WorkflowFilterResponseDto.fromJson(value);
case 'WorkflowResponseDto':
return WorkflowResponseDto.fromJson(value);
case 'WorkflowStepDto':
return WorkflowStepDto.fromJson(value);
case 'WorkflowStepResponseDto':
return WorkflowStepResponseDto.fromJson(value);
case 'WorkflowTrigger':
return WorkflowTriggerTypeTransformer().decode(value);
case 'WorkflowTriggerResponseDto':
return WorkflowTriggerResponseDto.fromJson(value);
case 'WorkflowType':
return WorkflowTypeTypeTransformer().decode(value);
case 'WorkflowUpdateDto':
return WorkflowUpdateDto.fromJson(value);
default:

View File

@@ -130,12 +130,6 @@ String parameterToString(dynamic value) {
if (value is Permission) {
return PermissionTypeTransformer().encode(value).toString();
}
if (value is PluginContextType) {
return PluginContextTypeTypeTransformer().encode(value).toString();
}
if (value is PluginTriggerType) {
return PluginTriggerTypeTypeTransformer().encode(value).toString();
}
if (value is QueueCommand) {
return QueueCommandTypeTransformer().encode(value).toString();
}
@@ -193,6 +187,12 @@ String parameterToString(dynamic value) {
if (value is VideoContainer) {
return VideoContainerTypeTransformer().encode(value).toString();
}
if (value is WorkflowTrigger) {
return WorkflowTriggerTypeTransformer().encode(value).toString();
}
if (value is WorkflowType) {
return WorkflowTypeTypeTransformer().encode(value).toString();
}
return value.toString();
}

View File

@@ -78,7 +78,7 @@ class JobName {
static const versionCheck = JobName._(r'VersionCheck');
static const ocrQueueAll = JobName._(r'OcrQueueAll');
static const ocr = JobName._(r'Ocr');
static const workflowRun = JobName._(r'WorkflowRun');
static const workflowAssetCreate = JobName._(r'WorkflowAssetCreate');
/// List of all possible values in this [enum][JobName].
static const values = <JobName>[
@@ -137,7 +137,7 @@ class JobName {
versionCheck,
ocrQueueAll,
ocr,
workflowRun,
workflowAssetCreate,
];
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
@@ -231,7 +231,7 @@ class JobNameTypeTransformer {
case r'VersionCheck': return JobName.versionCheck;
case r'OcrQueueAll': return JobName.ocrQueueAll;
case r'Ocr': return JobName.ocr;
case r'WorkflowRun': return JobName.workflowRun;
case r'WorkflowAssetCreate': return JobName.workflowAssetCreate;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');

View File

@@ -1,88 +0,0 @@
//
// 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;
/// Context type
class PluginContextType {
/// Instantiate a new enum with the provided [value].
const PluginContextType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const asset = PluginContextType._(r'asset');
static const album = PluginContextType._(r'album');
static const person = PluginContextType._(r'person');
/// List of all possible values in this [enum][PluginContextType].
static const values = <PluginContextType>[
asset,
album,
person,
];
static PluginContextType? fromJson(dynamic value) => PluginContextTypeTypeTransformer().decode(value);
static List<PluginContextType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginContextType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginContextType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [PluginContextType] to String,
/// and [decode] dynamic data back to [PluginContextType].
class PluginContextTypeTypeTransformer {
factory PluginContextTypeTypeTransformer() => _instance ??= const PluginContextTypeTypeTransformer._();
const PluginContextTypeTypeTransformer._();
String encode(PluginContextType data) => data.value;
/// Decodes a [dynamic value][data] to a PluginContextType.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
PluginContextType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'asset': return PluginContextType.asset;
case r'album': return PluginContextType.album;
case r'person': return PluginContextType.person;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [PluginContextTypeTypeTransformer] instance.
static PluginContextTypeTypeTransformer? _instance;
}

View File

@@ -1,158 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PluginFilterResponseDto {
/// Returns a new [PluginFilterResponseDto] instance.
PluginFilterResponseDto({
required this.description,
required this.id,
required this.methodName,
required this.pluginId,
required this.schema,
this.supportedContexts = const [],
required this.title,
});
/// Filter description
String description;
/// Filter ID
String id;
/// Method name
String methodName;
/// Plugin ID
String pluginId;
/// Filter schema
Object? schema;
/// Supported contexts
List<PluginContextType> supportedContexts;
/// Filter title
String title;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginFilterResponseDto &&
other.description == description &&
other.id == id &&
other.methodName == methodName &&
other.pluginId == pluginId &&
other.schema == schema &&
_deepEquality.equals(other.supportedContexts, supportedContexts) &&
other.title == title;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(description.hashCode) +
(id.hashCode) +
(methodName.hashCode) +
(pluginId.hashCode) +
(schema == null ? 0 : schema!.hashCode) +
(supportedContexts.hashCode) +
(title.hashCode);
@override
String toString() => 'PluginFilterResponseDto[description=$description, id=$id, methodName=$methodName, pluginId=$pluginId, schema=$schema, supportedContexts=$supportedContexts, title=$title]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'description'] = this.description;
json[r'id'] = this.id;
json[r'methodName'] = this.methodName;
json[r'pluginId'] = this.pluginId;
if (this.schema != null) {
json[r'schema'] = this.schema;
} else {
// json[r'schema'] = null;
}
json[r'supportedContexts'] = this.supportedContexts;
json[r'title'] = this.title;
return json;
}
/// Returns a new [PluginFilterResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginFilterResponseDto? fromJson(dynamic value) {
upgradeDto(value, "PluginFilterResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginFilterResponseDto(
description: mapValueOfType<String>(json, r'description')!,
id: mapValueOfType<String>(json, r'id')!,
methodName: mapValueOfType<String>(json, r'methodName')!,
pluginId: mapValueOfType<String>(json, r'pluginId')!,
schema: mapValueOfType<Object>(json, r'schema'),
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
title: mapValueOfType<String>(json, r'title')!,
);
}
return null;
}
static List<PluginFilterResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginFilterResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginFilterResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginFilterResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginFilterResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginFilterResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginFilterResponseDto-objects as value to a dart map
static Map<String, List<PluginFilterResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginFilterResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginFilterResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'description',
'id',
'methodName',
'pluginId',
'schema',
'supportedContexts',
'title',
};
}

View File

@@ -10,105 +10,97 @@
part of openapi.api;
class PluginActionResponseDto {
/// Returns a new [PluginActionResponseDto] instance.
PluginActionResponseDto({
class PluginMethodResponseDto {
/// Returns a new [PluginMethodResponseDto] instance.
PluginMethodResponseDto({
required this.description,
required this.id,
required this.methodName,
required this.pluginId,
required this.key,
required this.name,
required this.schema,
this.supportedContexts = const [],
required this.title,
this.types = const [],
});
/// Action description
/// Description
String description;
/// Action ID
String id;
/// Key
String key;
/// Method name
String methodName;
/// Name
String name;
/// Plugin ID
String pluginId;
/// Action schema
/// Schema
Object? schema;
/// Supported contexts
List<PluginContextType> supportedContexts;
/// Action title
/// Title
String title;
/// Workflow types
List<WorkflowType> types;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginActionResponseDto &&
bool operator ==(Object other) => identical(this, other) || other is PluginMethodResponseDto &&
other.description == description &&
other.id == id &&
other.methodName == methodName &&
other.pluginId == pluginId &&
other.key == key &&
other.name == name &&
other.schema == schema &&
_deepEquality.equals(other.supportedContexts, supportedContexts) &&
other.title == title;
other.title == title &&
_deepEquality.equals(other.types, types);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(description.hashCode) +
(id.hashCode) +
(methodName.hashCode) +
(pluginId.hashCode) +
(key.hashCode) +
(name.hashCode) +
(schema == null ? 0 : schema!.hashCode) +
(supportedContexts.hashCode) +
(title.hashCode);
(title.hashCode) +
(types.hashCode);
@override
String toString() => 'PluginActionResponseDto[description=$description, id=$id, methodName=$methodName, pluginId=$pluginId, schema=$schema, supportedContexts=$supportedContexts, title=$title]';
String toString() => 'PluginMethodResponseDto[description=$description, key=$key, name=$name, schema=$schema, title=$title, types=$types]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'description'] = this.description;
json[r'id'] = this.id;
json[r'methodName'] = this.methodName;
json[r'pluginId'] = this.pluginId;
json[r'key'] = this.key;
json[r'name'] = this.name;
if (this.schema != null) {
json[r'schema'] = this.schema;
} else {
// json[r'schema'] = null;
}
json[r'supportedContexts'] = this.supportedContexts;
json[r'title'] = this.title;
json[r'types'] = this.types;
return json;
}
/// Returns a new [PluginActionResponseDto] instance and imports its values from
/// Returns a new [PluginMethodResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginActionResponseDto? fromJson(dynamic value) {
upgradeDto(value, "PluginActionResponseDto");
static PluginMethodResponseDto? fromJson(dynamic value) {
upgradeDto(value, "PluginMethodResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginActionResponseDto(
return PluginMethodResponseDto(
description: mapValueOfType<String>(json, r'description')!,
id: mapValueOfType<String>(json, r'id')!,
methodName: mapValueOfType<String>(json, r'methodName')!,
pluginId: mapValueOfType<String>(json, r'pluginId')!,
key: mapValueOfType<String>(json, r'key')!,
name: mapValueOfType<String>(json, r'name')!,
schema: mapValueOfType<Object>(json, r'schema'),
supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']),
title: mapValueOfType<String>(json, r'title')!,
types: WorkflowType.listFromJson(json[r'types']),
);
}
return null;
}
static List<PluginActionResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginActionResponseDto>[];
static List<PluginMethodResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginMethodResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginActionResponseDto.fromJson(row);
final value = PluginMethodResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
@@ -117,12 +109,12 @@ class PluginActionResponseDto {
return result.toList(growable: growable);
}
static Map<String, PluginActionResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginActionResponseDto>{};
static Map<String, PluginMethodResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginMethodResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginActionResponseDto.fromJson(entry.value);
final value = PluginMethodResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
@@ -131,14 +123,14 @@ class PluginActionResponseDto {
return map;
}
// maps a json object with a list of PluginActionResponseDto-objects as value to a dart map
static Map<String, List<PluginActionResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginActionResponseDto>>{};
// maps a json object with a list of PluginMethodResponseDto-objects as value to a dart map
static Map<String, List<PluginMethodResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginMethodResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginActionResponseDto.listFromJson(entry.value, growable: growable,);
map[entry.key] = PluginMethodResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
@@ -147,12 +139,11 @@ class PluginActionResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'description',
'id',
'methodName',
'pluginId',
'key',
'name',
'schema',
'supportedContexts',
'title',
'types',
};
}

View File

@@ -13,21 +13,17 @@ part of openapi.api;
class PluginResponseDto {
/// Returns a new [PluginResponseDto] instance.
PluginResponseDto({
this.actions = const [],
required this.author,
required this.createdAt,
required this.description,
this.filters = const [],
required this.id,
this.methods = const [],
required this.name,
required this.title,
required this.updatedAt,
required this.version,
});
/// Plugin actions
List<PluginActionResponseDto> actions;
/// Plugin author
String author;
@@ -37,12 +33,12 @@ class PluginResponseDto {
/// Plugin description
String description;
/// Plugin filters
List<PluginFilterResponseDto> filters;
/// Plugin ID
String id;
/// Plugin methods
List<PluginMethodResponseDto> methods;
/// Plugin name
String name;
@@ -57,12 +53,11 @@ class PluginResponseDto {
@override
bool operator ==(Object other) => identical(this, other) || other is PluginResponseDto &&
_deepEquality.equals(other.actions, actions) &&
other.author == author &&
other.createdAt == createdAt &&
other.description == description &&
_deepEquality.equals(other.filters, filters) &&
other.id == id &&
_deepEquality.equals(other.methods, methods) &&
other.name == name &&
other.title == title &&
other.updatedAt == updatedAt &&
@@ -71,28 +66,26 @@ class PluginResponseDto {
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(actions.hashCode) +
(author.hashCode) +
(createdAt.hashCode) +
(description.hashCode) +
(filters.hashCode) +
(id.hashCode) +
(methods.hashCode) +
(name.hashCode) +
(title.hashCode) +
(updatedAt.hashCode) +
(version.hashCode);
@override
String toString() => 'PluginResponseDto[actions=$actions, author=$author, createdAt=$createdAt, description=$description, filters=$filters, id=$id, name=$name, title=$title, updatedAt=$updatedAt, version=$version]';
String toString() => 'PluginResponseDto[author=$author, createdAt=$createdAt, description=$description, id=$id, methods=$methods, name=$name, title=$title, updatedAt=$updatedAt, version=$version]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'actions'] = this.actions;
json[r'author'] = this.author;
json[r'createdAt'] = this.createdAt;
json[r'description'] = this.description;
json[r'filters'] = this.filters;
json[r'id'] = this.id;
json[r'methods'] = this.methods;
json[r'name'] = this.name;
json[r'title'] = this.title;
json[r'updatedAt'] = this.updatedAt;
@@ -109,12 +102,11 @@ class PluginResponseDto {
final json = value.cast<String, dynamic>();
return PluginResponseDto(
actions: PluginActionResponseDto.listFromJson(json[r'actions']),
author: mapValueOfType<String>(json, r'author')!,
createdAt: mapValueOfType<String>(json, r'createdAt')!,
description: mapValueOfType<String>(json, r'description')!,
filters: PluginFilterResponseDto.listFromJson(json[r'filters']),
id: mapValueOfType<String>(json, r'id')!,
methods: PluginMethodResponseDto.listFromJson(json[r'methods']),
name: mapValueOfType<String>(json, r'name')!,
title: mapValueOfType<String>(json, r'title')!,
updatedAt: mapValueOfType<String>(json, r'updatedAt')!,
@@ -166,12 +158,11 @@ class PluginResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'actions',
'author',
'createdAt',
'description',
'filters',
'id',
'methods',
'name',
'title',
'updatedAt',

View File

@@ -1,109 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PluginTriggerResponseDto {
/// Returns a new [PluginTriggerResponseDto] instance.
PluginTriggerResponseDto({
required this.contextType,
required this.type,
});
/// Context type
PluginContextType contextType;
/// Trigger type
PluginTriggerType type;
@override
bool operator ==(Object other) => identical(this, other) || other is PluginTriggerResponseDto &&
other.contextType == contextType &&
other.type == type;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(contextType.hashCode) +
(type.hashCode);
@override
String toString() => 'PluginTriggerResponseDto[contextType=$contextType, type=$type]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'contextType'] = this.contextType;
json[r'type'] = this.type;
return json;
}
/// Returns a new [PluginTriggerResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PluginTriggerResponseDto? fromJson(dynamic value) {
upgradeDto(value, "PluginTriggerResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return PluginTriggerResponseDto(
contextType: PluginContextType.fromJson(json[r'contextType'])!,
type: PluginTriggerType.fromJson(json[r'type'])!,
);
}
return null;
}
static List<PluginTriggerResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginTriggerResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginTriggerResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PluginTriggerResponseDto> mapFromJson(dynamic json) {
final map = <String, PluginTriggerResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PluginTriggerResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PluginTriggerResponseDto-objects as value to a dart map
static Map<String, List<PluginTriggerResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PluginTriggerResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PluginTriggerResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'contextType',
'type',
};
}

View File

@@ -1,85 +0,0 @@
//
// 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;
/// Trigger type
class PluginTriggerType {
/// Instantiate a new enum with the provided [value].
const PluginTriggerType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const assetCreate = PluginTriggerType._(r'AssetCreate');
static const personRecognized = PluginTriggerType._(r'PersonRecognized');
/// List of all possible values in this [enum][PluginTriggerType].
static const values = <PluginTriggerType>[
assetCreate,
personRecognized,
];
static PluginTriggerType? fromJson(dynamic value) => PluginTriggerTypeTypeTransformer().decode(value);
static List<PluginTriggerType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PluginTriggerType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PluginTriggerType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [PluginTriggerType] to String,
/// and [decode] dynamic data back to [PluginTriggerType].
class PluginTriggerTypeTypeTransformer {
factory PluginTriggerTypeTypeTransformer() => _instance ??= const PluginTriggerTypeTypeTransformer._();
const PluginTriggerTypeTypeTransformer._();
String encode(PluginTriggerType data) => data.value;
/// Decodes a [dynamic value][data] to a PluginTriggerType.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
PluginTriggerType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'AssetCreate': return PluginTriggerType.assetCreate;
case r'PersonRecognized': return PluginTriggerType.personRecognized;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [PluginTriggerTypeTypeTransformer] instance.
static PluginTriggerTypeTypeTransformer? _instance;
}

View File

@@ -1,118 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class WorkflowActionItemDto {
/// Returns a new [WorkflowActionItemDto] instance.
WorkflowActionItemDto({
this.actionConfig,
required this.pluginActionId,
});
/// Action configuration
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Object? actionConfig;
/// Plugin action ID
String pluginActionId;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowActionItemDto &&
other.actionConfig == actionConfig &&
other.pluginActionId == pluginActionId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(actionConfig == null ? 0 : actionConfig!.hashCode) +
(pluginActionId.hashCode);
@override
String toString() => 'WorkflowActionItemDto[actionConfig=$actionConfig, pluginActionId=$pluginActionId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.actionConfig != null) {
json[r'actionConfig'] = this.actionConfig;
} else {
// json[r'actionConfig'] = null;
}
json[r'pluginActionId'] = this.pluginActionId;
return json;
}
/// Returns a new [WorkflowActionItemDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static WorkflowActionItemDto? fromJson(dynamic value) {
upgradeDto(value, "WorkflowActionItemDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return WorkflowActionItemDto(
actionConfig: mapValueOfType<Object>(json, r'actionConfig'),
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
);
}
return null;
}
static List<WorkflowActionItemDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowActionItemDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowActionItemDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, WorkflowActionItemDto> mapFromJson(dynamic json) {
final map = <String, WorkflowActionItemDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = WorkflowActionItemDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of WorkflowActionItemDto-objects as value to a dart map
static Map<String, List<WorkflowActionItemDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<WorkflowActionItemDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = WorkflowActionItemDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'pluginActionId',
};
}

View File

@@ -1,140 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class WorkflowActionResponseDto {
/// Returns a new [WorkflowActionResponseDto] instance.
WorkflowActionResponseDto({
required this.actionConfig,
required this.id,
required this.order,
required this.pluginActionId,
required this.workflowId,
});
/// Action configuration
Object? actionConfig;
/// Action ID
String id;
/// Action order
num order;
/// Plugin action ID
String pluginActionId;
/// Workflow ID
String workflowId;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowActionResponseDto &&
other.actionConfig == actionConfig &&
other.id == id &&
other.order == order &&
other.pluginActionId == pluginActionId &&
other.workflowId == workflowId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(actionConfig == null ? 0 : actionConfig!.hashCode) +
(id.hashCode) +
(order.hashCode) +
(pluginActionId.hashCode) +
(workflowId.hashCode);
@override
String toString() => 'WorkflowActionResponseDto[actionConfig=$actionConfig, id=$id, order=$order, pluginActionId=$pluginActionId, workflowId=$workflowId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.actionConfig != null) {
json[r'actionConfig'] = this.actionConfig;
} else {
// json[r'actionConfig'] = null;
}
json[r'id'] = this.id;
json[r'order'] = this.order;
json[r'pluginActionId'] = this.pluginActionId;
json[r'workflowId'] = this.workflowId;
return json;
}
/// Returns a new [WorkflowActionResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static WorkflowActionResponseDto? fromJson(dynamic value) {
upgradeDto(value, "WorkflowActionResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return WorkflowActionResponseDto(
actionConfig: mapValueOfType<Object>(json, r'actionConfig'),
id: mapValueOfType<String>(json, r'id')!,
order: num.parse('${json[r'order']}'),
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!,
);
}
return null;
}
static List<WorkflowActionResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowActionResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowActionResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, WorkflowActionResponseDto> mapFromJson(dynamic json) {
final map = <String, WorkflowActionResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = WorkflowActionResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of WorkflowActionResponseDto-objects as value to a dart map
static Map<String, List<WorkflowActionResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<WorkflowActionResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = WorkflowActionResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'actionConfig',
'id',
'order',
'pluginActionId',
'workflowId',
};
}

View File

@@ -13,24 +13,14 @@ part of openapi.api;
class WorkflowCreateDto {
/// Returns a new [WorkflowCreateDto] instance.
WorkflowCreateDto({
this.actions = const [],
this.description,
this.enabled,
this.filters = const [],
required this.name,
required this.triggerType,
this.name,
this.steps = const [],
required this.trigger,
});
/// Workflow actions
List<WorkflowActionItemDto> actions;
/// Workflow description
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? description;
/// Workflow enabled
@@ -42,40 +32,36 @@ class WorkflowCreateDto {
///
bool? enabled;
/// Workflow filters
List<WorkflowFilterItemDto> filters;
/// Workflow name
String name;
String? name;
List<WorkflowStepDto> steps;
/// Workflow trigger type
PluginTriggerType triggerType;
WorkflowTrigger trigger;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowCreateDto &&
_deepEquality.equals(other.actions, actions) &&
other.description == description &&
other.enabled == enabled &&
_deepEquality.equals(other.filters, filters) &&
other.name == name &&
other.triggerType == triggerType;
_deepEquality.equals(other.steps, steps) &&
other.trigger == trigger;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(actions.hashCode) +
(description == null ? 0 : description!.hashCode) +
(enabled == null ? 0 : enabled!.hashCode) +
(filters.hashCode) +
(name.hashCode) +
(triggerType.hashCode);
(name == null ? 0 : name!.hashCode) +
(steps.hashCode) +
(trigger.hashCode);
@override
String toString() => 'WorkflowCreateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]';
String toString() => 'WorkflowCreateDto[description=$description, enabled=$enabled, name=$name, steps=$steps, trigger=$trigger]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'actions'] = this.actions;
if (this.description != null) {
json[r'description'] = this.description;
} else {
@@ -86,9 +72,13 @@ class WorkflowCreateDto {
} else {
// json[r'enabled'] = null;
}
json[r'filters'] = this.filters;
if (this.name != null) {
json[r'name'] = this.name;
json[r'triggerType'] = this.triggerType;
} else {
// json[r'name'] = null;
}
json[r'steps'] = this.steps;
json[r'trigger'] = this.trigger;
return json;
}
@@ -101,12 +91,11 @@ class WorkflowCreateDto {
final json = value.cast<String, dynamic>();
return WorkflowCreateDto(
actions: WorkflowActionItemDto.listFromJson(json[r'actions']),
description: mapValueOfType<String>(json, r'description'),
enabled: mapValueOfType<bool>(json, r'enabled'),
filters: WorkflowFilterItemDto.listFromJson(json[r'filters']),
name: mapValueOfType<String>(json, r'name')!,
triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!,
name: mapValueOfType<String>(json, r'name'),
steps: WorkflowStepDto.listFromJson(json[r'steps']),
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
);
}
return null;
@@ -154,10 +143,7 @@ class WorkflowCreateDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'actions',
'filters',
'name',
'triggerType',
'trigger',
};
}

View File

@@ -1,118 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class WorkflowFilterItemDto {
/// Returns a new [WorkflowFilterItemDto] instance.
WorkflowFilterItemDto({
this.filterConfig,
required this.pluginFilterId,
});
/// Filter configuration
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
Object? filterConfig;
/// Plugin filter ID
String pluginFilterId;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterItemDto &&
other.filterConfig == filterConfig &&
other.pluginFilterId == pluginFilterId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(filterConfig == null ? 0 : filterConfig!.hashCode) +
(pluginFilterId.hashCode);
@override
String toString() => 'WorkflowFilterItemDto[filterConfig=$filterConfig, pluginFilterId=$pluginFilterId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.filterConfig != null) {
json[r'filterConfig'] = this.filterConfig;
} else {
// json[r'filterConfig'] = null;
}
json[r'pluginFilterId'] = this.pluginFilterId;
return json;
}
/// Returns a new [WorkflowFilterItemDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static WorkflowFilterItemDto? fromJson(dynamic value) {
upgradeDto(value, "WorkflowFilterItemDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return WorkflowFilterItemDto(
filterConfig: mapValueOfType<Object>(json, r'filterConfig'),
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
);
}
return null;
}
static List<WorkflowFilterItemDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowFilterItemDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowFilterItemDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, WorkflowFilterItemDto> mapFromJson(dynamic json) {
final map = <String, WorkflowFilterItemDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = WorkflowFilterItemDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of WorkflowFilterItemDto-objects as value to a dart map
static Map<String, List<WorkflowFilterItemDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<WorkflowFilterItemDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = WorkflowFilterItemDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'pluginFilterId',
};
}

View File

@@ -1,140 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class WorkflowFilterResponseDto {
/// Returns a new [WorkflowFilterResponseDto] instance.
WorkflowFilterResponseDto({
required this.filterConfig,
required this.id,
required this.order,
required this.pluginFilterId,
required this.workflowId,
});
/// Filter configuration
Object? filterConfig;
/// Filter ID
String id;
/// Filter order
num order;
/// Plugin filter ID
String pluginFilterId;
/// Workflow ID
String workflowId;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterResponseDto &&
other.filterConfig == filterConfig &&
other.id == id &&
other.order == order &&
other.pluginFilterId == pluginFilterId &&
other.workflowId == workflowId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(filterConfig == null ? 0 : filterConfig!.hashCode) +
(id.hashCode) +
(order.hashCode) +
(pluginFilterId.hashCode) +
(workflowId.hashCode);
@override
String toString() => 'WorkflowFilterResponseDto[filterConfig=$filterConfig, id=$id, order=$order, pluginFilterId=$pluginFilterId, workflowId=$workflowId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.filterConfig != null) {
json[r'filterConfig'] = this.filterConfig;
} else {
// json[r'filterConfig'] = null;
}
json[r'id'] = this.id;
json[r'order'] = this.order;
json[r'pluginFilterId'] = this.pluginFilterId;
json[r'workflowId'] = this.workflowId;
return json;
}
/// Returns a new [WorkflowFilterResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static WorkflowFilterResponseDto? fromJson(dynamic value) {
upgradeDto(value, "WorkflowFilterResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return WorkflowFilterResponseDto(
filterConfig: mapValueOfType<Object>(json, r'filterConfig'),
id: mapValueOfType<String>(json, r'id')!,
order: num.parse('${json[r'order']}'),
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
workflowId: mapValueOfType<String>(json, r'workflowId')!,
);
}
return null;
}
static List<WorkflowFilterResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowFilterResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowFilterResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, WorkflowFilterResponseDto> mapFromJson(dynamic json) {
final map = <String, WorkflowFilterResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = WorkflowFilterResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of WorkflowFilterResponseDto-objects as value to a dart map
static Map<String, List<WorkflowFilterResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<WorkflowFilterResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = WorkflowFilterResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'filterConfig',
'id',
'order',
'pluginFilterId',
'workflowId',
};
}

View File

@@ -13,87 +13,84 @@ part of openapi.api;
class WorkflowResponseDto {
/// Returns a new [WorkflowResponseDto] instance.
WorkflowResponseDto({
this.actions = const [],
required this.createdAt,
required this.description,
required this.enabled,
this.filters = const [],
required this.id,
required this.name,
required this.ownerId,
required this.triggerType,
this.steps = const [],
required this.trigger,
required this.updatedAt,
});
/// Workflow actions
List<WorkflowActionResponseDto> actions;
/// Creation date
String createdAt;
/// Workflow description
String description;
String? description;
/// Workflow enabled
bool enabled;
/// Workflow filters
List<WorkflowFilterResponseDto> filters;
/// Workflow ID
String id;
/// Workflow name
String? name;
/// Owner user ID
String ownerId;
/// Workflow steps
List<WorkflowStepResponseDto> steps;
/// Workflow trigger type
PluginTriggerType triggerType;
WorkflowTrigger trigger;
/// Update date
String updatedAt;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto &&
_deepEquality.equals(other.actions, actions) &&
other.createdAt == createdAt &&
other.description == description &&
other.enabled == enabled &&
_deepEquality.equals(other.filters, filters) &&
other.id == id &&
other.name == name &&
other.ownerId == ownerId &&
other.triggerType == triggerType;
_deepEquality.equals(other.steps, steps) &&
other.trigger == trigger &&
other.updatedAt == updatedAt;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(actions.hashCode) +
(createdAt.hashCode) +
(description.hashCode) +
(description == null ? 0 : description!.hashCode) +
(enabled.hashCode) +
(filters.hashCode) +
(id.hashCode) +
(name == null ? 0 : name!.hashCode) +
(ownerId.hashCode) +
(triggerType.hashCode);
(steps.hashCode) +
(trigger.hashCode) +
(updatedAt.hashCode);
@override
String toString() => 'WorkflowResponseDto[actions=$actions, createdAt=$createdAt, description=$description, enabled=$enabled, filters=$filters, id=$id, name=$name, ownerId=$ownerId, triggerType=$triggerType]';
String toString() => 'WorkflowResponseDto[createdAt=$createdAt, description=$description, enabled=$enabled, id=$id, name=$name, steps=$steps, trigger=$trigger, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'actions'] = this.actions;
json[r'createdAt'] = this.createdAt;
if (this.description != null) {
json[r'description'] = this.description;
} else {
// json[r'description'] = null;
}
json[r'enabled'] = this.enabled;
json[r'filters'] = this.filters;
json[r'id'] = this.id;
if (this.name != null) {
json[r'name'] = this.name;
} else {
// json[r'name'] = null;
}
json[r'ownerId'] = this.ownerId;
json[r'triggerType'] = this.triggerType;
json[r'steps'] = this.steps;
json[r'trigger'] = this.trigger;
json[r'updatedAt'] = this.updatedAt;
return json;
}
@@ -106,15 +103,14 @@ class WorkflowResponseDto {
final json = value.cast<String, dynamic>();
return WorkflowResponseDto(
actions: WorkflowActionResponseDto.listFromJson(json[r'actions']),
createdAt: mapValueOfType<String>(json, r'createdAt')!,
description: mapValueOfType<String>(json, r'description')!,
description: mapValueOfType<String>(json, r'description'),
enabled: mapValueOfType<bool>(json, r'enabled')!,
filters: WorkflowFilterResponseDto.listFromJson(json[r'filters']),
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name'),
ownerId: mapValueOfType<String>(json, r'ownerId')!,
triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!,
steps: WorkflowStepResponseDto.listFromJson(json[r'steps']),
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
updatedAt: mapValueOfType<String>(json, r'updatedAt')!,
);
}
return null;
@@ -162,15 +158,14 @@ class WorkflowResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'actions',
'createdAt',
'description',
'enabled',
'filters',
'id',
'name',
'ownerId',
'triggerType',
'steps',
'trigger',
'updatedAt',
};
}

View File

@@ -0,0 +1,130 @@
//
// 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 WorkflowStepDto {
/// Returns a new [WorkflowStepDto] instance.
WorkflowStepDto({
this.config,
this.enabled,
required this.method,
});
/// Step configuration
Object? config;
/// 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 WorkflowStepDto &&
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() => 'WorkflowStepDto[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 [WorkflowStepDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static WorkflowStepDto? fromJson(dynamic value) {
upgradeDto(value, "WorkflowStepDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return WorkflowStepDto(
config: mapValueOfType<Object>(json, r'config'),
enabled: mapValueOfType<bool>(json, r'enabled'),
method: mapValueOfType<String>(json, r'method')!,
);
}
return null;
}
static List<WorkflowStepDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowStepDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowStepDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, WorkflowStepDto> mapFromJson(dynamic json) {
final map = <String, WorkflowStepDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = WorkflowStepDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of WorkflowStepDto-objects as value to a dart map
static Map<String, List<WorkflowStepDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<WorkflowStepDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = WorkflowStepDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'method',
};
}

View File

@@ -0,0 +1,122 @@
//
// 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 WorkflowStepResponseDto {
/// Returns a new [WorkflowStepResponseDto] instance.
WorkflowStepResponseDto({
required this.config,
required this.enabled,
required this.method,
});
/// Step configuration
Object? config;
/// Step is enabled
bool enabled;
/// Step plugin method
String method;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowStepResponseDto &&
other.config == config &&
other.enabled == enabled &&
other.method == method;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(config == null ? 0 : config!.hashCode) +
(enabled.hashCode) +
(method.hashCode);
@override
String toString() => 'WorkflowStepResponseDto[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;
}
json[r'enabled'] = this.enabled;
json[r'method'] = this.method;
return json;
}
/// Returns a new [WorkflowStepResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static WorkflowStepResponseDto? fromJson(dynamic value) {
upgradeDto(value, "WorkflowStepResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return WorkflowStepResponseDto(
config: mapValueOfType<Object>(json, r'config'),
enabled: mapValueOfType<bool>(json, r'enabled')!,
method: mapValueOfType<String>(json, r'method')!,
);
}
return null;
}
static List<WorkflowStepResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowStepResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowStepResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, WorkflowStepResponseDto> mapFromJson(dynamic json) {
final map = <String, WorkflowStepResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = WorkflowStepResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of WorkflowStepResponseDto-objects as value to a dart map
static Map<String, List<WorkflowStepResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<WorkflowStepResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = WorkflowStepResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'config',
'enabled',
'method',
};
}

View File

@@ -0,0 +1,85 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class WorkflowTrigger {
/// Instantiate a new enum with the provided [value].
const WorkflowTrigger._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const assetCreate = WorkflowTrigger._(r'AssetCreate');
static const personRecognized = WorkflowTrigger._(r'PersonRecognized');
/// List of all possible values in this [enum][WorkflowTrigger].
static const values = <WorkflowTrigger>[
assetCreate,
personRecognized,
];
static WorkflowTrigger? fromJson(dynamic value) => WorkflowTriggerTypeTransformer().decode(value);
static List<WorkflowTrigger> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowTrigger>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowTrigger.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [WorkflowTrigger] to String,
/// and [decode] dynamic data back to [WorkflowTrigger].
class WorkflowTriggerTypeTransformer {
factory WorkflowTriggerTypeTransformer() => _instance ??= const WorkflowTriggerTypeTransformer._();
const WorkflowTriggerTypeTransformer._();
String encode(WorkflowTrigger data) => data.value;
/// Decodes a [dynamic value][data] to a WorkflowTrigger.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
WorkflowTrigger? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'AssetCreate': return WorkflowTrigger.assetCreate;
case r'PersonRecognized': return WorkflowTrigger.personRecognized;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [WorkflowTriggerTypeTransformer] instance.
static WorkflowTriggerTypeTransformer? _instance;
}

View File

@@ -0,0 +1,109 @@
//
// 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 WorkflowTriggerResponseDto {
/// Returns a new [WorkflowTriggerResponseDto] instance.
WorkflowTriggerResponseDto({
required this.trigger,
this.types = const [],
});
/// Trigger type
WorkflowTrigger trigger;
/// Workflow types
List<WorkflowType> types;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowTriggerResponseDto &&
other.trigger == trigger &&
_deepEquality.equals(other.types, types);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(trigger.hashCode) +
(types.hashCode);
@override
String toString() => 'WorkflowTriggerResponseDto[trigger=$trigger, types=$types]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'trigger'] = this.trigger;
json[r'types'] = this.types;
return json;
}
/// Returns a new [WorkflowTriggerResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static WorkflowTriggerResponseDto? fromJson(dynamic value) {
upgradeDto(value, "WorkflowTriggerResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return WorkflowTriggerResponseDto(
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
types: WorkflowType.listFromJson(json[r'types']),
);
}
return null;
}
static List<WorkflowTriggerResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowTriggerResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowTriggerResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, WorkflowTriggerResponseDto> mapFromJson(dynamic json) {
final map = <String, WorkflowTriggerResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = WorkflowTriggerResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of WorkflowTriggerResponseDto-objects as value to a dart map
static Map<String, List<WorkflowTriggerResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<WorkflowTriggerResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = WorkflowTriggerResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'trigger',
'types',
};
}

View File

@@ -0,0 +1,85 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
/// Workflow types
class WorkflowType {
/// Instantiate a new enum with the provided [value].
const WorkflowType._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const assetV1 = WorkflowType._(r'AssetV1');
static const assetPersonV1 = WorkflowType._(r'AssetPersonV1');
/// List of all possible values in this [enum][WorkflowType].
static const values = <WorkflowType>[
assetV1,
assetPersonV1,
];
static WorkflowType? fromJson(dynamic value) => WorkflowTypeTypeTransformer().decode(value);
static List<WorkflowType> listFromJson(dynamic json, {bool growable = false,}) {
final result = <WorkflowType>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = WorkflowType.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [WorkflowType] to String,
/// and [decode] dynamic data back to [WorkflowType].
class WorkflowTypeTypeTransformer {
factory WorkflowTypeTypeTransformer() => _instance ??= const WorkflowTypeTypeTransformer._();
const WorkflowTypeTypeTransformer._();
String encode(WorkflowType data) => data.value;
/// Decodes a [dynamic value][data] to a WorkflowType.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
WorkflowType? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'AssetV1': return WorkflowType.assetV1;
case r'AssetPersonV1': return WorkflowType.assetPersonV1;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [WorkflowTypeTypeTransformer] instance.
static WorkflowTypeTypeTransformer? _instance;
}

View File

@@ -13,24 +13,14 @@ part of openapi.api;
class WorkflowUpdateDto {
/// Returns a new [WorkflowUpdateDto] instance.
WorkflowUpdateDto({
this.actions = const [],
this.description,
this.enabled,
this.filters = const [],
this.name,
this.triggerType,
this.steps = const [],
this.trigger,
});
/// Workflow actions
List<WorkflowActionItemDto> actions;
/// Workflow description
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? description;
/// Workflow enabled
@@ -42,18 +32,11 @@ class WorkflowUpdateDto {
///
bool? enabled;
/// Workflow filters
List<WorkflowFilterItemDto> filters;
/// Workflow name
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? name;
List<WorkflowStepDto> steps;
/// Workflow trigger type
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -61,33 +44,30 @@ class WorkflowUpdateDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
PluginTriggerType? triggerType;
WorkflowTrigger? trigger;
@override
bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto &&
_deepEquality.equals(other.actions, actions) &&
other.description == description &&
other.enabled == enabled &&
_deepEquality.equals(other.filters, filters) &&
other.name == name &&
other.triggerType == triggerType;
_deepEquality.equals(other.steps, steps) &&
other.trigger == trigger;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(actions.hashCode) +
(description == null ? 0 : description!.hashCode) +
(enabled == null ? 0 : enabled!.hashCode) +
(filters.hashCode) +
(name == null ? 0 : name!.hashCode) +
(triggerType == null ? 0 : triggerType!.hashCode);
(steps.hashCode) +
(trigger == null ? 0 : trigger!.hashCode);
@override
String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]';
String toString() => 'WorkflowUpdateDto[description=$description, enabled=$enabled, name=$name, steps=$steps, trigger=$trigger]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'actions'] = this.actions;
if (this.description != null) {
json[r'description'] = this.description;
} else {
@@ -98,16 +78,16 @@ class WorkflowUpdateDto {
} else {
// json[r'enabled'] = null;
}
json[r'filters'] = this.filters;
if (this.name != null) {
json[r'name'] = this.name;
} else {
// json[r'name'] = null;
}
if (this.triggerType != null) {
json[r'triggerType'] = this.triggerType;
json[r'steps'] = this.steps;
if (this.trigger != null) {
json[r'trigger'] = this.trigger;
} else {
// json[r'triggerType'] = null;
// json[r'trigger'] = null;
}
return json;
}
@@ -121,12 +101,11 @@ class WorkflowUpdateDto {
final json = value.cast<String, dynamic>();
return WorkflowUpdateDto(
actions: WorkflowActionItemDto.listFromJson(json[r'actions']),
description: mapValueOfType<String>(json, r'description'),
enabled: mapValueOfType<bool>(json, r'enabled'),
filters: WorkflowFilterItemDto.listFromJson(json[r'filters']),
name: mapValueOfType<String>(json, r'name'),
triggerType: PluginTriggerType.fromJson(json[r'triggerType']),
steps: WorkflowStepDto.listFromJson(json[r'steps']),
trigger: WorkflowTrigger.fromJson(json[r'trigger']),
);
}
return null;

File diff suppressed because it is too large Load Diff

View File

@@ -1561,51 +1561,31 @@ export type PersonStatisticsResponseDto = {
/** Number of assets */
assets: number;
};
export type PluginActionResponseDto = {
/** Action description */
export type PluginMethodResponseDto = {
/** Description */
description: string;
/** Action ID */
id: string;
/** Method name */
methodName: string;
/** Plugin ID */
pluginId: string;
/** Action schema */
/** Key */
key: string;
/** Name */
name: string;
/** Schema */
schema: object | null;
/** Supported contexts */
supportedContexts: PluginContextType[];
/** Action title */
title: string;
};
export type PluginFilterResponseDto = {
/** Filter description */
description: string;
/** Filter ID */
id: string;
/** Method name */
methodName: string;
/** Plugin ID */
pluginId: string;
/** Filter schema */
schema: object | null;
/** Supported contexts */
supportedContexts: PluginContextType[];
/** Filter title */
/** Title */
title: string;
/** Workflow types */
types: WorkflowType[];
};
export type PluginResponseDto = {
/** Plugin actions */
actions: PluginActionResponseDto[];
/** Plugin author */
author: string;
/** Creation date */
createdAt: string;
/** Plugin description */
description: string;
/** Plugin filters */
filters: PluginFilterResponseDto[];
/** Plugin ID */
id: string;
/** Plugin methods */
methods: PluginMethodResponseDto[];
/** Plugin name */
name: string;
/** Plugin title */
@@ -1615,12 +1595,6 @@ export type PluginResponseDto = {
/** Plugin version */
version: string;
};
export type PluginTriggerResponseDto = {
/** Context type */
contextType: PluginContextType;
/** Trigger type */
"type": PluginTriggerType;
};
export type QueueResponseDto = {
/** Whether the queue is paused */
isPaused: boolean;
@@ -2829,89 +2803,67 @@ export type CreateProfileImageResponseDto = {
/** User ID */
userId: string;
};
export type WorkflowActionResponseDto = {
/** Action configuration */
actionConfig: object | null;
/** Action ID */
id: string;
/** Action order */
order: number;
/** Plugin action ID */
pluginActionId: string;
/** Workflow ID */
workflowId: string;
};
export type WorkflowFilterResponseDto = {
/** Filter configuration */
filterConfig: object | null;
/** Filter ID */
id: string;
/** Filter order */
order: number;
/** Plugin filter ID */
pluginFilterId: string;
/** Workflow ID */
workflowId: string;
export type WorkflowStepResponseDto = {
/** Step configuration */
config: object | null;
/** Step is enabled */
enabled: boolean;
/** Step plugin method */
method: string;
};
export type WorkflowResponseDto = {
/** Workflow actions */
actions: WorkflowActionResponseDto[];
/** Creation date */
createdAt: string;
/** Workflow description */
description: string;
description: string | null;
/** Workflow enabled */
enabled: boolean;
/** Workflow filters */
filters: WorkflowFilterResponseDto[];
/** Workflow ID */
id: string;
/** Workflow name */
name: string | null;
/** Owner user ID */
ownerId: string;
/** Workflow steps */
steps: WorkflowStepResponseDto[];
/** Workflow trigger type */
triggerType: PluginTriggerType;
trigger: WorkflowTrigger;
/** Update date */
updatedAt: string;
};
export type WorkflowActionItemDto = {
/** Action configuration */
actionConfig?: object;
/** Plugin action ID */
pluginActionId: string;
};
export type WorkflowFilterItemDto = {
/** Filter configuration */
filterConfig?: object;
/** Plugin filter ID */
pluginFilterId: string;
export type WorkflowStepDto = {
/** Step configuration */
config?: object | null;
/** Step is enabled */
enabled?: boolean;
/** Step plugin method */
method: string;
};
export type WorkflowCreateDto = {
/** Workflow actions */
actions: WorkflowActionItemDto[];
/** Workflow description */
description?: string;
description?: string | null;
/** Workflow enabled */
enabled?: boolean;
/** Workflow filters */
filters: WorkflowFilterItemDto[];
/** Workflow name */
name: string;
name?: string | null;
steps?: WorkflowStepDto[];
/** Workflow trigger type */
triggerType: PluginTriggerType;
trigger: WorkflowTrigger;
};
export type WorkflowTriggerResponseDto = {
/** Trigger type */
trigger: WorkflowTrigger;
/** Workflow types */
types: WorkflowType[];
};
export type WorkflowUpdateDto = {
/** Workflow actions */
actions?: WorkflowActionItemDto[];
/** Workflow description */
description?: string;
description?: string | null;
/** Workflow enabled */
enabled?: boolean;
/** Workflow filters */
filters?: WorkflowFilterItemDto[];
/** Workflow name */
name?: string;
name?: string | null;
steps?: WorkflowStepDto[];
/** Workflow trigger type */
triggerType?: PluginTriggerType;
trigger?: WorkflowTrigger;
};
export type SyncAckV1 = {};
export type SyncAlbumDeleteV1 = {
@@ -5309,22 +5261,56 @@ export function getPersonThumbnail({ id }: {
/**
* List all plugins
*/
export function getPlugins(opts?: Oazapfts.RequestOpts) {
export function searchPlugins({ description, enabled, id, name, title, version }: {
description?: string;
enabled?: boolean;
id?: string;
name?: string;
title?: string;
version?: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PluginResponseDto[];
}>("/plugins", {
}>(`/plugins${QS.query(QS.explode({
description,
enabled,
id,
name,
title,
version
}))}`, {
...opts
}));
}
/**
* List all plugin triggers
* Retrieve plugin methods
*/
export function getPluginTriggers(opts?: Oazapfts.RequestOpts) {
export function searchPluginMethods({ description, enabled, id, name, pluginName, pluginVersion, title, trigger, $type }: {
description?: string;
enabled?: boolean;
id?: string;
name?: string;
pluginName?: string;
pluginVersion?: string;
title?: string;
trigger?: WorkflowTrigger;
$type?: WorkflowType;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PluginTriggerResponseDto[];
}>("/plugins/triggers", {
data: PluginMethodResponseDto[];
}>(`/plugins/methods${QS.query(QS.explode({
description,
enabled,
id,
name,
pluginName,
pluginVersion,
title,
trigger,
"type": $type
}))}`, {
...opts
}));
}
@@ -6753,11 +6739,23 @@ export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) {
/**
* List all workflows
*/
export function getWorkflows(opts?: Oazapfts.RequestOpts) {
export function searchWorkflows({ description, enabled, id, name, trigger }: {
description?: string;
enabled?: boolean;
id?: string;
name?: string;
trigger?: WorkflowTrigger;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: WorkflowResponseDto[];
}>("/workflows", {
}>(`/workflows${QS.query(QS.explode({
description,
enabled,
id,
name,
trigger
}))}`, {
...opts
}));
}
@@ -6776,6 +6774,17 @@ export function createWorkflow({ workflowCreateDto }: {
body: workflowCreateDto
})));
}
/**
* List all workflow triggers
*/
export function getWorkflowTriggers(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: WorkflowTriggerResponseDto[];
}>("/workflows/triggers", {
...opts
}));
}
/**
* Delete a workflow
*/
@@ -7145,12 +7154,11 @@ export enum PartnerDirection {
SharedBy = "shared-by",
SharedWith = "shared-with"
}
export enum PluginContextType {
Asset = "asset",
Album = "album",
Person = "person"
export enum WorkflowType {
AssetV1 = "AssetV1",
AssetPersonV1 = "AssetPersonV1"
}
export enum PluginTriggerType {
export enum WorkflowTrigger {
AssetCreate = "AssetCreate",
PersonRecognized = "PersonRecognized"
}
@@ -7218,7 +7226,7 @@ export enum JobName {
VersionCheck = "VersionCheck",
OcrQueueAll = "OcrQueueAll",
Ocr = "Ocr",
WorkflowRun = "WorkflowRun"
WorkflowAssetCreate = "WorkflowAssetCreate"
}
export enum SearchSuggestionType {
Country = "country",

2
packages/plugin-core/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/dist
/node_modules

View File

@@ -0,0 +1,11 @@
const esbuild = require('esbuild');
esbuild.build({
entryPoints: ['src/index.ts'],
outdir: 'dist',
bundle: true,
sourcemap: false,
minify: false, // might want to use true for production build
format: 'cjs', // needs to be CJS for now
target: ['es2020'], // don't go over es2020 because quickjs doesn't support it
});

View File

@@ -0,0 +1,191 @@
{
"name": "immich-plugin-core",
"version": "2.0.1",
"title": "Immich Core Plugin",
"description": "Core workflow capabilities for Immich",
"author": "Immich Team",
"wasmPath": "dist/plugin.wasm",
"methods": [
{
"name": "filterFileName",
"title": "Filter by filename",
"description": "Filter assets by filename pattern using text matching or regular expressions",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"title": "Filename pattern",
"description": "Text or regex pattern to match against filename"
},
"matchType": {
"type": "string",
"title": "Match type",
"enum": ["contains", "regex", "exact"],
"default": "contains",
"description": "Type of pattern matching to perform"
},
"caseSensitive": {
"type": "boolean",
"default": false,
"description": "Whether matching should be case-sensitive"
}
},
"required": ["pattern"]
}
},
{
"name": "filterFileType",
"title": "Filter by file type",
"description": "Filter assets by file type",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"fileTypes": {
"type": "array",
"title": "File types",
"items": {
"type": "string",
"enum": ["image", "video"]
},
"description": "Allowed file types"
}
},
"required": ["fileTypes"]
}
},
{
"name": "filterPerson",
"title": "Filter by person",
"description": "Filter by detected person",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"personIds": {
"type": "array",
"title": "Person IDs",
"items": {
"type": "string"
},
"description": "List of person to match",
"subType": "people-picker"
},
"matchAny": {
"type": "boolean",
"default": true,
"description": "Match any name (true) or require all names (false)"
}
},
"required": ["personIds"]
}
},
{
"name": "assetArchive",
"title": "Archive",
"description": "Move the asset to archive",
"types": ["AssetV1"],
"schema": {}
},
{
"name": "assetFavorite",
"title": "Favorite",
"description": "Mark the asset as favorite or unfavorite",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"inverse": {
"type": "boolean",
"title": "Inverse",
"description": "Unfavorite by default, set to true to favorite instead",
"default": false
}
}
}
},
{
"name": "albumAddAssets",
"title": "Add to Album",
"description": "Add the item to a specified album",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"albumId": {
"type": "string",
"title": "Album ID",
"description": "Target album ID",
"uiHint": "albumId"
}
},
"required": ["albumId"]
}
},
{
"name": "test",
"title": "Test",
"description": "Test method with complete configuration examples",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"number1": {
"type": "number",
"title": "Number 1",
"description": "Basic number"
},
"number2": {
"type": "number",
"title": "Number 2",
"array": true,
"description": "List of numbers"
},
"string1": {
"type": "string",
"title": "String 1",
"description": "Basic string"
},
"string2": {
"type": "string",
"title": "String 2",
"array": true,
"description": "List of strings"
},
"string3": {
"type": "string",
"title": "String 3",
"enum": ["choice-1", "choice-2"],
"description": "Select from a list"
},
"nested": {
"type": "object",
"title": "Nested",
"description": "Nested properties for nesting",
"properties": {
"nested1": {
"type": "string",
"title": "Nested 1",
"description": "Nested string"
},
"nested2": {
"type": "number",
"title": "Nested 2",
"description": "Nested number"
}
}
},
"albumId": {
"type": "string",
"title": "Album ID",
"description": "Target album ID",
"uiHint": "albumId"
}
},
"required": ["albumId"]
}
}
]
}

View File

@@ -8,4 +8,4 @@ run = "pnpm install --frozen-lockfile"
[tasks.build]
depends = ["install"]
run = "pnpm run build"
run = "pnpm run --filter @immich/plugin-sdk --filter @immich/plugin-core build"

View File

@@ -1,5 +1,5 @@
{
"name": "plugins",
"name": "@immich/plugin-core",
"version": "1.0.0",
"description": "",
"main": "src/index.ts",
@@ -14,6 +14,7 @@
"devDependencies": {
"@extism/js-pdk": "^1.0.1",
"esbuild": "^0.27.0",
"typescript": "^5.3.2"
"typescript": "^5.3.2",
"@immich/plugin-sdk": "workspace:*"
}
}

17
packages/plugin-core/src/index.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
// copy from
// import '@immich/plugin-sdk/host-functions';
declare module 'extism:host' {
interface user {
albumAddAssets(ptr: PTR): I64;
}
}
declare module 'main' {
export function assetFileFilter(): I32;
export function assetArchive(): I32;
export function assetFavorite(): I32;
export function assetLock(): I32;
export function assetTrash(): I32;
export function albumAddAssets(): I32;
export function test(): I32;
}

View File

@@ -0,0 +1,112 @@
import {
AssetStatus,
AssetVisibility,
WorkflowType,
wrapper,
} from '@immich/plugin-sdk';
type AssetFileFilterConfig = {
pattern: string;
matchType?: 'contains' | 'exact' | 'regex';
caseSensitive?: boolean;
};
export const assetFileFilter = () => {
return wrapper<WorkflowType.AssetV1>(({ data, config }) => {
const {
pattern,
matchType = 'contains',
caseSensitive = false,
} = config as AssetFileFilterConfig;
const { asset } = data;
const fileName = asset.originalFileName || '';
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
if (matchType === 'exact') {
return { workflow: { continue: searchName === searchPattern } };
}
if (matchType === 'regex') {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
return { workflow: { continue: regex.test(fileName) } };
}
return { workflow: { continue: searchName.includes(searchPattern) } };
});
};
type AssetArchiveConfig = {
inverse?: boolean;
};
export const assetArchive = () => {
return wrapper<WorkflowType.AssetV1, AssetArchiveConfig>(
({ config, data }) => {
const target: AssetVisibility = config.inverse
? AssetVisibility.Timeline
: AssetVisibility.Archive;
if (target !== data.asset.visibility) {
return {
changes: {
asset: { visibility: target },
},
};
}
}
);
};
type AssetFavoriteConfig = {
inverse?: boolean;
};
export const assetFavorite = () => {
return wrapper<WorkflowType.AssetV1, AssetFavoriteConfig>(
({ config, data }) => {
const target = config.inverse ? false : true;
if (target !== data.asset.isFavorite) {
return {
changes: {
asset: { isFavorite: target },
},
};
}
}
);
};
export const assetLock = () => {
return wrapper<WorkflowType.AssetV1>(() => ({
changes: { asset: { visibility: AssetVisibility.Locked } },
}));
};
type AssetTrashConfig = {
inverse?: boolean;
};
export const assetTrash = () => {
return wrapper<WorkflowType.AssetV1, AssetTrashConfig>(({ config }) => ({
changes: {
asset: config.inverse
? { deletedAt: null, status: AssetStatus.Active }
: { deletedAt: new Date().toISOString(), status: AssetStatus.Trashed },
},
}));
};
type AssetAddToAlbumConfig = {
albumId: string;
};
export const albumAddAssets = () => {
return wrapper<WorkflowType.AssetV1, AssetAddToAlbumConfig>(
({ config, data, functions }) => {
functions.albumAddAssets(config.albumId, [data.asset.id]);
return {};
}
);
};
export const test = () => {
return wrapper(() => ({}));
};

View File

@@ -1,19 +1,17 @@
{
"compilerOptions": {
"target": "es2020", // Specify ECMAScript target version
"module": "commonjs", // Specify module code generation
"lib": [
"es2020"
], // Specify a list of library files to be included in the compilation
"types": [
"@extism/js-pdk",
"./src/index.d.ts"
], // Specify a list of type definition files to be included in the compilation
"module": "nodenext", // Specify module code generation
"outDir": "./dist",
"lib": ["es2020"], // Specify a list of library files to be included in the compilation
"types": ["@extism/js-pdk"], // Specify a list of type definition files to be included in the compilation
"strict": true, // Enable all strict type-checking options
"esModuleInterop": true, // Enables compatibility with Babel-style module imports
"moduleResolution": "nodenext",
"declaration": true,
"emitDeclarationOnly": true,
"skipLibCheck": true, // Skip type checking of declaration files
"allowJs": true, // Allow JavaScript files to be compiled
"noEmit": true // Do not emit outputs (no .js or .d.ts files)
"allowJs": true // Allow JavaScript files to be compiled
},
"include": [
"src/**/*.ts" // Include all TypeScript files in src directory

2
packages/plugin-sdk/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/dist
/node_modules

View File

@@ -0,0 +1,11 @@
import esbuild from 'esbuild';
esbuild.build({
entryPoints: ['src/index.ts'],
outdir: 'dist',
bundle: true,
sourcemap: false,
minify: false,
format: 'esm',
target: ['es2020'],
});

View File

@@ -0,0 +1,38 @@
{
"name": "@immich/plugin-sdk",
"version": "0.0.0",
"description": "",
"main": "index.js",
"type": "module",
"exports": {
"./host-functions": {
"import": "./dist/host-functions.js",
"types": "./dist/host-functions.d.ts"
},
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "node esbuild.js && tsc --emitDeclarationOnly && tsc-alias"
},
"files": [
"dist"
],
"keywords": [],
"author": "",
"license": "GNU Affero General Public License version 3",
"packageManager": "pnpm@10.30.3",
"devDependencies": {
"@extism/js-pdk": "^1.1.1",
"@types/node": "^24.11.0",
"esbuild": "^0.27.3",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3"
},
"peerDependencies": {
"@extism/js-pdk": "^1.1.1"
}
}

View File

@@ -0,0 +1,33 @@
export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
PersonRecognized = 'PersonRecognized',
}
export enum WorkflowType {
AssetV1 = 'AssetV1',
AssetPersonV1 = 'AssetPersonV1',
}
export enum AssetType {
Image = 'IMAGE',
Video = 'VIDEO',
Audio = 'AUDIO',
Other = 'OTHER',
}
export enum AssetStatus {
Active = 'active',
Trashed = 'trashed',
Deleted = 'deleted',
}
export enum AssetVisibility {
Archive = 'archive',
Timeline = 'timeline',
/**
* Video part of the LivePhotos and MotionPhotos
*/
Hidden = 'hidden',
Locked = 'locked',
}

View File

@@ -0,0 +1,41 @@
declare module 'extism:host' {
interface user {
albumAddAssets(ptr: PTR): I64;
}
}
const host = Host.getFunctions();
type HostFunctionName = keyof typeof host;
const call = <T, R>(name: HostFunctionName, authToken: string, args: T) => {
const pointer1 = Memory.fromString(JSON.stringify({ authToken, args }));
const fn = host[name];
const handler = Memory.find(fn(pointer1.offset));
try {
const result = JSON.parse(handler.readString()) as
| {
success: true;
response: R;
}
| { success: false; status: number; message: string };
if (result.success) {
return result.response;
}
throw new Error(
`Failed to call host function "${name}", received ${
result.status
} - ${JSON.stringify(result.message)}`
);
} finally {
handler.free();
pointer1.free();
}
};
export const hostFunctions = (authToken: string) => ({
albumAddAssets: (albumId: string, assetIds: string[]) =>
call('albumAddAssets', authToken, [albumId, { ids: assetIds }]),
});

View File

@@ -0,0 +1,4 @@
export * from 'src/enum.js';
export * from 'src/host-functions.js';
export * from 'src/sdk.js';
export * from 'src/types.js';

View File

@@ -0,0 +1,47 @@
import type { WorkflowType } from 'src/enum.js';
import { hostFunctions } from 'src/host-functions.js';
import type {
ConfigValue,
WorkflowEventPayload,
WorkflowResponse,
} from 'src/types.js';
export const wrapper = <
T extends WorkflowType = WorkflowType,
TConfig extends ConfigValue = ConfigValue
>(
fn: (
payload: WorkflowEventPayload<T, TConfig> & {
functions: ReturnType<typeof hostFunctions>;
}
) => WorkflowResponse<T> | undefined
) => {
try {
const input = Host.inputString();
const event = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
const debug = event.workflow.debug ?? false;
if (debug) {
console.trace(`Event trigger: ${event.trigger}`);
console.trace(`Event type: ${event.type}`);
console.trace(`Event data: ${JSON.stringify(event.data)}`);
console.trace(`Event config: ${JSON.stringify(event.config)}`);
}
const response =
fn({ ...event, functions: hostFunctions(event.workflow.authToken) }) ??
{};
if (debug) {
console.trace(`Output workflow: ${JSON.stringify(response.workflow)}`);
console.trace(`Output changes: ${JSON.stringify(response.changes)}`);
console.trace(`Output data: ${JSON.stringify(response.data)}`);
}
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
return { workflow: { continue: false } };
}
};

View File

@@ -0,0 +1,124 @@
import type {
AssetStatus,
AssetType,
AssetVisibility,
WorkflowTrigger,
WorkflowType,
} from 'src/enum.js';
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
export type WorkflowEventMap = {
[WorkflowType.AssetV1]: AssetV1;
[WorkflowType.AssetPersonV1]: AssetPersonV1;
};
export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
export type WorkflowEventPayload<
T extends WorkflowType = WorkflowType,
TConfig = WorkflowStepConfig
> = {
trigger: WorkflowTrigger;
type: T;
data: WorkflowEventData<T>;
config: TConfig;
workflow: {
id: string;
authToken: string;
stepId: string;
debug?: boolean;
};
};
export type WorkflowResponse<T extends WorkflowType = WorkflowType> = {
workflow?: {
/** stop the workflow */
continue?: boolean;
};
changes?: DeepPartial<WorkflowEventData<T>>;
/** data to be passed to the next workflow step */
data?: Record<string, unknown>;
};
export type WorkflowStepConfig = {
[key: string]: ConfigValue;
};
export type ConfigValue =
| string
| number
| boolean
| null
| ConfigValue[]
| { [key: string]: ConfigValue };
export type AssetV1 = {
asset: {
id: string;
ownerId: string;
type: AssetType;
originalPath: string;
fileCreatedAt: Date;
fileModifiedAt: Date;
isFavorite: boolean;
checksum: Buffer; // sha1 checksum
livePhotoVideoId: string | null;
updatedAt: Date;
createdAt: Date;
originalFileName: string;
isOffline: boolean;
libraryId: string | null;
isExternal: boolean;
deletedAt: Date | null;
localDateTime: Date;
stackId: string | null;
duplicateId: string | null;
status: AssetStatus;
visibility: AssetVisibility;
isEdited: boolean;
exifInfo: {
make: string | null;
model: string | null;
exifImageWidth: number | null;
exifImageHeight: number | null;
fileSizeInByte: number | null;
orientation: string | null;
dateTimeOriginal: Date | null;
modifyDate: Date | null;
lensModel: string | null;
fNumber: number | null;
focalLength: number | null;
iso: number | null;
latitude: number | null;
longitude: number | null;
city: string | null;
state: string | null;
country: string | null;
description: string | null;
fps: number | null;
exposureTime: string | null;
livePhotoCID: string | null;
timeZone: string | null;
projectionType: string | null;
profileDescription: string | null;
colorspace: string | null;
bitsPerSample: number | null;
autoStackId: string | null;
rating: number | null;
tags: string[] | null;
updatedAt: Date | null;
} | null;
};
};
export type AssetPersonV1 = AssetV1 & {
person: {
id: string;
name: string;
};
};

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"module": "nodenext",
"target": "esnext",
"strict": true,
"removeComments": true,
"lib": ["esnext"],
"outDir": "./dist",
"types": ["node", "@extism/js-pdk"],
"sourceMap": false,
"declaration": true,
"emitDeclarationOnly": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"baseUrl": "./",
"paths": {
"src/*": ["./src/*"]
},
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
"esModuleInterop": true
}
}

2
plugins/.gitignore vendored
View File

@@ -1,2 +0,0 @@
node_modules
dist

View File

@@ -1,26 +0,0 @@
Copyright 2024, The Extism Authors.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -1,12 +0,0 @@
const esbuild = require('esbuild');
esbuild
.build({
entryPoints: ['src/index.ts'],
outdir: 'dist',
bundle: true,
sourcemap: true,
minify: false, // might want to use true for production build
format: 'cjs', // needs to be CJS for now
target: ['es2020'] // don't go over es2020 because quickjs doesn't support it
})

View File

@@ -1,159 +0,0 @@
{
"name": "immich-core",
"version": "2.0.1",
"title": "Immich Core",
"description": "Core workflow capabilities for Immich",
"author": "Immich Team",
"wasm": {
"path": "dist/plugin.wasm"
},
"filters": [
{
"methodName": "filterFileName",
"title": "Filter by filename",
"description": "Filter assets by filename pattern using text matching or regular expressions",
"supportedContexts": [
"asset"
],
"schema": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"title": "Filename pattern",
"description": "Text or regex pattern to match against filename"
},
"matchType": {
"type": "string",
"title": "Match type",
"enum": [
"contains",
"regex",
"exact"
],
"default": "contains",
"description": "Type of pattern matching to perform"
},
"caseSensitive": {
"type": "boolean",
"default": false,
"description": "Whether matching should be case-sensitive"
}
},
"required": [
"pattern"
]
}
},
{
"methodName": "filterFileType",
"title": "Filter by file type",
"description": "Filter assets by file type",
"supportedContexts": [
"asset"
],
"schema": {
"type": "object",
"properties": {
"fileTypes": {
"type": "array",
"title": "File types",
"items": {
"type": "string",
"enum": [
"image",
"video"
]
},
"description": "Allowed file types"
}
},
"required": [
"fileTypes"
]
}
},
{
"methodName": "filterPerson",
"title": "Filter by person",
"description": "Filter by detected person",
"supportedContexts": [
"person"
],
"schema": {
"type": "object",
"properties": {
"personIds": {
"type": "array",
"title": "Person IDs",
"items": {
"type": "string"
},
"description": "List of person to match",
"subType": "people-picker"
},
"matchAny": {
"type": "boolean",
"default": true,
"description": "Match any name (true) or require all names (false)"
}
},
"required": [
"personIds"
]
}
}
],
"actions": [
{
"methodName": "actionArchive",
"title": "Archive",
"description": "Move the asset to archive",
"supportedContexts": [
"asset"
],
"schema": {}
},
{
"methodName": "actionFavorite",
"title": "Favorite",
"description": "Mark the asset as favorite or unfavorite",
"supportedContexts": [
"asset"
],
"schema": {
"type": "object",
"properties": {
"favorite": {
"type": "boolean",
"default": true,
"description": "Set favorite (true) or unfavorite (false)"
}
}
}
},
{
"methodName": "actionAddToAlbum",
"title": "Add to Album",
"description": "Add the item to a specified album",
"supportedContexts": [
"asset",
"person"
],
"schema": {
"type": "object",
"properties": {
"albumId": {
"type": "string",
"title": "Album ID",
"description": "Target album ID",
"subType": "album-picker"
}
},
"required": [
"albumId"
]
}
}
]
}

View File

@@ -1,533 +0,0 @@
{
"name": "plugins",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plugins",
"version": "1.0.0",
"license": "AGPL-3.0",
"devDependencies": {
"@extism/js-pdk": "^1.0.1",
"esbuild": "^0.27.0",
"typescript": "^5.3.2"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@extism/js-pdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@extism/js-pdk/-/js-pdk-1.1.1.tgz",
"integrity": "sha512-VZLn/dX0ttA1uKk2PZeR/FL3N+nA1S5Vc7E5gdjkR60LuUIwCZT9cYON245V4HowHlBA7YOegh0TLjkx+wNbrA==",
"dev": true,
"license": "BSD-Clause-3",
"dependencies": {
"urlpattern-polyfill": "^8.0.2"
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/urlpattern-polyfill": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz",
"integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -1,12 +0,0 @@
declare module 'main' {
export function filterFileName(): I32;
export function actionAddToAlbum(): I32;
export function actionArchive(): I32;
}
declare module 'extism:host' {
interface user {
updateAsset(ptr: PTR): I32;
addAssetToAlbum(ptr: PTR): I32;
}
}

View File

@@ -1,71 +0,0 @@
const { updateAsset, addAssetToAlbum } = Host.getFunctions();
function parseInput() {
return JSON.parse(Host.inputString());
}
function returnOutput(output: any) {
Host.outputString(JSON.stringify(output));
return 0;
}
export function filterFileName() {
const input = parseInput();
const { data, config } = input;
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const fileName = data.asset.originalFileName || data.asset.fileName || '';
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
let passed = false;
if (matchType === 'exact') {
passed = searchName === searchPattern;
} else if (matchType === 'regex') {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
passed = regex.test(fileName);
} else {
// contains
passed = searchName.includes(searchPattern);
}
return returnOutput({ passed });
}
export function actionAddToAlbum() {
const input = parseInput();
const { authToken, config, data } = input;
const { albumId } = config;
const ptr = Memory.fromString(
JSON.stringify({
authToken,
assetId: data.asset.id,
albumId: albumId,
})
);
addAssetToAlbum(ptr.offset);
ptr.free();
return returnOutput({ success: true });
}
export function actionArchive() {
const input = parseInput();
const { authToken, data } = input;
const ptr = Memory.fromString(
JSON.stringify({
authToken,
id: data.asset.id,
visibility: 'archive',
})
);
updateAsset(ptr.offset);
ptr.free();
return returnOutput({ success: true });
}

67
pnpm-lock.yaml generated
View File

@@ -329,11 +329,14 @@ importers:
specifier: ^5.3.3
version: 5.9.3
plugins:
packages/plugin-core:
devDependencies:
'@extism/js-pdk':
specifier: ^1.0.1
version: 1.1.1
'@immich/plugin-sdk':
specifier: workspace:*
version: link:../plugin-sdk
esbuild:
specifier: ^0.27.0
version: 0.27.3
@@ -341,11 +344,32 @@ importers:
specifier: ^5.3.2
version: 5.9.3
packages/plugin-sdk:
devDependencies:
'@extism/js-pdk':
specifier: ^1.1.1
version: 1.1.1
'@types/node':
specifier: ^24.11.0
version: 24.11.0
esbuild:
specifier: ^0.27.3
version: 0.27.3
tsc-alias:
specifier: ^1.8.16
version: 1.8.16
typescript:
specifier: ^5.9.3
version: 5.9.3
server:
dependencies:
'@extism/extism':
specifier: 2.0.0-rc13
version: 2.0.0-rc13
'@immich/plugin-sdk':
specifier: workspace:*
version: link:../packages/plugin-sdk
'@immich/sql-tools':
specifier: ^0.3.2
version: 0.3.2
@@ -6121,6 +6145,10 @@ packages:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
commander@9.5.0:
resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==}
engines: {node: ^12.20.0 || >=14}
comment-json@4.4.1:
resolution: {integrity: sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg==}
engines: {node: '>= 6'}
@@ -9136,6 +9164,10 @@ packages:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0}
mylas@2.1.14:
resolution: {integrity: sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==}
engines: {node: '>=16.0.0'}
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@@ -9656,6 +9688,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
plimit-lit@1.6.1:
resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==}
engines: {node: '>=12'}
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -10288,6 +10324,10 @@ packages:
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
engines: {node: '>=0.6'}
queue-lit@1.5.2:
resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==}
engines: {node: '>=12'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -11510,6 +11550,11 @@ packages:
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
tsc-alias@1.8.16:
resolution: {integrity: sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==}
engines: {node: '>=16.20.2'}
hasBin: true
tsconfck@3.1.6:
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
engines: {node: ^18 || >=20}
@@ -18363,6 +18408,8 @@ snapshots:
commander@8.3.0: {}
commander@9.5.0: {}
comment-json@4.4.1:
dependencies:
array-timsort: 1.0.3
@@ -22075,6 +22122,8 @@ snapshots:
mute-stream@2.0.0: {}
mylas@2.1.14: {}
mz@2.7.0:
dependencies:
any-promise: 1.3.0
@@ -22618,6 +22667,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
plimit-lit@1.6.1:
dependencies:
queue-lit: 1.5.2
pluralize@8.0.0: {}
pmtiles@3.2.1:
@@ -23284,6 +23337,8 @@ snapshots:
dependencies:
side-channel: 1.1.0
queue-lit@1.5.2: {}
queue-microtask@1.2.3: {}
quick-lru@5.1.1: {}
@@ -24876,6 +24931,16 @@ snapshots:
ts-interface-checker@0.1.13: {}
tsc-alias@1.8.16:
dependencies:
chokidar: 3.6.0
commander: 9.5.0
get-tsconfig: 4.13.0
globby: 11.1.0
mylas: 2.1.14
normalize-path: 3.0.0
plimit-lit: 1.6.1
tsconfck@3.1.6(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3

View File

@@ -9,6 +9,7 @@ packages:
- plugins
- web
- .github
- packages/*
ignoredBuiltDependencies:
- '@nestjs/core'
- '@parcel/watcher'

View File

@@ -13,12 +13,13 @@ FROM builder AS server
WORKDIR /usr/src/app
COPY ./server ./server/
COPY ./packages/plugin-sdk ./packages/plugin-sdk/
RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile build && \
SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --filter @immich/plugin-sdk --frozen-lockfile build && \
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
FROM builder AS web
@@ -55,21 +56,21 @@ ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2026.1.1@sha256:a55c391f7582f34c58bce1a85090cd526596402ba77fc32b06c49b8404ef9c14 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /usr/src/app
COPY ./plugins/mise.toml ./plugins/
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml
COPY ./packages/plugin-core/mise.toml ./packages/plugin-core/
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/packages/plugin-core/mise.toml
ENV MISE_DATA_DIR=/buildcache/mise
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
mise install --cd plugins
mise install --cd packages/plugin-core
COPY ./plugins ./plugins/
COPY ./packages ./packages/
# Build plugins
RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
cd plugins && mise run build
cd packages/plugin-core && mise run build
FROM ghcr.io/immich-app/base-server-prod:202603031112@sha256:bb8c8645ee61977140121e56ba09db7ae656a7506f9a6af1be8461b4d81fdf03
@@ -81,8 +82,8 @@ ENV NODE_ENV=production \
COPY --from=server /output/server-pruned ./server
COPY --from=web /usr/src/app/web/build /build/www
COPY --from=cli /output/cli-pruned ./cli
COPY --from=plugins /usr/src/app/plugins/dist /build/corePlugin/dist
COPY --from=plugins /usr/src/app/plugins/manifest.json /build/corePlugin/manifest.json
COPY --from=plugins /usr/src/app/packages/plugin-core/dist /build/corePlugin/dist
COPY --from=plugins /usr/src/app/packages/plugin-core/manifest.json /build/corePlugin/manifest.json
RUN ln -s ../../cli/bin/immich server/bin/immich
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE

View File

@@ -35,6 +35,7 @@
},
"dependencies": {
"@extism/extism": "2.0.0-rc13",
"@immich/plugin-sdk": "workspace:*",
"@immich/sql-tools": "^0.3.2",
"@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4",

View File

@@ -0,0 +1,56 @@
import { PluginController } from 'src/controllers/plugin.controller';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PluginService } from 'src/services/plugin.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { factory } from 'test/small.factory';
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(PluginController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(PluginService);
beforeAll(async () => {
ctx = await controllerSetup(PluginController, [
{ provide: PluginService, useValue: service },
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('GET /plugins', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/plugins');
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.get(`/plugins`)
.query({ id: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
});
describe('GET /plugins/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/plugins/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.get(`/plugins/invalid`)
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
});
});

View File

@@ -1,7 +1,12 @@
import { Controller, Get, Param } from '@nestjs/common';
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
import {
PluginMethodResponseDto,
PluginMethodSearchDto,
PluginResponseDto,
PluginSearchDto,
} from 'src/dtos/plugin.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { PluginService } from 'src/services/plugin.service';
@@ -12,26 +17,26 @@ import { UUIDParamDto } from 'src/validation';
export class PluginController {
constructor(private service: PluginService) {}
@Get('triggers')
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
summary: 'List all plugin triggers',
description: 'Retrieve a list of all available plugin triggers.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
getPluginTriggers(): PluginTriggerResponseDto[] {
return this.service.getTriggers();
}
@Get()
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
summary: 'List all plugins',
description: 'Retrieve a list of plugins available to the authenticated user.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
history: HistoryBuilder.v3(),
})
getPlugins(): Promise<PluginResponseDto[]> {
return this.service.getAll();
searchPlugins(@Query() dto: PluginSearchDto): Promise<PluginResponseDto[]> {
return this.service.search(dto);
}
@Get('methods')
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
summary: 'Retrieve plugin methods',
description: 'Retrieve a list of plugin methods',
history: HistoryBuilder.v3(),
})
searchPluginMethods(@Query() dto: PluginMethodSearchDto): Promise<PluginMethodResponseDto[]> {
return this.service.searchMethods(dto);
}
@Get(':id')
@@ -39,7 +44,7 @@ export class PluginController {
@Endpoint({
summary: 'Retrieve a plugin',
description: 'Retrieve information about a specific plugin by its ID.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
history: HistoryBuilder.v3(),
})
getPlugin(@Param() { id }: UUIDParamDto): Promise<PluginResponseDto> {
return this.service.get(id);

View File

@@ -0,0 +1,113 @@
import { WorkflowController } from 'src/controllers/workflow.controller';
import { WorkflowTrigger } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { WorkflowService } from 'src/services/workflow.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { factory } from 'test/small.factory';
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(WorkflowController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(WorkflowService);
beforeAll(async () => {
ctx = await controllerSetup(WorkflowController, [
{ provide: WorkflowService, useValue: service },
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('POST /workflows', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/workflows').send({});
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require a valid trigger`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.post(`/workflows`)
.send({ trigger: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(
expect.arrayContaining([expect.stringContaining('trigger must be one of the following values')]),
),
);
});
it(`should require a valid enabled value`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.post(`/workflows`)
.send({ enabled: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining([expect.stringContaining('enabled must be a boolean')])),
);
});
it(`should not require a name`, async () => {
const { status } = await request(ctx.getHttpServer())
.post(`/workflows`)
.send({ trigger: WorkflowTrigger.AssetCreate })
.set('Authorization', `Bearer token`);
expect(status).toBe(201);
expect(service.create).toHaveBeenCalled();
});
});
describe('GET /workflows', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/workflows');
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.get(`/workflows`)
.query({ id: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
});
describe('GET /workflows/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/workflows/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.get(`/workflows/invalid`)
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
});
describe('PUT /workflows/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/workflows/${factory.uuid()}`).send({});
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/workflows/invalid`)
.set('Authorization', `Bearer token`)
.send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
});
});
});

View File

@@ -1,8 +1,14 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { WorkflowCreateDto, WorkflowResponseDto, WorkflowUpdateDto } from 'src/dtos/workflow.dto';
import {
WorkflowCreateDto,
WorkflowResponseDto,
WorkflowSearchDto,
WorkflowTriggerResponseDto,
WorkflowUpdateDto,
} from 'src/dtos/workflow.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { WorkflowService } from 'src/services/workflow.service';
@@ -18,7 +24,7 @@ export class WorkflowController {
@Endpoint({
summary: 'Create a workflow',
description: 'Create a new workflow, the workflow can also be created with empty filters and actions.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
history: HistoryBuilder.v3(),
})
createWorkflow(@Auth() auth: AuthDto, @Body() dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
return this.service.create(auth, dto);
@@ -29,10 +35,21 @@ export class WorkflowController {
@Endpoint({
summary: 'List all workflows',
description: 'Retrieve a list of workflows available to the authenticated user.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
history: HistoryBuilder.v3(),
})
getWorkflows(@Auth() auth: AuthDto): Promise<WorkflowResponseDto[]> {
return this.service.getAll(auth);
searchWorkflows(@Auth() auth: AuthDto, @Query() dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> {
return this.service.search(auth, dto);
}
@Get('triggers')
@Authenticated({ permission: false })
@Endpoint({
summary: 'List all workflow triggers',
description: 'Retrieve a list of all available workflow triggers.',
history: HistoryBuilder.v3(),
})
getWorkflowTriggers(): WorkflowTriggerResponseDto[] {
return this.service.getTriggers();
}
@Get(':id')
@@ -40,7 +57,7 @@ export class WorkflowController {
@Endpoint({
summary: 'Retrieve a workflow',
description: 'Retrieve information about a specific workflow by its ID.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
history: HistoryBuilder.v3(),
})
getWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<WorkflowResponseDto> {
return this.service.get(auth, id);
@@ -52,7 +69,7 @@ export class WorkflowController {
summary: 'Update a workflow',
description:
'Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
history: HistoryBuilder.v3(),
})
updateWorkflow(
@Auth() auth: AuthDto,
@@ -68,7 +85,7 @@ export class WorkflowController {
@Endpoint({
summary: 'Delete a workflow',
description: 'Delete a workflow by its ID.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
history: HistoryBuilder.v3(),
})
deleteWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);

View File

@@ -7,8 +7,6 @@ import {
AssetVisibility,
MemoryType,
Permission,
PluginContext,
PluginTriggerType,
SharedLinkType,
SourceType,
UserAvatarColor,
@@ -16,10 +14,8 @@ import {
} from 'src/enum';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { PluginTable } from 'src/schema/tables/plugin.table';
import { UserMetadataItem } from 'src/types';
import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types';
export type AuthUser = {
id: string;
@@ -278,43 +274,6 @@ export type AssetFace = {
export type Plugin = Selectable<PluginTable>;
export type PluginFilter = Selectable<PluginFilterTable> & {
methodName: string;
title: string;
description: string;
supportedContexts: PluginContext[];
schema: JSONSchema | null;
};
export type PluginAction = Selectable<PluginActionTable> & {
methodName: string;
title: string;
description: string;
supportedContexts: PluginContext[];
schema: JSONSchema | null;
};
export type Workflow = Selectable<WorkflowTable> & {
triggerType: PluginTriggerType;
name: string | null;
description: string;
enabled: boolean;
};
export type WorkflowFilter = Selectable<WorkflowFilterTable> & {
workflowId: string;
pluginFilterId: string;
filterConfig: FilterConfig | null;
order: number;
};
export type WorkflowAction = Selectable<WorkflowActionTable> & {
workflowId: string;
pluginActionId: string;
actionConfig: ActionConfig | null;
order: number;
};
const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const;
const userWithPrefixColumns = [
'user2.id',
@@ -345,6 +304,32 @@ export const columns = {
'asset.width',
'asset.height',
],
workflowAssetV1: [
'asset.id',
'asset.ownerId',
'asset.stackId',
'asset.livePhotoVideoId',
'asset.libraryId',
'asset.duplicateId',
'asset.createdAt',
'asset.updatedAt',
'asset.deletedAt',
'asset.fileCreatedAt',
'asset.fileModifiedAt',
'asset.localDateTime',
'asset.type',
'asset.status',
'asset.visibility',
'asset.duration',
'asset.checksum',
'asset.originalPath',
'asset.originalFileName',
'asset.isOffline',
'asset.isFavorite',
'asset.isExternal',
'asset.isEdited',
'asset.isFavorite',
],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
assetFilesForThumbnail: [
'asset_file.id',
@@ -476,17 +461,6 @@ export const columns = {
'asset_exif.tags',
'asset_exif.timeZone',
],
plugin: [
'plugin.id as id',
'plugin.name as name',
'plugin.title as title',
'plugin.description as description',
'plugin.author as author',
'plugin.version as version',
'plugin.wasmPath as wasmPath',
'plugin.createdAt as createdAt',
'plugin.updatedAt as updatedAt',
],
} as const;
export type LockableProperty = (typeof lockableProperties)[number];

View File

@@ -210,6 +210,10 @@ export class HistoryBuilder {
private hasDeprecated = false;
private items: HistoryEntry[] = [];
static v3() {
return new HistoryBuilder().added('v3.0.0');
}
added(version: string, description?: string) {
return this.push({ version, state: 'Added', description });
}

View File

@@ -3,7 +3,6 @@ import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
@@ -12,66 +11,31 @@ import {
Matches,
ValidateNested,
} from 'class-validator';
import { PluginContext } from 'src/enum';
import { JSONSchema } from 'src/types/plugin-schema.types';
import { WorkflowType } from 'src/enum';
import { JSONSchema } from 'src/types';
import { ValidateEnum } from 'src/validation';
class PluginManifestWasmDto {
@ApiProperty({ description: 'WASM file path' })
class PluginManifestMethodDto {
@ApiProperty({ description: 'Method name' })
@IsString()
@IsNotEmpty()
path!: string;
}
name!: string;
class PluginManifestFilterDto {
@ApiProperty({ description: 'Filter method name' })
@IsString()
@IsNotEmpty()
methodName!: string;
@ApiProperty({ description: 'Filter title' })
@ApiProperty({ description: 'Method title' })
@IsString()
@IsNotEmpty()
title!: string;
@ApiProperty({ description: 'Filter description' })
@IsString()
@IsNotEmpty()
description!: string;
@ApiProperty({ description: 'Supported contexts', enum: PluginContext, isArray: true })
@IsArray()
@ArrayMinSize(1)
@IsEnum(PluginContext, { each: true })
supportedContexts!: PluginContext[];
@ApiPropertyOptional({ description: 'Filter schema' })
@IsObject()
@IsOptional()
schema?: JSONSchema;
}
class PluginManifestActionDto {
@ApiProperty({ description: 'Action method name' })
@IsString()
@IsNotEmpty()
methodName!: string;
@ApiProperty({ description: 'Action title' })
@IsString()
@IsNotEmpty()
title!: string;
@ApiProperty({ description: 'Action description' })
@ApiProperty({ description: 'Method description' })
@IsString()
@IsNotEmpty()
description!: string;
@ArrayMinSize(1)
@ValidateEnum({ enum: PluginContext, name: 'PluginContext', each: true, description: 'Supported contexts' })
supportedContexts!: PluginContext[];
@ValidateEnum({ enum: WorkflowType, name: 'WorkflowType', each: true, description: 'Workflow type' })
types!: WorkflowType[];
@ApiPropertyOptional({ description: 'Action schema' })
@ApiPropertyOptional({ description: 'Method schema' })
@IsObject()
@IsOptional()
schema?: JSONSchema;
@@ -102,27 +66,20 @@ export class PluginManifestDto {
@IsNotEmpty()
description!: string;
@ApiProperty({ description: 'WASM file path' })
@IsString()
@IsNotEmpty()
wasmPath!: string;
@ApiProperty({ description: 'Plugin author' })
@IsString()
@IsNotEmpty()
author!: string;
@ApiProperty({ description: 'WASM configuration' })
@ValidateNested()
@Type(() => PluginManifestWasmDto)
wasm!: PluginManifestWasmDto;
@ApiPropertyOptional({ description: 'Plugin filters' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PluginManifestFilterDto)
@Type(() => PluginManifestMethodDto)
@IsOptional()
filters?: PluginManifestFilterDto[];
@ApiPropertyOptional({ description: 'Plugin actions' })
@IsArray()
@ValidateNested({ each: true })
@Type(() => PluginManifestActionDto)
@IsOptional()
actions?: PluginManifestActionDto[];
methods!: PluginManifestMethodDto[];
}

View File

@@ -1,15 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { PluginAction, PluginFilter } from 'src/database';
import { PluginContext as PluginContextType, PluginTriggerType } from 'src/enum';
import type { JSONSchema } from 'src/types/plugin-schema.types';
import { ValidateEnum } from 'src/validation';
import { WorkflowTrigger, WorkflowType } from 'src/enum';
import { JSONSchema } from 'src/types';
import { asMethodString } from 'src/utils/workflow';
import { ValidateBoolean, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
export class PluginTriggerResponseDto {
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', description: 'Trigger type' })
type!: PluginTriggerType;
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType', description: 'Context type' })
contextType!: PluginContextType;
export class PluginSearchDto {
@ValidateUUID({ optional: true, description: 'Plugin ID' })
id?: string;
@ValidateBoolean({ optional: true, description: 'Whether the plugin is enabled' })
enabled?: boolean;
@ValidateString({ optional: true })
name?: string;
@ValidateString({ optional: true })
version?: string;
@ValidateString({ optional: true })
title?: string;
@ValidateString({ optional: true })
description?: string;
}
export class PluginResponseDto {
@@ -29,45 +42,56 @@ export class PluginResponseDto {
createdAt!: string;
@ApiProperty({ description: 'Last update date' })
updatedAt!: string;
@ApiProperty({ description: 'Plugin filters' })
filters!: PluginFilterResponseDto[];
@ApiProperty({ description: 'Plugin actions' })
actions!: PluginActionResponseDto[];
@ApiProperty({ description: 'Plugin methods' })
methods!: PluginMethodResponseDto[];
}
export class PluginFilterResponseDto {
@ApiProperty({ description: 'Filter ID' })
id!: string;
@ApiProperty({ description: 'Plugin ID' })
pluginId!: string;
@ApiProperty({ description: 'Method name' })
methodName!: string;
@ApiProperty({ description: 'Filter title' })
title!: string;
@ApiProperty({ description: 'Filter description' })
description!: string;
export class PluginMethodSearchDto {
@ValidateUUID({ optional: true, description: 'Plugin method ID' })
id?: string;
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType', each: true, description: 'Supported contexts' })
supportedContexts!: PluginContextType[];
@ApiProperty({ description: 'Filter schema' })
schema!: JSONSchema | null;
@ValidateBoolean({ optional: true, description: 'Whether the plugin method is enabled' })
enabled?: boolean;
@ValidateString({ optional: true })
name?: string;
@ValidateString({ optional: true })
title?: string;
@ValidateString({ optional: true })
description?: string;
@ValidateEnum({ optional: true, enum: WorkflowType, name: 'WorkflowType' })
type?: WorkflowType;
@ValidateEnum({ optional: true, enum: WorkflowTrigger, name: 'WorkflowTrigger' })
trigger?: WorkflowTrigger;
@ValidateString({ optional: true })
pluginName?: string;
@ValidateString({ optional: true })
pluginVersion?: string;
}
export class PluginActionResponseDto {
@ApiProperty({ description: 'Action ID' })
id!: string;
@ApiProperty({ description: 'Plugin ID' })
pluginId!: string;
@ApiProperty({ description: 'Method name' })
methodName!: string;
@ApiProperty({ description: 'Action title' })
export class PluginMethodResponseDto {
@ApiProperty({ description: 'Key' })
key!: string;
@ApiProperty({ description: 'Name' })
name!: string;
@ApiProperty({ description: 'Title' })
title!: string;
@ApiProperty({ description: 'Action description' })
@ApiProperty({ description: 'Description' })
description!: string;
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType', each: true, description: 'Supported contexts' })
supportedContexts!: PluginContextType[];
@ApiProperty({ description: 'Action schema' })
@ValidateEnum({ name: 'WorkflowType', enum: WorkflowType, each: true, description: 'Workflow types' })
types!: WorkflowType[];
@ApiProperty({ description: 'Schema' })
schema!: JSONSchema | null;
}
@@ -78,21 +102,28 @@ export class PluginInstallDto {
manifestPath!: string;
}
export type MapPlugin = {
type Plugin = {
id: string;
name: string;
title: string;
description: string;
author: string;
version: string;
wasmPath: string;
createdAt: Date;
updatedAt: Date;
filters: PluginFilter[];
actions: PluginAction[];
methods: PluginMethod[];
};
export function mapPlugin(plugin: MapPlugin): PluginResponseDto {
type PluginMethod = {
pluginName: string;
name: string;
title: string;
description: string;
types: WorkflowType[];
schema: JSONSchema | null;
};
export function mapPlugin(plugin: Plugin): PluginResponseDto {
return {
id: plugin.id,
name: plugin.name,
@@ -102,7 +133,17 @@ export function mapPlugin(plugin: MapPlugin): PluginResponseDto {
version: plugin.version,
createdAt: plugin.createdAt.toISOString(),
updatedAt: plugin.updatedAt.toISOString(),
filters: plugin.filters,
actions: plugin.actions,
methods: plugin.methods.map((method) => mapMethod(method)),
};
}
export const mapMethod = (method: PluginMethod): PluginMethodResponseDto => {
return {
key: asMethodString({ pluginName: method.pluginName, methodName: method.name }),
name: method.name,
title: method.title,
description: method.description,
types: method.types,
schema: method.schema,
};
};

View File

@@ -1,160 +1,149 @@
import type { WorkflowStepConfig } from '@immich/plugin-sdk';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNotEmpty, IsObject, IsString, IsUUID, ValidateNested } from 'class-validator';
import { WorkflowAction, WorkflowFilter } from 'src/database';
import { PluginTriggerType } from 'src/enum';
import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types';
import { Optional, ValidateBoolean, ValidateEnum } from 'src/validation';
import { IsObject, ValidateNested } from 'class-validator';
import { WorkflowTrigger, WorkflowType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
export class WorkflowFilterItemDto {
@ApiProperty({ description: 'Plugin filter ID' })
@IsUUID()
pluginFilterId!: string;
@ApiPropertyOptional({ description: 'Filter configuration' })
@IsObject()
@Optional()
filterConfig?: FilterConfig;
export class WorkflowTriggerResponseDto {
@ValidateEnum({ enum: WorkflowTrigger, name: 'WorkflowTrigger', description: 'Trigger type' })
trigger!: WorkflowTrigger;
@ValidateEnum({ enum: WorkflowType, name: 'WorkflowType', description: 'Workflow types', each: true })
types!: WorkflowType[];
}
export class WorkflowActionItemDto {
@ApiProperty({ description: 'Plugin action ID' })
@IsUUID()
pluginActionId!: string;
export class WorkflowSearchDto {
@ValidateUUID({ optional: true, description: 'Workflow ID' })
id?: string;
@ApiPropertyOptional({ description: 'Action configuration' })
@IsObject()
@Optional()
actionConfig?: ActionConfig;
}
@ValidateEnum({
optional: true,
enum: WorkflowTrigger,
name: 'WorkflowTrigger',
description: 'Workflow trigger type',
})
trigger?: WorkflowTrigger;
export class WorkflowCreateDto {
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', description: 'Workflow trigger type' })
triggerType!: PluginTriggerType;
@ValidateString({ optional: true, description: 'Workflow name' })
name?: string;
@ApiProperty({ description: 'Workflow name' })
@IsString()
@IsNotEmpty()
name!: string;
@ApiPropertyOptional({ description: 'Workflow description' })
@IsString()
@Optional()
@ValidateString({ optional: true, description: 'Workflow description' })
description?: string;
@ValidateBoolean({ optional: true, description: 'Workflow enabled' })
enabled?: boolean;
@ApiProperty({ description: 'Workflow filters' })
@ValidateNested({ each: true })
@Type(() => WorkflowFilterItemDto)
filters!: WorkflowFilterItemDto[];
@ApiProperty({ description: 'Workflow actions' })
@ValidateNested({ each: true })
@Type(() => WorkflowActionItemDto)
actions!: WorkflowActionItemDto[];
}
export class WorkflowUpdateDto {
class WorkflowBase {
@ValidateString({ optional: true, nullable: true, description: 'Workflow name' })
name?: string | null;
@ValidateString({ optional: true, nullable: true, description: 'Workflow description' })
description?: string | null;
@ValidateBoolean({ optional: true, description: 'Workflow enabled' })
enabled?: boolean;
@ValidateNested({ each: true })
@Type(() => WorkflowStepDto)
@Optional()
steps?: WorkflowStepDto[];
}
export class WorkflowCreateDto extends WorkflowBase {
@ValidateEnum({ enum: WorkflowTrigger, name: 'WorkflowTrigger', description: 'Workflow trigger type' })
trigger!: WorkflowTrigger;
}
export class WorkflowUpdateDto extends WorkflowBase {
@ValidateEnum({
enum: PluginTriggerType,
name: 'PluginTriggerType',
enum: WorkflowTrigger,
name: 'WorkflowTrigger',
optional: true,
description: 'Workflow trigger type',
})
triggerType?: PluginTriggerType;
@ApiPropertyOptional({ description: 'Workflow name' })
@IsString()
@IsNotEmpty()
@Optional()
name?: string;
@ApiPropertyOptional({ description: 'Workflow description' })
@IsString()
@Optional()
description?: string;
@ValidateBoolean({ optional: true, description: 'Workflow enabled' })
enabled?: boolean;
@ApiPropertyOptional({ description: 'Workflow filters' })
@ValidateNested({ each: true })
@Type(() => WorkflowFilterItemDto)
@Optional()
filters?: WorkflowFilterItemDto[];
@ApiPropertyOptional({ description: 'Workflow actions' })
@ValidateNested({ each: true })
@Type(() => WorkflowActionItemDto)
@Optional()
actions?: WorkflowActionItemDto[];
trigger?: WorkflowTrigger;
}
export class WorkflowResponseDto {
@ApiProperty({ description: 'Workflow ID' })
id!: string;
@ApiProperty({ description: 'Owner user ID' })
ownerId!: string;
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', description: 'Workflow trigger type' })
triggerType!: PluginTriggerType;
@ValidateEnum({ enum: WorkflowTrigger, name: 'WorkflowTrigger', description: 'Workflow trigger type' })
trigger!: WorkflowTrigger;
@ApiProperty({ description: 'Workflow name' })
name!: string | null;
@ApiProperty({ description: 'Workflow description' })
description!: string;
description!: string | null;
@ApiProperty({ description: 'Creation date' })
createdAt!: string;
@ApiProperty({ description: 'Update date' })
updatedAt!: string;
@ApiProperty({ description: 'Workflow enabled' })
enabled!: boolean;
@ApiProperty({ description: 'Workflow filters' })
filters!: WorkflowFilterResponseDto[];
@ApiProperty({ description: 'Workflow actions' })
actions!: WorkflowActionResponseDto[];
@ApiProperty({ description: 'Workflow steps' })
steps!: WorkflowStepResponseDto[];
}
export class WorkflowFilterResponseDto {
@ApiProperty({ description: 'Filter ID' })
id!: string;
@ApiProperty({ description: 'Workflow ID' })
workflowId!: string;
@ApiProperty({ description: 'Plugin filter ID' })
pluginFilterId!: string;
@ApiProperty({ description: 'Filter configuration' })
filterConfig!: FilterConfig | null;
@ApiProperty({ description: 'Filter order', type: 'number' })
order!: number;
export class WorkflowStepDto {
@ValidateString({ description: 'Step plugin method' })
method!: string;
@ApiPropertyOptional({ description: 'Step configuration' })
@IsObject()
@Optional({ nullable: true })
config?: WorkflowStepConfig | null;
@ValidateBoolean({ optional: true, description: 'Step is enabled' })
enabled?: boolean;
}
export class WorkflowActionResponseDto {
@ApiProperty({ description: 'Action ID' })
id!: string;
@ApiProperty({ description: 'Workflow ID' })
workflowId!: string;
@ApiProperty({ description: 'Plugin action ID' })
pluginActionId!: string;
@ApiProperty({ description: 'Action configuration' })
actionConfig!: ActionConfig | null;
@ApiProperty({ description: 'Action order', type: 'number' })
order!: number;
export class WorkflowStepResponseDto {
@ApiProperty({ description: 'Step plugin method' })
method!: string;
@ApiProperty({ description: 'Step configuration' })
config!: WorkflowStepConfig | null;
@ValidateBoolean({ description: 'Step is enabled' })
enabled!: boolean;
}
export function mapWorkflowFilter(filter: WorkflowFilter): WorkflowFilterResponseDto {
export type Workflow = {
id: string;
createdAt: Date;
updatedAt: Date;
trigger: WorkflowTrigger;
name: string | null;
description: string | null;
enabled: boolean;
};
export type WorkflowStep = {
enabled: boolean;
methodName: string;
config: WorkflowStepConfig | null;
pluginName: string;
};
export const mapWorkflow = (workflow: Workflow & { steps: WorkflowStep[] }): WorkflowResponseDto => {
return {
id: filter.id,
workflowId: filter.workflowId,
pluginFilterId: filter.pluginFilterId,
filterConfig: filter.filterConfig,
order: filter.order,
id: workflow.id,
trigger: workflow.trigger,
name: workflow.name,
description: workflow.description,
createdAt: workflow.createdAt.toISOString(),
updatedAt: workflow.updatedAt.toISOString(),
enabled: workflow.enabled,
steps: workflow.steps.map((step) => ({
method: `${step.pluginName}#${step.methodName}`,
config: step.config,
enabled: step.enabled,
})),
};
}
export function mapWorkflowAction(action: WorkflowAction): WorkflowActionResponseDto {
return {
id: action.id,
workflowId: action.workflowId,
pluginActionId: action.pluginActionId,
actionConfig: action.actionConfig,
order: action.order,
};
}
};

View File

@@ -544,8 +544,11 @@ export enum BootstrapEventPriority {
StorageService = -195,
// Other services may need to queue jobs on bootstrap.
JobService = -190,
// Initialise config after other bootstrap services, stop other services from using config on bootstrap
// Initialize config after other bootstrap services, stop other services from using config on bootstrap
SystemConfig = 100,
PluginSync = 190,
// Load plugins into memory after sync
PluginLoad = 200,
}
export enum QueueName {
@@ -655,7 +658,7 @@ export enum JobName {
Ocr = 'Ocr',
// Workflow
WorkflowRun = 'WorkflowRun',
WorkflowAssetCreate = 'WorkflowAssetCreate',
}
export enum QueueCommand {
@@ -694,6 +697,7 @@ export enum DatabaseLock {
CLIPDimSize = 512,
Library = 1337,
NightlyJobs = 600,
PluginImport = 666,
MediaLocation = 700,
GetSystemConfig = 69,
BackupDatabase = 42,
@@ -882,13 +886,12 @@ export enum ApiTag {
Workflows = 'Workflows',
}
export enum PluginContext {
Asset = 'asset',
Album = 'album',
Person = 'person',
}
export enum PluginTriggerType {
export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
PersonRecognized = 'PersonRecognized',
}
export enum WorkflowType {
AssetV1 = 'AssetV1',
AssetPersonV1 = 'AssetPersonV1',
}

View File

@@ -1,17 +0,0 @@
import { PluginContext, PluginTriggerType } from 'src/enum';
export type PluginTrigger = {
type: PluginTriggerType;
contextType: PluginContext;
};
export const pluginTriggers: PluginTrigger[] = [
{
type: PluginTriggerType.AssetCreate,
contextType: PluginContext.Asset,
},
{
type: PluginTriggerType.PersonRecognized,
contextType: PluginContext.Person,
},
];

View File

@@ -1,48 +1,17 @@
-- NOTE: This file is auto generated by ./sql-generator
-- PluginRepository.getPlugin
-- PluginRepository.getForLoad
select
"plugin"."id" as "id",
"plugin"."name" as "name",
"plugin"."title" as "title",
"plugin"."description" as "description",
"plugin"."author" as "author",
"plugin"."version" as "version",
"plugin"."wasmPath" as "wasmPath",
"plugin"."createdAt" as "createdAt",
"plugin"."updatedAt" as "updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_filter"
where
"plugin_filter"."pluginId" = "plugin"."id"
) as agg
) as "filters",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_action"
where
"plugin_action"."pluginId" = "plugin"."id"
) as agg
) as "actions"
"id",
"name",
"version",
"wasmBytes"
from
"plugin"
where
"plugin"."id" = $1
"enabled" = $1
-- PluginRepository.getPluginByName
-- PluginRepository.search
select
"plugin"."id" as "id",
"plugin"."name" as "name",
@@ -50,7 +19,6 @@ select
"plugin"."description" as "description",
"plugin"."author" as "author",
"plugin"."version" as "version",
"plugin"."wasmPath" as "wasmPath",
"plugin"."createdAt" as "createdAt",
"plugin"."updatedAt" as "updatedAt",
(
@@ -61,99 +29,68 @@ select
select
*
from
"plugin_filter"
"plugin_method"
where
"plugin_filter"."pluginId" = "plugin"."id"
"plugin_method"."pluginId" = "plugin"."id"
) as agg
) as "filters",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_action"
where
"plugin_action"."pluginId" = "plugin"."id"
) as agg
) as "actions"
from
"plugin"
where
"plugin"."name" = $1
-- PluginRepository.getAllPlugins
select
"plugin"."id" as "id",
"plugin"."name" as "name",
"plugin"."title" as "title",
"plugin"."description" as "description",
"plugin"."author" as "author",
"plugin"."version" as "version",
"plugin"."wasmPath" as "wasmPath",
"plugin"."createdAt" as "createdAt",
"plugin"."updatedAt" as "updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_filter"
where
"plugin_filter"."pluginId" = "plugin"."id"
) as agg
) as "filters",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_action"
where
"plugin_action"."pluginId" = "plugin"."id"
) as agg
) as "actions"
) as "methods"
from
"plugin"
order by
"plugin"."name"
-- PluginRepository.getFilter
-- PluginRepository.getByName
select
*
"plugin"."id" as "id",
"plugin"."name" as "name",
"plugin"."title" as "title",
"plugin"."description" as "description",
"plugin"."author" as "author",
"plugin"."version" as "version",
"plugin"."createdAt" as "createdAt",
"plugin"."updatedAt" as "updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_method"
where
"plugin_method"."pluginId" = "plugin"."id"
) as agg
) as "methods"
from
"plugin_filter"
"plugin"
where
"id" = $1
"plugin"."name" = $1
-- PluginRepository.getFiltersByPlugin
-- PluginRepository.get
select
*
"plugin"."id" as "id",
"plugin"."name" as "name",
"plugin"."title" as "title",
"plugin"."description" as "description",
"plugin"."author" as "author",
"plugin"."version" as "version",
"plugin"."createdAt" as "createdAt",
"plugin"."updatedAt" as "updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_method"
where
"plugin_method"."pluginId" = "plugin"."id"
) as agg
) as "methods"
from
"plugin_filter"
"plugin"
where
"pluginId" = $1
-- PluginRepository.getAction
select
*
from
"plugin_action"
where
"id" = $1
-- PluginRepository.getActionsByPlugin
select
*
from
"plugin_action"
where
"pluginId" = $1
"plugin"."id" = $1

View File

@@ -1,70 +1,96 @@
-- NOTE: This file is auto generated by ./sql-generator
-- WorkflowRepository.getWorkflow
-- WorkflowRepository.search
select
*
"workflow"."id",
"workflow"."name",
"workflow"."description",
"workflow"."trigger",
"workflow"."enabled",
"workflow"."createdAt",
"workflow"."updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"plugin"."name" as "pluginName",
"plugin_method"."name" as "methodName",
"workflow_step"."config",
"workflow_step"."enabled"
from
"workflow_step"
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId"
) as agg
) as "steps"
from
"workflow"
order by
"createdAt" desc
-- WorkflowRepository.get
select
"workflow"."id",
"workflow"."name",
"workflow"."description",
"workflow"."trigger",
"workflow"."enabled",
"workflow"."createdAt",
"workflow"."updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"plugin"."name" as "pluginName",
"plugin_method"."name" as "methodName",
"workflow_step"."config",
"workflow_step"."enabled"
from
"workflow_step"
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId"
) as agg
) as "steps"
from
"workflow"
where
"id" = $1
order by
"createdAt" desc
-- WorkflowRepository.getWorkflowsByOwner
-- WorkflowRepository.getForWorkflowRun
select
*
"workflow"."id",
"workflow"."name",
"workflow"."trigger",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"workflow_step"."id",
"workflow_step"."config",
"plugin_method"."pluginId" as "pluginId",
"plugin_method"."name" as "methodName",
"plugin_method"."types" as "types"
from
"workflow_step"
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
where
"workflow_step"."workflowId" = "workflow"."id"
and "workflow_step"."enabled" = $1
) as agg
) as "steps"
from
"workflow"
where
"ownerId" = $1
order by
"createdAt" desc
-- WorkflowRepository.getWorkflowsByTrigger
select
*
from
"workflow"
where
"triggerType" = $1
and "enabled" = $2
-- WorkflowRepository.getWorkflowByOwnerAndTrigger
select
*
from
"workflow"
where
"ownerId" = $1
and "triggerType" = $2
"id" = $2
and "enabled" = $3
-- WorkflowRepository.deleteWorkflow
-- WorkflowRepository.delete
delete from "workflow"
where
"id" = $1
-- WorkflowRepository.getFilters
select
*
from
"workflow_filter"
where
"workflowId" = $1
order by
"order" asc
-- WorkflowRepository.deleteFiltersByWorkflow
delete from "workflow_filter"
where
"workflowId" = $1
-- WorkflowRepository.getActions
select
*
from
"workflow_action"
where
"workflowId" = $1
order by
"order" asc

View File

@@ -320,7 +320,7 @@ const getEnv = (): EnvData => {
root: folders.web,
indexHtml: join(folders.web, 'index.html'),
},
corePlugin: join(buildFolder, 'corePlugin'),
corePlugin: join(buildFolder, 'plugins', 'immich-plugin-core'),
},
setup: {

View File

@@ -119,6 +119,10 @@ export class LoggingRepository {
logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : [];
}
getLogLevel(): LogLevel {
return logLevels[0] || LogLevel.Fatal;
}
verbose(message: string, ...details: LogDetails) {
this.handleMessage(LogLevel.Verbose, message, details);
}

View File

@@ -1,176 +1,236 @@
import { CallContext, Plugin as ExtismPlugin, newPlugin } from '@extism/extism';
import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { Insertable, Kysely } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { readdir } from 'node:fs/promises';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import { PluginMethodSearchDto, PluginSearchDto } from 'src/dtos/plugin.dto';
import { LogLevel, WorkflowType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { DB } from 'src/schema';
import { PluginMethodTable } from 'src/schema/tables/plugin-method.table';
import { PluginTable } from 'src/schema/tables/plugin.table';
type PluginMethod = { pluginId: string; methodName: string };
type PluginLoad = { id: string; name: string; version: string; wasmBytes: Buffer };
type PluginMapItem = { plugin: ExtismPlugin; name: string; version: string };
export type PluginHostFunction = (callContext: CallContext, input: bigint) => any; // TODO probably needs to be bigint return as well
export type PluginLoadOptions = {
functions: Record<string, PluginHostFunction>;
};
export type PluginMethodSearchResponse = {
id: string;
name: string;
pluginName: string;
types: WorkflowType[];
};
const levels = {
[LogLevel.Verbose]: 'trace',
[LogLevel.Debug]: 'debug',
[LogLevel.Log]: 'info',
[LogLevel.Warn]: 'warn',
[LogLevel.Error]: 'error',
[LogLevel.Fatal]: 'error',
} as const;
const asExtismLogLevel = (logLevel: LogLevel) => levels[logLevel] || 'info';
@Injectable()
export class PluginRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
private pluginMap: Map<string, PluginMapItem> = new Map();
/**
* Loads a plugin from a validated manifest file in a transaction.
* This ensures all plugin, filter, and action operations are atomic.
* @param manifest The validated plugin manifest
* @param basePath The base directory path where the plugin is located
*/
async loadPlugin(manifest: PluginManifestDto, basePath: string) {
return this.db.transaction().execute(async (tx) => {
// Upsert the plugin
const plugin = await tx
.insertInto('plugin')
.values({
name: manifest.name,
title: manifest.title,
description: manifest.description,
author: manifest.author,
version: manifest.version,
wasmPath: `${basePath}/${manifest.wasm.path}`,
})
.onConflict((oc) =>
oc.column('name').doUpdateSet({
title: manifest.title,
description: manifest.description,
author: manifest.author,
version: manifest.version,
wasmPath: `${basePath}/${manifest.wasm.path}`,
}),
)
.returningAll()
.executeTakeFirstOrThrow();
const filters = manifest.filters
? await tx
.insertInto('plugin_filter')
.values(
manifest.filters.map((filter) => ({
pluginId: plugin.id,
methodName: filter.methodName,
title: filter.title,
description: filter.description,
supportedContexts: filter.supportedContexts,
schema: filter.schema,
})),
)
.onConflict((oc) =>
oc.column('methodName').doUpdateSet((eb) => ({
pluginId: eb.ref('excluded.pluginId'),
title: eb.ref('excluded.title'),
description: eb.ref('excluded.description'),
supportedContexts: eb.ref('excluded.supportedContexts'),
schema: eb.ref('excluded.schema'),
})),
)
.returningAll()
.execute()
: [];
const actions = manifest.actions
? await tx
.insertInto('plugin_action')
.values(
manifest.actions.map((action) => ({
pluginId: plugin.id,
methodName: action.methodName,
title: action.title,
description: action.description,
supportedContexts: action.supportedContexts,
schema: action.schema,
})),
)
.onConflict((oc) =>
oc.column('methodName').doUpdateSet((eb) => ({
pluginId: eb.ref('excluded.pluginId'),
title: eb.ref('excluded.title'),
description: eb.ref('excluded.description'),
supportedContexts: eb.ref('excluded.supportedContexts'),
schema: eb.ref('excluded.schema'),
})),
)
.returningAll()
.execute()
: [];
return { plugin, filters, actions };
});
}
async readDirectory(path: string) {
return readdir(path, { withFileTypes: true });
}
@GenerateSql({ params: [DummyValue.UUID] })
getPlugin(id: string) {
return this.db
.selectFrom('plugin')
.select((eb) => [
...columns.plugin,
jsonArrayFrom(
eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'),
).as('filters'),
jsonArrayFrom(
eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'),
).as('actions'),
])
.where('plugin.id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.STRING] })
getPluginByName(name: string) {
return this.db
.selectFrom('plugin')
.select((eb) => [
...columns.plugin,
jsonArrayFrom(
eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'),
).as('filters'),
jsonArrayFrom(
eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'),
).as('actions'),
])
.where('plugin.name', '=', name)
.executeTakeFirst();
constructor(
@InjectKysely() private db: Kysely<DB>,
private logger: LoggingRepository,
) {
this.logger.setContext(PluginRepository.name);
}
@GenerateSql()
getAllPlugins() {
getForLoad() {
return this.db
.selectFrom('plugin')
.select(['id', 'name', 'version', 'wasmBytes'])
.where('enabled', '=', true)
.execute();
}
private queryBuilder() {
return this.db
.selectFrom('plugin')
.select((eb) => [
...columns.plugin,
'plugin.id',
'plugin.name',
'plugin.title',
'plugin.description',
'plugin.author',
'plugin.version',
'plugin.createdAt',
'plugin.updatedAt',
jsonArrayFrom(
eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'),
).as('filters'),
jsonArrayFrom(
eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'),
).as('actions'),
])
eb
.selectFrom('plugin_method')
.select([
'plugin_method.name',
'plugin_method.title',
'plugin_method.description',
'plugin_method.types',
'plugin_method.schema',
'plugin.name as pluginName',
])
.whereRef('plugin_method.pluginId', '=', 'plugin.id'),
).as('methods'),
]);
}
@GenerateSql()
search(dto: PluginSearchDto = {}) {
return this.queryBuilder()
.$if(!!dto.id, (qb) => qb.where('plugin.id', '=', dto.id!))
.$if(!!dto.name, (qb) => qb.where('plugin.name', '=', dto.name!))
.$if(!!dto.title, (qb) => qb.where('plugin.title', '=', dto.title!))
.$if(!!dto.description, (qb) => qb.where('plugin.description', '=', dto.description!))
.$if(!!dto.version, (qb) => qb.where('plugin.version', '=', dto.version!))
.orderBy('plugin.name')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getFilter(id: string) {
return this.db.selectFrom('plugin_filter').selectAll().where('id', '=', id).executeTakeFirst();
@GenerateSql({ params: [DummyValue.STRING] })
getByName(name: string) {
return this.queryBuilder().where('plugin.name', '=', name).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getFiltersByPlugin(pluginId: string) {
return this.db.selectFrom('plugin_filter').selectAll().where('pluginId', '=', pluginId).execute();
get(id: string) {
return this.queryBuilder().where('plugin.id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getAction(id: string) {
return this.db.selectFrom('plugin_action').selectAll().where('id', '=', id).executeTakeFirst();
@GenerateSql()
getForValidation(): Promise<PluginMethodSearchResponse[]> {
return this.db
.selectFrom('plugin_method')
.innerJoin('plugin', 'plugin_method.pluginId', 'plugin.id')
.select(['plugin_method.id', 'plugin_method.name', 'plugin.name as pluginName', 'plugin_method.types'])
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getActionsByPlugin(pluginId: string) {
return this.db.selectFrom('plugin_action').selectAll().where('pluginId', '=', pluginId).execute();
@GenerateSql()
searchMethods(dto: PluginMethodSearchDto = {}) {
return this.db
.selectFrom('plugin_method')
.innerJoin('plugin', 'plugin.id', 'plugin_method.pluginId')
.select([
'plugin_method.id',
'plugin_method.name',
'plugin_method.title',
'plugin_method.description',
'plugin_method.pluginId',
'plugin_method.types',
'plugin_method.schema',
'plugin.name as pluginName',
])
.$if(!!dto.id, (qb) => qb.where('plugin_method.id', '=', dto.id!))
.$if(!!dto.name, (qb) => qb.where('plugin_method.name', '=', dto.name!))
.$if(!!dto.title, (qb) => qb.where('plugin_method.title', '=', dto.title!))
.$if(!!dto.type, (qb) => qb.where('plugin_method.types', '@>', [dto.type!]))
.$if(!!dto.description, (qb) => qb.where('plugin_method.description', '=', dto.description!))
.$if(!!dto.pluginVersion, (qb) => qb.where('plugin.version', '=', dto.pluginVersion!))
.$if(!!dto.pluginName, (qb) => qb.where('plugin.name', '=', dto.pluginName!))
.orderBy('plugin_method.name')
.execute();
}
async create(dto: Insertable<PluginTable>, initialMethods: Omit<Insertable<PluginMethodTable>, 'pluginId'>[]) {
return this.db.transaction().execute(async (tx) => {
// Upsert the plugin
const plugin = await tx
.insertInto('plugin')
.values(dto)
.onConflict((oc) =>
oc.columns(['name', 'version']).doUpdateSet((eb) => ({
title: eb.ref('excluded.title'),
description: eb.ref('excluded.description'),
author: eb.ref('excluded.author'),
version: eb.ref('excluded.version'),
wasmBytes: eb.ref('excluded.wasmBytes'),
})),
)
.returning(['id', 'name'])
.executeTakeFirstOrThrow();
// TODO: handle methods that were removed in a new version
const methods =
initialMethods.length > 0
? await tx
.insertInto('plugin_method')
.values(initialMethods.map((method) => ({ ...method, pluginId: plugin.id })))
.onConflict((oc) =>
oc.columns(['pluginId', 'name']).doUpdateSet((eb) => ({
pluginId: eb.ref('excluded.pluginId'),
title: eb.ref('excluded.title'),
description: eb.ref('excluded.description'),
types: eb.ref('excluded.types'),
schema: eb.ref('excluded.schema'),
})),
)
.returningAll()
.execute()
: [];
return { ...plugin, methods };
});
}
async load({ id, name, version, wasmBytes }: PluginLoad, { functions }: PluginLoadOptions) {
const data = new Uint8Array(wasmBytes.buffer, wasmBytes.byteOffset, wasmBytes.byteLength);
const pluginLabel = `${name}@${version}`;
try {
const logger = LoggingRepository.create(`Plugin:${pluginLabel}`);
const plugin = await newPlugin(
{ wasm: [{ data }] },
{
useWasi: true,
runInWorker: true,
functions: {
'extism:host/user': functions,
},
logLevel: asExtismLogLevel(logger.getLogLevel()),
logger: {
trace: (message) => logger.verbose(message),
info: (message) => logger.log(message),
debug: (message) => logger.debug(message),
warn: (message) => logger.warn(message),
error: (message) => logger.error(message),
} as Console,
},
);
this.pluginMap.set(id, { plugin, name, version });
} catch (error: Error | any) {
throw new Error(`Unable to instantiate plugin: ${pluginLabel}`, { cause: error });
}
}
async callMethod<T>({ pluginId, methodName }: PluginMethod, input: unknown) {
const item = this.pluginMap.get(pluginId);
if (!item) {
throw new Error(`No loaded plugin found for ${pluginId}`);
}
const { plugin, name, version } = item;
const methodLabel = `${name}@${version}#${methodName}`;
try {
const result = await plugin.call(methodName, JSON.stringify(input));
if (result) {
return result.json() as T;
}
return result as T;
} catch (error: Error | any) {
throw new Error(`Plugin method call failed: ${methodLabel}`, { cause: error });
}
}
}

View File

@@ -2,7 +2,15 @@ import { Injectable } from '@nestjs/common';
import archiver from 'archiver';
import chokidar, { ChokidarOptions } from 'chokidar';
import { escapePath, glob, globStream } from 'fast-glob';
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, ReadOptionsWithBuffer } from 'node:fs';
import {
constants,
createReadStream,
createWriteStream,
Dirent,
existsSync,
mkdirSync,
ReadOptionsWithBuffer,
} from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { PassThrough, Readable, Writable } from 'node:stream';
@@ -50,6 +58,10 @@ export class StorageRepository {
return fs.readdir(folder);
}
readdirWithTypes(folder: string): Promise<Dirent[]> {
return fs.readdir(folder, { withFileTypes: true });
}
copyFile(source: string, target: string) {
return fs.copyFile(source, target);
}
@@ -117,17 +129,24 @@ export class StorageRepository {
}
async readFile(filepath: string, options?: ReadOptionsWithBuffer<Buffer>): Promise<Buffer> {
const file = await fs.open(filepath);
try {
const { buffer } = await file.read(options);
return buffer as Buffer;
} finally {
await file.close();
// read a slice
if (options) {
const file = await fs.open(filepath);
try {
const { buffer } = await file.read(options);
return buffer as Buffer;
} finally {
await file.close();
}
}
// read everything
return fs.readFile(filepath);
}
async readTextFile(filepath: string): Promise<string> {
return fs.readFile(filepath, 'utf8');
async readJsonFile<T>(filepath: string): Promise<T> {
const file = await fs.readFile(filepath, 'utf8');
return JSON.parse(file) as T;
}
async checkFileExists(filepath: string, mode = constants.F_OK): Promise<boolean> {

View File

@@ -1,149 +1,177 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Updateable } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { PluginTriggerType } from 'src/enum';
import { WorkflowSearchDto } from 'src/dtos/workflow.dto';
import { DB } from 'src/schema';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
import { WorkflowTable } from 'src/schema/tables/workflow.table';
export type WorkflowStepUpsert = Omit<Insertable<WorkflowStepTable>, 'workflowId' | 'order'>;
@Injectable()
export class WorkflowRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
private queryBuilder(db?: Kysely<DB>) {
return (db ?? this.db)
.selectFrom('workflow')
.select([
'workflow.id',
'workflow.name',
'workflow.description',
'workflow.trigger',
'workflow.enabled',
'workflow.createdAt',
'workflow.updatedAt',
])
.select((eb) => [
jsonArrayFrom(
eb
.selectFrom('workflow_step')
.innerJoin('plugin_method', 'plugin_method.id', 'workflow_step.pluginMethodId')
.innerJoin('plugin', 'plugin.id', 'plugin_method.pluginId')
.select([
'plugin.name as pluginName',
'plugin_method.name as methodName',
'workflow_step.config',
'workflow_step.enabled',
]),
).as('steps'),
]);
}
@GenerateSql({ params: [DummyValue.UUID] })
getWorkflow(id: string) {
search(dto: WorkflowSearchDto & { ownerId?: string }) {
return this.queryBuilder()
.$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!))
.$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!))
.$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!))
.orderBy('createdAt', 'desc')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
get(id: string) {
return this.queryBuilder().where('id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForWorkflowRun(id: string) {
return this.db
.selectFrom('workflow')
.selectAll()
.select(['workflow.id', 'workflow.name', 'workflow.trigger'])
.select((eb) => [
jsonArrayFrom(
eb
.selectFrom('workflow_step')
.innerJoin('plugin_method', 'plugin_method.id', 'workflow_step.pluginMethodId')
.whereRef('workflow_step.workflowId', '=', 'workflow.id')
.where('workflow_step.enabled', '=', true)
.select([
'workflow_step.id',
'workflow_step.config',
'plugin_method.pluginId as pluginId',
'plugin_method.name as methodName',
'plugin_method.types as types',
]),
).as('steps'),
])
.where('id', '=', id)
.orderBy('createdAt', 'desc')
.where('enabled', '=', true)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getWorkflowsByOwner(ownerId: string) {
return this.db
.selectFrom('workflow')
.selectAll()
.where('ownerId', '=', ownerId)
.orderBy('createdAt', 'desc')
.execute();
}
@GenerateSql({ params: [PluginTriggerType.AssetCreate] })
getWorkflowsByTrigger(type: PluginTriggerType) {
return this.db
.selectFrom('workflow')
.selectAll()
.where('triggerType', '=', type)
.where('enabled', '=', true)
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, PluginTriggerType.AssetCreate] })
getWorkflowByOwnerAndTrigger(ownerId: string, type: PluginTriggerType) {
return this.db
.selectFrom('workflow')
.selectAll()
.where('ownerId', '=', ownerId)
.where('triggerType', '=', type)
.where('enabled', '=', true)
.execute();
}
async createWorkflow(
workflow: Insertable<WorkflowTable>,
filters: Insertable<WorkflowFilterTable>[],
actions: Insertable<WorkflowActionTable>[],
) {
return await this.db.transaction().execute(async (tx) => {
const createdWorkflow = await tx.insertInto('workflow').values(workflow).returningAll().executeTakeFirstOrThrow();
if (filters.length > 0) {
const newFilters = filters.map((filter) => ({
...filter,
workflowId: createdWorkflow.id,
}));
await tx.insertInto('workflow_filter').values(newFilters).execute();
}
if (actions.length > 0) {
const newActions = actions.map((action) => ({
...action,
workflowId: createdWorkflow.id,
}));
await tx.insertInto('workflow_action').values(newActions).execute();
}
return createdWorkflow;
create(dto: Insertable<WorkflowTable>, steps?: WorkflowStepUpsert[]) {
return this.db.transaction().execute(async (tx) => {
const { id } = await tx.insertInto('workflow').values(dto).returning(['id']).executeTakeFirstOrThrow();
return this.replaceAndReturn(tx, id, steps);
});
}
async updateWorkflow(
id: string,
workflow: Updateable<WorkflowTable>,
filters: Insertable<WorkflowFilterTable>[] | undefined,
actions: Insertable<WorkflowActionTable>[] | undefined,
) {
return await this.db.transaction().execute(async (trx) => {
if (Object.keys(workflow).length > 0) {
await trx.updateTable('workflow').set(workflow).where('id', '=', id).execute();
update(id: string, dto: Updateable<WorkflowTable>, steps?: WorkflowStepUpsert[]) {
return this.db.transaction().execute(async (tx) => {
if (Object.values(dto).some((prop) => prop !== undefined)) {
await tx.updateTable('workflow').set(dto).where('id', '=', id).executeTakeFirstOrThrow();
}
if (filters !== undefined) {
await trx.deleteFrom('workflow_filter').where('workflowId', '=', id).execute();
if (filters.length > 0) {
const filtersWithWorkflowId = filters.map((filter) => ({
...filter,
workflowId: id,
}));
await trx.insertInto('workflow_filter').values(filtersWithWorkflowId).execute();
}
}
if (actions !== undefined) {
await trx.deleteFrom('workflow_action').where('workflowId', '=', id).execute();
if (actions.length > 0) {
const actionsWithWorkflowId = actions.map((action) => ({
...action,
workflowId: id,
}));
await trx.insertInto('workflow_action').values(actionsWithWorkflowId).execute();
}
}
return await trx.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirstOrThrow();
return this.replaceAndReturn(tx, id, steps);
});
}
private async replaceAndReturn(tx: Kysely<DB>, workflowId: string, steps?: WorkflowStepUpsert[]) {
if (steps) {
await tx.deleteFrom('workflow_step').where('workflowId', '=', workflowId).execute();
if (steps.length > 0) {
await tx
.insertInto('workflow_step')
.values(
steps.map((step, i) => ({
workflowId,
enabled: step.enabled ?? true,
pluginMethodId: step.pluginMethodId,
config: step.config,
order: i,
})),
)
.returningAll()
.execute();
}
}
return this.queryBuilder(tx).where('id', '=', workflowId).executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] })
async deleteWorkflow(id: string) {
async delete(id: string) {
await this.db.deleteFrom('workflow').where('id', '=', id).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getFilters(workflowId: string) {
getForAssetV1(assetId: string) {
return this.db
.selectFrom('workflow_filter')
.selectAll()
.where('workflowId', '=', workflowId)
.orderBy('order', 'asc')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async deleteFiltersByWorkflow(workflowId: string) {
await this.db.deleteFrom('workflow_filter').where('workflowId', '=', workflowId).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getActions(workflowId: string) {
return this.db
.selectFrom('workflow_action')
.selectAll()
.where('workflowId', '=', workflowId)
.orderBy('order', 'asc')
.execute();
.selectFrom('asset')
.leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id')
.select((eb) => [
...columns.workflowAssetV1,
jsonObjectFrom(
eb
.selectFrom('asset_exif')
.select([
'asset_exif.make',
'asset_exif.model',
'asset_exif.orientation',
'asset_exif.dateTimeOriginal',
'asset_exif.modifyDate',
'asset_exif.exifImageWidth',
'asset_exif.exifImageHeight',
'asset_exif.fileSizeInByte',
'asset_exif.lensModel',
'asset_exif.fNumber',
'asset_exif.focalLength',
'asset_exif.iso',
'asset_exif.latitude',
'asset_exif.longitude',
'asset_exif.city',
'asset_exif.state',
'asset_exif.country',
'asset_exif.description',
'asset_exif.fps',
'asset_exif.exposureTime',
'asset_exif.livePhotoCID',
'asset_exif.timeZone',
'asset_exif.projectionType',
'asset_exif.profileDescription',
'asset_exif.colorspace',
'asset_exif.bitsPerSample',
'asset_exif.autoStackId',
'asset_exif.rating',
'asset_exif.tags',
'asset_exif.updatedAt',
])
.whereRef('asset_exif.assetId', '=', 'asset.id'),
).as('exifInfo'),
])
.where('id', '=', assetId)
.executeTakeFirstOrThrow();
}
}

View File

@@ -56,7 +56,8 @@ import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
import { PersonAuditTable } from 'src/schema/tables/person-audit.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
import { PluginMethodTable } from 'src/schema/tables/plugin-method.table';
import { PluginTable } from 'src/schema/tables/plugin.table';
import { SessionTable } from 'src/schema/tables/session.table';
import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
@@ -73,7 +74,8 @@ import { UserMetadataAuditTable } from 'src/schema/tables/user-metadata-audit.ta
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
import { WorkflowTable } from 'src/schema/tables/workflow.table';
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
@Database({ name: 'immich' })
@@ -132,11 +134,9 @@ export class ImmichDatabase {
UserTable,
VersionHistoryTable,
PluginTable,
PluginFilterTable,
PluginActionTable,
PluginMethodTable,
WorkflowTable,
WorkflowFilterTable,
WorkflowActionTable,
WorkflowStepTable,
];
functions = [
@@ -249,10 +249,8 @@ export interface DB {
version_history: VersionHistoryTable;
plugin: PluginTable;
plugin_filter: PluginFilterTable;
plugin_action: PluginActionTable;
plugin_method: PluginMethodTable;
workflow: WorkflowTable;
workflow_filter: WorkflowFilterTable;
workflow_action: WorkflowActionTable;
workflow_step: WorkflowStepTable;
}

View File

@@ -0,0 +1,15 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// take #2...
await sql`DROP TABLE "workflow_action";`.execute(db);
await sql`DROP TABLE "workflow_filter";`.execute(db);
await sql`DROP TABLE "workflow";`.execute(db);
await sql`DROP TABLE "plugin_action";`.execute(db);
await sql`DROP TABLE "plugin_filter";`.execute(db);
await sql`DROP TABLE "plugin";`.execute(db);
}
export async function down(): Promise<void> {
// unsupported
}

View File

@@ -0,0 +1,72 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "plugin" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"enabled" boolean NOT NULL DEFAULT true,
"name" character varying NOT NULL,
"version" character varying NOT NULL,
"title" character varying NOT NULL,
"description" character varying NOT NULL,
"author" character varying NOT NULL,
"wasmBytes" bytea NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "plugin_name_version_uq" UNIQUE ("name", "version"),
CONSTRAINT "plugin_name_uq" UNIQUE ("name"),
CONSTRAINT "plugin_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "plugin_name_idx" ON "plugin" ("name");`.execute(db);
await sql`CREATE TABLE "plugin_method" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"pluginId" uuid NOT NULL,
"name" character varying NOT NULL,
"title" character varying NOT NULL,
"description" character varying NOT NULL,
"types" character varying[] NOT NULL,
"schema" jsonb,
CONSTRAINT "plugin_method_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "plugin_method_pluginId_name_uq" UNIQUE ("pluginId", "name"),
CONSTRAINT "plugin_method_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "plugin_method_pluginId_idx" ON "plugin_method" ("pluginId");`.execute(db);
await sql`CREATE TABLE "workflow" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"ownerId" uuid NOT NULL,
"trigger" character varying NOT NULL,
"name" character varying,
"description" character varying,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"enabled" boolean NOT NULL DEFAULT true,
CONSTRAINT "workflow_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "workflow_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "workflow_ownerId_idx" ON "workflow" ("ownerId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "workflow_updatedAt"
BEFORE UPDATE ON "workflow"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
await sql`CREATE TABLE "workflow_step" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"enabled" boolean NOT NULL DEFAULT true,
"workflowId" uuid NOT NULL,
"pluginMethodId" uuid NOT NULL,
"config" jsonb,
"order" integer NOT NULL,
CONSTRAINT "workflow_step_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "workflow_step_pluginMethodId_fkey" FOREIGN KEY ("pluginMethodId") REFERENCES "plugin_method" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "workflow_step_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "workflow_step_workflowId_idx" ON "workflow_step" ("workflowId");`.execute(db);
await sql`CREATE INDEX "workflow_step_pluginMethodId_idx" ON "workflow_step" ("pluginMethodId");`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_workflow_updatedAt', '{"type":"trigger","name":"workflow_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"workflow_updatedAt\\"\\n BEFORE UPDATE ON \\"workflow\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(
db,
);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_filter_supportedContexts_idx';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_action_supportedContexts_idx';`.execute(db);
}
export async function down(): Promise<void> {
// not supported
}

View File

@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "workflow" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "workflow" DROP COLUMN "updateId";`.execute(db);
}

View File

@@ -111,7 +111,7 @@ export class AssetExifTable {
tags!: string[] | null;
@UpdateDateColumn({ default: () => 'clock_timestamp()' })
updatedAt!: Generated<Date>;
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;

View File

@@ -0,0 +1,29 @@
import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table, Unique } from '@immich/sql-tools';
import { WorkflowType } from 'src/enum';
import { PluginTable } from 'src/schema/tables/plugin.table';
import { JSONSchema } from 'src/types';
@Unique({ columns: ['pluginId', 'name'] })
@Table('plugin_method')
export class PluginMethodTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
pluginId!: string;
@Column()
name!: string;
@Column()
title!: string;
@Column()
description!: string;
@Column({ type: 'character varying', array: true })
types!: Generated<WorkflowType[]>;
@Column({ type: 'jsonb', nullable: true })
schema!: JSONSchema | null;
}

View File

@@ -1,25 +1,29 @@
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryGeneratedColumn,
Table,
Timestamp,
Unique,
UpdateDateColumn,
} from '@immich/sql-tools';
import { PluginContext } from 'src/enum';
import type { JSONSchema } from 'src/types/plugin-schema.types';
@Unique({ columns: ['name', 'version'] })
@Table('plugin')
export class PluginTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@Column({ type: 'boolean', default: true })
enabled!: Generated<boolean>;
@Column({ index: true, unique: true })
name!: string;
@Column()
version!: string;
@Column()
title!: string;
@@ -29,11 +33,8 @@ export class PluginTable {
@Column()
author!: string;
@Column()
version!: string;
@Column()
wasmPath!: string;
@Column({ type: 'bytea' })
wasmBytes!: Buffer;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@@ -41,55 +42,3 @@ export class PluginTable {
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
}
@Index({ columns: ['supportedContexts'], using: 'gin' })
@Table('plugin_filter')
export class PluginFilterTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
@Column({ index: true })
pluginId!: string;
@Column({ index: true, unique: true })
methodName!: string;
@Column()
title!: string;
@Column()
description!: string;
@Column({ type: 'character varying', array: true })
supportedContexts!: Generated<PluginContext[]>;
@Column({ type: 'jsonb', nullable: true })
schema!: JSONSchema | null;
}
@Index({ columns: ['supportedContexts'], using: 'gin' })
@Table('plugin_action')
export class PluginActionTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
@Column({ index: true })
pluginId!: string;
@Column({ index: true, unique: true })
methodName!: string;
@Column()
title!: string;
@Column()
description!: string;
@Column({ type: 'character varying', array: true })
supportedContexts!: Generated<PluginContext[]>;
@Column({ type: 'jsonb', nullable: true })
schema!: JSONSchema | null;
}

View File

@@ -0,0 +1,26 @@
import { WorkflowStepConfig } from '@immich/plugin-sdk';
import { Column, ForeignKeyColumn, PrimaryGeneratedColumn, Table } from '@immich/sql-tools';
import { Generated } from 'kysely';
import { PluginMethodTable } from 'src/schema/tables/plugin-method.table';
import { WorkflowTable } from 'src/schema/tables/workflow.table';
@Table('workflow_step')
export class WorkflowStepTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@Column({ type: 'boolean', default: true })
enabled!: boolean;
@ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
workflowId!: string;
@ForeignKeyColumn(() => PluginMethodTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
pluginMethodId!: string;
@Column({ type: 'jsonb', nullable: true })
config!: WorkflowStepConfig | null;
@Column({ type: 'integer' })
order!: number;
}

View File

@@ -3,17 +3,17 @@ import {
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { PluginTriggerType } from 'src/enum';
import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { WorkflowTrigger } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table';
import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types';
@Table('workflow')
@UpdatedAtTrigger('workflow_updatedAt')
export class WorkflowTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@@ -22,57 +22,23 @@ export class WorkflowTable {
ownerId!: string;
@Column()
triggerType!: PluginTriggerType;
trigger!: WorkflowTrigger;
@Column({ nullable: true })
name!: string | null;
@Column()
description!: string;
@Column({ nullable: true })
description!: string | null;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn()
updateId!: Generated<string>;
@Column({ type: 'boolean', default: true })
enabled!: boolean;
}
@Index({ columns: ['workflowId', 'order'] })
@Index({ columns: ['pluginFilterId'] })
@Table('workflow_filter')
export class WorkflowFilterTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
workflowId!: Generated<string>;
@ForeignKeyColumn(() => PluginFilterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
pluginFilterId!: string;
@Column({ type: 'jsonb', nullable: true })
filterConfig!: FilterConfig | null;
@Column({ type: 'integer' })
order!: number;
}
@Index({ columns: ['workflowId', 'order'] })
@Index({ columns: ['pluginActionId'] })
@Table('workflow_action')
export class WorkflowActionTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
workflowId!: Generated<string>;
@ForeignKeyColumn(() => PluginActionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
pluginActionId!: string;
@Column({ type: 'jsonb', nullable: true })
actionConfig!: ActionConfig | null;
@Column({ type: 'integer' })
order!: number;
enabled!: Generated<boolean>;
}

View File

@@ -1,4 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ClassConstructor } from 'class-transformer';
import { Insertable } from 'kysely';
import sanitize from 'sanitize-filename';
import { SystemConfig } from 'src/config';
@@ -187,6 +188,67 @@ export class BaseService {
);
}
static create<T extends BaseService>(Service: ClassConstructor<T>, ctx: BaseService) {
const service = new Service(
LoggingRepository.create(),
ctx.accessRepository,
ctx.activityRepository,
ctx.albumRepository,
ctx.albumUserRepository,
ctx.apiKeyRepository,
ctx.appRepository,
ctx.assetRepository,
ctx.assetEditRepository,
ctx.assetJobRepository,
ctx.auditRepository,
ctx.configRepository,
ctx.cronRepository,
ctx.cryptoRepository,
ctx.databaseRepository,
ctx.downloadRepository,
ctx.duplicateRepository,
ctx.emailRepository,
ctx.eventRepository,
ctx.jobRepository,
ctx.libraryRepository,
ctx.machineLearningRepository,
ctx.mapRepository,
ctx.mediaRepository,
ctx.memoryRepository,
ctx.metadataRepository,
ctx.moveRepository,
ctx.notificationRepository,
ctx.oauthRepository,
ctx.ocrRepository,
ctx.partnerRepository,
ctx.personRepository,
ctx.pluginRepository,
ctx.processRepository,
ctx.searchRepository,
ctx.serverInfoRepository,
ctx.sessionRepository,
ctx.sharedLinkRepository,
ctx.sharedLinkAssetRepository,
ctx.stackRepository,
ctx.storageRepository,
ctx.syncRepository,
ctx.syncCheckpointRepository,
ctx.systemMetadataRepository,
ctx.tagRepository,
ctx.telemetryRepository,
ctx.trashRepository,
ctx.userRepository,
ctx.versionRepository,
ctx.viewRepository,
ctx.websocketRepository,
ctx.workflowRepository,
);
service.logger.setContext(this.name);
return service as T;
}
get worker() {
return this.configRepository.getWorker();
}

View File

@@ -45,6 +45,7 @@ import { UserAdminService } from 'src/services/user-admin.service';
import { UserService } from 'src/services/user.service';
import { VersionService } from 'src/services/version.service';
import { ViewService } from 'src/services/view.service';
import { WorkflowExecutionService } from 'src/services/workflow-execution.service';
import { WorkflowService } from 'src/services/workflow.service';
export const services = [
@@ -95,5 +96,6 @@ export const services = [
UserService,
VersionService,
ViewService,
WorkflowExecutionService,
WorkflowService,
];

View File

@@ -1,120 +0,0 @@
import { CurrentPlugin } from '@extism/extism';
import { UnauthorizedException } from '@nestjs/common';
import { Updateable } from 'kysely';
import { Permission } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetTable } from 'src/schema/tables/asset.table';
import { requireAccess } from 'src/utils/access';
/**
* Plugin host functions that are exposed to WASM plugins via Extism.
* These functions allow plugins to interact with the Immich system.
*/
export class PluginHostFunctions {
constructor(
private assetRepository: AssetRepository,
private albumRepository: AlbumRepository,
private accessRepository: AccessRepository,
private cryptoRepository: CryptoRepository,
private logger: LoggingRepository,
private pluginJwtSecret: string,
) {}
/**
* Creates Extism host function bindings for the plugin.
* These are the functions that WASM plugins can call.
*/
getHostFunctions() {
return {
'extism:host/user': {
updateAsset: (cp: CurrentPlugin, offs: bigint) => this.handleUpdateAsset(cp, offs),
addAssetToAlbum: (cp: CurrentPlugin, offs: bigint) => this.handleAddAssetToAlbum(cp, offs),
},
};
}
/**
* Host function wrapper for updateAsset.
* Reads the input from the plugin, parses it, and calls the actual update function.
*/
private async handleUpdateAsset(cp: CurrentPlugin, offs: bigint) {
const input = JSON.parse(cp.read(offs)!.text());
await this.updateAsset(input);
}
/**
* Host function wrapper for addAssetToAlbum.
* Reads the input from the plugin, parses it, and calls the actual add function.
*/
private async handleAddAssetToAlbum(cp: CurrentPlugin, offs: bigint) {
const input = JSON.parse(cp.read(offs)!.text());
await this.addAssetToAlbum(input);
}
/**
* Validates the JWT token and returns the auth context.
*/
private validateToken(authToken: string): { userId: string } {
try {
const auth = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.pluginJwtSecret);
if (!auth.userId) {
throw new UnauthorizedException('Invalid token: missing userId');
}
return auth;
} catch (error) {
this.logger.error('Token validation failed:', error);
throw new UnauthorizedException('Invalid token');
}
}
/**
* Updates an asset with the given properties.
*/
async updateAsset(input: { authToken: string } & Updateable<AssetTable> & { id: string }) {
const { authToken, id, ...assetData } = input;
// Validate token
const auth = this.validateToken(authToken);
// Check access to the asset
await requireAccess(this.accessRepository, {
auth: { user: { id: auth.userId } } as any,
permission: Permission.AssetUpdate,
ids: [id],
});
this.logger.log(`Updating asset ${id} -- ${JSON.stringify(assetData)}`);
await this.assetRepository.update({ id, ...assetData });
}
/**
* Adds an asset to an album.
*/
async addAssetToAlbum(input: { authToken: string; assetId: string; albumId: string }) {
const { authToken, assetId, albumId } = input;
// Validate token
const auth = this.validateToken(authToken);
// Check access to both the asset and the album
await requireAccess(this.accessRepository, {
auth: { user: { id: auth.userId } } as any,
permission: Permission.AssetRead,
ids: [assetId],
});
await requireAccess(this.accessRepository, {
auth: { user: { id: auth.userId } } as any,
permission: Permission.AlbumUpdate,
ids: [albumId],
});
this.logger.log(`Adding asset ${assetId} to album ${albumId}`);
await this.albumRepository.addAssetIds(albumId, [assetId]);
return 0;
}
}

View File

@@ -1,322 +1,34 @@
import { Plugin as ExtismPlugin, newPlugin } from '@extism/extism';
import { BadRequestException, Injectable } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validateOrReject } from 'class-validator';
import { join } from 'node:path';
import { Asset, WorkflowAction, WorkflowFilter } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import { mapPlugin, PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum';
import { pluginTriggers } from 'src/plugins';
import { ArgOf } from 'src/repositories/event.repository';
import {
mapMethod,
mapPlugin,
PluginMethodResponseDto,
PluginMethodSearchDto,
PluginResponseDto,
PluginSearchDto,
} from 'src/dtos/plugin.dto';
import { BaseService } from 'src/services/base.service';
import { PluginHostFunctions } from 'src/services/plugin-host.functions';
import { IWorkflowJob, JobItem, JobOf, WorkflowData } from 'src/types';
interface WorkflowContext {
authToken: string;
asset: Asset;
}
interface PluginInput<T = unknown> {
authToken: string;
config: T;
data: {
asset: Asset;
};
}
import { isMethodCompatible } from 'src/utils/workflow';
@Injectable()
export class PluginService extends BaseService {
private pluginJwtSecret!: string;
private loadedPlugins: Map<string, ExtismPlugin> = new Map();
private hostFunctions!: PluginHostFunctions;
@OnEvent({ name: 'AppBootstrap' })
async onBootstrap() {
this.pluginJwtSecret = this.cryptoRepository.randomBytesAsText(32);
await this.loadPluginsFromManifests();
this.hostFunctions = new PluginHostFunctions(
this.assetRepository,
this.albumRepository,
this.accessRepository,
this.cryptoRepository,
this.logger,
this.pluginJwtSecret,
);
await this.loadPlugins();
}
getTriggers(): PluginTriggerResponseDto[] {
return pluginTriggers;
}
//
// CRUD operations for plugins
//
async getAll(): Promise<PluginResponseDto[]> {
const plugins = await this.pluginRepository.getAllPlugins();
async search(dto: PluginSearchDto): Promise<PluginResponseDto[]> {
const plugins = await this.pluginRepository.search(dto);
return plugins.map((plugin) => mapPlugin(plugin));
}
async get(id: string): Promise<PluginResponseDto> {
const plugin = await this.pluginRepository.getPlugin(id);
const plugin = await this.pluginRepository.get(id);
if (!plugin) {
throw new BadRequestException('Plugin not found');
}
return mapPlugin(plugin);
}
///////////////////////////////////////////
// Plugin Loader
//////////////////////////////////////////
async loadPluginsFromManifests(): Promise<void> {
// Load core plugin
const { resourcePaths, plugins } = this.configRepository.getEnv();
const coreManifestPath = `${resourcePaths.corePlugin}/manifest.json`;
const coreManifest = await this.readAndValidateManifest(coreManifestPath);
await this.loadPluginToDatabase(coreManifest, resourcePaths.corePlugin);
this.logger.log(`Successfully processed core plugin: ${coreManifest.name} (version ${coreManifest.version})`);
// Load external plugins
if (plugins.external.allow && plugins.external.installFolder) {
await this.loadExternalPlugins(plugins.external.installFolder);
}
}
private async loadExternalPlugins(installFolder: string): Promise<void> {
try {
const entries = await this.pluginRepository.readDirectory(installFolder);
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const pluginFolder = join(installFolder, entry.name);
const manifestPath = join(pluginFolder, 'manifest.json');
try {
const manifest = await this.readAndValidateManifest(manifestPath);
await this.loadPluginToDatabase(manifest, pluginFolder);
this.logger.log(`Successfully processed external plugin: ${manifest.name} (version ${manifest.version})`);
} catch (error) {
this.logger.warn(`Failed to load external plugin from ${manifestPath}:`, error);
}
}
} catch (error) {
this.logger.error(`Failed to scan external plugins folder ${installFolder}:`, error);
}
}
private async loadPluginToDatabase(manifest: PluginManifestDto, basePath: string): Promise<void> {
const currentPlugin = await this.pluginRepository.getPluginByName(manifest.name);
if (currentPlugin != null && currentPlugin.version === manifest.version) {
this.logger.log(`Plugin ${manifest.name} is up to date (version ${manifest.version}). Skipping`);
return;
}
const { plugin, filters, actions } = await this.pluginRepository.loadPlugin(manifest, basePath);
this.logger.log(`Upserted plugin: ${plugin.name} (ID: ${plugin.id}, version: ${plugin.version})`);
for (const filter of filters) {
this.logger.log(`Upserted plugin filter: ${filter.methodName} (ID: ${filter.id})`);
}
for (const action of actions) {
this.logger.log(`Upserted plugin action: ${action.methodName} (ID: ${action.id})`);
}
}
private async readAndValidateManifest(manifestPath: string): Promise<PluginManifestDto> {
const content = await this.storageRepository.readTextFile(manifestPath);
const manifestData = JSON.parse(content);
const manifest = plainToInstance(PluginManifestDto, manifestData);
await validateOrReject(manifest, {
whitelist: true,
forbidNonWhitelisted: true,
});
return manifest;
}
///////////////////////////////////////////
// Plugin Execution
///////////////////////////////////////////
private async loadPlugins() {
const plugins = await this.pluginRepository.getAllPlugins();
for (const plugin of plugins) {
try {
this.logger.debug(`Loading plugin: ${plugin.name} from ${plugin.wasmPath}`);
const extismPlugin = await newPlugin(plugin.wasmPath, {
useWasi: true,
functions: this.hostFunctions.getHostFunctions(),
});
this.loadedPlugins.set(plugin.id, extismPlugin);
this.logger.log(`Successfully loaded plugin: ${plugin.name}`);
} catch (error) {
this.logger.error(`Failed to load plugin ${plugin.name}:`, error);
}
}
}
@OnEvent({ name: 'AssetCreate' })
async handleAssetCreate({ asset }: ArgOf<'AssetCreate'>) {
await this.handleTrigger(PluginTriggerType.AssetCreate, {
ownerId: asset.ownerId,
event: { userId: asset.ownerId, asset },
});
}
private async handleTrigger<T extends PluginTriggerType>(
triggerType: T,
params: { ownerId: string; event: WorkflowData[T] },
): Promise<void> {
const workflows = await this.workflowRepository.getWorkflowByOwnerAndTrigger(params.ownerId, triggerType);
if (workflows.length === 0) {
return;
}
const jobs: JobItem[] = workflows.map((workflow) => ({
name: JobName.WorkflowRun,
data: {
id: workflow.id,
type: triggerType,
event: params.event,
} as IWorkflowJob<T>,
}));
await this.jobRepository.queueAll(jobs);
this.logger.debug(`Queued ${jobs.length} workflow execution jobs for trigger ${triggerType}`);
}
@OnJob({ name: JobName.WorkflowRun, queue: QueueName.Workflow })
async handleWorkflowRun({ id: workflowId, type, event }: JobOf<JobName.WorkflowRun>): Promise<JobStatus> {
try {
const workflow = await this.workflowRepository.getWorkflow(workflowId);
if (!workflow) {
this.logger.error(`Workflow ${workflowId} not found`);
return JobStatus.Failed;
}
const workflowFilters = await this.workflowRepository.getFilters(workflowId);
const workflowActions = await this.workflowRepository.getActions(workflowId);
switch (type) {
case PluginTriggerType.AssetCreate: {
const data = event as WorkflowData[PluginTriggerType.AssetCreate];
const asset = data.asset;
const authToken = this.cryptoRepository.signJwt({ userId: data.userId }, this.pluginJwtSecret);
const context = {
authToken,
asset,
};
const filtersPassed = await this.executeFilters(workflowFilters, context);
if (!filtersPassed) {
return JobStatus.Skipped;
}
await this.executeActions(workflowActions, context);
this.logger.debug(`Workflow ${workflowId} executed successfully`);
return JobStatus.Success;
}
case PluginTriggerType.PersonRecognized: {
this.logger.error('unimplemented');
return JobStatus.Skipped;
}
default: {
this.logger.error(`Unknown workflow trigger type: ${type}`);
return JobStatus.Failed;
}
}
} catch (error) {
this.logger.error(`Error executing workflow ${workflowId}:`, error);
return JobStatus.Failed;
}
}
private async executeFilters(workflowFilters: WorkflowFilter[], context: WorkflowContext): Promise<boolean> {
for (const workflowFilter of workflowFilters) {
const filter = await this.pluginRepository.getFilter(workflowFilter.pluginFilterId);
if (!filter) {
this.logger.error(`Filter ${workflowFilter.pluginFilterId} not found`);
return false;
}
const pluginInstance = this.loadedPlugins.get(filter.pluginId);
if (!pluginInstance) {
this.logger.error(`Plugin ${filter.pluginId} not loaded`);
return false;
}
const filterInput: PluginInput = {
authToken: context.authToken,
config: workflowFilter.filterConfig,
data: {
asset: context.asset,
},
};
this.logger.debug(`Calling filter ${filter.methodName} with input: ${JSON.stringify(filterInput)}`);
const filterResult = await pluginInstance.call(
filter.methodName,
new TextEncoder().encode(JSON.stringify(filterInput)),
);
if (!filterResult) {
this.logger.error(`Filter ${filter.methodName} returned null`);
return false;
}
const result = JSON.parse(filterResult.text());
if (result.passed === false) {
this.logger.debug(`Filter ${filter.methodName} returned false, stopping workflow execution`);
return false;
}
}
return true;
}
private async executeActions(workflowActions: WorkflowAction[], context: WorkflowContext): Promise<void> {
for (const workflowAction of workflowActions) {
const action = await this.pluginRepository.getAction(workflowAction.pluginActionId);
if (!action) {
throw new Error(`Action ${workflowAction.pluginActionId} not found`);
}
const pluginInstance = this.loadedPlugins.get(action.pluginId);
if (!pluginInstance) {
throw new Error(`Plugin ${action.pluginId} not loaded`);
}
const actionInput: PluginInput = {
authToken: context.authToken,
config: workflowAction.actionConfig,
data: {
asset: context.asset,
},
};
this.logger.debug(`Calling action ${action.methodName} with input: ${JSON.stringify(actionInput)}`);
await pluginInstance.call(action.methodName, JSON.stringify(actionInput));
}
async searchMethods(dto: PluginMethodSearchDto): Promise<PluginMethodResponseDto[]> {
const methods = await this.pluginRepository.searchMethods(dto);
return methods
.filter((method) => !dto.trigger || isMethodCompatible(method, dto.trigger))
.map((method) => mapMethod(method));
}
}

View File

@@ -0,0 +1,301 @@
import { CurrentPlugin } from '@extism/extism';
import { WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk';
import { HttpException, UnauthorizedException } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import _ from 'lodash';
import { join } from 'node:path';
import { OnEvent, OnJob } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import {
BootstrapEventPriority,
DatabaseLock,
ImmichWorker,
JobName,
JobStatus,
QueueName,
WorkflowTrigger,
WorkflowType,
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { AlbumService } from 'src/services/album.service';
import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types';
type ExecuteOptions<T extends WorkflowType = any> = {
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>;
write: (changes: Partial<WorkflowEventData<T>>) => Promise<void>;
};
export class WorkflowExecutionService extends BaseService {
private jwtSecret!: string;
@OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.PluginSync, workers: [ImmichWorker.Microservices] })
async onPluginSync() {
await this.databaseRepository.withLock(DatabaseLock.PluginImport, async () => {
// 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 });
if (plugins.external.allow && plugins.external.installFolder) {
await this.importFolders(plugins.external.installFolder);
}
});
}
@OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.PluginLoad, workers: [ImmichWorker.Microservices] })
async onPluginLoad() {
this.jwtSecret = this.cryptoRepository.randomBytesAsText(32);
const albumService = BaseService.create(AlbumService, this);
const albumAddAssets = this.createFunction<[id: string, dto: BulkIdsDto]>(async (authDto, args) =>
albumService.addAssets(authDto, ...args),
);
const plugins = await this.pluginRepository.getForLoad();
for (const plugin of plugins) {
const pluginLabel = `${plugin.name}@${plugin.version}`;
try {
await this.pluginRepository.load(plugin, {
functions: {
albumAddAssets,
},
});
this.logger.log(`Loaded plugin: ${pluginLabel}`);
} catch (error) {
this.logger.error(`Unable to load plugin ${pluginLabel}`, error);
}
}
}
private createFunction<T>(fn: (authDto: AuthDto, args: T) => Promise<unknown>) {
return async (plugin: CurrentPlugin, offset: bigint) => {
try {
const handle = plugin.read(offset);
if (!handle) {
return;
}
const { authToken, args } = handle.json() as { authToken: string; args: T };
if (!authToken) {
throw new Error('authToken is required');
}
const authDto = this.validate(authToken);
const response = await fn(authDto, args);
return plugin.store(JSON.stringify({ success: true, response }));
} catch (error: Error | any) {
if (error instanceof HttpException) {
this.logger.error(`Plugin host exception: ${error}`);
return plugin.store(
JSON.stringify({ success: false, status: error.getStatus(), message: error.getResponse() }),
);
}
this.logger.error(`Plugin host exception: ${error}`, error?.stack);
return plugin.store(
JSON.stringify({
success: false,
status: 500,
message: `Internal server error: ${error}`,
}),
);
}
};
}
private async importFolders(installFolder: string): Promise<void> {
try {
const entries = await this.storageRepository.readdirWithTypes(installFolder);
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
await this.importFolder(join(installFolder, entry.name));
}
} catch (error) {
this.logger.error(`Failed to import plugins folder ${installFolder}:`, error);
}
}
private async importFolder(folder: string, options?: { force?: boolean }) {
try {
const manifestPath = join(folder, 'manifest.json');
const dto = await this.storageRepository.readJsonFile(manifestPath);
const manifest = plainToInstance(PluginManifestDto, dto);
const errors = await validate(manifest, { whitelist: true, forbidNonWhitelisted: true });
if (errors.length > 0) {
this.logger.warn(`Invalid plugin manifest at ${manifestPath}:\n${errors.map((e) => e.toString()).join('\n')}`);
return;
}
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.create(
{
enabled: true,
name: manifest.name,
title: manifest.title,
description: manifest.description,
author: manifest.author,
version: manifest.version,
wasmBytes,
},
manifest.methods.map((method) => ({
name: method.name,
title: method.title,
description: method.description,
types: method.types,
schema: method.schema,
})),
);
if (existing) {
this.logger.log(
`Upgraded plugin ${manifest.name} (${plugin.methods.length} methods) from ${existing.version} to ${manifest.version} `,
);
} else {
this.logger.log(
`Imported plugin ${manifest.name}@${manifest.version} (${plugin.methods.length} methods) from ${folder}`,
);
}
return manifest;
} catch {
this.logger.warn(`Failed to import plugin from ${folder}:`);
}
}
private validate(authToken: string): AuthDto {
try {
const jwt = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.jwtSecret);
if (!jwt.userId) {
throw new UnauthorizedException('Invalid token: missing userId');
}
return {
user: {
id: jwt.userId,
},
} as AuthDto;
} catch (error) {
this.logger.error('Token validation failed:', error);
throw new UnauthorizedException('Invalid token');
}
}
private sign(userId: string) {
return this.cryptoRepository.signJwt({ userId }, this.jwtSecret);
}
@OnEvent({ name: 'AssetCreate' })
async onAssetCreate({ asset }: ArgOf<'AssetCreate'>) {
const dto = { ownerId: asset.ownerId, trigger: WorkflowTrigger.AssetCreate };
const items = await this.workflowRepository.search(dto);
await this.jobRepository.queueAll(
items.map((workflow) => ({
name: JobName.WorkflowAssetCreate,
data: { workflowId: workflow.id, assetId: asset.id },
})),
);
}
@OnJob({ name: JobName.WorkflowAssetCreate, queue: QueueName.Workflow })
async handleWorkflowAssetCreate({ workflowId, assetId }: JobOf<JobName.WorkflowAssetCreate>) {
await this.execute(workflowId, (type: WorkflowType) => {
switch (type) {
case WorkflowType.AssetV1: {
return <ExecuteOptions<WorkflowType.AssetV1>>{
read: async () => {
const asset = await this.workflowRepository.getForAssetV1(assetId);
return {
data: { asset },
authUserId: asset.ownerId,
};
},
write: async (changes) => {
if (changes.asset) {
await this.assetRepository.update({
id: assetId,
..._.omitBy(
{
isFavorite: changes.asset?.isFavorite,
visibility: changes.asset?.visibility,
},
_.isUndefined,
),
});
}
},
};
}
}
});
}
private async execute(workflowId: string, getHandler: (type: WorkflowType) => ExecuteOptions | undefined) {
const workflow = await this.workflowRepository.getForWorkflowRun(workflowId);
if (!workflow) {
return;
}
// TODO infer from steps
const type = 'AssetV1' as WorkflowType;
const handler = getHandler(type);
if (!handler) {
this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`);
return;
}
try {
const { read, write } = handler;
const readResult = await read(type);
let data = readResult.data;
for (const step of workflow.steps) {
const payload: WorkflowEventPayload = {
trigger: workflow.trigger,
type,
config: step.config ?? {},
workflow: {
id: workflowId,
authToken: this.sign(readResult.authUserId),
stepId: step.id,
},
data,
};
const result = await this.pluginRepository.callMethod<WorkflowResponse>(step, payload);
if (result?.changes) {
await write(result.changes);
data = await read(type);
}
const shouldContinue = result?.workflow?.continue ?? true;
if (!shouldContinue) {
break;
}
}
this.logger.debug(`Workflow ${workflowId} executed successfully`);
} catch (error) {
this.logger.error(`Error executing workflow ${workflowId}:`, error);
return JobStatus.Failed;
}
}
}

View File

@@ -1,159 +1,105 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Workflow } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto';
import {
mapWorkflowAction,
mapWorkflowFilter,
mapWorkflow,
WorkflowCreateDto,
WorkflowResponseDto,
WorkflowSearchDto,
WorkflowTriggerResponseDto,
WorkflowUpdateDto,
} from 'src/dtos/workflow.dto';
import { Permission, PluginContext, PluginTriggerType } from 'src/enum';
import { pluginTriggers } from 'src/plugins';
import { Permission, WorkflowTrigger } from 'src/enum';
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
import { BaseService } from 'src/services/base.service';
import { getWorkflowTriggers, isMethodCompatible, resolveMethod } from 'src/utils/workflow';
@Injectable()
export class WorkflowService extends BaseService {
async create(auth: AuthDto, dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
const context = this.getContextForTrigger(dto.triggerType);
const filterInserts = await this.validateAndMapFilters(dto.filters, context);
const actionInserts = await this.validateAndMapActions(dto.actions, context);
const workflow = await this.workflowRepository.createWorkflow(
{
ownerId: auth.user.id,
triggerType: dto.triggerType,
name: dto.name,
description: dto.description || '',
enabled: dto.enabled ?? true,
},
filterInserts,
actionInserts,
);
return this.mapWorkflow(workflow);
getTriggers(): WorkflowTriggerResponseDto[] {
return getWorkflowTriggers();
}
async getAll(auth: AuthDto): Promise<WorkflowResponseDto[]> {
const workflows = await this.workflowRepository.getWorkflowsByOwner(auth.user.id);
return Promise.all(workflows.map((workflow) => this.mapWorkflow(workflow)));
async search(auth: AuthDto, dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> {
const workflows = await this.workflowRepository.search({ ...dto, ownerId: auth.user.id });
return workflows.map((workflow) => mapWorkflow(workflow));
}
async get(auth: AuthDto, id: string): Promise<WorkflowResponseDto> {
await this.requireAccess({ auth, permission: Permission.WorkflowRead, ids: [id] });
const workflow = await this.findOrFail(id);
return this.mapWorkflow(workflow);
return mapWorkflow(workflow);
}
async create(auth: AuthDto, dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
const { steps: stepsDto, ...workflowDto } = dto;
const steps = await this.resolveAndValidateSteps(stepsDto ?? [], workflowDto.trigger);
const workflow = await this.workflowRepository.create(
{
...workflowDto,
ownerId: auth.user.id,
},
steps.map((step) => ({
enabled: step.enabled ?? true,
config: step.config,
pluginMethodId: step.pluginMethod.id,
})),
);
return mapWorkflow({ ...workflow, steps: [] });
}
async update(auth: AuthDto, id: string, dto: WorkflowUpdateDto): Promise<WorkflowResponseDto> {
await this.requireAccess({ auth, permission: Permission.WorkflowUpdate, ids: [id] });
if (Object.values(dto).filter((prop) => prop !== undefined).length === 0) {
throw new BadRequestException('No fields to update');
}
const workflow = await this.findOrFail(id);
const context = this.getContextForTrigger(dto.triggerType ?? workflow.triggerType);
const { filters, actions, ...workflowUpdate } = dto;
const filterInserts = filters && (await this.validateAndMapFilters(filters, context));
const actionInserts = actions && (await this.validateAndMapActions(actions, context));
const updatedWorkflow = await this.workflowRepository.updateWorkflow(
const { steps: stepsDto, ...workflowDto } = dto;
const current = await this.findOrFail(id);
const steps = stepsDto ? await this.resolveAndValidateSteps(stepsDto, dto.trigger ?? current.trigger) : undefined;
const workflow = await this.workflowRepository.update(
id,
workflowUpdate,
filterInserts,
actionInserts,
workflowDto,
steps?.map((step) => ({
enabled: step.enabled ?? true,
config: step.config,
pluginMethodId: step.pluginMethod.id,
})),
);
return this.mapWorkflow(updatedWorkflow);
return mapWorkflow(workflow);
}
async delete(auth: AuthDto, id: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.WorkflowDelete, ids: [id] });
await this.workflowRepository.deleteWorkflow(id);
await this.workflowRepository.delete(id);
}
private async validateAndMapFilters(
filters: Array<{ pluginFilterId: string; filterConfig?: any }>,
requiredContext: PluginContext,
) {
for (const dto of filters) {
const filter = await this.pluginRepository.getFilter(dto.pluginFilterId);
if (!filter) {
throw new BadRequestException(`Invalid filter ID: ${dto.pluginFilterId}`);
private async resolveAndValidateSteps<T extends { method: string }>(steps: T[], trigger: WorkflowTrigger) {
const methods = await this.pluginRepository.getForValidation();
const results: Array<T & { pluginMethod: PluginMethodSearchResponse }> = [];
for (const step of steps) {
const pluginMethod = resolveMethod(methods, step.method);
if (!pluginMethod) {
throw new BadRequestException(`Unknown method ${step.method}`);
}
if (!filter.supportedContexts.includes(requiredContext)) {
throw new BadRequestException(
`Filter "${filter.title}" does not support ${requiredContext} context. Supported contexts: ${filter.supportedContexts.join(', ')}`,
);
if (!isMethodCompatible(pluginMethod, trigger)) {
throw new BadRequestException(`Method "${step.method}" is incompatible with workflow trigger: "${trigger}"`);
}
results.push({ ...step, pluginMethod });
}
return filters.map((dto, index) => ({
pluginFilterId: dto.pluginFilterId,
filterConfig: dto.filterConfig || null,
order: index,
}));
}
// TODO make sure all steps can use a common WorkflowType
private async validateAndMapActions(
actions: Array<{ pluginActionId: string; actionConfig?: any }>,
requiredContext: PluginContext,
) {
for (const dto of actions) {
const action = await this.pluginRepository.getAction(dto.pluginActionId);
if (!action) {
throw new BadRequestException(`Invalid action ID: ${dto.pluginActionId}`);
}
if (!action.supportedContexts.includes(requiredContext)) {
throw new BadRequestException(
`Action "${action.title}" does not support ${requiredContext} context. Supported contexts: ${action.supportedContexts.join(', ')}`,
);
}
}
return actions.map((dto, index) => ({
pluginActionId: dto.pluginActionId,
actionConfig: dto.actionConfig || null,
order: index,
}));
}
private getContextForTrigger(type: PluginTriggerType) {
const trigger = pluginTriggers.find((t) => t.type === type);
if (!trigger) {
throw new BadRequestException(`Invalid trigger type: ${type}`);
}
return trigger.contextType;
return results;
}
private async findOrFail(id: string) {
const workflow = await this.workflowRepository.getWorkflow(id);
const workflow = await this.workflowRepository.get(id);
if (!workflow) {
throw new BadRequestException('Workflow not found');
}
return workflow;
}
private async mapWorkflow(workflow: Workflow): Promise<WorkflowResponseDto> {
const filters = await this.workflowRepository.getFilters(workflow.id);
const actions = await this.workflowRepository.getActions(workflow.id);
return {
id: workflow.id,
ownerId: workflow.ownerId,
triggerType: workflow.triggerType,
name: workflow.name,
description: workflow.description,
createdAt: workflow.createdAt.toISOString(),
enabled: workflow.enabled,
filters: filters.map((f) => mapWorkflowFilter(f)),
actions: actions.map((a) => mapWorkflowAction(a)),
};
}
}

View File

@@ -1,6 +1,6 @@
import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants';
import { Asset, AssetFile } from 'src/database';
import { AssetFile } from 'src/database';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
@@ -12,7 +12,6 @@ import {
ImageFormat,
JobName,
MemoryType,
PluginTriggerType,
QueueName,
StorageFolder,
SyncEntityType,
@@ -20,6 +19,8 @@ import {
TranscodeTarget,
UserMetadataKey,
VideoCodec,
WorkflowTrigger,
WorkflowType,
} from 'src/enum';
export type DeepPartial<T> =
@@ -259,22 +260,11 @@ export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob {
recipientId: string;
}
export interface WorkflowData {
[PluginTriggerType.AssetCreate]: {
userId: string;
asset: Asset;
};
[PluginTriggerType.PersonRecognized]: {
personId: string;
assetId: string;
};
}
export interface IWorkflowJob<T extends PluginTriggerType = PluginTriggerType> {
export type IWorkflowJob<T extends WorkflowType = WorkflowType> = {
id: string;
trigger: WorkflowTrigger;
type: T;
event: WorkflowData[T];
}
};
export interface JobCounts {
active: number;
@@ -385,7 +375,7 @@ export type JobItem =
| { name: JobName.Ocr; data: IEntityJob }
// Workflow
| { name: JobName.WorkflowRun; data: IWorkflowJob }
| { name: JobName.WorkflowAssetCreate; data: { workflowId: string; assetId: string } }
// Editor
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
@@ -548,3 +538,23 @@ export interface UserMetadata extends Record<UserMetadataKey, Record<string, any
[UserMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: string };
[UserMetadataKey.Onboarding]: { isOnboarded: boolean };
}
export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object';
export type JSONSchemaProperty = {
type: JSONSchemaType;
description?: string;
default?: any;
enum?: string[];
array?: boolean;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
};
export interface JSONSchema {
type: 'object';
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
additionalProperties?: boolean;
description?: string;
}

View File

@@ -1,35 +0,0 @@
/**
* JSON Schema types for plugin configuration schemas
* Based on JSON Schema Draft 7
*/
export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null';
export interface JSONSchemaProperty {
type?: JSONSchemaType | JSONSchemaType[];
description?: string;
default?: any;
enum?: any[];
items?: JSONSchemaProperty;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
additionalProperties?: boolean | JSONSchemaProperty;
}
export interface JSONSchema {
type: 'object';
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
additionalProperties?: boolean;
description?: string;
}
export type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue };
export interface FilterConfig {
[key: string]: ConfigValue;
}
export interface ActionConfig {
[key: string]: ConfigValue;
}

View File

@@ -0,0 +1,60 @@
import { WorkflowTrigger, WorkflowType } from 'src/enum';
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
export const triggerMap: Record<WorkflowTrigger, WorkflowType[]> = {
[WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1],
[WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetV1],
};
export const getWorkflowTriggers = () =>
Object.entries(triggerMap).map(([trigger, types]) => ({ trigger: trigger as WorkflowTrigger, types }));
/** some types extend other types and have implied compatibility */
const inferredMap: Record<WorkflowType, WorkflowType[]> = {
[WorkflowType.AssetV1]: [],
[WorkflowType.AssetPersonV1]: [WorkflowType.AssetV1],
};
const withImpliedItems = (type: WorkflowType): WorkflowType[] => [type, ...inferredMap[type]];
export const isMethodCompatible = (pluginMethod: { types: WorkflowType[] }, trigger: WorkflowTrigger) => {
const validTypes = triggerMap[trigger];
const pluginCompatibility = pluginMethod.types.map((type) => withImpliedItems(type));
for (const requested of validTypes) {
for (const pluginCompatibilityGroup of pluginCompatibility) {
if (pluginCompatibilityGroup.includes(requested)) {
return true;
}
}
}
return false;
};
export const resolveMethod = (methods: PluginMethodSearchResponse[], method: string) => {
const result = parseMethodString(method);
if (!result) {
return;
}
const { pluginName, methodName } = result;
return methods.find((method) => method.pluginName === pluginName && method.name === methodName);
};
export const asMethodString = (method: { pluginName: string; methodName: string }) => {
return `${method.pluginName}#${method.methodName}`;
};
const METHOD_REGEX = /^(?<name>[^@#\s]+)(?:@(?<version>[^#\s]*))?#(?<method>[^@#\s]+)$/;
export const parseMethodString = (method: string) => {
const matches = METHOD_REGEX.exec(method);
if (!matches) {
return;
}
const pluginName = matches.groups?.name;
const version = matches.groups?.version;
const methodName = matches.groups?.method;
return { pluginName, version, methodName };
};

View File

@@ -410,7 +410,6 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
case OcrRepository:
case PartnerRepository:
case PersonRepository:
case PluginRepository:
case SearchRepository:
case SessionRepository:
case SharedLinkRepository:
@@ -442,6 +441,10 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
return new key(LoggingRepository.create());
}
case PluginRepository: {
return new key(db, LoggingRepository.create());
}
case StorageRepository: {
return new key(LoggingRepository.create());
}
@@ -473,7 +476,6 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
case OcrRepository:
case PartnerRepository:
case PersonRepository:
case PluginRepository:
case SessionRepository:
case SyncRepository:
case SyncCheckpointRepository:

View File

@@ -1,5 +1,5 @@
import { Kysely } from 'kysely';
import { PluginContext } from 'src/enum';
import { WorkflowType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
@@ -9,7 +9,8 @@ import { newMediumService } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
let pluginRepo: PluginRepository;
const wasmBytes = Buffer.from('some-wasm-binary-data');
const setup = (db?: Kysely<DB>) => {
return newMediumService(PluginService, {
@@ -21,7 +22,6 @@ const setup = (db?: Kysely<DB>) => {
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
pluginRepo = new PluginRepository(defaultDatabase);
});
afterEach(async () => {
@@ -32,214 +32,202 @@ describe(PluginService.name, () => {
describe('getAll', () => {
it('should return empty array when no plugins exist', async () => {
const { sut } = setup();
const plugins = await sut.getAll();
expect(plugins).toEqual([]);
await expect(sut.search({})).resolves.toEqual([]);
});
it('should return plugin without filters and actions', async () => {
const { sut } = setup();
it('should return plugin without methods', async () => {
const { ctx, sut } = setup();
const result = await pluginRepo.loadPlugin(
const result = await ctx.get(PluginRepository).create(
{
enabled: true,
name: 'test-plugin',
title: 'Test Plugin',
description: 'A test plugin',
author: 'Test Author',
version: '1.0.0',
wasm: { path: '/path/to/test.wasm' },
wasmBytes,
},
'/test/base/path',
[],
);
const plugins = await sut.getAll();
const plugins = await sut.search({});
expect(plugins).toHaveLength(1);
expect(plugins[0]).toMatchObject({
id: result.plugin.id,
id: result.id,
name: 'test-plugin',
description: 'A test plugin',
author: 'Test Author',
version: '1.0.0',
filters: [],
actions: [],
methods: [],
});
});
it('should return plugin with filters and actions', async () => {
const { sut } = setup();
it('should return plugin with multiple methods', async () => {
const { ctx, sut } = setup();
const result = await pluginRepo.loadPlugin(
const result = await ctx.get(PluginRepository).create(
{
enabled: true,
name: 'full-plugin',
title: 'Full Plugin',
description: 'A plugin with filters and actions',
description: 'A plugin with multiple methods',
author: 'Test Author',
version: '1.0.0',
wasm: { path: '/path/to/full.wasm' },
filters: [
{
methodName: 'test-filter',
title: 'Test Filter',
description: 'A test filter',
supportedContexts: [PluginContext.Asset],
schema: { type: 'object', properties: {} },
},
],
actions: [
{
methodName: 'test-action',
title: 'Test Action',
description: 'A test action',
supportedContexts: [PluginContext.Asset],
schema: { type: 'object', properties: {} },
},
],
wasmBytes,
},
'/test/base/path',
[
{
name: 'test-filter',
title: 'Test Filter',
description: 'A test filter',
types: [WorkflowType.AssetV1],
schema: { type: 'object', properties: {} },
},
{
name: 'test-action',
title: 'Test Action',
description: 'A test action',
types: [WorkflowType.AssetV1],
schema: { type: 'object', properties: {} },
},
],
);
const plugins = await sut.getAll();
const plugins = await sut.search({});
expect(plugins).toHaveLength(1);
expect(plugins[0]).toMatchObject({
id: result.plugin.id,
id: result.id,
name: 'full-plugin',
filters: [
methods: [
{
id: result.filters[0].id,
pluginId: result.plugin.id,
methodName: 'test-filter',
id: result.methods[0].id,
pluginId: result.id,
name: 'test-filter',
title: 'Test Filter',
description: 'A test filter',
supportedContexts: [PluginContext.Asset],
types: [WorkflowType.AssetV1],
schema: { type: 'object', properties: {} },
},
],
actions: [
{
id: result.actions[0].id,
pluginId: result.plugin.id,
methodName: 'test-action',
id: result.methods[1].id,
pluginId: result.id,
name: 'test-action',
title: 'Test Action',
description: 'A test action',
supportedContexts: [PluginContext.Asset],
types: [WorkflowType.AssetV1],
schema: { type: 'object', properties: {} },
},
],
});
});
it('should return multiple plugins with their respective filters and actions', async () => {
const { sut } = setup();
it('should return multiple plugins with their respective methods', async () => {
const { ctx, sut } = setup();
await pluginRepo.loadPlugin(
await ctx.get(PluginRepository).create(
{
enabled: true,
name: 'plugin-1',
title: 'Plugin 1',
description: 'First plugin',
author: 'Author 1',
version: '1.0.0',
wasm: { path: '/path/to/plugin1.wasm' },
filters: [
{
methodName: 'filter-1',
title: 'Filter 1',
description: 'Filter for plugin 1',
supportedContexts: [PluginContext.Asset],
schema: undefined,
},
],
wasmBytes,
},
'/test/base/path',
[
{
name: 'filter-1',
title: 'Filter 1',
description: 'Filter for plugin 1',
types: [WorkflowType.AssetV1],
schema: undefined,
},
],
);
await pluginRepo.loadPlugin(
await ctx.get(PluginRepository).create(
{
enabled: true,
name: 'plugin-2',
title: 'Plugin 2',
description: 'Second plugin',
author: 'Author 2',
version: '2.0.0',
wasm: { path: '/path/to/plugin2.wasm' },
actions: [
{
methodName: 'action-2',
title: 'Action 2',
description: 'Action for plugin 2',
supportedContexts: [PluginContext.Album],
schema: undefined,
},
],
wasmBytes,
},
'/test/base/path',
[
{
name: 'action-2',
title: 'Action 2',
description: 'Action for plugin 2',
types: [WorkflowType.AssetV1],
schema: undefined,
},
],
);
const plugins = await sut.getAll();
const plugins = await sut.search({});
expect(plugins).toHaveLength(2);
expect(plugins[0].name).toBe('plugin-1');
expect(plugins[0].filters).toHaveLength(1);
expect(plugins[0].actions).toHaveLength(0);
expect(plugins[0].methods).toHaveLength(1);
expect(plugins[1].name).toBe('plugin-2');
expect(plugins[1].filters).toHaveLength(0);
expect(plugins[1].actions).toHaveLength(1);
expect(plugins[1].methods).toHaveLength(1);
});
it('should handle plugin with multiple filters and actions', async () => {
const { sut } = setup();
it('should handle plugin with multiple methods', async () => {
const { ctx, sut } = setup();
await pluginRepo.loadPlugin(
await ctx.get(PluginRepository).create(
{
enabled: true,
name: 'multi-plugin',
title: 'Multi Plugin',
description: 'Plugin with multiple items',
description: 'Plugin with multiple methods',
author: 'Test Author',
version: '1.0.0',
wasm: { path: '/path/to/multi.wasm' },
filters: [
{
methodName: 'filter-a',
title: 'Filter A',
description: 'First filter',
supportedContexts: [PluginContext.Asset],
schema: undefined,
},
{
methodName: 'filter-b',
title: 'Filter B',
description: 'Second filter',
supportedContexts: [PluginContext.Album],
schema: undefined,
},
],
actions: [
{
methodName: 'action-x',
title: 'Action X',
description: 'First action',
supportedContexts: [PluginContext.Asset],
schema: undefined,
},
{
methodName: 'action-y',
title: 'Action Y',
description: 'Second action',
supportedContexts: [PluginContext.Person],
schema: undefined,
},
],
wasmBytes,
},
'/test/base/path',
[
{
name: 'filter-a',
title: 'Filter A',
description: 'First filter',
types: [WorkflowType.AssetV1],
schema: undefined,
},
{
name: 'filter-b',
title: 'Filter B',
description: 'Second filter',
types: [WorkflowType.AssetV1],
schema: undefined,
},
{
name: 'action-x',
title: 'Action X',
description: 'First action',
types: [WorkflowType.AssetV1],
schema: undefined,
},
{
name: 'action-y',
title: 'Action Y',
description: 'Second action',
types: [WorkflowType.AssetV1],
schema: undefined,
},
],
);
const plugins = await sut.getAll();
const plugins = await sut.search({});
expect(plugins).toHaveLength(1);
expect(plugins[0].filters).toHaveLength(2);
expect(plugins[0].actions).toHaveLength(2);
expect(plugins[0].methods).toHaveLength(4);
});
});
@@ -250,55 +238,51 @@ describe(PluginService.name, () => {
await expect(sut.get('00000000-0000-0000-0000-000000000000')).rejects.toThrow('Plugin not found');
});
it('should return single plugin with filters and actions', async () => {
const { sut } = setup();
it('should return single plugin with methods', async () => {
const { ctx, sut } = setup();
const result = await pluginRepo.loadPlugin(
const result = await ctx.get(PluginRepository).create(
{
enabled: true,
name: 'single-plugin',
title: 'Single Plugin',
description: 'A single plugin',
author: 'Test Author',
version: '1.0.0',
wasm: { path: '/path/to/single.wasm' },
filters: [
{
methodName: 'single-filter',
title: 'Single Filter',
description: 'A single filter',
supportedContexts: [PluginContext.Asset],
schema: undefined,
},
],
actions: [
{
methodName: 'single-action',
title: 'Single Action',
description: 'A single action',
supportedContexts: [PluginContext.Asset],
schema: undefined,
},
],
wasmBytes,
},
'/test/base/path',
);
const pluginResult = await sut.get(result.plugin.id);
expect(pluginResult).toMatchObject({
id: result.plugin.id,
name: 'single-plugin',
filters: [
[
{
id: result.filters[0].id,
methodName: 'single-filter',
name: 'single-filter',
title: 'Single Filter',
description: 'A single filter',
types: [WorkflowType.AssetV1],
schema: undefined,
},
{
name: 'single-action',
title: 'Single Action',
description: 'A single action',
types: [WorkflowType.AssetV1],
schema: undefined,
},
],
actions: [
);
const pluginResult = await sut.get(result.id);
expect(pluginResult).toMatchObject({
id: result.id,
name: 'single-plugin',
methods: [
{
id: result.actions[0].id,
methodName: 'single-action',
id: result.methods[0].id,
name: 'single-filter',
title: 'Single Filter',
},
{
id: result.methods[1].id,
name: 'single-action',
title: 'Single Action',
},
],

View File

@@ -1,5 +1,5 @@
import { Kysely } from 'kysely';
import { PluginContext, PluginTriggerType } from 'src/enum';
import { WorkflowTrigger, WorkflowType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
@@ -20,53 +20,48 @@ const setup = (db?: Kysely<DB>) => {
});
};
const wasmBytes = Buffer.from('random-wasm-bytes');
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(WorkflowService.name, () => {
let testPluginId: string;
let testFilterId: string;
let testActionId: string;
beforeAll(async () => {
// Create a test plugin with filters and actions once for all tests
const pluginRepo = new PluginRepository(defaultDatabase);
const result = await pluginRepo.loadPlugin(
const { ctx } = setup();
// Create a test plugin with methods and actions once for all tests
const pluginRepo = ctx.get(PluginRepository);
const result = await pluginRepo.create(
{
enabled: true,
name: 'test-core-plugin',
title: 'Test Core Plugin',
description: 'A test core plugin for workflow tests',
author: 'Test Author',
version: '1.0.0',
wasm: {
path: '/test/path.wasm',
},
filters: [
{
methodName: 'test-filter',
title: 'Test Filter',
description: 'A test filter',
supportedContexts: [PluginContext.Asset],
schema: undefined,
},
],
actions: [
{
methodName: 'test-action',
title: 'Test Action',
description: 'A test action',
supportedContexts: [PluginContext.Asset],
schema: undefined,
},
],
wasmBytes,
},
'/plugins/test-core-plugin',
[
{
name: 'test-filter',
title: 'Test Filter',
description: 'A test filter',
types: [WorkflowType.AssetV1],
schema: undefined,
},
{
name: 'test-action',
title: 'Test Action',
description: 'A test action',
types: [WorkflowType.AssetV1],
schema: undefined,
},
],
);
testPluginId = result.plugin.id;
testFilterId = result.filters[0].id;
testActionId = result.actions[0].id;
testPluginId = result.id;
});
afterAll(async () => {
@@ -74,229 +69,48 @@ describe(WorkflowService.name, () => {
});
describe('create', () => {
it('should create a workflow without filters or actions', async () => {
it('should create a workflow without methods or actions', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const workflow = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
trigger: WorkflowTrigger.AssetCreate,
name: 'test-workflow',
description: 'A test workflow',
enabled: true,
filters: [],
actions: [],
});
expect(workflow).toMatchObject({
id: expect.any(String),
ownerId: user.id,
triggerType: PluginTriggerType.AssetCreate,
trigger: WorkflowTrigger.AssetCreate,
name: 'test-workflow',
description: 'A test workflow',
enabled: true,
filters: [],
actions: [],
});
});
it('should create a workflow with filters and actions', async () => {
it('should create a workflow with methods and actions', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const workflow = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
trigger: WorkflowTrigger.AssetCreate,
name: 'test-workflow-with-relations',
description: 'A test workflow with filters and actions',
description: 'A test workflow with methods and actions',
enabled: true,
filters: [
{
pluginFilterId: testFilterId,
filterConfig: { key: 'value' },
},
],
actions: [
{
pluginActionId: testActionId,
actionConfig: { action: 'test' },
},
],
});
expect(workflow).toMatchObject({
id: expect.any(String),
ownerId: user.id,
triggerType: PluginTriggerType.AssetCreate,
trigger: WorkflowTrigger.AssetCreate,
name: 'test-workflow-with-relations',
enabled: true,
});
expect(workflow.filters).toHaveLength(1);
expect(workflow.filters[0]).toMatchObject({
id: expect.any(String),
workflowId: workflow.id,
pluginFilterId: testFilterId,
filterConfig: { key: 'value' },
order: 0,
});
expect(workflow.actions).toHaveLength(1);
expect(workflow.actions[0]).toMatchObject({
id: expect.any(String),
workflowId: workflow.id,
pluginActionId: testActionId,
actionConfig: { action: 'test' },
order: 0,
});
});
it('should throw error when creating workflow with invalid filter', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
await expect(
sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'invalid-workflow',
description: 'A workflow with invalid filter',
enabled: true,
filters: [{ pluginFilterId: factory.uuid(), filterConfig: { key: 'value' } }],
actions: [],
}),
).rejects.toThrow('Invalid filter ID');
});
it('should throw error when creating workflow with invalid action', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
await expect(
sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'invalid-workflow',
description: 'A workflow with invalid action',
enabled: true,
filters: [],
actions: [{ pluginActionId: factory.uuid(), actionConfig: { action: 'test' } }],
}),
).rejects.toThrow('Invalid action ID');
});
it('should throw error when filter does not support trigger context', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
// Create a plugin with a filter that only supports Album context
const pluginRepo = new PluginRepository(defaultDatabase);
const result = await pluginRepo.loadPlugin(
{
name: 'album-only-plugin',
title: 'Album Only Plugin',
description: 'Plugin with album-only filter',
author: 'Test Author',
version: '1.0.0',
wasm: { path: '/test/album-plugin.wasm' },
filters: [
{
methodName: 'album-filter',
title: 'Album Filter',
description: 'A filter that only works with albums',
supportedContexts: [PluginContext.Album],
schema: undefined,
},
],
},
'/plugins/test-core-plugin',
);
await expect(
sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'invalid-context-workflow',
description: 'A workflow with context mismatch',
enabled: true,
filters: [{ pluginFilterId: result.filters[0].id }],
actions: [],
}),
).rejects.toThrow('does not support asset context');
});
it('should throw error when action does not support trigger context', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
// Create a plugin with an action that only supports Person context
const pluginRepo = new PluginRepository(defaultDatabase);
const result = await pluginRepo.loadPlugin(
{
name: 'person-only-plugin',
title: 'Person Only Plugin',
description: 'Plugin with person-only action',
author: 'Test Author',
version: '1.0.0',
wasm: { path: '/test/person-plugin.wasm' },
actions: [
{
methodName: 'person-action',
title: 'Person Action',
description: 'An action that only works with persons',
supportedContexts: [PluginContext.Person],
schema: undefined,
},
],
},
'/plugins/test-core-plugin',
);
await expect(
sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'invalid-context-workflow',
description: 'A workflow with context mismatch',
enabled: true,
filters: [],
actions: [{ pluginActionId: result.actions[0].id }],
}),
).rejects.toThrow('does not support asset context');
});
it('should create workflow with multiple filters and actions in correct order', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const workflow = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'multi-step-workflow',
description: 'A workflow with multiple filters and actions',
enabled: true,
filters: [
{ pluginFilterId: testFilterId, filterConfig: { step: 1 } },
{ pluginFilterId: testFilterId, filterConfig: { step: 2 } },
],
actions: [
{ pluginActionId: testActionId, actionConfig: { step: 1 } },
{ pluginActionId: testActionId, actionConfig: { step: 2 } },
{ pluginActionId: testActionId, actionConfig: { step: 3 } },
],
});
expect(workflow.filters).toHaveLength(2);
expect(workflow.filters[0].order).toBe(0);
expect(workflow.filters[0].filterConfig).toEqual({ step: 1 });
expect(workflow.filters[1].order).toBe(1);
expect(workflow.filters[1].filterConfig).toEqual({ step: 2 });
expect(workflow.actions).toHaveLength(3);
expect(workflow.actions[0].order).toBe(0);
expect(workflow.actions[1].order).toBe(1);
expect(workflow.actions[2].order).toBe(2);
});
});
@@ -307,24 +121,20 @@ describe(WorkflowService.name, () => {
const auth = factory.auth({ user });
const workflow1 = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
trigger: WorkflowTrigger.AssetCreate,
name: 'workflow-1',
description: 'First workflow',
enabled: true,
filters: [],
actions: [],
});
const workflow2 = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
trigger: WorkflowTrigger.AssetCreate,
name: 'workflow-2',
description: 'Second workflow',
enabled: false,
filters: [],
actions: [],
});
const workflows = await sut.getAll(auth);
const workflows = await sut.search(auth, {});
expect(workflows).toHaveLength(2);
expect(workflows).toEqual(
@@ -340,7 +150,7 @@ describe(WorkflowService.name, () => {
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const workflows = await sut.getAll(auth);
const workflows = await sut.search(auth, {});
expect(workflows).toEqual([]);
});
@@ -353,424 +163,15 @@ describe(WorkflowService.name, () => {
const auth2 = factory.auth({ user: user2 });
await sut.create(auth1, {
triggerType: PluginTriggerType.AssetCreate,
trigger: WorkflowTrigger.AssetCreate,
name: 'user1-workflow',
description: 'User 1 workflow',
enabled: true,
filters: [],
actions: [],
});
const user2Workflows = await sut.getAll(auth2);
const user2Workflows = await sut.search(auth2, {});
expect(user2Workflows).toEqual([]);
});
});
describe('get', () => {
it('should return a specific workflow by id', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'A test workflow',
enabled: true,
filters: [{ pluginFilterId: testFilterId, filterConfig: { key: 'value' } }],
actions: [{ pluginActionId: testActionId, actionConfig: { action: 'test' } }],
});
const workflow = await sut.get(auth, created.id);
expect(workflow).toMatchObject({
id: created.id,
name: 'test-workflow',
description: 'A test workflow',
enabled: true,
});
expect(workflow.filters).toHaveLength(1);
expect(workflow.actions).toHaveLength(1);
});
it('should throw error when workflow does not exist', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
await expect(sut.get(auth, '66da82df-e424-4bf4-b6f3-5d8e71620dae')).rejects.toThrow();
});
it('should throw error when user does not have access to workflow', async () => {
const { sut, ctx } = setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth1 = factory.auth({ user: user1 });
const auth2 = factory.auth({ user: user2 });
const workflow = await sut.create(auth1, {
triggerType: PluginTriggerType.AssetCreate,
name: 'private-workflow',
description: 'Private workflow',
enabled: true,
filters: [],
actions: [],
});
await expect(sut.get(auth2, workflow.id)).rejects.toThrow();
});
});
describe('update', () => {
it('should update workflow basic fields', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'original-workflow',
description: 'Original description',
enabled: true,
filters: [],
actions: [],
});
const updated = await sut.update(auth, created.id, {
name: 'updated-workflow',
description: 'Updated description',
enabled: false,
});
expect(updated).toMatchObject({
id: created.id,
name: 'updated-workflow',
description: 'Updated description',
enabled: false,
});
});
it('should update workflow filters', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [{ pluginFilterId: testFilterId, filterConfig: { old: 'config' } }],
actions: [],
});
const updated = await sut.update(auth, created.id, {
filters: [
{ pluginFilterId: testFilterId, filterConfig: { new: 'config' } },
{ pluginFilterId: testFilterId, filterConfig: { second: 'filter' } },
],
});
expect(updated.filters).toHaveLength(2);
expect(updated.filters[0].filterConfig).toEqual({ new: 'config' });
expect(updated.filters[1].filterConfig).toEqual({ second: 'filter' });
});
it('should update workflow actions', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [],
actions: [{ pluginActionId: testActionId, actionConfig: { old: 'config' } }],
});
const updated = await sut.update(auth, created.id, {
actions: [
{ pluginActionId: testActionId, actionConfig: { new: 'config' } },
{ pluginActionId: testActionId, actionConfig: { second: 'action' } },
],
});
expect(updated.actions).toHaveLength(2);
expect(updated.actions[0].actionConfig).toEqual({ new: 'config' });
expect(updated.actions[1].actionConfig).toEqual({ second: 'action' });
});
it('should clear filters when updated with empty array', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [{ pluginFilterId: testFilterId, filterConfig: { key: 'value' } }],
actions: [],
});
const updated = await sut.update(auth, created.id, {
filters: [],
});
expect(updated.filters).toHaveLength(0);
});
it('should throw error when no fields to update', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [],
actions: [],
});
await expect(sut.update(auth, created.id, {})).rejects.toThrow('No fields to update');
});
it('should throw error when updating non-existent workflow', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
await expect(sut.update(auth, factory.uuid(), { name: 'updated-name' })).rejects.toThrow();
});
it('should throw error when user does not have access to update workflow', async () => {
const { sut, ctx } = setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth1 = factory.auth({ user: user1 });
const auth2 = factory.auth({ user: user2 });
const workflow = await sut.create(auth1, {
triggerType: PluginTriggerType.AssetCreate,
name: 'private-workflow',
description: 'Private',
enabled: true,
filters: [],
actions: [],
});
await expect(
sut.update(auth2, workflow.id, {
name: 'hacked-workflow',
}),
).rejects.toThrow();
});
it('should throw error when updating with invalid filter', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [],
actions: [],
});
await expect(
sut.update(auth, created.id, {
filters: [{ pluginFilterId: factory.uuid(), filterConfig: {} }],
}),
).rejects.toThrow();
});
it('should throw error when updating with invalid action', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [],
actions: [],
});
await expect(
sut.update(auth, created.id, { actions: [{ pluginActionId: factory.uuid(), actionConfig: {} }] }),
).rejects.toThrow();
});
it('should update trigger type', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.PersonRecognized,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [],
actions: [],
});
await sut.update(auth, created.id, {
triggerType: PluginTriggerType.AssetCreate,
});
const fetched = await sut.get(auth, created.id);
expect(fetched.triggerType).toBe(PluginTriggerType.AssetCreate);
});
it('should add filters', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [],
actions: [],
});
await sut.update(auth, created.id, {
filters: [
{ pluginFilterId: testFilterId, filterConfig: { first: true } },
{ pluginFilterId: testFilterId, filterConfig: { second: true } },
],
});
const fetched = await sut.get(auth, created.id);
expect(fetched.filters).toHaveLength(2);
expect(fetched.filters[0].filterConfig).toEqual({ first: true });
expect(fetched.filters[1].filterConfig).toEqual({ second: true });
});
it('should replace existing filters', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [{ pluginFilterId: testFilterId, filterConfig: { original: true } }],
actions: [],
});
await sut.update(auth, created.id, {
filters: [{ pluginFilterId: testFilterId, filterConfig: { replaced: true } }],
});
const fetched = await sut.get(auth, created.id);
expect(fetched.filters).toHaveLength(1);
expect(fetched.filters[0].filterConfig).toEqual({ replaced: true });
});
it('should remove existing filters', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [{ pluginFilterId: testFilterId, filterConfig: { toRemove: true } }],
actions: [],
});
await sut.update(auth, created.id, {
filters: [],
});
const fetched = await sut.get(auth, created.id);
expect(fetched.filters).toHaveLength(0);
});
});
describe('delete', () => {
it('should delete a workflow', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const workflow = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [],
actions: [],
});
await sut.delete(auth, workflow.id);
await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access');
});
it('should delete workflow with filters and actions', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const workflow = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [{ pluginFilterId: testFilterId, filterConfig: {} }],
actions: [{ pluginActionId: testActionId, actionConfig: {} }],
});
await sut.delete(auth, workflow.id);
await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access');
});
it('should throw error when deleting non-existent workflow', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
await expect(sut.delete(auth, factory.uuid())).rejects.toThrow();
});
it('should throw error when user does not have access to delete workflow', async () => {
const { sut, ctx } = setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth1 = factory.auth({ user: user1 });
const auth2 = factory.auth({ user: user2 });
const workflow = await sut.create(auth1, {
triggerType: PluginTriggerType.AssetCreate,
name: 'private-workflow',
description: 'Private',
enabled: true,
filters: [],
actions: [],
});
await expect(sut.delete(auth2, workflow.id)).rejects.toThrow();
});
});
});

View File

@@ -0,0 +1,213 @@
import { WorkflowStepConfig } from '@immich/plugin-sdk';
import { Kysely } from 'kysely';
import { AssetVisibility, WorkflowTrigger } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository';
import { DB } from 'src/schema';
import { WorkflowExecutionService } from 'src/services/workflow-execution.service';
import { resolveMethod } from 'src/utils/workflow';
import { MediumTestContext } from 'test/medium.factory';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { getKyselyDB } from 'test/utils';
let initialized = false;
class WorkflowTestContext extends MediumTestContext<WorkflowExecutionService> {
constructor(database: Kysely<DB>) {
super(WorkflowExecutionService, {
database,
real: [
AccessRepository,
AlbumRepository,
AssetRepository,
CryptoRepository,
DatabaseRepository,
LoggingRepository,
StorageRepository,
PluginRepository,
WorkflowRepository,
],
mock: [ConfigRepository],
});
}
async init() {
if (initialized) {
return;
}
const mockData = mockEnvData({});
mockData.resourcePaths.corePlugin = '../packages/plugin-core';
mockData.plugins.external.allow = false;
this.getMock(ConfigRepository).getEnv.mockReturnValue(mockData);
await this.sut.onPluginSync();
await this.sut.onPluginLoad();
initialized = true;
}
}
type WorkflowTemplate = {
ownerId: string;
trigger: WorkflowTrigger;
steps: WorkflowTemplateStep[];
};
type WorkflowTemplateStep = {
method: string;
config?: WorkflowStepConfig;
};
const createWorkflow = async (template: WorkflowTemplate) => {
const workflowRepo = ctx.get(WorkflowRepository);
const pluginRepo = ctx.get(PluginRepository);
const methods = await pluginRepo.getForValidation();
const steps = template.steps.map((step) => {
const pluginMethod = resolveMethod(methods, step.method);
if (!pluginMethod) {
throw new Error(`Plugin method not found: ${step.method}`);
}
return { ...step, pluginMethod };
});
return workflowRepo.create(
{
enabled: true,
name: 'Test workflow',
description: 'A workflow to test the core plugin',
ownerId: template.ownerId,
trigger: template.trigger,
},
steps.map((step) => ({
enabled: true,
pluginMethodId: step.pluginMethod.id,
config: step.config,
})),
);
};
let ctx: WorkflowTestContext;
beforeAll(async () => {
const db = await getKyselyDB();
ctx = new WorkflowTestContext(db);
await ctx.init();
});
describe('core plugin', () => {
describe('assetArchive', () => {
it('should archive an asset', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [{ method: 'immich-plugin-core#assetArchive' }],
});
await ctx.sut.handleWorkflowAssetCreate({ workflowId: workflow.id, assetId: asset.id });
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Archive,
});
});
it('should unarchive an asset', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Archive });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [{ method: 'immich-plugin-core#assetArchive', config: { inverse: true } }],
});
await ctx.sut.handleWorkflowAssetCreate({ workflowId: workflow.id, assetId: asset.id });
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
visibility: AssetVisibility.Timeline,
});
});
});
describe('assetFavoriteAsset', () => {
it('should favorite an asset', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [{ method: 'immich-plugin-core#assetFavorite' }],
});
await ctx.sut.handleWorkflowAssetCreate({ workflowId: workflow.id, assetId: asset.id });
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
});
it('should unfavorite an asset', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [{ method: 'immich-plugin-core#assetFavorite', config: { inverse: true } }],
});
await ctx.sut.handleWorkflowAssetCreate({ workflowId: workflow.id, assetId: asset.id });
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
});
});
describe('albumAddAssets', () => {
it('should add an asset to an album', async () => {
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const workflow = await createWorkflow({
ownerId: user.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [{ method: 'immich-plugin-core#albumAddAssets', config: { albumId: album.id } }],
});
await ctx.sut.handleWorkflowAssetCreate({ workflowId: workflow.id, assetId: asset.id });
const assetIds = await ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id]);
expect([...assetIds]).toEqual([asset.id]);
});
it('should require album access', async () => {
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user1.id, isFavorite: true });
const { album } = await ctx.newAlbum({ ownerId: user2.id });
const workflow = await createWorkflow({
ownerId: user1.id,
trigger: WorkflowTrigger.AssetCreate,
steps: [{ method: 'immich-plugin-core#albumAddAssets', config: { albumId: album.id } }],
});
await ctx.sut.handleWorkflowAssetCreate({ workflowId: workflow.id, assetId: asset.id });
const assetIds = await ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id]);
expect([...assetIds]).not.toContain(asset.id);
});
});
});

Some files were not shown because too many files have changed in this diff Show More