mirror of
https://github.com/immich-app/immich.git
synced 2026-05-27 01:52:33 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 977c885763 | |||
| 8682be4774 | |||
| dc66892ca1 |
@@ -698,6 +698,7 @@
|
||||
"birthdate_saved": "Date of birth saved successfully",
|
||||
"birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.",
|
||||
"blurred_background": "Blurred background",
|
||||
"browse_templates": "Browse templates",
|
||||
"bugs_and_feature_requests": "Bugs & Feature Requests",
|
||||
"build": "Build",
|
||||
"build_image": "Build Image",
|
||||
@@ -839,6 +840,7 @@
|
||||
"copy_error": "Copy error",
|
||||
"copy_file_path": "Copy file path",
|
||||
"copy_image": "Copy Image",
|
||||
"copy_json": "Copy JSON",
|
||||
"copy_link": "Copy link",
|
||||
"copy_link_to_clipboard": "Copy link to clipboard",
|
||||
"copy_password": "Copy password",
|
||||
@@ -978,6 +980,8 @@
|
||||
"downloading_media": "Downloading media",
|
||||
"drag_to_reorder": "Drag to reorder",
|
||||
"drop_files_to_upload": "Drop files anywhere to upload",
|
||||
"duplicate": "Duplicate",
|
||||
"duplicate_workflow": "Duplicate workflow",
|
||||
"duplicates": "Duplicates",
|
||||
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates.",
|
||||
"duration": "Duration",
|
||||
@@ -2417,6 +2421,7 @@
|
||||
"use_browser_locale_description": "Format dates, times, and numbers based on your browser locale",
|
||||
"use_current_connection": "Use current connection",
|
||||
"use_custom_date_range": "Use custom date range instead",
|
||||
"use_template": "Use template",
|
||||
"user": "User",
|
||||
"user_has_been_deleted": "This user has been deleted.",
|
||||
"user_id": "User ID",
|
||||
@@ -2491,6 +2496,7 @@
|
||||
"workflow_name": "Workflow name",
|
||||
"workflow_navigation_prompt": "Are you sure you want to leave without saving your changes?",
|
||||
"workflow_summary": "Workflow summary",
|
||||
"workflow_templates": "Workflow templates",
|
||||
"workflow_update_success": "Workflow updated successfully",
|
||||
"workflow_updated": "Workflow updated",
|
||||
"workflows": "Workflows",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
ARG DEVICE=cpu
|
||||
|
||||
FROM python:3.11-bookworm@sha256:121d86b6d08752968a7dddbc708849e5f3a839bbff47f32212b46d2a1d842bab AS builder-cpu
|
||||
FROM python:3.11-bookworm@sha256:970c99f886b839fc8829289040c1845dadaf2cae46b37acc7710333158ec29b4 AS builder-cpu
|
||||
|
||||
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS builder-openvino
|
||||
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS builder-openvino
|
||||
|
||||
FROM builder-cpu AS builder-cuda
|
||||
|
||||
@@ -39,12 +39,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
|
||||
|
||||
FROM python:3.11-slim-bookworm@sha256:8dca233de9f3d9bb410665f00a4da6dd06f331083137e0e98ccf227236fcc438 AS prod-cpu
|
||||
FROM python:3.11-slim-bookworm@sha256:9c6f90801e6b68e772b7c0ca74260cbf7af9f320acec894e26fccdaccfbe3b47 AS prod-cpu
|
||||
|
||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
||||
MACHINE_LEARNING_MODEL_ARENA=false
|
||||
|
||||
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS prod-openvino
|
||||
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS prod-openvino
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
|
||||
Generated
+447
-495
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.da
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart' show assetExifProvider;
|
||||
import 'package:immich_mobile/providers/infrastructure/tag.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
@@ -21,6 +22,7 @@ import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/action.service.dart';
|
||||
import 'package:immich_mobile/services/download.service.dart';
|
||||
import 'package:immich_mobile/services/foreground_upload.service.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
@@ -536,14 +538,22 @@ class ActionNotifier extends Notifier<void> {
|
||||
return ActionResult(count: ids.length, success: false, error: 'Expected single asset for applying edits');
|
||||
}
|
||||
|
||||
final completer = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) {
|
||||
final eventAsset = SyncAssetV1.fromJson(data["asset"]);
|
||||
return eventAsset?.id == ids.first;
|
||||
}, const Duration(seconds: 10));
|
||||
Future<void> editReady;
|
||||
if (ref.read(serverInfoProvider).serverVersion >= const SemVer(major: 3, minor: 0, patch: 0)) {
|
||||
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV2", (dynamic data) {
|
||||
final eventAsset = SyncAssetV2.fromJson(data["asset"]);
|
||||
return eventAsset?.id == ids.first;
|
||||
}, const Duration(seconds: 10));
|
||||
} else {
|
||||
editReady = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) {
|
||||
final eventAsset = SyncAssetV1.fromJson(data["asset"]);
|
||||
return eventAsset?.id == ids.first;
|
||||
}, const Duration(seconds: 10));
|
||||
}
|
||||
|
||||
try {
|
||||
await _service.applyEdits(ids.first, edits);
|
||||
await completer;
|
||||
await editReady;
|
||||
return const ActionResult(count: 1, success: true);
|
||||
} catch (error, stack) {
|
||||
_logger.severe('Failed to apply edits to assets', error, stack);
|
||||
|
||||
@@ -334,7 +334,9 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
|
||||
void _updateScaleBoundaries() {
|
||||
final prev = controller.scaleBoundaries;
|
||||
if (prev == widget.scaleBoundaries) return;
|
||||
if (prev == widget.scaleBoundaries) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (prev != null && controller.scale != null && prev.initialScale > 0) {
|
||||
final ratio = widget.scaleBoundaries.initialScale / prev.initialScale;
|
||||
|
||||
Generated
+3
@@ -206,6 +206,7 @@ Class | Method | HTTP request | Description
|
||||
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
|
||||
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
|
||||
*PluginsApi* | [**searchPluginMethods**](doc//PluginsApi.md#searchpluginmethods) | **GET** /plugins/methods | Retrieve plugin methods
|
||||
*PluginsApi* | [**searchPluginTemplates**](doc//PluginsApi.md#searchplugintemplates) | **GET** /plugins/templates | Retrieve workflow templates
|
||||
*PluginsApi* | [**searchPlugins**](doc//PluginsApi.md#searchplugins) | **GET** /plugins | List all plugins
|
||||
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
|
||||
*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue
|
||||
@@ -491,6 +492,8 @@ Class | Method | HTTP request | Description
|
||||
- [PlacesResponseDto](doc//PlacesResponseDto.md)
|
||||
- [PluginMethodResponseDto](doc//PluginMethodResponseDto.md)
|
||||
- [PluginResponseDto](doc//PluginResponseDto.md)
|
||||
- [PluginTemplateResponseDto](doc//PluginTemplateResponseDto.md)
|
||||
- [PluginTemplateStepResponseDto](doc//PluginTemplateStepResponseDto.md)
|
||||
- [PurchaseResponse](doc//PurchaseResponse.md)
|
||||
- [PurchaseUpdate](doc//PurchaseUpdate.md)
|
||||
- [QueueCommand](doc//QueueCommand.md)
|
||||
|
||||
Generated
+2
@@ -237,6 +237,8 @@ part 'model/pin_code_setup_dto.dart';
|
||||
part 'model/places_response_dto.dart';
|
||||
part 'model/plugin_method_response_dto.dart';
|
||||
part 'model/plugin_response_dto.dart';
|
||||
part 'model/plugin_template_response_dto.dart';
|
||||
part 'model/plugin_template_step_response_dto.dart';
|
||||
part 'model/purchase_response.dart';
|
||||
part 'model/purchase_update.dart';
|
||||
part 'model/queue_command.dart';
|
||||
|
||||
Generated
+51
@@ -204,6 +204,57 @@ class PluginsApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Retrieve workflow templates
|
||||
///
|
||||
/// Retrieve workflow templates provided by installed plugins
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> searchPluginTemplatesWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/plugins/templates';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retrieve workflow templates
|
||||
///
|
||||
/// Retrieve workflow templates provided by installed plugins
|
||||
Future<List<PluginTemplateResponseDto>?> searchPluginTemplates() async {
|
||||
final response = await searchPluginTemplatesWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<PluginTemplateResponseDto>') as List)
|
||||
.cast<PluginTemplateResponseDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// List all plugins
|
||||
///
|
||||
/// Retrieve a list of plugins available to the authenticated user.
|
||||
|
||||
Generated
+4
@@ -520,6 +520,10 @@ class ApiClient {
|
||||
return PluginMethodResponseDto.fromJson(value);
|
||||
case 'PluginResponseDto':
|
||||
return PluginResponseDto.fromJson(value);
|
||||
case 'PluginTemplateResponseDto':
|
||||
return PluginTemplateResponseDto.fromJson(value);
|
||||
case 'PluginTemplateStepResponseDto':
|
||||
return PluginTemplateStepResponseDto.fromJson(value);
|
||||
case 'PurchaseResponse':
|
||||
return PurchaseResponse.fromJson(value);
|
||||
case 'PurchaseUpdate':
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class PluginTemplateResponseDto {
|
||||
/// Returns a new [PluginTemplateResponseDto] instance.
|
||||
PluginTemplateResponseDto({
|
||||
required this.description,
|
||||
required this.key,
|
||||
this.steps = const [],
|
||||
required this.title,
|
||||
required this.trigger,
|
||||
});
|
||||
|
||||
/// Template description
|
||||
String description;
|
||||
|
||||
/// Template key (unique across all templates)
|
||||
String key;
|
||||
|
||||
/// Workflow steps
|
||||
List<PluginTemplateStepResponseDto> steps;
|
||||
|
||||
/// Template title
|
||||
String title;
|
||||
|
||||
WorkflowTrigger trigger;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateResponseDto &&
|
||||
other.description == description &&
|
||||
other.key == key &&
|
||||
_deepEquality.equals(other.steps, steps) &&
|
||||
other.title == title &&
|
||||
other.trigger == trigger;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(description.hashCode) +
|
||||
(key.hashCode) +
|
||||
(steps.hashCode) +
|
||||
(title.hashCode) +
|
||||
(trigger.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PluginTemplateResponseDto[description=$description, key=$key, steps=$steps, title=$title, trigger=$trigger]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'description'] = this.description;
|
||||
json[r'key'] = this.key;
|
||||
json[r'steps'] = this.steps;
|
||||
json[r'title'] = this.title;
|
||||
json[r'trigger'] = this.trigger;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [PluginTemplateResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static PluginTemplateResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "PluginTemplateResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return PluginTemplateResponseDto(
|
||||
description: mapValueOfType<String>(json, r'description')!,
|
||||
key: mapValueOfType<String>(json, r'key')!,
|
||||
steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']),
|
||||
title: mapValueOfType<String>(json, r'title')!,
|
||||
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<PluginTemplateResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <PluginTemplateResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = PluginTemplateResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, PluginTemplateResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, PluginTemplateResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = PluginTemplateResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of PluginTemplateResponseDto-objects as value to a dart map
|
||||
static Map<String, List<PluginTemplateResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<PluginTemplateResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = PluginTemplateResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'description',
|
||||
'key',
|
||||
'steps',
|
||||
'title',
|
||||
'trigger',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class PluginTemplateStepResponseDto {
|
||||
/// Returns a new [PluginTemplateStepResponseDto] instance.
|
||||
PluginTemplateStepResponseDto({
|
||||
this.config = const {},
|
||||
this.enabled,
|
||||
required this.method,
|
||||
});
|
||||
|
||||
/// Step configuration
|
||||
Map<String, Object>? config;
|
||||
|
||||
/// Whether the step is enabled
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
bool? enabled;
|
||||
|
||||
/// Step plugin method
|
||||
String method;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PluginTemplateStepResponseDto &&
|
||||
_deepEquality.equals(other.config, config) &&
|
||||
other.enabled == enabled &&
|
||||
other.method == method;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(config == null ? 0 : config!.hashCode) +
|
||||
(enabled == null ? 0 : enabled!.hashCode) +
|
||||
(method.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PluginTemplateStepResponseDto[config=$config, enabled=$enabled, method=$method]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
if (this.config != null) {
|
||||
json[r'config'] = this.config;
|
||||
} else {
|
||||
// json[r'config'] = null;
|
||||
}
|
||||
if (this.enabled != null) {
|
||||
json[r'enabled'] = this.enabled;
|
||||
} else {
|
||||
// json[r'enabled'] = null;
|
||||
}
|
||||
json[r'method'] = this.method;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [PluginTemplateStepResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static PluginTemplateStepResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "PluginTemplateStepResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return PluginTemplateStepResponseDto(
|
||||
config: mapCastOfType<String, Object>(json, r'config'),
|
||||
enabled: mapValueOfType<bool>(json, r'enabled'),
|
||||
method: mapValueOfType<String>(json, r'method')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<PluginTemplateStepResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <PluginTemplateStepResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = PluginTemplateStepResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, PluginTemplateStepResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, PluginTemplateStepResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = PluginTemplateStepResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of PluginTemplateStepResponseDto-objects as value to a dart map
|
||||
static Map<String, List<PluginTemplateStepResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<PluginTemplateStepResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = PluginTemplateStepResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'config',
|
||||
'method',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8818,6 +8818,50 @@
|
||||
"x-immich-permission": "plugin.read"
|
||||
}
|
||||
},
|
||||
"/plugins/templates": {
|
||||
"get": {
|
||||
"description": "Retrieve workflow templates provided by installed plugins",
|
||||
"operationId": "searchPluginTemplates",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PluginTemplateResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Retrieve workflow templates",
|
||||
"tags": [
|
||||
"Plugins"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3.0.0",
|
||||
"state": "Added"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "plugin.read"
|
||||
}
|
||||
},
|
||||
"/plugins/{id}": {
|
||||
"get": {
|
||||
"description": "Retrieve information about a specific plugin by its ID.",
|
||||
@@ -20131,6 +20175,64 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginTemplateResponseDto": {
|
||||
"properties": {
|
||||
"description": {
|
||||
"description": "Template description",
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"description": "Template key (unique across all templates)",
|
||||
"type": "string"
|
||||
},
|
||||
"steps": {
|
||||
"description": "Workflow steps",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PluginTemplateStepResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"title": {
|
||||
"description": "Template title",
|
||||
"type": "string"
|
||||
},
|
||||
"trigger": {
|
||||
"$ref": "#/components/schemas/WorkflowTrigger",
|
||||
"description": "Workflow trigger"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"description",
|
||||
"key",
|
||||
"steps",
|
||||
"title",
|
||||
"trigger"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginTemplateStepResponseDto": {
|
||||
"properties": {
|
||||
"config": {
|
||||
"additionalProperties": {},
|
||||
"description": "Step configuration",
|
||||
"nullable": true,
|
||||
"type": "object"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Whether the step is enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"method": {
|
||||
"description": "Step plugin method",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"config",
|
||||
"method"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PurchaseResponse": {
|
||||
"properties": {
|
||||
"hideBuyButtonUntil": {
|
||||
|
||||
@@ -5,6 +5,36 @@
|
||||
"description": "Core workflow capabilities for Immich",
|
||||
"author": "Immich Team",
|
||||
"wasmPath": "dist/plugin.wasm",
|
||||
"templates": [
|
||||
{
|
||||
"name": "auto-archive-screenshots",
|
||||
"title": "Auto-archive screenshots",
|
||||
"description": "Archive uploads with \"screenshot\" in the filename and optionally add them to an album",
|
||||
"trigger": "AssetCreate",
|
||||
"steps": [
|
||||
{
|
||||
"method": "immich-plugin-core#assetFileFilter",
|
||||
"config": {
|
||||
"pattern": "screenshot",
|
||||
"matchType": "contains",
|
||||
"caseSensitive": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "immich-plugin-core#assetAddToAlbums",
|
||||
"config": {
|
||||
"albumIds": []
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "immich-plugin-core#assetArchive",
|
||||
"config": {
|
||||
"inverse": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
{
|
||||
"name": "assetFileFilter",
|
||||
|
||||
@@ -100,6 +100,11 @@ export const assetTrash = () => {
|
||||
|
||||
export const assetAddToAlbums = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { albumIds: string[] }>(({ config, data, functions }) => {
|
||||
if (config.albumIds.length === 0) {
|
||||
// noop
|
||||
return {};
|
||||
}
|
||||
|
||||
if (config.albumIds.length === 1) {
|
||||
functions.albumAddAssets(config.albumIds[0], [data.asset.id]);
|
||||
return {};
|
||||
|
||||
@@ -1514,6 +1514,28 @@ export type PluginResponseDto = {
|
||||
/** Plugin version */
|
||||
version: string;
|
||||
};
|
||||
export type PluginTemplateStepResponseDto = {
|
||||
/** Step configuration */
|
||||
config: {
|
||||
[key: string]: any;
|
||||
} | null;
|
||||
/** Whether the step is enabled */
|
||||
enabled?: boolean;
|
||||
/** Step plugin method */
|
||||
method: string;
|
||||
};
|
||||
export type PluginTemplateResponseDto = {
|
||||
/** Template description */
|
||||
description: string;
|
||||
/** Template key (unique across all templates) */
|
||||
key: string;
|
||||
/** Workflow steps */
|
||||
steps: PluginTemplateStepResponseDto[];
|
||||
/** Template title */
|
||||
title: string;
|
||||
/** Workflow trigger */
|
||||
trigger: WorkflowTrigger;
|
||||
};
|
||||
export type QueueResponseDto = {
|
||||
/** Whether the queue is paused */
|
||||
isPaused: boolean;
|
||||
@@ -5242,6 +5264,17 @@ export function searchPluginMethods({ description, enabled, id, name, pluginName
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Retrieve workflow templates
|
||||
*/
|
||||
export function searchPluginTemplates(opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: PluginTemplateResponseDto[];
|
||||
}>("/plugins/templates", {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Retrieve a plugin
|
||||
*/
|
||||
|
||||
Generated
+34
-28
@@ -609,7 +609,7 @@ importers:
|
||||
version: 10.0.1(eslint@10.4.0(jiti@2.7.0))
|
||||
'@nestjs/cli':
|
||||
specifier: ^11.0.2
|
||||
version: 11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)
|
||||
version: 11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.22))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)
|
||||
'@nestjs/schematics':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@6.0.3)
|
||||
@@ -618,7 +618,7 @@ importers:
|
||||
version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21)(@nestjs/platform-express@11.1.21)
|
||||
'@swc/core':
|
||||
specifier: ^1.4.14
|
||||
version: 1.15.33(@swc/helpers@0.5.21)
|
||||
version: 1.15.33(@swc/helpers@0.5.22)
|
||||
'@types/archiver':
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0
|
||||
@@ -738,7 +738,7 @@ importers:
|
||||
version: 8.59.4(eslint@10.4.0(jiti@2.7.0))(typescript@6.0.3)
|
||||
unplugin-swc:
|
||||
specifier: ^1.4.5
|
||||
version: 1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.21))(rollup@4.60.4)
|
||||
version: 1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.22))(rollup@4.60.4)
|
||||
vite-tsconfig-paths:
|
||||
specifier: ^6.0.0
|
||||
version: 6.1.1(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0))
|
||||
@@ -758,8 +758,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../packages/sdk
|
||||
'@immich/ui':
|
||||
specifier: ^0.79.0
|
||||
version: 0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||
specifier: ^0.79.2
|
||||
version: 0.79.2(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))
|
||||
'@mapbox/mapbox-gl-rtl-text':
|
||||
specifier: 0.4.0
|
||||
version: 0.4.0
|
||||
@@ -1691,6 +1691,10 @@ packages:
|
||||
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/runtime@7.29.7':
|
||||
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -3204,8 +3208,8 @@ packages:
|
||||
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
|
||||
hasBin: true
|
||||
|
||||
'@immich/ui@0.79.0':
|
||||
resolution: {integrity: sha512-UEQZrP8CTc4Kth1xCV8/6Xmk1P51GQKISC7vKqcrM0BO0fxCaNwJK8Ocn6R8baVqH52JYfPb1yQR9bweBnCjXw==}
|
||||
'@immich/ui@0.79.2':
|
||||
resolution: {integrity: sha512-tnpYhYHrjrFJK18QglRMzPUtHv6q5V6tW38HiAraQJBv7MCg+yaJDrdF8omM2L5F311FGlv1PZLJYvmR4e49PA==}
|
||||
peerDependencies:
|
||||
'@sveltejs/kit': ^2.13.0
|
||||
svelte: ^5.0.0
|
||||
@@ -4982,8 +4986,8 @@ packages:
|
||||
'@swc/counter@0.1.3':
|
||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
|
||||
'@swc/helpers@0.5.22':
|
||||
resolution: {integrity: sha512-/e2Ly3Docn9kYByap6TV4oquJ3wQuz3c+kC74riqtkwU9CwTMeuj6t2rW+bRr4pyOx/CYQM4wr0RgaKQwGEz0A==}
|
||||
|
||||
'@swc/types@0.1.26':
|
||||
resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
|
||||
@@ -13787,6 +13791,8 @@ snapshots:
|
||||
|
||||
'@babel/runtime@7.29.2': {}
|
||||
|
||||
'@babel/runtime@7.29.7': {}
|
||||
|
||||
'@babel/template@7.28.6':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
@@ -15879,7 +15885,7 @@ snapshots:
|
||||
pg-connection-string: 2.13.0
|
||||
postgres: 3.4.9
|
||||
|
||||
'@immich/ui@0.79.0(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
|
||||
'@immich/ui@0.79.2(@sveltejs/kit@2.60.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.8(@typescript-eslint/types@8.59.4))(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))(typescript@6.0.3)(vite@8.0.13(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(sass@1.99.0)(terser@5.47.1)(tsx@4.22.3)(yaml@2.9.0)))(svelte@5.55.8(@typescript-eslint/types@8.59.4))':
|
||||
dependencies:
|
||||
'@internationalized/date': 3.12.1
|
||||
'@mdi/js': 7.4.47
|
||||
@@ -16035,7 +16041,7 @@ snapshots:
|
||||
|
||||
'@internationalized/date@3.12.1':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.21
|
||||
'@swc/helpers': 0.5.22
|
||||
|
||||
'@ioredis/commands@1.5.1': {}
|
||||
|
||||
@@ -16438,7 +16444,7 @@ snapshots:
|
||||
bullmq: 5.76.10
|
||||
tslib: 2.8.1
|
||||
|
||||
'@nestjs/cli@11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)':
|
||||
'@nestjs/cli@11.0.21(@swc/core@1.15.33(@swc/helpers@0.5.22))(@types/node@24.12.4)(esbuild@0.28.0)(lightningcss@1.32.0)(prettier@3.8.3)':
|
||||
dependencies:
|
||||
'@angular-devkit/core': 19.2.24(chokidar@4.0.3)
|
||||
'@angular-devkit/schematics': 19.2.24(chokidar@4.0.3)
|
||||
@@ -16449,17 +16455,17 @@ snapshots:
|
||||
chokidar: 4.0.3
|
||||
cli-table3: 0.6.5
|
||||
commander: 4.1.1
|
||||
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0))
|
||||
fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0))
|
||||
glob: 13.0.6
|
||||
node-emoji: 1.11.0
|
||||
ora: 5.4.1
|
||||
tsconfig-paths: 4.2.0
|
||||
tsconfig-paths-webpack-plugin: 4.2.0
|
||||
typescript: 5.9.3
|
||||
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)
|
||||
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
|
||||
webpack-node-externals: 3.0.0
|
||||
optionalDependencies:
|
||||
'@swc/core': 1.15.33(@swc/helpers@0.5.21)
|
||||
'@swc/core': 1.15.33(@swc/helpers@0.5.22)
|
||||
transitivePeerDependencies:
|
||||
- '@minify-html/node'
|
||||
- '@swc/css'
|
||||
@@ -17438,7 +17444,7 @@ snapshots:
|
||||
|
||||
'@slorber/react-helmet-async@1.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@babel/runtime': 7.29.7
|
||||
invariant: 2.2.4
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.6
|
||||
@@ -17647,7 +17653,7 @@ snapshots:
|
||||
'@swc/core-win32-x64-msvc@1.15.33':
|
||||
optional: true
|
||||
|
||||
'@swc/core@1.15.33(@swc/helpers@0.5.21)':
|
||||
'@swc/core@1.15.33(@swc/helpers@0.5.22)':
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
'@swc/types': 0.1.26
|
||||
@@ -17664,11 +17670,11 @@ snapshots:
|
||||
'@swc/core-win32-arm64-msvc': 1.15.33
|
||||
'@swc/core-win32-ia32-msvc': 1.15.33
|
||||
'@swc/core-win32-x64-msvc': 1.15.33
|
||||
'@swc/helpers': 0.5.21
|
||||
'@swc/helpers': 0.5.22
|
||||
|
||||
'@swc/counter@0.1.3': {}
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
'@swc/helpers@0.5.22':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
@@ -21084,7 +21090,7 @@ snapshots:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)):
|
||||
fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)):
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
chalk: 4.1.2
|
||||
@@ -21099,7 +21105,7 @@ snapshots:
|
||||
semver: 7.8.0
|
||||
tapable: 2.3.3
|
||||
typescript: 5.9.3
|
||||
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)
|
||||
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
|
||||
|
||||
form-data-encoder@2.1.4: {}
|
||||
|
||||
@@ -25761,15 +25767,15 @@ snapshots:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
terser-webpack-plugin@5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)):
|
||||
terser-webpack-plugin@5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.3
|
||||
terser: 5.47.1
|
||||
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)
|
||||
webpack: 5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)
|
||||
optionalDependencies:
|
||||
'@swc/core': 1.15.33(@swc/helpers@0.5.21)
|
||||
'@swc/core': 1.15.33(@swc/helpers@0.5.22)
|
||||
esbuild: 0.28.0
|
||||
lightningcss: 1.32.0
|
||||
|
||||
@@ -26176,10 +26182,10 @@ snapshots:
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
unplugin-swc@1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.21))(rollup@4.60.4):
|
||||
unplugin-swc@1.5.9(@swc/core@1.15.33(@swc/helpers@0.5.22))(rollup@4.60.4):
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.3.0(rollup@4.60.4)
|
||||
'@swc/core': 1.15.33(@swc/helpers@0.5.21)
|
||||
'@swc/core': 1.15.33(@swc/helpers@0.5.22)
|
||||
load-tsconfig: 0.2.5
|
||||
unplugin: 2.3.11
|
||||
transitivePeerDependencies:
|
||||
@@ -26578,7 +26584,7 @@ snapshots:
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0):
|
||||
webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0):
|
||||
dependencies:
|
||||
'@types/eslint-scope': 3.7.7
|
||||
'@types/estree': 1.0.9
|
||||
@@ -26602,7 +26608,7 @@ snapshots:
|
||||
neo-async: 2.6.2
|
||||
schema-utils: 4.3.3
|
||||
tapable: 2.3.3
|
||||
terser-webpack-plugin: 5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.21))(esbuild@0.28.0)(lightningcss@1.32.0))
|
||||
terser-webpack-plugin: 5.6.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0)(webpack@5.106.0(@swc/core@1.15.33(@swc/helpers@0.5.22))(esbuild@0.28.0)(lightningcss@1.32.0))
|
||||
watchpack: 2.5.1
|
||||
webpack-sources: 3.4.1
|
||||
transitivePeerDependencies:
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
PluginMethodSearchDto,
|
||||
PluginResponseDto,
|
||||
PluginSearchDto,
|
||||
PluginTemplateResponseDto,
|
||||
} from 'src/dtos/plugin.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { Authenticated } from 'src/middleware/auth.guard';
|
||||
@@ -39,6 +40,17 @@ export class PluginController {
|
||||
return this.service.searchMethods(dto);
|
||||
}
|
||||
|
||||
@Get('templates')
|
||||
@Authenticated({ permission: Permission.PluginRead })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve workflow templates',
|
||||
description: 'Retrieve workflow templates provided by installed plugins',
|
||||
history: HistoryBuilder.v3(),
|
||||
})
|
||||
searchPluginTemplates(): Promise<PluginTemplateResponseDto[]> {
|
||||
return this.service.searchTemplates();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Authenticated({ permission: Permission.PluginRead })
|
||||
@Endpoint({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { JsonSchemaSchema } from 'src/dtos/json-schema.dto';
|
||||
import { WorkflowTypeSchema } from 'src/enum';
|
||||
import { WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
|
||||
import z from 'zod';
|
||||
|
||||
const pluginNameRegex = /^[a-z0-9-]+[a-z0-9]$/;
|
||||
@@ -23,6 +23,24 @@ const PluginManifestMethodSchema = z
|
||||
})
|
||||
.meta({ id: 'PluginManifestMethodDto' });
|
||||
|
||||
const PluginManifestTemplateStepSchema = z
|
||||
.object({
|
||||
method: z.string().min(1).describe('Step plugin method (pluginName#methodName)'),
|
||||
config: z.record(z.string(), z.unknown()).nullable().optional().describe('Step configuration'),
|
||||
enabled: z.boolean().optional().describe('Whether the step is enabled'),
|
||||
})
|
||||
.meta({ id: 'PluginManifestTemplateStepDto' });
|
||||
|
||||
const PluginManifestTemplateSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).describe('Template name (must be unique within the manifest)'),
|
||||
title: z.string().min(1).describe('Template title'),
|
||||
description: z.string().min(1).describe('Template description'),
|
||||
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
|
||||
steps: z.array(PluginManifestTemplateStepSchema).describe('Workflow steps'),
|
||||
})
|
||||
.meta({ id: 'PluginManifestTemplateDto' });
|
||||
|
||||
const PluginManifestSchema = z
|
||||
.object({
|
||||
name: z
|
||||
@@ -39,6 +57,14 @@ const PluginManifestSchema = z
|
||||
wasmPath: z.string().min(1).describe('WASM file path'),
|
||||
author: z.string().min(1).describe('Plugin author'),
|
||||
methods: z.array(PluginManifestMethodSchema).optional().default([]).describe('Plugin methods'),
|
||||
templates: z
|
||||
.array(PluginManifestTemplateSchema)
|
||||
.optional()
|
||||
.default([])
|
||||
.refine((templates) => new Set(templates.map((t) => t.name)).size === templates.length, {
|
||||
error: 'Template names must be unique within the manifest',
|
||||
})
|
||||
.describe('Workflow templates'),
|
||||
})
|
||||
.meta({ id: 'PluginManifestDto' });
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { JsonSchemaDto } from 'src/dtos/json-schema.dto';
|
||||
import { WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
|
||||
import { asMethodString } from 'src/utils/workflow';
|
||||
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
|
||||
import { asPluginKey } from 'src/utils/workflow';
|
||||
import z from 'zod';
|
||||
|
||||
const PluginSearchSchema = z
|
||||
@@ -43,6 +43,24 @@ const PluginResponseSchema = z
|
||||
})
|
||||
.meta({ id: 'PluginResponseDto' });
|
||||
|
||||
const PluginTemplateStepResponseSchema = z
|
||||
.object({
|
||||
method: z.string().describe('Step plugin method'),
|
||||
config: z.record(z.string(), z.unknown()).nullable().describe('Step configuration'),
|
||||
enabled: z.boolean().optional().describe('Whether the step is enabled'),
|
||||
})
|
||||
.meta({ id: 'PluginTemplateStepResponseDto' });
|
||||
|
||||
const PluginTemplateResponseSchema = z
|
||||
.object({
|
||||
key: z.string().describe('Template key (unique across all templates)'),
|
||||
title: z.string().describe('Template title'),
|
||||
description: z.string().describe('Template description'),
|
||||
trigger: WorkflowTriggerSchema.describe('Workflow trigger'),
|
||||
steps: z.array(PluginTemplateStepResponseSchema).describe('Workflow steps'),
|
||||
})
|
||||
.meta({ id: 'PluginTemplateResponseDto' });
|
||||
|
||||
const PluginMethodSearchSchema = z
|
||||
.object({
|
||||
id: z.uuidv4().optional().describe('Plugin method ID'),
|
||||
@@ -61,6 +79,33 @@ export class PluginSearchDto extends createZodDto(PluginSearchSchema) {}
|
||||
export class PluginResponseDto extends createZodDto(PluginResponseSchema) {}
|
||||
export class PluginMethodSearchDto extends createZodDto(PluginMethodSearchSchema) {}
|
||||
export class PluginMethodResponseDto extends createZodDto(PluginMethodResponseSchema) {}
|
||||
export class PluginTemplateResponseDto extends createZodDto(PluginTemplateResponseSchema) {}
|
||||
|
||||
export type PluginTemplate = {
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
trigger: WorkflowTrigger;
|
||||
steps: Array<{
|
||||
method: string;
|
||||
config?: Record<string, unknown> | null;
|
||||
enabled?: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const mapTemplate = (plugin: { name: string }, template: PluginTemplate): PluginTemplateResponseDto => {
|
||||
return {
|
||||
key: asPluginKey({ pluginName: plugin.name, name: template.name }),
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
trigger: template.trigger,
|
||||
steps: template.steps.map((step) => ({
|
||||
method: step.method,
|
||||
config: step.config ?? null,
|
||||
enabled: step.enabled,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
type Plugin = {
|
||||
id: string;
|
||||
@@ -101,7 +146,7 @@ export function mapPlugin(plugin: Plugin): PluginResponseDto {
|
||||
|
||||
export const mapMethod = (method: PluginMethod): PluginMethodResponseDto => {
|
||||
return {
|
||||
key: asMethodString({ pluginName: method.pluginName, methodName: method.name }),
|
||||
key: asPluginKey({ pluginName: method.pluginName, name: method.name }),
|
||||
name: method.name,
|
||||
title: method.title,
|
||||
hostFunctions: method.hostFunctions,
|
||||
|
||||
@@ -35,6 +35,7 @@ select
|
||||
"plugin"."version",
|
||||
"plugin"."createdAt",
|
||||
"plugin"."updatedAt",
|
||||
"plugin"."templates",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -60,6 +61,42 @@ from
|
||||
order by
|
||||
"plugin"."name"
|
||||
|
||||
-- PluginRepository.getByHash
|
||||
select
|
||||
"plugin"."id",
|
||||
"plugin"."name",
|
||||
"plugin"."title",
|
||||
"plugin"."description",
|
||||
"plugin"."author",
|
||||
"plugin"."version",
|
||||
"plugin"."createdAt",
|
||||
"plugin"."updatedAt",
|
||||
"plugin"."templates",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"plugin_method"."name",
|
||||
"plugin_method"."title",
|
||||
"plugin_method"."description",
|
||||
"plugin_method"."types",
|
||||
"plugin_method"."schema",
|
||||
"plugin_method"."hostFunctions",
|
||||
"plugin_method"."uiHints",
|
||||
"plugin"."name" as "pluginName"
|
||||
from
|
||||
"plugin_method"
|
||||
where
|
||||
"plugin_method"."pluginId" = "plugin"."id"
|
||||
) as agg
|
||||
) as "methods"
|
||||
from
|
||||
"plugin"
|
||||
where
|
||||
"plugin"."sha256hash" = $1
|
||||
|
||||
-- PluginRepository.getByName
|
||||
select
|
||||
"plugin"."id",
|
||||
@@ -70,6 +107,7 @@ select
|
||||
"plugin"."version",
|
||||
"plugin"."createdAt",
|
||||
"plugin"."updatedAt",
|
||||
"plugin"."templates",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -105,6 +143,7 @@ select
|
||||
"plugin"."version",
|
||||
"plugin"."createdAt",
|
||||
"plugin"."updatedAt",
|
||||
"plugin"."templates",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
|
||||
@@ -274,6 +274,7 @@ export class DatabaseRepository {
|
||||
columns: { ignoreExtra: true },
|
||||
functions: { ignoreExtra: false },
|
||||
parameters: { ignoreExtra: true },
|
||||
extensions: { ignoreExtra: true },
|
||||
});
|
||||
|
||||
return drift;
|
||||
|
||||
@@ -81,6 +81,7 @@ export class PluginRepository {
|
||||
'plugin.version',
|
||||
'plugin.createdAt',
|
||||
'plugin.updatedAt',
|
||||
'plugin.templates',
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('plugin_method')
|
||||
@@ -102,6 +103,11 @@ export class PluginRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
getByHash(hash: Buffer) {
|
||||
return this.queryBuilder().where('plugin.sha256hash', '=', hash).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
getByName(name: string) {
|
||||
return this.queryBuilder().where('plugin.name', '=', name).executeTakeFirst();
|
||||
@@ -151,6 +157,8 @@ export class PluginRepository {
|
||||
author: eb.ref('excluded.author'),
|
||||
version: eb.ref('excluded.version'),
|
||||
wasmBytes: eb.ref('excluded.wasmBytes'),
|
||||
templates: eb.ref('excluded.templates'),
|
||||
sha256hash: eb.ref('excluded.sha256hash'),
|
||||
})),
|
||||
)
|
||||
.returning(['id', 'name'])
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "plugin" ADD "templates" jsonb NOT NULL DEFAULT '[]';`.execute(db);
|
||||
await sql`ALTER TABLE "plugin" ADD "sha256hash" bytea NOT NULL DEFAULT decode('20464b37ad726d03d878d38d873c40a52d1fdfb754feda956ebb464afd689e2f', 'hex');`.execute(db);
|
||||
await sql`ALTER TABLE "plugin" ALTER COLUMN "sha256hash" DROP DEFAULT;`.execute(db);
|
||||
await sql`ALTER TABLE "plugin" ALTER COLUMN "templates" DROP DEFAULT;`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "plugin" DROP COLUMN "templates";`.execute(db);
|
||||
await sql`ALTER TABLE "plugin" DROP COLUMN "sha256hash";`.execute(db);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from '@immich/sql-tools';
|
||||
import { PluginTemplate } from 'src/dtos/plugin.dto';
|
||||
|
||||
@Unique({ columns: ['name', 'version'] })
|
||||
@Table('plugin')
|
||||
@@ -36,6 +37,12 @@ export class PluginTable {
|
||||
@Column({ type: 'bytea' })
|
||||
wasmBytes!: Buffer;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
templates!: PluginTemplate[];
|
||||
|
||||
@Column({ type: 'bytea' })
|
||||
sha256hash!: Buffer;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
mapMethod,
|
||||
mapPlugin,
|
||||
mapTemplate,
|
||||
PluginMethodResponseDto,
|
||||
PluginMethodSearchDto,
|
||||
PluginResponseDto,
|
||||
PluginSearchDto,
|
||||
PluginTemplateResponseDto,
|
||||
} from 'src/dtos/plugin.dto';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { isMethodCompatible } from 'src/utils/workflow';
|
||||
@@ -31,4 +33,9 @@ export class PluginService extends BaseService {
|
||||
.filter((method) => !dto.trigger || isMethodCompatible(method, dto.trigger))
|
||||
.map((method) => mapMethod(method));
|
||||
}
|
||||
|
||||
async searchTemplates(): Promise<PluginTemplateResponseDto[]> {
|
||||
const plugins = await this.pluginRepository.search();
|
||||
return plugins.flatMap((plugin) => plugin.templates.map((template) => mapTemplate(plugin, template)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
|
||||
import {
|
||||
BootstrapEventPriority,
|
||||
DatabaseLock,
|
||||
ImmichEnvironment,
|
||||
ImmichWorker,
|
||||
JobName,
|
||||
JobStatus,
|
||||
@@ -43,8 +44,8 @@ export class WorkflowExecutionService extends BaseService {
|
||||
// TODO avoid importing plugins in each worker
|
||||
// Can this use system metadata similar to geocoding?
|
||||
|
||||
const { resourcePaths, plugins } = this.configRepository.getEnv();
|
||||
await this.importFolder(resourcePaths.corePlugin, { force: true });
|
||||
const { environment, resourcePaths, plugins } = this.configRepository.getEnv();
|
||||
await this.importFolder(resourcePaths.corePlugin, { force: environment === ImmichEnvironment.Development });
|
||||
|
||||
if (plugins.external.allow && plugins.external.installFolder) {
|
||||
await this.importFolders(plugins.external.installFolder);
|
||||
@@ -166,7 +167,19 @@ export class WorkflowExecutionService extends BaseService {
|
||||
private async importFolder(folder: string, options?: { force?: boolean }) {
|
||||
try {
|
||||
const manifestPath = join(folder, 'manifest.json');
|
||||
const dto = await this.storageRepository.readJsonFile(manifestPath);
|
||||
const bytes = await this.storageRepository.readFile(manifestPath);
|
||||
const contents = bytes.toString('utf8');
|
||||
const sha256hash = this.cryptoRepository.hashSha256(contents) as Buffer;
|
||||
|
||||
if (!options?.force) {
|
||||
const match = await this.pluginRepository.getByHash(sha256hash);
|
||||
if (match) {
|
||||
this.logger.log(`Plugin up to date (name=${match.name}@${match.version}, hash=${sha256hash.toString('hex')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dto = JSON.parse(contents);
|
||||
const result = PluginManifestDto.schema.safeParse(dto);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues.map((issue) => ` - [${issue.path.join('.')}] ${issue.message}`).join('\n');
|
||||
@@ -176,22 +189,21 @@ export class WorkflowExecutionService extends BaseService {
|
||||
const manifest = result.data;
|
||||
|
||||
const existing = await this.pluginRepository.getByName(manifest.name);
|
||||
if (existing && existing.version === manifest.version && options?.force !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasmPath = `${folder}/${manifest.wasmPath}`;
|
||||
const wasmBytes = await this.storageRepository.readFile(wasmPath);
|
||||
|
||||
const plugin = await this.pluginRepository.upsert(
|
||||
{
|
||||
// NOTE: new properties here need to be added to the on conflict clause in the repository
|
||||
enabled: true,
|
||||
name: manifest.name,
|
||||
title: manifest.title,
|
||||
description: manifest.description,
|
||||
author: manifest.author,
|
||||
version: manifest.version,
|
||||
templates: manifest.templates,
|
||||
wasmBytes,
|
||||
sha256hash,
|
||||
},
|
||||
manifest.methods,
|
||||
);
|
||||
|
||||
@@ -50,8 +50,8 @@ export const resolveMethod = (methods: PluginMethodSearchResponse[], method: str
|
||||
return methods.find((method) => method.pluginName === pluginName && method.name === methodName);
|
||||
};
|
||||
|
||||
export const asMethodString = (method: { pluginName: string; methodName: string }) => {
|
||||
return `${method.pluginName}#${method.methodName}`;
|
||||
export const asPluginKey = (method: { pluginName: string; name: string }) => {
|
||||
return `${method.pluginName}#${method.name}`;
|
||||
};
|
||||
|
||||
const METHOD_REGEX = /^(?<name>[^@#\s]+)(?:@(?<version>[^#\s]*))?#(?<method>[^@#\s]+)$/;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getKyselyDB } from 'test/utils';
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const wasmBytes = Buffer.from('some-wasm-binary-data');
|
||||
const sha256hash = Buffer.from('some-manifest-hash');
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(PluginService, {
|
||||
@@ -46,7 +47,9 @@ describe(PluginService.name, () => {
|
||||
description: 'A test plugin',
|
||||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
templates: [],
|
||||
wasmBytes,
|
||||
sha256hash,
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -75,7 +78,9 @@ describe(PluginService.name, () => {
|
||||
description: 'A plugin with multiple methods',
|
||||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
templates: [],
|
||||
wasmBytes,
|
||||
sha256hash,
|
||||
},
|
||||
[
|
||||
{
|
||||
@@ -130,7 +135,9 @@ describe(PluginService.name, () => {
|
||||
description: 'First plugin',
|
||||
author: 'Author 1',
|
||||
version: '1.0.0',
|
||||
templates: [],
|
||||
wasmBytes,
|
||||
sha256hash,
|
||||
},
|
||||
[
|
||||
{
|
||||
@@ -150,7 +157,9 @@ describe(PluginService.name, () => {
|
||||
description: 'Second plugin',
|
||||
author: 'Author 2',
|
||||
version: '2.0.0',
|
||||
templates: [],
|
||||
wasmBytes,
|
||||
sha256hash,
|
||||
},
|
||||
[
|
||||
{
|
||||
@@ -183,7 +192,9 @@ describe(PluginService.name, () => {
|
||||
description: 'Plugin with multiple methods',
|
||||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
templates: [],
|
||||
wasmBytes,
|
||||
sha256hash,
|
||||
},
|
||||
[
|
||||
{
|
||||
@@ -242,6 +253,8 @@ describe(PluginService.name, () => {
|
||||
description: 'A single plugin',
|
||||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
templates: [],
|
||||
sha256hash,
|
||||
wasmBytes,
|
||||
},
|
||||
[
|
||||
|
||||
@@ -21,6 +21,7 @@ const setup = (db?: Kysely<DB>) => {
|
||||
};
|
||||
|
||||
const wasmBytes = Buffer.from('random-wasm-bytes');
|
||||
const sha256hash = Buffer.from('some-manifest-hash');
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
@@ -41,7 +42,9 @@ describe(WorkflowService.name, () => {
|
||||
description: 'A test core plugin for workflow tests',
|
||||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
templates: [],
|
||||
wasmBytes,
|
||||
sha256hash,
|
||||
},
|
||||
[
|
||||
{
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@
|
||||
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
||||
"@immich/justified-layout-wasm": "^0.4.3",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@immich/ui": "^0.79.0",
|
||||
"@immich/ui": "^0.79.2",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
|
||||
@@ -127,7 +127,7 @@ export class EditManager {
|
||||
|
||||
try {
|
||||
// Setup the websocket listener before sending the edit request
|
||||
const editCompleted = waitForWebsocketEvent('AssetEditReadyV1', (event) => event.asset.id === assetId, 10_000);
|
||||
const editCompleted = waitForWebsocketEvent('AssetEditReadyV2', (event) => event.asset.id === assetId, 10_000);
|
||||
|
||||
await (edits.length === 0
|
||||
? removeAssetEdits({ id: assetId })
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
getWorkflowTriggers,
|
||||
searchPluginMethods,
|
||||
searchPluginTemplates,
|
||||
WorkflowTrigger,
|
||||
type PluginMethodResponseDto,
|
||||
type PluginTemplateResponseDto,
|
||||
type WorkflowTriggerResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -16,6 +18,7 @@ class PluginManager {
|
||||
#methodMap = new SvelteMap<string, PluginMethodResponseDto>();
|
||||
#methods = $state<PluginMethodResponseDto[]>([]);
|
||||
#triggers = $state<WorkflowTriggerResponseDto[]>([]);
|
||||
#templates = $state<PluginTemplateResponseDto[]>([]);
|
||||
|
||||
constructor() {
|
||||
eventManager.on({
|
||||
@@ -33,6 +36,10 @@ class PluginManager {
|
||||
return this.#triggers;
|
||||
}
|
||||
|
||||
get templates() {
|
||||
return this.#templates;
|
||||
}
|
||||
|
||||
ready() {
|
||||
return this.initialize();
|
||||
}
|
||||
@@ -70,7 +77,11 @@ class PluginManager {
|
||||
}
|
||||
|
||||
private async load() {
|
||||
const [methods, triggers] = await Promise.all([searchPluginMethods({}), getWorkflowTriggers()]);
|
||||
const [methods, triggers, templates] = await Promise.all([
|
||||
searchPluginMethods({}),
|
||||
getWorkflowTriggers(),
|
||||
searchPluginTemplates(),
|
||||
]);
|
||||
|
||||
this.#methods = methods;
|
||||
for (const method of this.#methods) {
|
||||
@@ -78,6 +89,7 @@ class PluginManager {
|
||||
}
|
||||
|
||||
this.#triggers = triggers;
|
||||
this.#templates = templates;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import WorkflowTriggerPicker from '$lib/modals/WorkflowTriggerPicker.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { handleCreateWorkflow } from '$lib/services/workflow.service';
|
||||
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
|
||||
import { WorkflowTrigger, type WorkflowResponseDto } from '@immich/sdk';
|
||||
import { Text, Field, FormModal, IconButton, Input, modalManager, Textarea, VStack } from '@immich/ui';
|
||||
import { mdiPencilOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
workflow: WorkflowResponseDto;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const { workflow, onClose }: Props = $props();
|
||||
|
||||
let name = $state(workflow.name ?? '');
|
||||
let description = $state(workflow.description ?? '');
|
||||
let trigger = $state<WorkflowTrigger>(workflow.trigger);
|
||||
|
||||
const onSubmit = async () => {
|
||||
const response = await handleCreateWorkflow({
|
||||
name,
|
||||
description,
|
||||
trigger,
|
||||
steps: workflow.steps,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
if (response) {
|
||||
await goto(Route.viewWorkflow({ id: response.id }));
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormModal
|
||||
title={$t('duplicate_workflow')}
|
||||
{onClose}
|
||||
{onSubmit}
|
||||
disabled={!name || !trigger}
|
||||
size="medium"
|
||||
submitText={$t('create')}
|
||||
>
|
||||
<VStack gap={4}>
|
||||
<Field label={$t('name')} required>
|
||||
<Input placeholder={$t('workflow_name')} bind:value={name} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('description')} for="workflow-description">
|
||||
<Textarea id="workflow-description" grow placeholder={$t('workflow_description')} bind:value={description} />
|
||||
</Field>
|
||||
</VStack>
|
||||
</FormModal>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
|
||||
import { handleCreateWorkflow } from '$lib/services/workflow.service';
|
||||
import { type PluginTemplateResponseDto } from '@immich/sdk';
|
||||
import { FormModal, Icon, ListButton, Text } from '@immich/ui';
|
||||
import { mdiFlashOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
let selected = $state<PluginTemplateResponseDto>();
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await handleCreateWorkflow({
|
||||
trigger: selected.trigger,
|
||||
steps: selected.steps,
|
||||
name: selected.title,
|
||||
description: selected.description,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = (template: PluginTemplateResponseDto) => selected?.key === template.key;
|
||||
</script>
|
||||
|
||||
<FormModal
|
||||
title={$t('workflow_templates')}
|
||||
{onClose}
|
||||
{onSubmit}
|
||||
disabled={!selected}
|
||||
size="medium"
|
||||
submitText={$t('use_template')}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each pluginManager.templates as template (template.key)}
|
||||
<ListButton
|
||||
selected={isSelected(template)}
|
||||
onclick={() => (selected = isSelected(template) ? undefined : template)}
|
||||
>
|
||||
<div class="flex w-full items-center gap-3 text-start">
|
||||
<div
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-immich-primary/10 text-immich-primary dark:bg-immich-dark-primary/15 dark:text-immich-dark-primary"
|
||||
>
|
||||
<Icon icon={mdiFlashOutline} size="18" />
|
||||
</div>
|
||||
<div class="min-w-0 grow">
|
||||
<Text fontWeight="medium">{template.title}</Text>
|
||||
<Text size="tiny" color="muted">{template.description}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</ListButton>
|
||||
{/each}
|
||||
</div>
|
||||
</FormModal>
|
||||
@@ -42,7 +42,7 @@ import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
|
||||
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
|
||||
import { downloadUrl } from '$lib/utils/asset-utils';
|
||||
import { downloadUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
|
||||
|
||||
@@ -3,10 +3,8 @@ import { toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiContentCopy, mdiDownload, mdiUpload } from '@mdi/js';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
import { downloadBlob } from '$lib/utils/asset-utils';
|
||||
import { copyToClipboard, downloadJson } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
|
||||
@@ -19,7 +17,7 @@ export const getSystemConfigActions = (
|
||||
title: $t('copy_to_clipboard'),
|
||||
description: $t('admin.copy_config_to_clipboard_description'),
|
||||
icon: mdiContentCopy,
|
||||
onAction: () => handleCopyToClipboard(config),
|
||||
onAction: () => copyToClipboard(config),
|
||||
shortcuts: { shift: true, key: 'c' },
|
||||
};
|
||||
|
||||
@@ -27,7 +25,7 @@ export const getSystemConfigActions = (
|
||||
title: $t('export_as_json'),
|
||||
description: $t('admin.export_config_as_json_description'),
|
||||
icon: mdiDownload,
|
||||
onAction: () => handleDownloadConfig(config),
|
||||
onAction: () => downloadJson(config, 'immich-config.json'),
|
||||
shortcuts: [
|
||||
{ shift: true, key: 's' },
|
||||
{ shift: true, key: 'd' },
|
||||
@@ -65,31 +63,6 @@ export const handleSystemConfigSave = async (update: Partial<SystemConfigDto>) =
|
||||
}
|
||||
};
|
||||
|
||||
// https://stackoverflow.com/questions/16167581/sort-object-properties-and-json-stringify/43636793#43636793
|
||||
const jsonReplacer = (_key: string, value: unknown) =>
|
||||
value instanceof Object && !Array.isArray(value)
|
||||
? Object.keys(value)
|
||||
.sort()
|
||||
// eslint-disable-next-line unicorn/no-array-reduce
|
||||
.reduce((sorted: { [key: string]: unknown }, key) => {
|
||||
sorted[key] = (value as { [key: string]: unknown })[key];
|
||||
return sorted;
|
||||
}, {})
|
||||
: value;
|
||||
|
||||
export const handleCopyToClipboard = async (config: SystemConfigDto) => {
|
||||
await copyToClipboard(JSON.stringify(config, jsonReplacer, 2));
|
||||
};
|
||||
|
||||
export const handleDownloadConfig = (config: SystemConfigDto) => {
|
||||
const blob = new Blob([JSON.stringify(config, jsonReplacer, 2)], { type: 'application/json' });
|
||||
const downloadKey = 'immich-config.json';
|
||||
downloadManager.add(downloadKey, blob.size);
|
||||
downloadManager.update(downloadKey, blob.size);
|
||||
downloadBlob(blob, downloadKey);
|
||||
setTimeout(() => downloadManager.clear(downloadKey), 5000);
|
||||
};
|
||||
|
||||
export const handleUploadConfig = () => {
|
||||
const input = globalThis.document.createElement('input');
|
||||
input.setAttribute('type', 'file');
|
||||
|
||||
@@ -10,11 +10,25 @@ import {
|
||||
type WorkflowUpdateDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiCodeJson, mdiDelete, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
|
||||
import {
|
||||
mdiCodeJson,
|
||||
mdiContentCopy,
|
||||
mdiContentDuplicate,
|
||||
mdiDeleteOutline,
|
||||
mdiDownload,
|
||||
mdiFileDocumentMultipleOutline,
|
||||
mdiPause,
|
||||
mdiPencil,
|
||||
mdiPlay,
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import WorkflowDuplicateModal from '$lib/modals/WorkflowDuplicateModal.svelte';
|
||||
import WorkflowTemplatePicker from '$lib/modals/WorkflowTemplatePicker.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { copyToClipboard, downloadJson } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
|
||||
@@ -33,17 +47,63 @@ export const getWorkflowsActions = ($t: MessageFormatter) => {
|
||||
}),
|
||||
};
|
||||
|
||||
return { Create };
|
||||
const UseTemplate: ActionItem = {
|
||||
title: $t('browse_templates'),
|
||||
icon: mdiFileDocumentMultipleOutline,
|
||||
onAction: () => modalManager.show(WorkflowTemplatePicker, {}),
|
||||
};
|
||||
|
||||
return { Create, UseTemplate };
|
||||
};
|
||||
|
||||
export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowResponseDto) => {
|
||||
const ToggleEnabled: ActionItem = {
|
||||
title: workflow.enabled ? $t('disable') : $t('enable'),
|
||||
icon: workflow.enabled ? mdiPause : mdiPlay,
|
||||
color: workflow.enabled ? 'danger' : 'primary',
|
||||
onAction: () => handleUpdateWorkflow(workflow.id, { enabled: !workflow.enabled }),
|
||||
};
|
||||
|
||||
const CopyJson: ActionItem = {
|
||||
title: $t('copy_json'),
|
||||
icon: mdiContentCopy,
|
||||
onAction: () =>
|
||||
copyToClipboard(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
enabled: workflow.enabled,
|
||||
trigger: workflow.trigger,
|
||||
steps: workflow.steps,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
const Download: ActionItem = {
|
||||
title: $t('download'),
|
||||
icon: mdiDownload,
|
||||
onAction: () =>
|
||||
downloadJson(
|
||||
{
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
enabled: workflow.enabled,
|
||||
trigger: workflow.trigger,
|
||||
steps: workflow.steps,
|
||||
},
|
||||
'workflow.json',
|
||||
),
|
||||
};
|
||||
|
||||
const Duplicate: ActionItem = {
|
||||
title: $t('duplicate'),
|
||||
icon: mdiContentDuplicate,
|
||||
onAction: async () => modalManager.show(WorkflowDuplicateModal, { workflow }),
|
||||
};
|
||||
|
||||
const Edit: ActionItem = {
|
||||
title: $t('edit'),
|
||||
icon: mdiPencil,
|
||||
@@ -52,14 +112,12 @@ export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowRespo
|
||||
|
||||
const Delete: ActionItem = {
|
||||
title: $t('delete'),
|
||||
icon: mdiDelete,
|
||||
icon: mdiDeleteOutline,
|
||||
color: 'danger',
|
||||
onAction: async () => {
|
||||
await handleDeleteWorkflow(workflow);
|
||||
},
|
||||
onAction: () => handleDeleteWorkflow(workflow),
|
||||
};
|
||||
|
||||
return { ToggleEnabled, Edit, Delete };
|
||||
return { CopyJson, Download, Duplicate, ToggleEnabled, Edit, Delete };
|
||||
};
|
||||
|
||||
export const getWorkflowShowSchemaAction = (
|
||||
@@ -72,12 +130,14 @@ export const getWorkflowShowSchemaAction = (
|
||||
onAction: onToggle,
|
||||
});
|
||||
|
||||
const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
|
||||
export const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
const response = await createWorkflow({ workflowCreateDto: dto });
|
||||
eventManager.emit('WorkflowCreate', response);
|
||||
toastManager.success();
|
||||
return response;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_create'));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type NotificationDto,
|
||||
type ServerVersionResponseDto,
|
||||
type SyncAssetEditV1,
|
||||
type SyncAssetV1,
|
||||
type SyncAssetV2,
|
||||
} from '@immich/sdk';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { get, writable } from 'svelte/store';
|
||||
@@ -41,7 +41,7 @@ export interface Events {
|
||||
AppRestartV1: (event: AppRestartEvent) => void;
|
||||
|
||||
MaintenanceStatusV1: (event: MaintenanceStatusResponseDto) => void;
|
||||
AssetEditReadyV1: (data: { asset: SyncAssetV1; edit: SyncAssetEditV1[] }) => void;
|
||||
AssetEditReadyV2: (data: { asset: SyncAssetV2; edit: SyncAssetEditV1[] }) => void;
|
||||
}
|
||||
|
||||
const websocket: Socket<Events> = io({
|
||||
|
||||
+51
-2
@@ -24,6 +24,7 @@ import { init, register, t } from 'svelte-i18n';
|
||||
import { derived, get } from 'svelte/store';
|
||||
import { defaultLang, locales } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
|
||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@@ -249,17 +250,65 @@ export const getProfileImageUrl = (user: UserResponseDto) =>
|
||||
export const getPeopleThumbnailUrl = (person: PersonResponseDto, updatedAt?: string) =>
|
||||
createUrl(getPeopleThumbnailPath(person.id), { updatedAt: updatedAt ?? person.updatedAt });
|
||||
|
||||
export const copyToClipboard = async (secret: string) => {
|
||||
export const copyToClipboard = async (secret: string | unknown) => {
|
||||
const $t = get(t);
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(secret);
|
||||
const value = typeof secret === 'string' ? secret : JSON.stringify(secret, jsonReplacer, 2);
|
||||
await navigator.clipboard.writeText(value);
|
||||
toastManager.info($t('copied_to_clipboard'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_copy_to_clipboard'));
|
||||
}
|
||||
};
|
||||
|
||||
// https://stackoverflow.com/questions/16167581/sort-object-properties-and-json-stringify/43636793#43636793
|
||||
const jsonReplacer = (_key: string, value: unknown) =>
|
||||
value instanceof Object && !Array.isArray(value)
|
||||
? Object.keys(value)
|
||||
.sort()
|
||||
// eslint-disable-next-line unicorn/no-array-reduce
|
||||
.reduce((sorted: { [key: string]: unknown }, key) => {
|
||||
sorted[key] = (value as { [key: string]: unknown })[key];
|
||||
return sorted;
|
||||
}, {})
|
||||
: value;
|
||||
|
||||
export const downloadJson = (data: unknown, filename: string) => {
|
||||
const blob = new Blob([JSON.stringify(data, jsonReplacer, 2)], { type: 'application/json' });
|
||||
const downloadKey = filename;
|
||||
downloadManager.add(downloadKey, blob.size);
|
||||
downloadManager.update(downloadKey, blob.size);
|
||||
downloadBlob(blob, downloadKey);
|
||||
setTimeout(() => downloadManager.clear(downloadKey), 5000);
|
||||
};
|
||||
|
||||
export const downloadBlob = (data: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(data);
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const downloadUrl = (url: string, filename: string) => {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const oauth = {
|
||||
isCallback: (location: Location) => {
|
||||
const search = location.search;
|
||||
|
||||
@@ -26,7 +26,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { downloadRequest, withError } from '$lib/utils';
|
||||
import { downloadBlob, downloadRequest, withError } from '$lib/utils';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
@@ -73,32 +73,6 @@ export const removeTag = async ({
|
||||
return assetIds;
|
||||
};
|
||||
|
||||
export const downloadBlob = (data: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(data);
|
||||
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const downloadUrl = (url: string, filename: string) => {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const downloadArchive = async (fileName: string, options: Omit<DownloadInfoDto, 'archiveSize'>) => {
|
||||
const archiveSize = authManager.authenticated ? authManager.preferences.download.archiveSize : undefined;
|
||||
const dto = { ...options, archiveSize };
|
||||
|
||||
@@ -16,12 +16,11 @@
|
||||
CardTitle,
|
||||
CodeBlock,
|
||||
Container,
|
||||
ContextMenuButton,
|
||||
Icon,
|
||||
IconButton,
|
||||
MenuItemType,
|
||||
menuManager,
|
||||
} from '@immich/ui';
|
||||
import { mdiClose, mdiDotsVertical, mdiFlashOutline } from '@mdi/js';
|
||||
import { mdiClose, mdiFlashOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { PageData } from './$types';
|
||||
@@ -36,7 +35,7 @@
|
||||
|
||||
const expandedIds = new SvelteSet<string>();
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
const onToggleExpand = (id: string) => {
|
||||
if (expandedIds.has(id)) {
|
||||
expandedIds.delete(id);
|
||||
} else {
|
||||
@@ -44,22 +43,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
const showWorkflowMenu = (event: MouseEvent, workflow: WorkflowResponseDto) => {
|
||||
const { ToggleEnabled, Edit, Delete } = getWorkflowActions($t, workflow);
|
||||
void menuManager.show({
|
||||
target: event.currentTarget as HTMLElement,
|
||||
position: 'top-left',
|
||||
items: [
|
||||
ToggleEnabled,
|
||||
Edit,
|
||||
getWorkflowShowSchemaAction($t, expandedIds.has(workflow.id), () => toggleExpanded(workflow.id)),
|
||||
MenuItemType.Divider,
|
||||
Delete,
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const { Create } = $derived(getWorkflowsActions($t));
|
||||
const { Create, UseTemplate } = $derived(getWorkflowsActions($t));
|
||||
|
||||
const onWorkflowCreate = async (response: WorkflowResponseDto) => {
|
||||
await goto(Route.viewWorkflow(response));
|
||||
@@ -76,7 +60,7 @@
|
||||
|
||||
<OnEvents {onWorkflowCreate} {onWorkflowUpdate} {onWorkflowDelete} />
|
||||
|
||||
<UserPageLayout title={data.meta.title} actions={[Create]} scrollbar={false}>
|
||||
<UserPageLayout title={data.meta.title} actions={[UseTemplate, Create]} scrollbar={false}>
|
||||
<section class="flex place-content-center sm:mx-4">
|
||||
<Container center size="large" class="pb-28">
|
||||
{#if workflows.length === 0}
|
||||
@@ -91,6 +75,8 @@
|
||||
{:else}
|
||||
<div class="my-6 flex flex-col gap-3">
|
||||
{#each workflows as workflow (workflow.id)}
|
||||
{@const { ToggleEnabled, Duplicate, Edit, Delete } = getWorkflowActions($t, workflow)}
|
||||
|
||||
<Card class="group shadow-none transition-colors hover:border-primary">
|
||||
<CardHeader>
|
||||
<a
|
||||
@@ -128,17 +114,16 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
icon={mdiDotsVertical}
|
||||
aria-label={$t('menu')}
|
||||
onclick={(event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
showWorkflowMenu(event, workflow);
|
||||
}}
|
||||
<ContextMenuButton
|
||||
position="top-left"
|
||||
items={[
|
||||
ToggleEnabled,
|
||||
Edit,
|
||||
Duplicate,
|
||||
getWorkflowShowSchemaAction($t, expandedIds.has(workflow.id), () => onToggleExpand(workflow.id)),
|
||||
MenuItemType.Divider,
|
||||
Delete,
|
||||
]}
|
||||
/>
|
||||
</a>
|
||||
|
||||
@@ -152,7 +137,7 @@
|
||||
fullWidth
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onclick={() => toggleExpanded(workflow.id)}
|
||||
onclick={() => onToggleExpand(workflow.id)}
|
||||
>
|
||||
{$t('close')}
|
||||
</Button>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import WorkflowEditStepModal from '$lib/modals/WorkflowEditStepModal.svelte';
|
||||
import WorkflowTriggerPicker from '$lib/modals/WorkflowTriggerPicker.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
|
||||
import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service';
|
||||
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
|
||||
import type { WorkflowResponseDto, WorkflowStepDto, WorkflowUpdateDto } from '@immich/sdk';
|
||||
import {
|
||||
@@ -28,6 +28,7 @@
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
MenuItemType,
|
||||
modalManager,
|
||||
Switch,
|
||||
Textarea,
|
||||
@@ -83,7 +84,7 @@
|
||||
let dropTargetIndex = $state<number | null>(null);
|
||||
|
||||
const workflowSummary = $derived({ name, description, trigger, steps });
|
||||
const workflowJsonContent = $derived<WorkflowJsonContent>({ description, enabled, name, steps, trigger });
|
||||
const workflowJsonContent = $derived<WorkflowJsonContent>({ name, description, enabled, trigger, steps });
|
||||
|
||||
const hasChanges = $derived(
|
||||
enabled !== savedWorkflow.enabled ||
|
||||
@@ -217,6 +218,12 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onWorkflowDelete = async (response: WorkflowResponseDto) => {
|
||||
if (id === response.id) {
|
||||
await goto(Route.workflows());
|
||||
}
|
||||
};
|
||||
|
||||
const confirmNavigation = async () => {
|
||||
if (!hasChanges) {
|
||||
return true;
|
||||
@@ -273,60 +280,73 @@
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const { Download, Duplicate, CopyJson, Delete } = $derived(
|
||||
getWorkflowActions($t, { ...savedWorkflow, name, description, enabled, trigger, steps }),
|
||||
);
|
||||
</script>
|
||||
|
||||
<OnEvents {onWorkflowUpdate} />
|
||||
<OnEvents {onWorkflowUpdate} {onWorkflowDelete} />
|
||||
|
||||
<AppShell class="">
|
||||
<AppShellBar>
|
||||
<ActionBar static {onClose} translations={{ close: $t('back') }} closeIcon={mdiArrowLeft}>
|
||||
<ActionBar
|
||||
shape="round"
|
||||
static
|
||||
{onClose}
|
||||
translations={{ close: $t('back') }}
|
||||
closeIcon={mdiArrowLeft}
|
||||
actions={[Duplicate, CopyJson, Download, Delete].map((item) => ({ ...item, color: undefined }))}
|
||||
>
|
||||
<ControlBarHeader>
|
||||
<ControlBarTitle>{data.workflow.name}</ControlBarTitle>
|
||||
<ControlBarDescription>{data.workflow.description}</ControlBarDescription>
|
||||
</ControlBarHeader>
|
||||
<ControlBarContent class="flex items-center justify-end gap-6">
|
||||
<div class="flex gap-1 rounded-full border border-light-200 bg-light p-1" role="group">
|
||||
{#if hasChanges}
|
||||
<Button
|
||||
variant={editMode === 'visual' ? 'filled' : 'ghost'}
|
||||
color={editMode === 'visual' ? 'primary' : 'secondary'}
|
||||
variant="filled"
|
||||
size="small"
|
||||
leadingIcon={mdiFormatListBulletedSquare}
|
||||
aria-pressed={editMode === 'visual'}
|
||||
onclick={() => (editMode = 'visual')}
|
||||
shape="round"
|
||||
color="primary"
|
||||
leadingIcon={mdiContentSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
loading={isSaving}
|
||||
onclick={saveWorkflow}
|
||||
>
|
||||
{$t('visual')}
|
||||
{$t('save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={editMode === 'json' ? 'filled' : 'ghost'}
|
||||
color={editMode === 'json' ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
leadingIcon={mdiCodeJson}
|
||||
aria-pressed={editMode === 'json'}
|
||||
onclick={() => (editMode = 'json')}
|
||||
shape="round"
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="filled"
|
||||
size="small"
|
||||
color="primary"
|
||||
leadingIcon={mdiContentSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
loading={isSaving}
|
||||
onclick={saveWorkflow}
|
||||
>
|
||||
{$t('save')}
|
||||
</Button>
|
||||
{/if}
|
||||
</ControlBarContent>
|
||||
</ActionBar>
|
||||
</AppShellBar>
|
||||
|
||||
<Container size="medium" class="pt-8 pb-24" center>
|
||||
<VStack gap={4}>
|
||||
<div class="flex gap-1 rounded-full border border-light-200 bg-light p-1" role="group">
|
||||
<Button
|
||||
variant={editMode === 'visual' ? 'filled' : 'ghost'}
|
||||
color={editMode === 'visual' ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
leadingIcon={mdiFormatListBulletedSquare}
|
||||
aria-pressed={editMode === 'visual'}
|
||||
onclick={() => (editMode = 'visual')}
|
||||
shape="round"
|
||||
>
|
||||
{$t('visual')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={editMode === 'json' ? 'filled' : 'ghost'}
|
||||
color={editMode === 'json' ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
leadingIcon={mdiCodeJson}
|
||||
aria-pressed={editMode === 'json'}
|
||||
onclick={() => (editMode = 'json')}
|
||||
shape="round"
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if editMode === 'visual'}
|
||||
<Card class="shadow-none" expandable>
|
||||
<CardHeader>
|
||||
|
||||
Reference in New Issue
Block a user