feat: hash manifest file

pull/28553/head
Jason Rasmussen 2026-05-26 13:40:21 -04:00
parent 04e179c7d6
commit 74cce90005
No known key found for this signature in database
GPG Key ID: 2EF24B77EAFA4A41
24 changed files with 268 additions and 186 deletions

View File

@ -698,6 +698,7 @@
"birthdate_saved": "Date of birth saved successfully",
"birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.",
"blurred_background": "Blurred background",
"browse_templates": "Browse templates",
"bugs_and_feature_requests": "Bugs & Feature Requests",
"build": "Build",
"build_image": "Build Image",

View File

@ -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

View File

@ -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.

View File

@ -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',
};
}

View File

@ -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"

View File

@ -5,6 +5,36 @@
"description": "Core workflow capabilities for Immich",
"author": "Immich Team",
"wasmPath": "dist/plugin.wasm",
"templates": [
{
"name": "auto-archive-screenshots",
"title": "Auto-archive screenshots",
"description": "Archive uploads with \"screenshot\" in the filename and optionally add them to an album",
"trigger": "AssetCreate",
"steps": [
{
"method": "immich-plugin-core#assetFileFilter",
"config": {
"pattern": "screenshot",
"matchType": "contains",
"caseSensitive": false
}
},
{
"method": "immich-plugin-core#assetAddToAlbums",
"config": {
"albumIds": []
}
},
{
"method": "immich-plugin-core#assetArchive",
"config": {
"inverse": false
}
}
]
}
],
"methods": [
{
"name": "assetFileFilter",
@ -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 }
}
]
}
]
}

View File

@ -100,6 +100,11 @@ export const assetTrash = () => {
export const assetAddToAlbums = () => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[] }>(({ config, data, functions }) => {
if (config.albumIds.length === 0) {
// noop
return {};
}
if (config.albumIds.length === 1) {
functions.albumAddAssets(config.albumIds[0], [data.asset.id]);
return {};

View File

@ -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[];

View File

@ -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')

View File

@ -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' });

View File

@ -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,

View File

@ -35,6 +35,7 @@ select
"plugin"."version",
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
(
select
coalesce(json_agg(agg), '[]')
@ -60,6 +61,42 @@ from
order by
"plugin"."name"
-- PluginRepository.getByHash
select
"plugin"."id",
"plugin"."name",
"plugin"."title",
"plugin"."description",
"plugin"."author",
"plugin"."version",
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"plugin_method"."name",
"plugin_method"."title",
"plugin_method"."description",
"plugin_method"."types",
"plugin_method"."schema",
"plugin_method"."hostFunctions",
"plugin_method"."uiHints",
"plugin"."name" as "pluginName"
from
"plugin_method"
where
"plugin_method"."pluginId" = "plugin"."id"
) as agg
) as "methods"
from
"plugin"
where
"plugin"."sha256hash" = $1
-- PluginRepository.getByName
select
"plugin"."id",
@ -70,6 +107,7 @@ select
"plugin"."version",
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
(
select
coalesce(json_agg(agg), '[]')
@ -105,6 +143,7 @@ select
"plugin"."version",
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
(
select
coalesce(json_agg(agg), '[]')

View File

@ -274,6 +274,7 @@ export class DatabaseRepository {
columns: { ignoreExtra: true },
functions: { ignoreExtra: false },
parameters: { ignoreExtra: true },
extensions: { ignoreExtra: true },
});
return drift;

View File

@ -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'])

View File

@ -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);
}

View File

@ -8,6 +8,7 @@ import {
Unique,
UpdateDateColumn,
} from '@immich/sql-tools';
import { PluginTemplate } from 'src/dtos/plugin.dto';
@Unique({ columns: ['name', 'version'] })
@Table('plugin')
@ -36,6 +37,12 @@ export class PluginTable {
@Column({ type: 'bytea' })
wasmBytes!: Buffer;
@Column({ type: 'jsonb', default: '[]' })
templates!: Generated<PluginTemplate[]>;
@Column({ type: 'bytea' })
sha256hash!: Buffer;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;

View File

@ -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)));
}
}

View File

@ -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,
);

View File

@ -50,8 +50,8 @@ export const resolveMethod = (methods: PluginMethodSearchResponse[], method: str
return methods.find((method) => method.pluginName === pluginName && method.name === methodName);
};
export const asMethodString = (method: { pluginName: string; methodName: string }) => {
return `${method.pluginName}#${method.methodName}`;
export const asPluginKey = (method: { pluginName: string; name: string }) => {
return `${method.pluginName}#${method.name}`;
};
const METHOD_REGEX = /^(?<name>[^@#\s]+)(?:@(?<version>[^#\s]*))?#(?<method>[^@#\s]+)$/;

View File

@ -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,
},
[

View File

@ -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,
},
[
{

View File

@ -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;

View File

@ -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>

View File

@ -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;
}
};