feat: hash manifest file
parent
04e179c7d6
commit
74cce90005
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -205,8 +205,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* | [**getTemplates**](doc//PluginsApi.md#gettemplates) | **GET** /plugins/templates | Retrieve workflow templates
|
||||
*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
|
||||
|
|
|
|||
|
|
@ -73,57 +73,6 @@ class PluginsApi {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Retrieve workflow templates
|
||||
///
|
||||
/// Retrieve premade workflow templates provided by installed plugins
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
Future<Response> getTemplatesWithHttpInfo() 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 premade workflow templates provided by installed plugins
|
||||
Future<List<PluginTemplateResponseDto>?> getTemplates() async {
|
||||
final response = await getTemplatesWithHttpInfo();
|
||||
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;
|
||||
}
|
||||
|
||||
/// Retrieve plugin methods
|
||||
///
|
||||
/// Retrieve a list of plugin methods
|
||||
|
|
@ -255,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.
|
||||
|
|
|
|||
|
|
@ -14,59 +14,52 @@ class PluginTemplateResponseDto {
|
|||
/// Returns a new [PluginTemplateResponseDto] instance.
|
||||
PluginTemplateResponseDto({
|
||||
required this.description,
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.pluginName,
|
||||
required this.key,
|
||||
this.steps = const [],
|
||||
required this.title,
|
||||
required this.trigger,
|
||||
});
|
||||
|
||||
/// Template description
|
||||
String description;
|
||||
|
||||
/// Template identifier (pluginName#templateName)
|
||||
String id;
|
||||
|
||||
/// Template name
|
||||
String name;
|
||||
|
||||
/// Owning plugin name
|
||||
String pluginName;
|
||||
/// 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.id == id &&
|
||||
other.name == name &&
|
||||
other.pluginName == pluginName &&
|
||||
other.key == key &&
|
||||
_deepEquality.equals(other.steps, steps) &&
|
||||
other.title == title &&
|
||||
other.trigger == trigger;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(description.hashCode) +
|
||||
(id.hashCode) +
|
||||
(name.hashCode) +
|
||||
(pluginName.hashCode) +
|
||||
(key.hashCode) +
|
||||
(steps.hashCode) +
|
||||
(title.hashCode) +
|
||||
(trigger.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PluginTemplateResponseDto[description=$description, id=$id, name=$name, pluginName=$pluginName, steps=$steps, trigger=$trigger]';
|
||||
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'id'] = this.id;
|
||||
json[r'name'] = this.name;
|
||||
json[r'pluginName'] = this.pluginName;
|
||||
json[r'key'] = this.key;
|
||||
json[r'steps'] = this.steps;
|
||||
json[r'title'] = this.title;
|
||||
json[r'trigger'] = this.trigger;
|
||||
return json;
|
||||
}
|
||||
|
|
@ -81,10 +74,9 @@ class PluginTemplateResponseDto {
|
|||
|
||||
return PluginTemplateResponseDto(
|
||||
description: mapValueOfType<String>(json, r'description')!,
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
name: mapValueOfType<String>(json, r'name')!,
|
||||
pluginName: mapValueOfType<String>(json, r'pluginName')!,
|
||||
key: mapValueOfType<String>(json, r'key')!,
|
||||
steps: PluginTemplateStepResponseDto.listFromJson(json[r'steps']),
|
||||
title: mapValueOfType<String>(json, r'title')!,
|
||||
trigger: WorkflowTrigger.fromJson(json[r'trigger'])!,
|
||||
);
|
||||
}
|
||||
|
|
@ -134,10 +126,9 @@ class PluginTemplateResponseDto {
|
|||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'description',
|
||||
'id',
|
||||
'name',
|
||||
'pluginName',
|
||||
'key',
|
||||
'steps',
|
||||
'title',
|
||||
'trigger',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8841,8 +8841,8 @@
|
|||
},
|
||||
"/plugins/templates": {
|
||||
"get": {
|
||||
"description": "Retrieve premade workflow templates provided by installed plugins",
|
||||
"operationId": "getTemplates",
|
||||
"description": "Retrieve workflow templates provided by installed plugins",
|
||||
"operationId": "searchPluginTemplates",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
|
|
@ -20202,16 +20202,8 @@
|
|||
"description": "Template description",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"description": "Template identifier (pluginName#templateName)",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "Template name",
|
||||
"type": "string"
|
||||
},
|
||||
"pluginName": {
|
||||
"description": "Owning plugin name",
|
||||
"key": {
|
||||
"description": "Template key (unique across all templates)",
|
||||
"type": "string"
|
||||
},
|
||||
"steps": {
|
||||
|
|
@ -20221,6 +20213,10 @@
|
|||
},
|
||||
"type": "array"
|
||||
},
|
||||
"title": {
|
||||
"description": "Template title",
|
||||
"type": "string"
|
||||
},
|
||||
"trigger": {
|
||||
"$ref": "#/components/schemas/WorkflowTrigger",
|
||||
"description": "Workflow trigger"
|
||||
|
|
@ -20228,10 +20224,9 @@
|
|||
},
|
||||
"required": [
|
||||
"description",
|
||||
"id",
|
||||
"name",
|
||||
"pluginName",
|
||||
"key",
|
||||
"steps",
|
||||
"title",
|
||||
"trigger"
|
||||
],
|
||||
"type": "object"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -254,26 +284,5 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"templates": [
|
||||
{
|
||||
"name": "Archive screenshots to album",
|
||||
"description": "Add uploads with \"screenshot\" in the filename to an album and archive them",
|
||||
"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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -1527,14 +1527,12 @@ export type PluginTemplateStepResponseDto = {
|
|||
export type PluginTemplateResponseDto = {
|
||||
/** Template description */
|
||||
description: string;
|
||||
/** Template identifier (pluginName#templateName) */
|
||||
id: string;
|
||||
/** Template name */
|
||||
name: string;
|
||||
/** Owning plugin name */
|
||||
pluginName: string;
|
||||
/** Template key (unique across all templates) */
|
||||
key: string;
|
||||
/** Workflow steps */
|
||||
steps: PluginTemplateStepResponseDto[];
|
||||
/** Template title */
|
||||
title: string;
|
||||
/** Workflow trigger */
|
||||
trigger: WorkflowTrigger;
|
||||
};
|
||||
|
|
@ -5269,7 +5267,7 @@ export function searchPluginMethods({ description, enabled, id, name, pluginName
|
|||
/**
|
||||
* Retrieve workflow templates
|
||||
*/
|
||||
export function getTemplates(opts?: Oazapfts.RequestOpts) {
|
||||
export function searchPluginTemplates(opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: PluginTemplateResponseDto[];
|
||||
|
|
|
|||
|
|
@ -44,11 +44,11 @@ export class PluginController {
|
|||
@Authenticated({ permission: Permission.PluginRead })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve workflow templates',
|
||||
description: 'Retrieve premade workflow templates provided by installed plugins',
|
||||
description: 'Retrieve workflow templates provided by installed plugins',
|
||||
history: HistoryBuilder.v3(),
|
||||
})
|
||||
getTemplates(): Promise<PluginTemplateResponseDto[]> {
|
||||
return this.service.getTemplates();
|
||||
searchPluginTemplates(): Promise<PluginTemplateResponseDto[]> {
|
||||
return this.service.searchTemplates();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ const PluginManifestTemplateStepSchema = z
|
|||
|
||||
const PluginManifestTemplateSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).describe('Template name'),
|
||||
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'),
|
||||
|
|
@ -56,7 +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([]).describe('Workflow templates'),
|
||||
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 { WorkflowTrigger, WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
|
||||
import { asMethodString } from 'src/utils/workflow';
|
||||
import { asPluginKey } from 'src/utils/workflow';
|
||||
import z from 'zod';
|
||||
|
||||
const PluginSearchSchema = z
|
||||
|
|
@ -53,9 +53,8 @@ const PluginTemplateStepResponseSchema = z
|
|||
|
||||
const PluginTemplateResponseSchema = z
|
||||
.object({
|
||||
id: z.string().describe('Template identifier (pluginName#templateName)'),
|
||||
pluginName: z.string().describe('Owning plugin name'),
|
||||
name: z.string().describe('Template name'),
|
||||
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'),
|
||||
|
|
@ -83,8 +82,8 @@ export class PluginMethodResponseDto extends createZodDto(PluginMethodResponseSc
|
|||
export class PluginTemplateResponseDto extends createZodDto(PluginTemplateResponseSchema) {}
|
||||
|
||||
export type PluginTemplate = {
|
||||
pluginName: string;
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
trigger: WorkflowTrigger;
|
||||
steps: Array<{
|
||||
|
|
@ -94,11 +93,10 @@ export type PluginTemplate = {
|
|||
}>;
|
||||
};
|
||||
|
||||
export const mapTemplate = (template: PluginTemplate): PluginTemplateResponseDto => {
|
||||
export const mapTemplate = (plugin: { name: string }, template: PluginTemplate): PluginTemplateResponseDto => {
|
||||
return {
|
||||
id: `${template.pluginName}#${template.name}`,
|
||||
pluginName: template.pluginName,
|
||||
name: template.name,
|
||||
key: asPluginKey({ pluginName: plugin.name, name: template.name }),
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
trigger: template.trigger,
|
||||
steps: template.steps.map((step) => ({
|
||||
|
|
@ -148,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,12 @@
|
|||
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);
|
||||
}
|
||||
|
||||
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', default: '[]' })
|
||||
templates!: Generated<PluginTemplate[]>;
|
||||
|
||||
@Column({ type: 'bytea' })
|
||||
sha256hash!: Buffer;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { join } from 'node:path';
|
||||
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
|
||||
import {
|
||||
mapMethod,
|
||||
mapPlugin,
|
||||
|
|
@ -9,7 +7,6 @@ import {
|
|||
PluginMethodSearchDto,
|
||||
PluginResponseDto,
|
||||
PluginSearchDto,
|
||||
PluginTemplate,
|
||||
PluginTemplateResponseDto,
|
||||
} from 'src/dtos/plugin.dto';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
|
|
@ -37,31 +34,8 @@ export class PluginService extends BaseService {
|
|||
.map((method) => mapMethod(method));
|
||||
}
|
||||
|
||||
async getTemplates(): Promise<PluginTemplateResponseDto[]> {
|
||||
const templates = await this.loadTemplates();
|
||||
return templates.map((template) => mapTemplate(template));
|
||||
}
|
||||
|
||||
private async loadTemplates(): Promise<PluginTemplate[]> {
|
||||
const { resourcePaths } = this.configRepository.getEnv();
|
||||
|
||||
try {
|
||||
const templates: PluginTemplate[] = [];
|
||||
const dto = await this.storageRepository.readJsonFile(join(resourcePaths.corePlugin, 'manifest.json'));
|
||||
const result = PluginManifestDto.schema.safeParse(dto);
|
||||
|
||||
if (!result.success) {
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const template of result.data.templates) {
|
||||
templates.push({ ...template, pluginName: result.data.name });
|
||||
}
|
||||
|
||||
return templates;
|
||||
} catch {
|
||||
this.logger.warn(`Failed to load plugin templates from folder: ${resourcePaths.corePlugin}`);
|
||||
return [];
|
||||
}
|
||||
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, {
|
||||
|
|
@ -47,6 +48,7 @@ describe(PluginService.name, () => {
|
|||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
wasmBytes,
|
||||
sha256hash,
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
|
@ -76,6 +78,7 @@ describe(PluginService.name, () => {
|
|||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
wasmBytes,
|
||||
sha256hash,
|
||||
},
|
||||
[
|
||||
{
|
||||
|
|
@ -131,6 +134,7 @@ describe(PluginService.name, () => {
|
|||
author: 'Author 1',
|
||||
version: '1.0.0',
|
||||
wasmBytes,
|
||||
sha256hash,
|
||||
},
|
||||
[
|
||||
{
|
||||
|
|
@ -151,6 +155,7 @@ describe(PluginService.name, () => {
|
|||
author: 'Author 2',
|
||||
version: '2.0.0',
|
||||
wasmBytes,
|
||||
sha256hash,
|
||||
},
|
||||
[
|
||||
{
|
||||
|
|
@ -184,6 +189,7 @@ describe(PluginService.name, () => {
|
|||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
wasmBytes,
|
||||
sha256hash,
|
||||
},
|
||||
[
|
||||
{
|
||||
|
|
@ -242,6 +248,7 @@ describe(PluginService.name, () => {
|
|||
description: 'A single plugin',
|
||||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
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();
|
||||
|
|
@ -42,6 +43,7 @@ describe(WorkflowService.name, () => {
|
|||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
wasmBytes,
|
||||
sha256hash,
|
||||
},
|
||||
[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
getTemplates,
|
||||
getWorkflowTriggers,
|
||||
searchPluginMethods,
|
||||
searchPluginTemplates,
|
||||
WorkflowTrigger,
|
||||
type PluginMethodResponseDto,
|
||||
type PluginTemplateResponseDto,
|
||||
|
|
@ -80,7 +80,7 @@ class PluginManager {
|
|||
const [methods, triggers, templates] = await Promise.all([
|
||||
searchPluginMethods({}),
|
||||
getWorkflowTriggers(),
|
||||
getTemplates(),
|
||||
searchPluginTemplates(),
|
||||
]);
|
||||
|
||||
this.#methods = methods;
|
||||
|
|
|
|||
|
|
@ -18,23 +18,37 @@
|
|||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const response = await handleCreateWorkflow({
|
||||
|
||||
const success = await handleCreateWorkflow({
|
||||
trigger: selected.trigger,
|
||||
steps: selected.steps,
|
||||
name: selected.name,
|
||||
name: selected.title,
|
||||
description: selected.description,
|
||||
enabled: false,
|
||||
});
|
||||
if (response) {
|
||||
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = (template: PluginTemplateResponseDto) => selected?.key === template.key;
|
||||
</script>
|
||||
|
||||
<FormModal title={$t('workflow_templates')} {onClose} {onSubmit} disabled={!selected} size="medium">
|
||||
<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.id)}
|
||||
<ListButton selected={selected?.id === template.id} onclick={() => (selected = template)}>
|
||||
{#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"
|
||||
|
|
@ -42,7 +56,7 @@
|
|||
<Icon icon={mdiFlashOutline} size="18" />
|
||||
</div>
|
||||
<div class="min-w-0 grow">
|
||||
<Text fontWeight="medium">{template.name}</Text>
|
||||
<Text fontWeight="medium">{template.title}</Text>
|
||||
<Text size="tiny" color="muted">{template.description}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export const getWorkflowsActions = ($t: MessageFormatter) => {
|
|||
};
|
||||
|
||||
const UseTemplate: ActionItem = {
|
||||
title: $t('use_template'),
|
||||
title: $t('browse_templates'),
|
||||
icon: mdiFileDocumentMultipleOutline,
|
||||
onAction: () => modalManager.show(WorkflowTemplatePicker, {}),
|
||||
};
|
||||
|
|
@ -85,9 +85,10 @@ export const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
|
|||
try {
|
||||
const response = await createWorkflow({ workflowCreateDto: dto });
|
||||
eventManager.emit('WorkflowCreate', response);
|
||||
return response;
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_create'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue