mirror of
https://github.com/immich-app/immich.git
synced 2026-05-21 23:26:31 -04:00
feat: workflows & plugins (#26727)
feat: plugins chore: better types feat: plugins
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { PluginController } from 'src/controllers/plugin.controller';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { PluginService } from 'src/services/plugin.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(PluginController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(PluginService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(PluginController, [
|
||||
{ provide: PluginService, useValue: service },
|
||||
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /plugins', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/plugins');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require id to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get(`/plugins`)
|
||||
.query({ id: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /plugins/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/plugins/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require id to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get(`/plugins/invalid`)
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
|
||||
import {
|
||||
PluginMethodResponseDto,
|
||||
PluginMethodSearchDto,
|
||||
PluginResponseDto,
|
||||
PluginSearchDto,
|
||||
} from 'src/dtos/plugin.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { Authenticated } from 'src/middleware/auth.guard';
|
||||
import { PluginService } from 'src/services/plugin.service';
|
||||
@@ -12,26 +17,26 @@ import { UUIDParamDto } from 'src/validation';
|
||||
export class PluginController {
|
||||
constructor(private service: PluginService) {}
|
||||
|
||||
@Get('triggers')
|
||||
@Authenticated({ permission: Permission.PluginRead })
|
||||
@Endpoint({
|
||||
summary: 'List all plugin triggers',
|
||||
description: 'Retrieve a list of all available plugin triggers.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
})
|
||||
getPluginTriggers(): PluginTriggerResponseDto[] {
|
||||
return this.service.getTriggers();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Authenticated({ permission: Permission.PluginRead })
|
||||
@Endpoint({
|
||||
summary: 'List all plugins',
|
||||
description: 'Retrieve a list of plugins available to the authenticated user.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
history: HistoryBuilder.v3(),
|
||||
})
|
||||
getPlugins(): Promise<PluginResponseDto[]> {
|
||||
return this.service.getAll();
|
||||
searchPlugins(@Query() dto: PluginSearchDto): Promise<PluginResponseDto[]> {
|
||||
return this.service.search(dto);
|
||||
}
|
||||
|
||||
@Get('methods')
|
||||
@Authenticated({ permission: Permission.PluginRead })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve plugin methods',
|
||||
description: 'Retrieve a list of plugin methods',
|
||||
history: HistoryBuilder.v3(),
|
||||
})
|
||||
searchPluginMethods(@Query() dto: PluginMethodSearchDto): Promise<PluginMethodResponseDto[]> {
|
||||
return this.service.searchMethods(dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@@ -39,7 +44,7 @@ export class PluginController {
|
||||
@Endpoint({
|
||||
summary: 'Retrieve a plugin',
|
||||
description: 'Retrieve information about a specific plugin by its ID.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
history: HistoryBuilder.v3(),
|
||||
})
|
||||
getPlugin(@Param() { id }: UUIDParamDto): Promise<PluginResponseDto> {
|
||||
return this.service.get(id);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { WorkflowController } from 'src/controllers/workflow.controller';
|
||||
import { WorkflowTrigger } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { WorkflowService } from 'src/services/workflow.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(WorkflowController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(WorkflowService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(WorkflowController, [
|
||||
{ provide: WorkflowService, useValue: service },
|
||||
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /workflows', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/workflows').send({});
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require a valid trigger`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post(`/workflows`)
|
||||
.send({ trigger: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([
|
||||
{ path: ['trigger'], message: expect.stringContaining('Invalid option: expected one of') },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it(`should require a valid enabled value`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post(`/workflows`)
|
||||
.send({ enabled: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.validationError([{ path: ['enabled'], message: 'Invalid input: expected boolean, received string' }]),
|
||||
);
|
||||
});
|
||||
|
||||
it(`should not require a name`, async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.post(`/workflows`)
|
||||
.send({ trigger: WorkflowTrigger.AssetCreate })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(201);
|
||||
expect(service.create).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /workflows', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/workflows');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require id to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get(`/workflows`)
|
||||
.query({ id: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /workflows/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/workflows/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require id to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get(`/workflows/invalid`)
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /workflows/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/workflows/${factory.uuid()}`).send({});
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require id to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/workflows/invalid`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { WorkflowCreateDto, WorkflowResponseDto, WorkflowUpdateDto } from 'src/dtos/workflow.dto';
|
||||
import {
|
||||
WorkflowCreateDto,
|
||||
WorkflowResponseDto,
|
||||
WorkflowSearchDto,
|
||||
WorkflowShareResponseDto,
|
||||
WorkflowTriggerResponseDto,
|
||||
WorkflowUpdateDto,
|
||||
} from 'src/dtos/workflow.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { WorkflowService } from 'src/services/workflow.service';
|
||||
@@ -18,7 +25,7 @@ export class WorkflowController {
|
||||
@Endpoint({
|
||||
summary: 'Create a workflow',
|
||||
description: 'Create a new workflow, the workflow can also be created with empty filters and actions.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
history: HistoryBuilder.v3(),
|
||||
})
|
||||
createWorkflow(@Auth() auth: AuthDto, @Body() dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
|
||||
return this.service.create(auth, dto);
|
||||
@@ -29,10 +36,21 @@ export class WorkflowController {
|
||||
@Endpoint({
|
||||
summary: 'List all workflows',
|
||||
description: 'Retrieve a list of workflows available to the authenticated user.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
history: HistoryBuilder.v3(),
|
||||
})
|
||||
getWorkflows(@Auth() auth: AuthDto): Promise<WorkflowResponseDto[]> {
|
||||
return this.service.getAll(auth);
|
||||
searchWorkflows(@Auth() auth: AuthDto, @Query() dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> {
|
||||
return this.service.search(auth, dto);
|
||||
}
|
||||
|
||||
@Get('triggers')
|
||||
@Authenticated({ permission: false })
|
||||
@Endpoint({
|
||||
summary: 'List all workflow triggers',
|
||||
description: 'Retrieve a list of all available workflow triggers.',
|
||||
history: HistoryBuilder.v3(),
|
||||
})
|
||||
getWorkflowTriggers(): WorkflowTriggerResponseDto[] {
|
||||
return this.service.getTriggers();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@@ -40,19 +58,30 @@ export class WorkflowController {
|
||||
@Endpoint({
|
||||
summary: 'Retrieve a workflow',
|
||||
description: 'Retrieve information about a specific workflow by its ID.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
history: HistoryBuilder.v3(),
|
||||
})
|
||||
getWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<WorkflowResponseDto> {
|
||||
return this.service.get(auth, id);
|
||||
}
|
||||
|
||||
@Get(':id/share')
|
||||
@Authenticated({ permission: Permission.WorkflowRead })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve a workflow',
|
||||
description: 'Retrieve a workflow details without ids, default values, etc.',
|
||||
history: HistoryBuilder.v3(),
|
||||
})
|
||||
getWorkflowForShare(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<WorkflowShareResponseDto> {
|
||||
return this.service.share(auth, id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@Authenticated({ permission: Permission.WorkflowUpdate })
|
||||
@Endpoint({
|
||||
summary: 'Update a workflow',
|
||||
description:
|
||||
'Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
history: HistoryBuilder.v3(),
|
||||
})
|
||||
updateWorkflow(
|
||||
@Auth() auth: AuthDto,
|
||||
@@ -68,7 +97,7 @@ export class WorkflowController {
|
||||
@Endpoint({
|
||||
summary: 'Delete a workflow',
|
||||
description: 'Delete a workflow by its ID.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
history: HistoryBuilder.v3(),
|
||||
})
|
||||
deleteWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.delete(auth, id);
|
||||
|
||||
+37
-52
@@ -8,8 +8,6 @@ import {
|
||||
ChecksumAlgorithm,
|
||||
MemoryType,
|
||||
Permission,
|
||||
PluginContext,
|
||||
PluginTriggerType,
|
||||
SharedLinkType,
|
||||
SourceType,
|
||||
UserAvatarColor,
|
||||
@@ -18,10 +16,8 @@ import {
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table';
|
||||
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
import { PluginTable } from 'src/schema/tables/plugin.table';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types';
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
@@ -276,42 +272,7 @@ export type AssetFace = {
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
export type PluginFilter = Selectable<PluginFilterTable> & {
|
||||
methodName: string;
|
||||
title: string;
|
||||
description: string;
|
||||
supportedContexts: PluginContext[];
|
||||
schema: JSONSchema | null;
|
||||
};
|
||||
|
||||
export type PluginAction = Selectable<PluginActionTable> & {
|
||||
methodName: string;
|
||||
title: string;
|
||||
description: string;
|
||||
supportedContexts: PluginContext[];
|
||||
schema: JSONSchema | null;
|
||||
};
|
||||
|
||||
export type Workflow = Selectable<WorkflowTable> & {
|
||||
triggerType: PluginTriggerType;
|
||||
name: string | null;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowFilter = Selectable<WorkflowFilterTable> & {
|
||||
workflowId: string;
|
||||
pluginFilterId: string;
|
||||
filterConfig: FilterConfig | null;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type WorkflowAction = Selectable<WorkflowActionTable> & {
|
||||
workflowId: string;
|
||||
pluginActionId: string;
|
||||
actionConfig: ActionConfig | null;
|
||||
order: number;
|
||||
};
|
||||
export type Plugin = Selectable<PluginTable>;
|
||||
|
||||
const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const;
|
||||
const userWithPrefixColumns = [
|
||||
@@ -343,6 +304,32 @@ export const columns = {
|
||||
'asset.height',
|
||||
'asset.isEdited',
|
||||
],
|
||||
workflowAssetV1: [
|
||||
'asset.id',
|
||||
'asset.ownerId',
|
||||
'asset.stackId',
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.libraryId',
|
||||
'asset.duplicateId',
|
||||
'asset.createdAt',
|
||||
'asset.updatedAt',
|
||||
'asset.deletedAt',
|
||||
'asset.fileCreatedAt',
|
||||
'asset.fileModifiedAt',
|
||||
'asset.localDateTime',
|
||||
'asset.type',
|
||||
'asset.status',
|
||||
'asset.visibility',
|
||||
'asset.duration',
|
||||
'asset.checksum',
|
||||
'asset.originalPath',
|
||||
'asset.originalFileName',
|
||||
'asset.isOffline',
|
||||
'asset.isFavorite',
|
||||
'asset.isExternal',
|
||||
'asset.isEdited',
|
||||
'asset.isFavorite',
|
||||
],
|
||||
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
|
||||
assetFilesForThumbnail: [
|
||||
'asset_file.id',
|
||||
@@ -374,6 +361,15 @@ export const columns = {
|
||||
tag: ['tag.id', 'tag.value', 'tag.createdAt', 'tag.updatedAt', 'tag.color', 'tag.parentId'],
|
||||
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
|
||||
notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'],
|
||||
pluginMethod: [
|
||||
'plugin_method.name',
|
||||
'plugin_method.title',
|
||||
'plugin_method.description',
|
||||
'plugin_method.types',
|
||||
'plugin_method.schema',
|
||||
'plugin_method.hostFunctions',
|
||||
'plugin_method.uiHints',
|
||||
],
|
||||
syncAsset: [
|
||||
'asset.id',
|
||||
'asset.ownerId',
|
||||
@@ -487,17 +483,6 @@ export const columns = {
|
||||
'asset_exif.tags',
|
||||
'asset_exif.timeZone',
|
||||
],
|
||||
plugin: [
|
||||
'plugin.id as id',
|
||||
'plugin.name as name',
|
||||
'plugin.title as title',
|
||||
'plugin.description as description',
|
||||
'plugin.author as author',
|
||||
'plugin.version as version',
|
||||
'plugin.wasmPath as wasmPath',
|
||||
'plugin.createdAt as createdAt',
|
||||
'plugin.updatedAt as updatedAt',
|
||||
],
|
||||
} as const;
|
||||
|
||||
export type LockableProperty = (typeof lockableProperties)[number];
|
||||
|
||||
@@ -200,6 +200,10 @@ export class HistoryBuilder {
|
||||
private hasDeprecated = false;
|
||||
private items: HistoryEntry[] = [];
|
||||
|
||||
static v3() {
|
||||
return new HistoryBuilder().added('v3.0.0');
|
||||
}
|
||||
|
||||
added(version: string, description?: string) {
|
||||
return this.push({ version, state: 'Added', description });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import z from 'zod';
|
||||
|
||||
export const JsonSchemaTypeSchema = z
|
||||
.enum(['string', 'number', 'integer', 'boolean', 'object'])
|
||||
.meta({ id: 'JsonSchemaType' });
|
||||
|
||||
const JsonSchemaPropertySchema = z
|
||||
.object({
|
||||
type: JsonSchemaTypeSchema.optional().default('object').describe('Type'),
|
||||
title: z.string().describe('Title'),
|
||||
description: z.string().describe('Description'),
|
||||
default: z.any().optional().describe('Default value'),
|
||||
enum: z.array(z.string()).optional().describe('Valid choices for enum types'),
|
||||
array: z.boolean().optional().describe('Type is an array type'),
|
||||
required: z.array(z.string()).optional().describe('A list of required properties'),
|
||||
uiHint: z.string().optional(),
|
||||
get properties() {
|
||||
return z.record(z.string(), JsonSchemaPropertySchema).optional();
|
||||
},
|
||||
})
|
||||
.meta({ id: 'JsonSchemaPropertyDto' });
|
||||
|
||||
export const JsonSchemaSchema = z
|
||||
.object({
|
||||
...JsonSchemaPropertySchema.shape,
|
||||
title: z.string().optional().describe('Title'),
|
||||
description: z.string().optional().describe('Description'),
|
||||
})
|
||||
.meta({ id: 'JsonSchemaDto' });
|
||||
|
||||
export class JsonSchemaDto extends createZodDto(JsonSchemaSchema) {}
|
||||
@@ -1,39 +1,29 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { PluginContextSchema } from 'src/enum';
|
||||
import { JSONSchemaSchema } from 'src/types/plugin-schema.types';
|
||||
import { JsonSchemaSchema } from 'src/dtos/json-schema.dto';
|
||||
import { WorkflowTypeSchema } from 'src/enum';
|
||||
import z from 'zod';
|
||||
|
||||
const pluginNameRegex = /^[a-z0-9-]+[a-z0-9]$/;
|
||||
const semverRegex =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
||||
|
||||
const PluginManifestWasmSchema = z
|
||||
.object({
|
||||
path: z.string().describe('WASM file path'),
|
||||
})
|
||||
.meta({ id: 'PluginManifestWasmDto' });
|
||||
export const PluginManifestMethodSchemaSchema = JsonSchemaSchema.nullable()
|
||||
.optional()
|
||||
.transform((value) => (value && Object.keys(value).length === 0 ? null : value));
|
||||
|
||||
const PluginManifestFilterSchema = z
|
||||
const PluginManifestMethodSchema = z
|
||||
.object({
|
||||
methodName: z.string().describe('Filter method name'),
|
||||
title: z.string().describe('Filter title'),
|
||||
description: z.string().describe('Filter description'),
|
||||
supportedContexts: z.array(PluginContextSchema).min(1).describe('Supported contexts'),
|
||||
schema: JSONSchemaSchema.optional(),
|
||||
name: z.string().min(1).describe('Method name'),
|
||||
title: z.string().min(1).describe('Method title'),
|
||||
description: z.string().min(1).describe('Method description'),
|
||||
types: z.array(WorkflowTypeSchema).min(1).describe('Workflow type'),
|
||||
hostFunctions: z.boolean().optional().default(false).describe('Method uses host functions'),
|
||||
schema: PluginManifestMethodSchemaSchema.describe('Schema'),
|
||||
uiHints: z.array(z.string()).optional().describe('Ui hints, for example "filter"'),
|
||||
})
|
||||
.meta({ id: 'PluginManifestFilterDto' });
|
||||
.meta({ id: 'PluginManifestMethodDto' });
|
||||
|
||||
const PluginManifestActionSchema = z
|
||||
.object({
|
||||
methodName: z.string().describe('Action method name'),
|
||||
title: z.string().describe('Action title'),
|
||||
description: z.string().describe('Action description'),
|
||||
supportedContexts: z.array(PluginContextSchema).min(1).describe('Supported contexts'),
|
||||
schema: JSONSchemaSchema.optional(),
|
||||
})
|
||||
.meta({ id: 'PluginManifestActionDto' });
|
||||
|
||||
export const PluginManifestSchema = z
|
||||
const PluginManifestSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
@@ -44,12 +34,11 @@ export const PluginManifestSchema = z
|
||||
)
|
||||
.describe('Plugin name (lowercase, numbers, hyphens only)'),
|
||||
version: z.string().regex(semverRegex).describe('Plugin version (semver)'),
|
||||
title: z.string().describe('Plugin title'),
|
||||
description: z.string().describe('Plugin description'),
|
||||
author: z.string().describe('Plugin author'),
|
||||
wasm: PluginManifestWasmSchema,
|
||||
filters: z.array(PluginManifestFilterSchema).optional().describe('Plugin filters'),
|
||||
actions: z.array(PluginManifestActionSchema).optional().describe('Plugin actions'),
|
||||
title: z.string().min(1).describe('Plugin title'),
|
||||
description: z.string().min(1).describe('Plugin description'),
|
||||
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'),
|
||||
})
|
||||
.meta({ id: 'PluginManifestDto' });
|
||||
|
||||
|
||||
@@ -1,39 +1,33 @@
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { PluginAction, PluginFilter } from 'src/database';
|
||||
import { PluginContextSchema, PluginTriggerTypeSchema } from 'src/enum';
|
||||
import { JSONSchemaSchema } from 'src/types/plugin-schema.types';
|
||||
import { JsonSchemaDto } from 'src/dtos/json-schema.dto';
|
||||
import { WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
|
||||
import { asMethodString } from 'src/utils/workflow';
|
||||
import z from 'zod';
|
||||
|
||||
const PluginTriggerResponseSchema = z
|
||||
const PluginSearchSchema = z
|
||||
.object({
|
||||
type: PluginTriggerTypeSchema,
|
||||
contextType: PluginContextSchema,
|
||||
id: z.uuidv4().optional().describe('Plugin ID'),
|
||||
enabled: z.boolean().optional().describe('Whether the plugin is enabled'),
|
||||
name: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
.meta({ id: 'PluginTriggerResponseDto' });
|
||||
.meta({ id: 'PluginSearchDto' });
|
||||
|
||||
const PluginFilterResponseSchema = z
|
||||
const PluginMethodResponseSchema = z
|
||||
.object({
|
||||
id: z.string().describe('Filter ID'),
|
||||
pluginId: z.string().describe('Plugin ID'),
|
||||
methodName: z.string().describe('Method name'),
|
||||
title: z.string().describe('Filter title'),
|
||||
description: z.string().describe('Filter description'),
|
||||
supportedContexts: z.array(PluginContextSchema).describe('Supported contexts'),
|
||||
schema: JSONSchemaSchema.nullable().describe('Filter schema'),
|
||||
key: z.string().describe('Key'),
|
||||
name: z.string().describe('Name'),
|
||||
title: z.string().describe('Title'),
|
||||
description: z.string().describe('Description'),
|
||||
types: z.array(WorkflowTypeSchema).describe('Workflow types'),
|
||||
uiHints: z.array(z.string()).describe('Ui hints'),
|
||||
// TODO fix this
|
||||
schema: z.object().optional(),
|
||||
hostFunctions: z.boolean(),
|
||||
})
|
||||
.meta({ id: 'PluginFilterResponseDto' });
|
||||
|
||||
const PluginActionResponseSchema = z
|
||||
.object({
|
||||
id: z.string().describe('Action ID'),
|
||||
pluginId: z.string().describe('Plugin ID'),
|
||||
methodName: z.string().describe('Method name'),
|
||||
title: z.string().describe('Action title'),
|
||||
description: z.string().describe('Action description'),
|
||||
supportedContexts: z.array(PluginContextSchema).describe('Supported contexts'),
|
||||
schema: JSONSchemaSchema.nullable().describe('Action schema'),
|
||||
})
|
||||
.meta({ id: 'PluginActionResponseDto' });
|
||||
.meta({ id: 'PluginMethodResponseDto' });
|
||||
|
||||
const PluginResponseSchema = z
|
||||
.object({
|
||||
@@ -45,29 +39,53 @@ const PluginResponseSchema = z
|
||||
version: z.string().describe('Plugin version'),
|
||||
createdAt: z.string().describe('Creation date'),
|
||||
updatedAt: z.string().describe('Last update date'),
|
||||
filters: z.array(PluginFilterResponseSchema).describe('Plugin filters'),
|
||||
actions: z.array(PluginActionResponseSchema).describe('Plugin actions'),
|
||||
methods: z.array(PluginMethodResponseSchema).describe('Plugin methods'),
|
||||
})
|
||||
.meta({ id: 'PluginResponseDto' });
|
||||
|
||||
export class PluginTriggerResponseDto extends createZodDto(PluginTriggerResponseSchema) {}
|
||||
export class PluginResponseDto extends createZodDto(PluginResponseSchema) {}
|
||||
const PluginMethodSearchSchema = z
|
||||
.object({
|
||||
id: z.uuidv4().optional().describe('Plugin method ID'),
|
||||
enabled: z.boolean().optional().describe('Whether the plugin method is enabled'),
|
||||
name: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
type: WorkflowTypeSchema.optional().describe('Workflow types'),
|
||||
trigger: WorkflowTriggerSchema.optional().describe('Workflow trigger'),
|
||||
pluginName: z.string().optional().describe('Plugin name'),
|
||||
pluginVersion: z.string().optional().describe('Plugin version'),
|
||||
})
|
||||
.meta({ id: 'PluginMethodSearchDto' });
|
||||
|
||||
type MapPlugin = {
|
||||
export class PluginSearchDto extends createZodDto(PluginSearchSchema) {}
|
||||
export class PluginResponseDto extends createZodDto(PluginResponseSchema) {}
|
||||
export class PluginMethodSearchDto extends createZodDto(PluginMethodSearchSchema) {}
|
||||
export class PluginMethodResponseDto extends createZodDto(PluginMethodResponseSchema) {}
|
||||
|
||||
type Plugin = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
author: string;
|
||||
version: string;
|
||||
wasmPath: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
filters: PluginFilter[];
|
||||
actions: PluginAction[];
|
||||
methods: PluginMethod[];
|
||||
};
|
||||
|
||||
export function mapPlugin(plugin: MapPlugin): PluginResponseDto {
|
||||
type PluginMethod = {
|
||||
pluginName: string;
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
types: WorkflowType[];
|
||||
schema: JsonSchemaDto | null;
|
||||
hostFunctions: boolean;
|
||||
uiHints: string[];
|
||||
};
|
||||
|
||||
export function mapPlugin(plugin: Plugin): PluginResponseDto {
|
||||
return {
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
@@ -77,7 +95,19 @@ export function mapPlugin(plugin: MapPlugin): PluginResponseDto {
|
||||
version: plugin.version,
|
||||
createdAt: plugin.createdAt.toISOString(),
|
||||
updatedAt: plugin.updatedAt.toISOString(),
|
||||
filters: plugin.filters,
|
||||
actions: plugin.actions,
|
||||
methods: plugin.methods.map((method) => mapMethod(method)),
|
||||
};
|
||||
}
|
||||
|
||||
export const mapMethod = (method: PluginMethod): PluginMethodResponseDto => {
|
||||
return {
|
||||
key: asMethodString({ pluginName: method.pluginName, methodName: method.name }),
|
||||
name: method.name,
|
||||
title: method.title,
|
||||
hostFunctions: method.hostFunctions,
|
||||
uiHints: method.uiHints,
|
||||
description: method.description,
|
||||
types: method.types,
|
||||
schema: method.schema as any,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,101 +1,135 @@
|
||||
import type { WorkflowStepConfig } from '@immich/plugin-sdk';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import type { WorkflowAction, WorkflowFilter } from 'src/database';
|
||||
import { PluginTriggerTypeSchema } from 'src/enum';
|
||||
import { ActionConfigSchema, FilterConfigSchema } from 'src/types/plugin-schema.types';
|
||||
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
|
||||
import z from 'zod';
|
||||
|
||||
const WorkflowFilterItemSchema = z
|
||||
const WorkflowTriggerResponseSchema = z
|
||||
.object({
|
||||
pluginFilterId: z.uuidv4().describe('Plugin filter ID'),
|
||||
filterConfig: FilterConfigSchema.optional(),
|
||||
trigger: WorkflowTriggerSchema.describe('Trigger type'),
|
||||
types: z.array(WorkflowTypeSchema).describe('Workflow types'),
|
||||
})
|
||||
.meta({ id: 'WorkflowFilterItemDto' });
|
||||
.meta({ id: 'WorkflowTriggerResponseDto' });
|
||||
|
||||
const WorkflowActionItemSchema = z
|
||||
const WorkflowSearchSchema = z
|
||||
.object({
|
||||
pluginActionId: z.uuidv4().describe('Plugin action ID'),
|
||||
actionConfig: ActionConfigSchema.optional(),
|
||||
id: z.uuidv4().optional().describe('Workflow ID'),
|
||||
trigger: WorkflowTriggerSchema.optional().describe('Workflow trigger type'),
|
||||
name: z.string().optional().describe('Workflow name'),
|
||||
description: z.string().optional().describe('Workflow description'),
|
||||
enabled: z.boolean().optional().describe('Workflow enabled'),
|
||||
})
|
||||
.meta({ id: 'WorkflowActionItemDto' });
|
||||
.meta({ id: 'WorkflowSearchDto' });
|
||||
|
||||
const WorkflowStepSchema = 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('Step is enabled'),
|
||||
})
|
||||
.meta({ id: 'WorkflowStepDto' });
|
||||
|
||||
const WorkflowShareStepSchema = 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('Step is enabled'),
|
||||
})
|
||||
.meta({ id: 'WorkflowShareStepDto' });
|
||||
|
||||
const WorkflowCreateSchema = z
|
||||
.object({
|
||||
triggerType: PluginTriggerTypeSchema,
|
||||
name: z.string().describe('Workflow name'),
|
||||
description: z.string().optional().describe('Workflow description'),
|
||||
trigger: WorkflowTriggerSchema.describe('Workflow trigger type'),
|
||||
name: z.string().nullable().optional().describe('Workflow name'),
|
||||
description: z.string().nullable().optional().describe('Workflow description'),
|
||||
enabled: z.boolean().optional().describe('Workflow enabled'),
|
||||
filters: z.array(WorkflowFilterItemSchema).describe('Workflow filters'),
|
||||
actions: z.array(WorkflowActionItemSchema).describe('Workflow actions'),
|
||||
steps: z.array(WorkflowStepSchema).optional(),
|
||||
})
|
||||
.meta({ id: 'WorkflowCreateDto' });
|
||||
|
||||
const WorkflowUpdateSchema = z
|
||||
.object({
|
||||
triggerType: PluginTriggerTypeSchema.optional(),
|
||||
name: z.string().optional().describe('Workflow name'),
|
||||
description: z.string().optional().describe('Workflow description'),
|
||||
trigger: WorkflowTriggerSchema.optional().describe('Workflow trigger type'),
|
||||
name: z.string().nullable().optional().describe('Workflow name'),
|
||||
description: z.string().nullable().optional().describe('Workflow description'),
|
||||
enabled: z.boolean().optional().describe('Workflow enabled'),
|
||||
filters: z.array(WorkflowFilterItemSchema).optional().describe('Workflow filters'),
|
||||
actions: z.array(WorkflowActionItemSchema).optional().describe('Workflow actions'),
|
||||
steps: z.array(WorkflowStepSchema).optional(),
|
||||
})
|
||||
.meta({ id: 'WorkflowUpdateDto' });
|
||||
|
||||
const WorkflowFilterResponseSchema = z
|
||||
.object({
|
||||
id: z.string().describe('Filter ID'),
|
||||
workflowId: z.string().describe('Workflow ID'),
|
||||
pluginFilterId: z.string().describe('Plugin filter ID'),
|
||||
filterConfig: FilterConfigSchema.nullable(),
|
||||
order: z.int().describe('Filter order'),
|
||||
})
|
||||
.meta({ id: 'WorkflowFilterResponseDto' });
|
||||
|
||||
const WorkflowActionResponseSchema = z
|
||||
.object({
|
||||
id: z.string().describe('Action ID'),
|
||||
workflowId: z.string().describe('Workflow ID'),
|
||||
pluginActionId: z.string().describe('Plugin action ID'),
|
||||
actionConfig: ActionConfigSchema.nullable(),
|
||||
order: z.int().describe('Action order'),
|
||||
})
|
||||
.meta({ id: 'WorkflowActionResponseDto' });
|
||||
|
||||
const WorkflowResponseSchema = z
|
||||
.object({
|
||||
id: z.string().describe('Workflow ID'),
|
||||
ownerId: z.string().describe('Owner user ID'),
|
||||
triggerType: PluginTriggerTypeSchema,
|
||||
trigger: WorkflowTriggerSchema.describe('Workflow trigger type'),
|
||||
name: z.string().nullable().describe('Workflow name'),
|
||||
description: z.string().describe('Workflow description'),
|
||||
description: z.string().nullable().describe('Workflow description'),
|
||||
createdAt: z.string().describe('Creation date'),
|
||||
updatedAt: z.string().describe('Update date'),
|
||||
enabled: z.boolean().describe('Workflow enabled'),
|
||||
filters: z.array(WorkflowFilterResponseSchema).describe('Workflow filters'),
|
||||
actions: z.array(WorkflowActionResponseSchema).describe('Workflow actions'),
|
||||
steps: z.array(WorkflowStepSchema).describe('Workflow steps'),
|
||||
})
|
||||
.meta({ id: 'WorkflowResponseDto' });
|
||||
|
||||
const WorkflowShareResponseSchema = z
|
||||
.object({
|
||||
trigger: WorkflowTriggerSchema.describe('Workflow trigger type'),
|
||||
name: z.string().nullable().describe('Workflow name'),
|
||||
description: z.string().nullable().describe('Workflow description'),
|
||||
steps: z.array(WorkflowShareStepSchema).describe('Workflow steps'),
|
||||
})
|
||||
.meta({ id: 'WorkflowShareResponseDto' });
|
||||
|
||||
export class WorkflowTriggerResponseDto extends createZodDto(WorkflowTriggerResponseSchema) {}
|
||||
export class WorkflowSearchDto extends createZodDto(WorkflowSearchSchema) {}
|
||||
export class WorkflowCreateDto extends createZodDto(WorkflowCreateSchema) {}
|
||||
export class WorkflowUpdateDto extends createZodDto(WorkflowUpdateSchema) {}
|
||||
export class WorkflowResponseDto extends createZodDto(WorkflowResponseSchema) {}
|
||||
class WorkflowFilterResponseDto extends createZodDto(WorkflowFilterResponseSchema) {}
|
||||
class WorkflowActionResponseDto extends createZodDto(WorkflowActionResponseSchema) {}
|
||||
export class WorkflowShareResponseDto extends createZodDto(WorkflowShareResponseSchema) {}
|
||||
|
||||
export function mapWorkflowFilter(filter: WorkflowFilter): WorkflowFilterResponseDto {
|
||||
return {
|
||||
id: filter.id,
|
||||
workflowId: filter.workflowId,
|
||||
pluginFilterId: filter.pluginFilterId,
|
||||
filterConfig: filter.filterConfig,
|
||||
order: filter.order,
|
||||
};
|
||||
}
|
||||
type Workflow = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
trigger: WorkflowTrigger;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export function mapWorkflowAction(action: WorkflowAction): WorkflowActionResponseDto {
|
||||
type WorkflowStep = {
|
||||
enabled: boolean;
|
||||
methodName: string;
|
||||
config: WorkflowStepConfig | null;
|
||||
pluginName: string;
|
||||
};
|
||||
|
||||
export const mapWorkflow = (workflow: Workflow & { steps: WorkflowStep[] }): WorkflowResponseDto => {
|
||||
return {
|
||||
id: action.id,
|
||||
workflowId: action.workflowId,
|
||||
pluginActionId: action.pluginActionId,
|
||||
actionConfig: action.actionConfig,
|
||||
order: action.order,
|
||||
id: workflow.id,
|
||||
enabled: workflow.enabled,
|
||||
trigger: workflow.trigger,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
createdAt: workflow.createdAt.toISOString(),
|
||||
updatedAt: workflow.updatedAt.toISOString(),
|
||||
steps: workflow.steps.map((step) => ({
|
||||
method: `${step.pluginName}#${step.methodName}`,
|
||||
// TODO fix this
|
||||
config: step.config as any,
|
||||
enabled: step.enabled,
|
||||
})),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const mapWorkflowShare = (workflow: Workflow & { steps: WorkflowStep[] }): WorkflowShareResponseDto => {
|
||||
return {
|
||||
trigger: workflow.trigger,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
steps: workflow.steps.map((step) => ({
|
||||
method: `${step.pluginName}#${step.methodName}`,
|
||||
// TODO fix this
|
||||
config: step.config as any,
|
||||
enabled: step.enabled ? undefined : false,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
+17
-6
@@ -749,8 +749,11 @@ export enum BootstrapEventPriority {
|
||||
StorageService = -195,
|
||||
// Other services may need to queue jobs on bootstrap.
|
||||
JobService = -190,
|
||||
// Initialise config after other bootstrap services, stop other services from using config on bootstrap
|
||||
// Initialize config after other bootstrap services, stop other services from using config on bootstrap
|
||||
SystemConfig = 100,
|
||||
PluginSync = 190,
|
||||
// Load plugins into memory after sync
|
||||
PluginLoad = 200,
|
||||
}
|
||||
|
||||
export enum QueueName {
|
||||
@@ -863,7 +866,7 @@ export enum JobName {
|
||||
Ocr = 'Ocr',
|
||||
|
||||
// Workflow
|
||||
WorkflowRun = 'WorkflowRun',
|
||||
WorkflowAssetCreate = 'WorkflowAssetCreate',
|
||||
}
|
||||
|
||||
export const JobNameSchema = z.enum(JobName).describe('Job name').meta({ id: 'JobName' });
|
||||
@@ -909,6 +912,7 @@ export enum DatabaseLock {
|
||||
CLIPDimSize = 512,
|
||||
Library = 1337,
|
||||
NightlyJobs = 600,
|
||||
PluginImport = 666,
|
||||
MediaLocation = 700,
|
||||
GetSystemConfig = 69,
|
||||
BackupDatabase = 42,
|
||||
@@ -1160,12 +1164,19 @@ export enum PluginContext {
|
||||
|
||||
export const PluginContextSchema = z.enum(PluginContext).describe('Plugin context').meta({ id: 'PluginContextType' });
|
||||
|
||||
export enum PluginTriggerType {
|
||||
export enum WorkflowTrigger {
|
||||
AssetCreate = 'AssetCreate',
|
||||
PersonRecognized = 'PersonRecognized',
|
||||
}
|
||||
|
||||
export const PluginTriggerTypeSchema = z
|
||||
.enum(PluginTriggerType)
|
||||
export const WorkflowTriggerSchema = z
|
||||
.enum(WorkflowTrigger)
|
||||
.describe('Plugin trigger type')
|
||||
.meta({ id: 'PluginTriggerType' });
|
||||
.meta({ id: 'WorkflowTrigger' });
|
||||
|
||||
export enum WorkflowType {
|
||||
AssetV1 = 'AssetV1',
|
||||
AssetPersonV1 = 'AssetPersonV1',
|
||||
}
|
||||
|
||||
export const WorkflowTypeSchema = z.enum(WorkflowType).describe('Workflow type').meta({ id: 'WorkflowType' });
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { PluginContext, PluginTriggerType } from 'src/enum';
|
||||
|
||||
export type PluginTrigger = {
|
||||
type: PluginTriggerType;
|
||||
contextType: PluginContext;
|
||||
};
|
||||
|
||||
export const pluginTriggers: PluginTrigger[] = [
|
||||
{
|
||||
type: PluginTriggerType.AssetCreate,
|
||||
contextType: PluginContext.Asset,
|
||||
},
|
||||
{
|
||||
type: PluginTriggerType.PersonRecognized,
|
||||
contextType: PluginContext.Person,
|
||||
},
|
||||
];
|
||||
@@ -1,159 +1,159 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- PluginRepository.getPlugin
|
||||
-- PluginRepository.getForLoad
|
||||
select
|
||||
"plugin"."id" as "id",
|
||||
"plugin"."name" as "name",
|
||||
"plugin"."title" as "title",
|
||||
"plugin"."description" as "description",
|
||||
"plugin"."author" as "author",
|
||||
"plugin"."version" as "version",
|
||||
"plugin"."wasmPath" as "wasmPath",
|
||||
"plugin"."createdAt" as "createdAt",
|
||||
"plugin"."updatedAt" as "updatedAt",
|
||||
"plugin"."id",
|
||||
"plugin"."name",
|
||||
"plugin"."version",
|
||||
"plugin"."wasmBytes",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
"plugin_method"."name",
|
||||
"plugin_method"."hostFunctions"
|
||||
from
|
||||
"plugin_filter"
|
||||
"plugin_method"
|
||||
where
|
||||
"plugin_filter"."pluginId" = "plugin"."id"
|
||||
"plugin_method"."pluginId" = "plugin"."id"
|
||||
) as agg
|
||||
) as "filters",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
from
|
||||
"plugin_action"
|
||||
where
|
||||
"plugin_action"."pluginId" = "plugin"."id"
|
||||
) as agg
|
||||
) as "actions"
|
||||
) as "methods"
|
||||
from
|
||||
"plugin"
|
||||
where
|
||||
"plugin"."id" = $1
|
||||
"enabled" = $1
|
||||
|
||||
-- PluginRepository.getPluginByName
|
||||
-- PluginRepository.search
|
||||
select
|
||||
"plugin"."id" as "id",
|
||||
"plugin"."name" as "name",
|
||||
"plugin"."title" as "title",
|
||||
"plugin"."description" as "description",
|
||||
"plugin"."author" as "author",
|
||||
"plugin"."version" as "version",
|
||||
"plugin"."wasmPath" as "wasmPath",
|
||||
"plugin"."createdAt" as "createdAt",
|
||||
"plugin"."updatedAt" as "updatedAt",
|
||||
"plugin"."id",
|
||||
"plugin"."name",
|
||||
"plugin"."title",
|
||||
"plugin"."description",
|
||||
"plugin"."author",
|
||||
"plugin"."version",
|
||||
"plugin"."createdAt",
|
||||
"plugin"."updatedAt",
|
||||
(
|
||||
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_filter"
|
||||
"plugin_method"
|
||||
where
|
||||
"plugin_filter"."pluginId" = "plugin"."id"
|
||||
"plugin_method"."pluginId" = "plugin"."id"
|
||||
) as agg
|
||||
) as "filters",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
from
|
||||
"plugin_action"
|
||||
where
|
||||
"plugin_action"."pluginId" = "plugin"."id"
|
||||
) as agg
|
||||
) as "actions"
|
||||
from
|
||||
"plugin"
|
||||
where
|
||||
"plugin"."name" = $1
|
||||
|
||||
-- PluginRepository.getAllPlugins
|
||||
select
|
||||
"plugin"."id" as "id",
|
||||
"plugin"."name" as "name",
|
||||
"plugin"."title" as "title",
|
||||
"plugin"."description" as "description",
|
||||
"plugin"."author" as "author",
|
||||
"plugin"."version" as "version",
|
||||
"plugin"."wasmPath" as "wasmPath",
|
||||
"plugin"."createdAt" as "createdAt",
|
||||
"plugin"."updatedAt" as "updatedAt",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
from
|
||||
"plugin_filter"
|
||||
where
|
||||
"plugin_filter"."pluginId" = "plugin"."id"
|
||||
) as agg
|
||||
) as "filters",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
from
|
||||
"plugin_action"
|
||||
where
|
||||
"plugin_action"."pluginId" = "plugin"."id"
|
||||
) as agg
|
||||
) as "actions"
|
||||
) as "methods"
|
||||
from
|
||||
"plugin"
|
||||
order by
|
||||
"plugin"."name"
|
||||
|
||||
-- PluginRepository.getFilter
|
||||
-- PluginRepository.getByName
|
||||
select
|
||||
*
|
||||
"plugin"."id",
|
||||
"plugin"."name",
|
||||
"plugin"."title",
|
||||
"plugin"."description",
|
||||
"plugin"."author",
|
||||
"plugin"."version",
|
||||
"plugin"."createdAt",
|
||||
"plugin"."updatedAt",
|
||||
(
|
||||
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_filter"
|
||||
"plugin"
|
||||
where
|
||||
"id" = $1
|
||||
"plugin"."name" = $1
|
||||
|
||||
-- PluginRepository.getFiltersByPlugin
|
||||
-- PluginRepository.get
|
||||
select
|
||||
*
|
||||
"plugin"."id",
|
||||
"plugin"."name",
|
||||
"plugin"."title",
|
||||
"plugin"."description",
|
||||
"plugin"."author",
|
||||
"plugin"."version",
|
||||
"plugin"."createdAt",
|
||||
"plugin"."updatedAt",
|
||||
(
|
||||
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_filter"
|
||||
"plugin"
|
||||
where
|
||||
"pluginId" = $1
|
||||
"plugin"."id" = $1
|
||||
|
||||
-- PluginRepository.getAction
|
||||
-- PluginRepository.getForValidation
|
||||
select
|
||||
*
|
||||
"plugin_method"."id",
|
||||
"plugin_method"."name",
|
||||
"plugin"."name" as "pluginName",
|
||||
"plugin_method"."types"
|
||||
from
|
||||
"plugin_action"
|
||||
where
|
||||
"id" = $1
|
||||
"plugin_method"
|
||||
inner join "plugin" on "plugin_method"."pluginId" = "plugin"."id"
|
||||
|
||||
-- PluginRepository.getActionsByPlugin
|
||||
-- PluginRepository.searchMethods
|
||||
select
|
||||
*
|
||||
"plugin"."name" as "pluginName",
|
||||
"plugin_method"."pluginId",
|
||||
"plugin_method"."id",
|
||||
"plugin_method"."name",
|
||||
"plugin_method"."title",
|
||||
"plugin_method"."description",
|
||||
"plugin_method"."types",
|
||||
"plugin_method"."schema",
|
||||
"plugin_method"."hostFunctions",
|
||||
"plugin_method"."uiHints"
|
||||
from
|
||||
"plugin_action"
|
||||
where
|
||||
"pluginId" = $1
|
||||
"plugin_method"
|
||||
inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId"
|
||||
order by
|
||||
"plugin_method"."name"
|
||||
|
||||
@@ -1,70 +1,101 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- WorkflowRepository.getWorkflow
|
||||
-- WorkflowRepository.search
|
||||
select
|
||||
*
|
||||
"workflow"."id",
|
||||
"workflow"."name",
|
||||
"workflow"."description",
|
||||
"workflow"."trigger",
|
||||
"workflow"."enabled",
|
||||
"workflow"."createdAt",
|
||||
"workflow"."updatedAt",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"plugin"."name" as "pluginName",
|
||||
"plugin_method"."name" as "methodName",
|
||||
"workflow_step"."config",
|
||||
"workflow_step"."enabled"
|
||||
from
|
||||
"workflow_step"
|
||||
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
|
||||
inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId"
|
||||
where
|
||||
"workflow"."id" = "workflow_step"."workflowId"
|
||||
) as agg
|
||||
) as "steps"
|
||||
from
|
||||
"workflow"
|
||||
order by
|
||||
"createdAt" desc
|
||||
|
||||
-- WorkflowRepository.get
|
||||
select
|
||||
"workflow"."id",
|
||||
"workflow"."name",
|
||||
"workflow"."description",
|
||||
"workflow"."trigger",
|
||||
"workflow"."enabled",
|
||||
"workflow"."createdAt",
|
||||
"workflow"."updatedAt",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"plugin"."name" as "pluginName",
|
||||
"plugin_method"."name" as "methodName",
|
||||
"workflow_step"."config",
|
||||
"workflow_step"."enabled"
|
||||
from
|
||||
"workflow_step"
|
||||
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
|
||||
inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId"
|
||||
where
|
||||
"workflow"."id" = "workflow_step"."workflowId"
|
||||
) as agg
|
||||
) as "steps"
|
||||
from
|
||||
"workflow"
|
||||
where
|
||||
"id" = $1
|
||||
order by
|
||||
"createdAt" desc
|
||||
|
||||
-- WorkflowRepository.getWorkflowsByOwner
|
||||
-- WorkflowRepository.getForWorkflowRun
|
||||
select
|
||||
*
|
||||
"workflow"."id",
|
||||
"workflow"."name",
|
||||
"workflow"."trigger",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"workflow_step"."id",
|
||||
"workflow_step"."config",
|
||||
"plugin_method"."pluginId" as "pluginId",
|
||||
"plugin_method"."name" as "methodName",
|
||||
"plugin_method"."types" as "types",
|
||||
"plugin_method"."hostFunctions"
|
||||
from
|
||||
"workflow_step"
|
||||
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
|
||||
where
|
||||
"workflow_step"."workflowId" = "workflow"."id"
|
||||
and "workflow_step"."enabled" = $1
|
||||
) as agg
|
||||
) as "steps"
|
||||
from
|
||||
"workflow"
|
||||
where
|
||||
"ownerId" = $1
|
||||
order by
|
||||
"createdAt" desc
|
||||
|
||||
-- WorkflowRepository.getWorkflowsByTrigger
|
||||
select
|
||||
*
|
||||
from
|
||||
"workflow"
|
||||
where
|
||||
"triggerType" = $1
|
||||
and "enabled" = $2
|
||||
|
||||
-- WorkflowRepository.getWorkflowByOwnerAndTrigger
|
||||
select
|
||||
*
|
||||
from
|
||||
"workflow"
|
||||
where
|
||||
"ownerId" = $1
|
||||
and "triggerType" = $2
|
||||
"id" = $2
|
||||
and "enabled" = $3
|
||||
|
||||
-- WorkflowRepository.deleteWorkflow
|
||||
-- WorkflowRepository.delete
|
||||
delete from "workflow"
|
||||
where
|
||||
"id" = $1
|
||||
|
||||
-- WorkflowRepository.getFilters
|
||||
select
|
||||
*
|
||||
from
|
||||
"workflow_filter"
|
||||
where
|
||||
"workflowId" = $1
|
||||
order by
|
||||
"order" asc
|
||||
|
||||
-- WorkflowRepository.deleteFiltersByWorkflow
|
||||
delete from "workflow_filter"
|
||||
where
|
||||
"workflowId" = $1
|
||||
|
||||
-- WorkflowRepository.getActions
|
||||
select
|
||||
*
|
||||
from
|
||||
"workflow_action"
|
||||
where
|
||||
"workflowId" = $1
|
||||
order by
|
||||
"order" asc
|
||||
|
||||
@@ -346,7 +346,7 @@ const getEnv = (): EnvData => {
|
||||
root: folders.web,
|
||||
indexHtml: join(folders.web, 'index.html'),
|
||||
},
|
||||
corePlugin: join(buildFolder, 'corePlugin'),
|
||||
corePlugin: join(buildFolder, 'plugins', 'immich-plugin-core'),
|
||||
},
|
||||
|
||||
setup: {
|
||||
|
||||
@@ -119,6 +119,10 @@ export class LoggingRepository {
|
||||
logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : [];
|
||||
}
|
||||
|
||||
getLogLevel(): LogLevel {
|
||||
return logLevels[0] || LogLevel.Fatal;
|
||||
}
|
||||
|
||||
verbose(message: string, ...details: LogDetails) {
|
||||
this.handleMessage(LogLevel.Verbose, message, details);
|
||||
}
|
||||
|
||||
@@ -1,176 +1,254 @@
|
||||
import { CallContext, Plugin as ExtismPlugin, newPlugin } from '@extism/extism';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { createPool, Pool } from 'generic-pool';
|
||||
import { Insertable, Kysely } from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
|
||||
import { PluginMethodSearchDto, PluginSearchDto } from 'src/dtos/plugin.dto';
|
||||
import { LogLevel, WorkflowType } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { PluginMethodTable } from 'src/schema/tables/plugin-method.table';
|
||||
import { PluginTable } from 'src/schema/tables/plugin.table';
|
||||
|
||||
type PluginMethod = { pluginKey: string; methodName: string };
|
||||
type PluginLoad = { key: string; label: string; wasmBytes: Buffer };
|
||||
|
||||
export type PluginHostFunction = (callContext: CallContext, input: bigint) => Promise<bigint> | bigint;
|
||||
export type PluginLoadOptions = {
|
||||
runInWorker?: boolean;
|
||||
functions?: Record<string, PluginHostFunction>;
|
||||
};
|
||||
|
||||
export type PluginMethodSearchResponse = {
|
||||
id: string;
|
||||
name: string;
|
||||
pluginName: string;
|
||||
types: WorkflowType[];
|
||||
};
|
||||
|
||||
const levels = {
|
||||
[LogLevel.Verbose]: 'trace',
|
||||
[LogLevel.Debug]: 'debug',
|
||||
[LogLevel.Log]: 'info',
|
||||
[LogLevel.Warn]: 'warn',
|
||||
[LogLevel.Error]: 'error',
|
||||
[LogLevel.Fatal]: 'error',
|
||||
} as const;
|
||||
|
||||
const asExtismLogLevel = (logLevel: LogLevel) => levels[logLevel] || 'info';
|
||||
|
||||
@Injectable()
|
||||
export class PluginRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
private pluginMap: Map<string, { label: string; pool: Pool<ExtismPlugin> }> = new Map();
|
||||
|
||||
/**
|
||||
* Loads a plugin from a validated manifest file in a transaction.
|
||||
* This ensures all plugin, filter, and action operations are atomic.
|
||||
* @param manifest The validated plugin manifest
|
||||
* @param basePath The base directory path where the plugin is located
|
||||
*/
|
||||
async loadPlugin(manifest: PluginManifestDto, basePath: string) {
|
||||
return this.db.transaction().execute(async (tx) => {
|
||||
// Upsert the plugin
|
||||
const plugin = await tx
|
||||
.insertInto('plugin')
|
||||
.values({
|
||||
name: manifest.name,
|
||||
title: manifest.title,
|
||||
description: manifest.description,
|
||||
author: manifest.author,
|
||||
version: manifest.version,
|
||||
wasmPath: `${basePath}/${manifest.wasm.path}`,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.column('name').doUpdateSet({
|
||||
title: manifest.title,
|
||||
description: manifest.description,
|
||||
author: manifest.author,
|
||||
version: manifest.version,
|
||||
wasmPath: `${basePath}/${manifest.wasm.path}`,
|
||||
}),
|
||||
)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const filters = manifest.filters
|
||||
? await tx
|
||||
.insertInto('plugin_filter')
|
||||
.values(
|
||||
manifest.filters.map((filter) => ({
|
||||
pluginId: plugin.id,
|
||||
methodName: filter.methodName,
|
||||
title: filter.title,
|
||||
description: filter.description,
|
||||
supportedContexts: filter.supportedContexts,
|
||||
schema: filter.schema,
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) =>
|
||||
oc.column('methodName').doUpdateSet((eb) => ({
|
||||
pluginId: eb.ref('excluded.pluginId'),
|
||||
title: eb.ref('excluded.title'),
|
||||
description: eb.ref('excluded.description'),
|
||||
supportedContexts: eb.ref('excluded.supportedContexts'),
|
||||
schema: eb.ref('excluded.schema'),
|
||||
})),
|
||||
)
|
||||
.returningAll()
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
const actions = manifest.actions
|
||||
? await tx
|
||||
.insertInto('plugin_action')
|
||||
.values(
|
||||
manifest.actions.map((action) => ({
|
||||
pluginId: plugin.id,
|
||||
methodName: action.methodName,
|
||||
title: action.title,
|
||||
description: action.description,
|
||||
supportedContexts: action.supportedContexts,
|
||||
schema: action.schema,
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) =>
|
||||
oc.column('methodName').doUpdateSet((eb) => ({
|
||||
pluginId: eb.ref('excluded.pluginId'),
|
||||
title: eb.ref('excluded.title'),
|
||||
description: eb.ref('excluded.description'),
|
||||
supportedContexts: eb.ref('excluded.supportedContexts'),
|
||||
schema: eb.ref('excluded.schema'),
|
||||
})),
|
||||
)
|
||||
.returningAll()
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
return { plugin, filters, actions };
|
||||
});
|
||||
}
|
||||
|
||||
async readDirectory(path: string) {
|
||||
return readdir(path, { withFileTypes: true });
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getPlugin(id: string) {
|
||||
return this.db
|
||||
.selectFrom('plugin')
|
||||
.select((eb) => [
|
||||
...columns.plugin,
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'),
|
||||
).as('filters'),
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'),
|
||||
).as('actions'),
|
||||
])
|
||||
.where('plugin.id', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
getPluginByName(name: string) {
|
||||
return this.db
|
||||
.selectFrom('plugin')
|
||||
.select((eb) => [
|
||||
...columns.plugin,
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'),
|
||||
).as('filters'),
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'),
|
||||
).as('actions'),
|
||||
])
|
||||
.where('plugin.name', '=', name)
|
||||
.executeTakeFirst();
|
||||
constructor(
|
||||
@InjectKysely() private db: Kysely<DB>,
|
||||
private logger: LoggingRepository,
|
||||
) {
|
||||
this.logger.setContext(PluginRepository.name);
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
getAllPlugins() {
|
||||
getForLoad() {
|
||||
return this.db
|
||||
.selectFrom('plugin')
|
||||
.select((eb) => [
|
||||
...columns.plugin,
|
||||
'plugin.id',
|
||||
'plugin.name',
|
||||
'plugin.version',
|
||||
'plugin.wasmBytes',
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'),
|
||||
).as('filters'),
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'),
|
||||
).as('actions'),
|
||||
eb
|
||||
.selectFrom('plugin_method')
|
||||
.whereRef('plugin_method.pluginId', '=', 'plugin.id')
|
||||
.select(['plugin_method.name', 'plugin_method.hostFunctions']),
|
||||
).as('methods'),
|
||||
])
|
||||
.where('enabled', '=', true)
|
||||
.execute();
|
||||
}
|
||||
|
||||
private queryBuilder() {
|
||||
return this.db.selectFrom('plugin').select((eb) => [
|
||||
'plugin.id',
|
||||
'plugin.name',
|
||||
'plugin.title',
|
||||
'plugin.description',
|
||||
'plugin.author',
|
||||
'plugin.version',
|
||||
'plugin.createdAt',
|
||||
'plugin.updatedAt',
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('plugin_method')
|
||||
.select([...columns.pluginMethod, 'plugin.name as pluginName'])
|
||||
.whereRef('plugin_method.pluginId', '=', 'plugin.id'),
|
||||
).as('methods'),
|
||||
]);
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
search(dto: PluginSearchDto = {}) {
|
||||
return this.queryBuilder()
|
||||
.$if(!!dto.id, (qb) => qb.where('plugin.id', '=', dto.id!))
|
||||
.$if(!!dto.name, (qb) => qb.where('plugin.name', '=', dto.name!))
|
||||
.$if(!!dto.title, (qb) => qb.where('plugin.title', '=', dto.title!))
|
||||
.$if(!!dto.description, (qb) => qb.where('plugin.description', '=', dto.description!))
|
||||
.$if(!!dto.version, (qb) => qb.where('plugin.version', '=', dto.version!))
|
||||
.orderBy('plugin.name')
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getFilter(id: string) {
|
||||
return this.db.selectFrom('plugin_filter').selectAll().where('id', '=', id).executeTakeFirst();
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
getByName(name: string) {
|
||||
return this.queryBuilder().where('plugin.name', '=', name).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getFiltersByPlugin(pluginId: string) {
|
||||
return this.db.selectFrom('plugin_filter').selectAll().where('pluginId', '=', pluginId).execute();
|
||||
get(id: string) {
|
||||
return this.queryBuilder().where('plugin.id', '=', id).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getAction(id: string) {
|
||||
return this.db.selectFrom('plugin_action').selectAll().where('id', '=', id).executeTakeFirst();
|
||||
@GenerateSql()
|
||||
getForValidation(): Promise<PluginMethodSearchResponse[]> {
|
||||
return this.db
|
||||
.selectFrom('plugin_method')
|
||||
.innerJoin('plugin', 'plugin_method.pluginId', 'plugin.id')
|
||||
.select(['plugin_method.id', 'plugin_method.name', 'plugin.name as pluginName', 'plugin_method.types'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getActionsByPlugin(pluginId: string) {
|
||||
return this.db.selectFrom('plugin_action').selectAll().where('pluginId', '=', pluginId).execute();
|
||||
@GenerateSql()
|
||||
searchMethods(dto: PluginMethodSearchDto = {}) {
|
||||
return this.db
|
||||
.selectFrom('plugin_method')
|
||||
.innerJoin('plugin', 'plugin.id', 'plugin_method.pluginId')
|
||||
.select(['plugin.name as pluginName', 'plugin_method.pluginId', 'plugin_method.id', ...columns.pluginMethod])
|
||||
.$if(!!dto.id, (qb) => qb.where('plugin_method.id', '=', dto.id!))
|
||||
.$if(!!dto.name, (qb) => qb.where('plugin_method.name', '=', dto.name!))
|
||||
.$if(!!dto.title, (qb) => qb.where('plugin_method.title', '=', dto.title!))
|
||||
.$if(!!dto.type, (qb) => qb.where('plugin_method.types', '@>', [dto.type!]))
|
||||
.$if(!!dto.description, (qb) => qb.where('plugin_method.description', '=', dto.description!))
|
||||
.$if(!!dto.pluginVersion, (qb) => qb.where('plugin.version', '=', dto.pluginVersion!))
|
||||
.$if(!!dto.pluginName, (qb) => qb.where('plugin.name', '=', dto.pluginName!))
|
||||
.orderBy('plugin_method.name')
|
||||
.execute();
|
||||
}
|
||||
|
||||
async upsert(dto: Insertable<PluginTable>, initialMethods: Omit<Insertable<PluginMethodTable>, 'pluginId'>[]) {
|
||||
return this.db.transaction().execute(async (tx) => {
|
||||
// Upsert the plugin
|
||||
const plugin = await tx
|
||||
.insertInto('plugin')
|
||||
.values(dto)
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['name', 'version']).doUpdateSet((eb) => ({
|
||||
title: eb.ref('excluded.title'),
|
||||
description: eb.ref('excluded.description'),
|
||||
author: eb.ref('excluded.author'),
|
||||
version: eb.ref('excluded.version'),
|
||||
wasmBytes: eb.ref('excluded.wasmBytes'),
|
||||
})),
|
||||
)
|
||||
.returning(['id', 'name'])
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
// prune methods that no longer exist
|
||||
if (initialMethods.length > 0) {
|
||||
await tx
|
||||
.deleteFrom('plugin_method')
|
||||
.where('plugin_method.pluginId', '=', plugin.id)
|
||||
.where(
|
||||
'name',
|
||||
'not in',
|
||||
initialMethods.map((method) => method.name),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
const methods =
|
||||
initialMethods.length > 0
|
||||
? await tx
|
||||
.insertInto('plugin_method')
|
||||
.values(initialMethods.map((method) => ({ ...method, pluginId: plugin.id })))
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['pluginId', 'name']).doUpdateSet(({ ref }) => ({
|
||||
pluginId: ref('excluded.pluginId'),
|
||||
name: ref('excluded.name'),
|
||||
title: ref('excluded.title'),
|
||||
description: ref('excluded.description'),
|
||||
types: ref('excluded.types'),
|
||||
hostFunctions: ref('excluded.hostFunctions'),
|
||||
uiHints: ref('excluded.uiHints'),
|
||||
schema: ref('excluded.schema'),
|
||||
})),
|
||||
)
|
||||
.returningAll()
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
return { ...plugin, methods };
|
||||
});
|
||||
}
|
||||
|
||||
async load({ key, label, wasmBytes }: PluginLoad, { runInWorker, functions }: PluginLoadOptions) {
|
||||
const data = new Uint8Array(wasmBytes.buffer, wasmBytes.byteOffset, wasmBytes.byteLength);
|
||||
const logger = LoggingRepository.create(`Plugin:${label}`);
|
||||
const pool = createPool<ExtismPlugin>(
|
||||
{
|
||||
create: () =>
|
||||
newPlugin(
|
||||
{ wasm: [{ data }] },
|
||||
{
|
||||
useWasi: true,
|
||||
runInWorker,
|
||||
functions: {
|
||||
'extism:host/user': functions ?? {},
|
||||
},
|
||||
logger: {
|
||||
trace: (message) => logger.verbose(message),
|
||||
info: (message) => logger.log(message),
|
||||
debug: (message) => logger.debug(message),
|
||||
warn: (message) => logger.warn(message),
|
||||
error: (message) => logger.error(message),
|
||||
} as Console,
|
||||
logLevel: asExtismLogLevel(logger.getLogLevel()),
|
||||
},
|
||||
),
|
||||
destroy: (plugin) => plugin.close(),
|
||||
},
|
||||
{ min: 1, max: 5 },
|
||||
);
|
||||
|
||||
try {
|
||||
await pool.ready();
|
||||
this.pluginMap.set(key, { pool, label });
|
||||
} catch (error: Error | any) {
|
||||
throw new Error(`Unable to instantiate plugin: ${key}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async callMethod<T>({ pluginKey, methodName }: PluginMethod, input: unknown) {
|
||||
const item = this.pluginMap.get(pluginKey);
|
||||
if (!item) {
|
||||
throw new Error(`No loaded plugin found for ${pluginKey}`);
|
||||
}
|
||||
|
||||
const { pool, label } = item;
|
||||
|
||||
try {
|
||||
const plugin = await pool.acquire();
|
||||
try {
|
||||
const result = await plugin.call(methodName, JSON.stringify(input));
|
||||
return (result ? result.json() : result) as T;
|
||||
} finally {
|
||||
await pool.release(plugin);
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
throw new Error(`Plugin method call failed: ${label}#${methodName}`, { cause: error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,15 @@ import { Injectable } from '@nestjs/common';
|
||||
import archiver from 'archiver';
|
||||
import chokidar, { ChokidarOptions } from 'chokidar';
|
||||
import { escapePath, glob, globStream } from 'fast-glob';
|
||||
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, ReadOptionsWithBuffer } from 'node:fs';
|
||||
import {
|
||||
constants,
|
||||
createReadStream,
|
||||
createWriteStream,
|
||||
Dirent,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
ReadOptionsWithBuffer,
|
||||
} from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { PassThrough, Readable, Writable } from 'node:stream';
|
||||
@@ -50,6 +58,10 @@ export class StorageRepository {
|
||||
return fs.readdir(folder);
|
||||
}
|
||||
|
||||
readdirWithTypes(folder: string): Promise<Dirent[]> {
|
||||
return fs.readdir(folder, { withFileTypes: true });
|
||||
}
|
||||
|
||||
copyFile(source: string, target: string) {
|
||||
return fs.copyFile(source, target);
|
||||
}
|
||||
@@ -117,17 +129,24 @@ export class StorageRepository {
|
||||
}
|
||||
|
||||
async readFile(filepath: string, options?: ReadOptionsWithBuffer<Buffer>): Promise<Buffer> {
|
||||
const file = await fs.open(filepath);
|
||||
try {
|
||||
const { buffer } = await file.read(options);
|
||||
return buffer as Buffer;
|
||||
} finally {
|
||||
await file.close();
|
||||
// read a slice
|
||||
if (options) {
|
||||
const file = await fs.open(filepath);
|
||||
try {
|
||||
const { buffer } = await file.read(options);
|
||||
return buffer as Buffer;
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
}
|
||||
|
||||
// read everything
|
||||
return fs.readFile(filepath);
|
||||
}
|
||||
|
||||
async readTextFile(filepath: string): Promise<string> {
|
||||
return fs.readFile(filepath, 'utf8');
|
||||
async readJsonFile<T>(filepath: string): Promise<T> {
|
||||
const file = await fs.readFile(filepath, 'utf8');
|
||||
return JSON.parse(file) as T;
|
||||
}
|
||||
|
||||
async checkFileExists(filepath: string, mode = constants.F_OK): Promise<boolean> {
|
||||
|
||||
@@ -1,149 +1,180 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Insertable, Kysely, Updateable } from 'kysely';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { PluginTriggerType } from 'src/enum';
|
||||
import { WorkflowSearchDto } from 'src/dtos/workflow.dto';
|
||||
import { DB } from 'src/schema';
|
||||
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
|
||||
import { WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
|
||||
export type WorkflowStepUpsert = Omit<Insertable<WorkflowStepTable>, 'workflowId' | 'order'>;
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
private queryBuilder(db?: Kysely<DB>) {
|
||||
return (db ?? this.db)
|
||||
.selectFrom('workflow')
|
||||
.select([
|
||||
'workflow.id',
|
||||
'workflow.name',
|
||||
'workflow.description',
|
||||
'workflow.trigger',
|
||||
'workflow.enabled',
|
||||
'workflow.createdAt',
|
||||
'workflow.updatedAt',
|
||||
])
|
||||
.select((eb) => [
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('workflow_step')
|
||||
.innerJoin('plugin_method', 'plugin_method.id', 'workflow_step.pluginMethodId')
|
||||
.innerJoin('plugin', 'plugin.id', 'plugin_method.pluginId')
|
||||
.whereRef('workflow.id', '=', 'workflow_step.workflowId')
|
||||
.select([
|
||||
'plugin.name as pluginName',
|
||||
'plugin_method.name as methodName',
|
||||
'workflow_step.config',
|
||||
'workflow_step.enabled',
|
||||
]),
|
||||
).as('steps'),
|
||||
]);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getWorkflow(id: string) {
|
||||
search(dto: WorkflowSearchDto & { ownerId?: string }) {
|
||||
return this.queryBuilder()
|
||||
.$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!))
|
||||
.$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!))
|
||||
.$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!))
|
||||
.orderBy('createdAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
get(id: string) {
|
||||
return this.queryBuilder().where('id', '=', id).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getForWorkflowRun(id: string) {
|
||||
return this.db
|
||||
.selectFrom('workflow')
|
||||
.selectAll()
|
||||
.select(['workflow.id', 'workflow.name', 'workflow.trigger'])
|
||||
.select((eb) => [
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('workflow_step')
|
||||
.innerJoin('plugin_method', 'plugin_method.id', 'workflow_step.pluginMethodId')
|
||||
.whereRef('workflow_step.workflowId', '=', 'workflow.id')
|
||||
.where('workflow_step.enabled', '=', true)
|
||||
.select([
|
||||
'workflow_step.id',
|
||||
'workflow_step.config',
|
||||
'plugin_method.pluginId as pluginId',
|
||||
'plugin_method.name as methodName',
|
||||
'plugin_method.types as types',
|
||||
'plugin_method.hostFunctions',
|
||||
]),
|
||||
).as('steps'),
|
||||
])
|
||||
.where('id', '=', id)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.where('enabled', '=', true)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getWorkflowsByOwner(ownerId: string) {
|
||||
return this.db
|
||||
.selectFrom('workflow')
|
||||
.selectAll()
|
||||
.where('ownerId', '=', ownerId)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [PluginTriggerType.AssetCreate] })
|
||||
getWorkflowsByTrigger(type: PluginTriggerType) {
|
||||
return this.db
|
||||
.selectFrom('workflow')
|
||||
.selectAll()
|
||||
.where('triggerType', '=', type)
|
||||
.where('enabled', '=', true)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, PluginTriggerType.AssetCreate] })
|
||||
getWorkflowByOwnerAndTrigger(ownerId: string, type: PluginTriggerType) {
|
||||
return this.db
|
||||
.selectFrom('workflow')
|
||||
.selectAll()
|
||||
.where('ownerId', '=', ownerId)
|
||||
.where('triggerType', '=', type)
|
||||
.where('enabled', '=', true)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async createWorkflow(
|
||||
workflow: Insertable<WorkflowTable>,
|
||||
filters: Insertable<WorkflowFilterTable>[],
|
||||
actions: Insertable<WorkflowActionTable>[],
|
||||
) {
|
||||
return await this.db.transaction().execute(async (tx) => {
|
||||
const createdWorkflow = await tx.insertInto('workflow').values(workflow).returningAll().executeTakeFirstOrThrow();
|
||||
|
||||
if (filters.length > 0) {
|
||||
const newFilters = filters.map((filter) => ({
|
||||
...filter,
|
||||
workflowId: createdWorkflow.id,
|
||||
}));
|
||||
|
||||
await tx.insertInto('workflow_filter').values(newFilters).execute();
|
||||
}
|
||||
|
||||
if (actions.length > 0) {
|
||||
const newActions = actions.map((action) => ({
|
||||
...action,
|
||||
workflowId: createdWorkflow.id,
|
||||
}));
|
||||
await tx.insertInto('workflow_action').values(newActions).execute();
|
||||
}
|
||||
|
||||
return createdWorkflow;
|
||||
create(dto: Insertable<WorkflowTable>, steps?: WorkflowStepUpsert[]) {
|
||||
return this.db.transaction().execute(async (tx) => {
|
||||
const { id } = await tx.insertInto('workflow').values(dto).returning(['id']).executeTakeFirstOrThrow();
|
||||
return this.replaceAndReturn(tx, id, steps);
|
||||
});
|
||||
}
|
||||
|
||||
async updateWorkflow(
|
||||
id: string,
|
||||
workflow: Updateable<WorkflowTable>,
|
||||
filters: Insertable<WorkflowFilterTable>[] | undefined,
|
||||
actions: Insertable<WorkflowActionTable>[] | undefined,
|
||||
) {
|
||||
return await this.db.transaction().execute(async (trx) => {
|
||||
if (Object.keys(workflow).length > 0) {
|
||||
await trx.updateTable('workflow').set(workflow).where('id', '=', id).execute();
|
||||
update(id: string, dto: Updateable<WorkflowTable>, steps?: WorkflowStepUpsert[]) {
|
||||
return this.db.transaction().execute(async (tx) => {
|
||||
if (Object.values(dto).some((prop) => prop !== undefined)) {
|
||||
await tx.updateTable('workflow').set(dto).where('id', '=', id).executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
if (filters !== undefined) {
|
||||
await trx.deleteFrom('workflow_filter').where('workflowId', '=', id).execute();
|
||||
if (filters.length > 0) {
|
||||
const filtersWithWorkflowId = filters.map((filter) => ({
|
||||
...filter,
|
||||
workflowId: id,
|
||||
}));
|
||||
await trx.insertInto('workflow_filter').values(filtersWithWorkflowId).execute();
|
||||
}
|
||||
}
|
||||
|
||||
if (actions !== undefined) {
|
||||
await trx.deleteFrom('workflow_action').where('workflowId', '=', id).execute();
|
||||
if (actions.length > 0) {
|
||||
const actionsWithWorkflowId = actions.map((action) => ({
|
||||
...action,
|
||||
workflowId: id,
|
||||
}));
|
||||
await trx.insertInto('workflow_action').values(actionsWithWorkflowId).execute();
|
||||
}
|
||||
}
|
||||
|
||||
return await trx.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirstOrThrow();
|
||||
return this.replaceAndReturn(tx, id, steps);
|
||||
});
|
||||
}
|
||||
|
||||
private async replaceAndReturn(tx: Kysely<DB>, workflowId: string, steps?: WorkflowStepUpsert[]) {
|
||||
if (steps) {
|
||||
await tx.deleteFrom('workflow_step').where('workflowId', '=', workflowId).execute();
|
||||
if (steps.length > 0) {
|
||||
await tx
|
||||
.insertInto('workflow_step')
|
||||
.values(
|
||||
steps.map((step, i) => ({
|
||||
workflowId,
|
||||
enabled: step.enabled ?? true,
|
||||
pluginMethodId: step.pluginMethodId,
|
||||
config: step.config,
|
||||
order: i,
|
||||
})),
|
||||
)
|
||||
.returningAll()
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
return this.queryBuilder(tx).where('id', '=', workflowId).executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async deleteWorkflow(id: string) {
|
||||
async delete(id: string) {
|
||||
await this.db.deleteFrom('workflow').where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getFilters(workflowId: string) {
|
||||
getForAssetV1(assetId: string) {
|
||||
return this.db
|
||||
.selectFrom('workflow_filter')
|
||||
.selectAll()
|
||||
.where('workflowId', '=', workflowId)
|
||||
.orderBy('order', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async deleteFiltersByWorkflow(workflowId: string) {
|
||||
await this.db.deleteFrom('workflow_filter').where('workflowId', '=', workflowId).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getActions(workflowId: string) {
|
||||
return this.db
|
||||
.selectFrom('workflow_action')
|
||||
.selectAll()
|
||||
.where('workflowId', '=', workflowId)
|
||||
.orderBy('order', 'asc')
|
||||
.execute();
|
||||
.selectFrom('asset')
|
||||
.leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id')
|
||||
.select((eb) => [
|
||||
...columns.workflowAssetV1,
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom('asset_exif')
|
||||
.select([
|
||||
'asset_exif.make',
|
||||
'asset_exif.model',
|
||||
'asset_exif.orientation',
|
||||
'asset_exif.dateTimeOriginal',
|
||||
'asset_exif.modifyDate',
|
||||
'asset_exif.exifImageWidth',
|
||||
'asset_exif.exifImageHeight',
|
||||
'asset_exif.fileSizeInByte',
|
||||
'asset_exif.lensModel',
|
||||
'asset_exif.fNumber',
|
||||
'asset_exif.focalLength',
|
||||
'asset_exif.iso',
|
||||
'asset_exif.latitude',
|
||||
'asset_exif.longitude',
|
||||
'asset_exif.city',
|
||||
'asset_exif.state',
|
||||
'asset_exif.country',
|
||||
'asset_exif.description',
|
||||
'asset_exif.fps',
|
||||
'asset_exif.exposureTime',
|
||||
'asset_exif.livePhotoCID',
|
||||
'asset_exif.timeZone',
|
||||
'asset_exif.projectionType',
|
||||
'asset_exif.profileDescription',
|
||||
'asset_exif.colorspace',
|
||||
'asset_exif.bitsPerSample',
|
||||
'asset_exif.autoStackId',
|
||||
'asset_exif.rating',
|
||||
'asset_exif.tags',
|
||||
'asset_exif.updatedAt',
|
||||
])
|
||||
.whereRef('asset_exif.assetId', '=', 'asset.id'),
|
||||
).as('exifInfo'),
|
||||
])
|
||||
.where('id', '=', assetId)
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,8 @@ import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
|
||||
import { PartnerTable } from 'src/schema/tables/partner.table';
|
||||
import { PersonAuditTable } from 'src/schema/tables/person-audit.table';
|
||||
import { PersonTable } from 'src/schema/tables/person.table';
|
||||
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
|
||||
import { PluginMethodTable } from 'src/schema/tables/plugin-method.table';
|
||||
import { PluginTable } from 'src/schema/tables/plugin.table';
|
||||
import { SessionTable } from 'src/schema/tables/session.table';
|
||||
import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table';
|
||||
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||
@@ -82,7 +83,8 @@ import {
|
||||
VideoStreamSessionTable,
|
||||
VideoStreamVariantTable,
|
||||
} from 'src/schema/tables/video-stream.table';
|
||||
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
|
||||
import { WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
|
||||
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
|
||||
@Database({ name: 'immich' })
|
||||
@@ -143,11 +145,9 @@ export class ImmichDatabase {
|
||||
VideoStreamVariantTable,
|
||||
VideoStreamSegmentTable,
|
||||
PluginTable,
|
||||
PluginFilterTable,
|
||||
PluginActionTable,
|
||||
PluginMethodTable,
|
||||
WorkflowTable,
|
||||
WorkflowFilterTable,
|
||||
WorkflowActionTable,
|
||||
WorkflowStepTable,
|
||||
];
|
||||
|
||||
functions = [
|
||||
@@ -264,10 +264,8 @@ export interface DB {
|
||||
video_stream_segment: VideoStreamSegmentTable;
|
||||
|
||||
plugin: PluginTable;
|
||||
plugin_filter: PluginFilterTable;
|
||||
plugin_action: PluginActionTable;
|
||||
plugin_method: PluginMethodTable;
|
||||
|
||||
workflow: WorkflowTable;
|
||||
workflow_filter: WorkflowFilterTable;
|
||||
workflow_action: WorkflowActionTable;
|
||||
workflow_step: WorkflowStepTable;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "workflow" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "workflow" DROP COLUMN "updateId";`.execute(db);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
// take #2...
|
||||
await sql`DROP TABLE "workflow_action";`.execute(db);
|
||||
await sql`DROP TABLE "workflow_filter";`.execute(db);
|
||||
await sql`DROP TABLE "workflow";`.execute(db);
|
||||
await sql`DROP TABLE "plugin_action";`.execute(db);
|
||||
await sql`DROP TABLE "plugin_filter";`.execute(db);
|
||||
await sql`DROP TABLE "plugin";`.execute(db);
|
||||
|
||||
await sql`CREATE TABLE "plugin" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
"name" character varying NOT NULL,
|
||||
"version" character varying NOT NULL,
|
||||
"title" character varying NOT NULL,
|
||||
"description" character varying NOT NULL,
|
||||
"author" character varying NOT NULL,
|
||||
"wasmBytes" bytea NOT NULL,
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "plugin_name_version_uq" UNIQUE ("name", "version"),
|
||||
CONSTRAINT "plugin_name_uq" UNIQUE ("name"),
|
||||
CONSTRAINT "plugin_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "plugin_name_idx" ON "plugin" ("name");`.execute(db);
|
||||
await sql`CREATE TABLE "plugin_method" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"pluginId" uuid NOT NULL,
|
||||
"name" character varying NOT NULL,
|
||||
"title" character varying NOT NULL,
|
||||
"description" character varying NOT NULL,
|
||||
"types" character varying[] NOT NULL,
|
||||
"hostFunctions" boolean NOT NULL DEFAULT false,
|
||||
"uiHints" character varying[] NOT NULL DEFAULT '{}',
|
||||
"schema" jsonb,
|
||||
CONSTRAINT "plugin_method_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "plugin_method_pluginId_name_uq" UNIQUE ("pluginId", "name"),
|
||||
CONSTRAINT "plugin_method_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "plugin_method_pluginId_idx" ON "plugin_method" ("pluginId");`.execute(db);
|
||||
await sql`CREATE TABLE "workflow" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"ownerId" uuid NOT NULL,
|
||||
"trigger" character varying NOT NULL,
|
||||
"name" character varying,
|
||||
"description" character varying,
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
CONSTRAINT "workflow_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "workflow_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "workflow_ownerId_idx" ON "workflow" ("ownerId");`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "workflow_updatedAt"
|
||||
BEFORE UPDATE ON "workflow"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION updated_at();`.execute(db);
|
||||
await sql`CREATE TABLE "workflow_step" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
"workflowId" uuid NOT NULL,
|
||||
"pluginMethodId" uuid NOT NULL,
|
||||
"config" jsonb,
|
||||
"order" integer NOT NULL,
|
||||
CONSTRAINT "workflow_step_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "workflow_step_pluginMethodId_fkey" FOREIGN KEY ("pluginMethodId") REFERENCES "plugin_method" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "workflow_step_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "workflow_step_workflowId_idx" ON "workflow_step" ("workflowId");`.execute(db);
|
||||
await sql`CREATE INDEX "workflow_step_pluginMethodId_idx" ON "workflow_step" ("pluginMethodId");`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_workflow_updatedAt', '{"type":"trigger","name":"workflow_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"workflow_updatedAt\\"\\n BEFORE UPDATE ON \\"workflow\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_filter_supportedContexts_idx';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_action_supportedContexts_idx';`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// not supported
|
||||
}
|
||||
@@ -111,7 +111,7 @@ export class AssetExifTable {
|
||||
tags!: string[] | null;
|
||||
|
||||
@UpdateDateColumn({ default: () => 'clock_timestamp()' })
|
||||
updatedAt!: Generated<Date>;
|
||||
updatedAt!: Generated<Timestamp>;
|
||||
|
||||
@UpdateIdColumn({ index: true })
|
||||
updateId!: Generated<string>;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table, Unique } from '@immich/sql-tools';
|
||||
import { JsonSchemaDto } from 'src/dtos/json-schema.dto';
|
||||
import { WorkflowType } from 'src/enum';
|
||||
import { PluginTable } from 'src/schema/tables/plugin.table';
|
||||
|
||||
@Unique({ columns: ['pluginId', 'name'] })
|
||||
@Table('plugin_method')
|
||||
export class PluginMethodTable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
pluginId!: string;
|
||||
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
@Column()
|
||||
title!: string;
|
||||
|
||||
@Column()
|
||||
description!: string;
|
||||
|
||||
@Column({ type: 'character varying', array: true })
|
||||
types!: Generated<WorkflowType[]>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
hostFunctions!: Generated<boolean>;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
schema!: JsonSchemaDto | null;
|
||||
|
||||
@Column({ type: 'character varying', default: [], array: true })
|
||||
uiHints!: Generated<string[]>;
|
||||
}
|
||||
@@ -1,25 +1,29 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Timestamp,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from '@immich/sql-tools';
|
||||
import { PluginContext } from 'src/enum';
|
||||
import type { JSONSchema } from 'src/types/plugin-schema.types';
|
||||
|
||||
@Unique({ columns: ['name', 'version'] })
|
||||
@Table('plugin')
|
||||
export class PluginTable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: Generated<string>;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled!: Generated<boolean>;
|
||||
|
||||
@Column({ index: true, unique: true })
|
||||
name!: string;
|
||||
|
||||
@Column()
|
||||
version!: string;
|
||||
|
||||
@Column()
|
||||
title!: string;
|
||||
|
||||
@@ -29,11 +33,8 @@ export class PluginTable {
|
||||
@Column()
|
||||
author!: string;
|
||||
|
||||
@Column()
|
||||
version!: string;
|
||||
|
||||
@Column()
|
||||
wasmPath!: string;
|
||||
@Column({ type: 'bytea' })
|
||||
wasmBytes!: Buffer;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
@@ -41,55 +42,3 @@ export class PluginTable {
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
@Index({ columns: ['supportedContexts'], using: 'gin' })
|
||||
@Table('plugin_filter')
|
||||
export class PluginFilterTable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
@Column({ index: true })
|
||||
pluginId!: string;
|
||||
|
||||
@Column({ index: true, unique: true })
|
||||
methodName!: string;
|
||||
|
||||
@Column()
|
||||
title!: string;
|
||||
|
||||
@Column()
|
||||
description!: string;
|
||||
|
||||
@Column({ type: 'character varying', array: true })
|
||||
supportedContexts!: Generated<PluginContext[]>;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
schema!: JSONSchema | null;
|
||||
}
|
||||
|
||||
@Index({ columns: ['supportedContexts'], using: 'gin' })
|
||||
@Table('plugin_action')
|
||||
export class PluginActionTable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
@Column({ index: true })
|
||||
pluginId!: string;
|
||||
|
||||
@Column({ index: true, unique: true })
|
||||
methodName!: string;
|
||||
|
||||
@Column()
|
||||
title!: string;
|
||||
|
||||
@Column()
|
||||
description!: string;
|
||||
|
||||
@Column({ type: 'character varying', array: true })
|
||||
supportedContexts!: Generated<PluginContext[]>;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
schema!: JSONSchema | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { WorkflowStepConfig } from '@immich/plugin-sdk';
|
||||
import { Column, ForeignKeyColumn, PrimaryGeneratedColumn, Table } from '@immich/sql-tools';
|
||||
import { Generated } from 'kysely';
|
||||
import { PluginMethodTable } from 'src/schema/tables/plugin-method.table';
|
||||
import { WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
|
||||
@Table('workflow_step')
|
||||
export class WorkflowStepTable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: Generated<string>;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled!: boolean;
|
||||
|
||||
@ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
workflowId!: string;
|
||||
|
||||
@ForeignKeyColumn(() => PluginMethodTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
pluginMethodId!: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
config!: WorkflowStepConfig | null;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
order!: number;
|
||||
}
|
||||
@@ -3,17 +3,17 @@ import {
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from '@immich/sql-tools';
|
||||
import { PluginTriggerType } from 'src/enum';
|
||||
import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { WorkflowTrigger } from 'src/enum';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types';
|
||||
|
||||
@Table('workflow')
|
||||
@UpdatedAtTrigger('workflow_updatedAt')
|
||||
export class WorkflowTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
@@ -22,57 +22,23 @@ export class WorkflowTable {
|
||||
ownerId!: string;
|
||||
|
||||
@Column()
|
||||
triggerType!: PluginTriggerType;
|
||||
trigger!: WorkflowTrigger;
|
||||
|
||||
@Column({ nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@Column()
|
||||
description!: string;
|
||||
@Column({ nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Generated<Timestamp>;
|
||||
|
||||
@UpdateIdColumn()
|
||||
updateId!: Generated<string>;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled!: boolean;
|
||||
}
|
||||
|
||||
@Index({ columns: ['workflowId', 'order'] })
|
||||
@Index({ columns: ['pluginFilterId'] })
|
||||
@Table('workflow_filter')
|
||||
export class WorkflowFilterTable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
workflowId!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => PluginFilterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
pluginFilterId!: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
filterConfig!: FilterConfig | null;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
order!: number;
|
||||
}
|
||||
|
||||
@Index({ columns: ['workflowId', 'order'] })
|
||||
@Index({ columns: ['pluginActionId'] })
|
||||
@Table('workflow_action')
|
||||
export class WorkflowActionTable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
workflowId!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => PluginActionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
pluginActionId!: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
actionConfig!: ActionConfig | null;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
order!: number;
|
||||
enabled!: Generated<boolean>;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ import { ViewRepository } from 'src/repositories/view-repository';
|
||||
import { WebsocketRepository } from 'src/repositories/websocket.repository';
|
||||
import { WorkflowRepository } from 'src/repositories/workflow.repository';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { ClassConstructor } from 'src/types';
|
||||
import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access';
|
||||
import { getConfig, updateConfig } from 'src/utils/config';
|
||||
|
||||
@@ -187,6 +188,66 @@ export class BaseService {
|
||||
);
|
||||
}
|
||||
|
||||
static create<T extends BaseService>(Service: ClassConstructor<T>, ctx: BaseService) {
|
||||
const service = new Service(
|
||||
LoggingRepository.create(),
|
||||
ctx.accessRepository,
|
||||
ctx.activityRepository,
|
||||
ctx.albumRepository,
|
||||
ctx.albumUserRepository,
|
||||
ctx.apiKeyRepository,
|
||||
ctx.appRepository,
|
||||
ctx.assetRepository,
|
||||
ctx.assetEditRepository,
|
||||
ctx.assetJobRepository,
|
||||
ctx.configRepository,
|
||||
ctx.cronRepository,
|
||||
ctx.cryptoRepository,
|
||||
ctx.databaseRepository,
|
||||
ctx.downloadRepository,
|
||||
ctx.duplicateRepository,
|
||||
ctx.emailRepository,
|
||||
ctx.eventRepository,
|
||||
ctx.jobRepository,
|
||||
ctx.libraryRepository,
|
||||
ctx.machineLearningRepository,
|
||||
ctx.mapRepository,
|
||||
ctx.mediaRepository,
|
||||
ctx.memoryRepository,
|
||||
ctx.metadataRepository,
|
||||
ctx.moveRepository,
|
||||
ctx.notificationRepository,
|
||||
ctx.oauthRepository,
|
||||
ctx.ocrRepository,
|
||||
ctx.partnerRepository,
|
||||
ctx.personRepository,
|
||||
ctx.pluginRepository,
|
||||
ctx.processRepository,
|
||||
ctx.searchRepository,
|
||||
ctx.serverInfoRepository,
|
||||
ctx.sessionRepository,
|
||||
ctx.sharedLinkRepository,
|
||||
ctx.sharedLinkAssetRepository,
|
||||
ctx.stackRepository,
|
||||
ctx.storageRepository,
|
||||
ctx.syncRepository,
|
||||
ctx.syncCheckpointRepository,
|
||||
ctx.systemMetadataRepository,
|
||||
ctx.tagRepository,
|
||||
ctx.telemetryRepository,
|
||||
ctx.trashRepository,
|
||||
ctx.userRepository,
|
||||
ctx.versionRepository,
|
||||
ctx.viewRepository,
|
||||
ctx.websocketRepository,
|
||||
ctx.workflowRepository,
|
||||
);
|
||||
|
||||
service.logger.setContext(this.name);
|
||||
|
||||
return service as T;
|
||||
}
|
||||
|
||||
get worker() {
|
||||
return this.configRepository.getWorker();
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import { UserAdminService } from 'src/services/user-admin.service';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
import { ViewService } from 'src/services/view.service';
|
||||
import { WorkflowExecutionService } from 'src/services/workflow-execution.service';
|
||||
import { WorkflowService } from 'src/services/workflow.service';
|
||||
|
||||
export const services = [
|
||||
@@ -93,5 +94,6 @@ export const services = [
|
||||
UserService,
|
||||
VersionService,
|
||||
ViewService,
|
||||
WorkflowExecutionService,
|
||||
WorkflowService,
|
||||
];
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { CurrentPlugin } from '@extism/extism';
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { Updateable } from 'kysely';
|
||||
import { Permission } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { requireAccess } from 'src/utils/access';
|
||||
|
||||
/**
|
||||
* Plugin host functions that are exposed to WASM plugins via Extism.
|
||||
* These functions allow plugins to interact with the Immich system.
|
||||
*/
|
||||
export class PluginHostFunctions {
|
||||
constructor(
|
||||
private assetRepository: AssetRepository,
|
||||
private albumRepository: AlbumRepository,
|
||||
private accessRepository: AccessRepository,
|
||||
private cryptoRepository: CryptoRepository,
|
||||
private logger: LoggingRepository,
|
||||
private pluginJwtSecret: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates Extism host function bindings for the plugin.
|
||||
* These are the functions that WASM plugins can call.
|
||||
*/
|
||||
getHostFunctions() {
|
||||
return {
|
||||
'extism:host/user': {
|
||||
updateAsset: (cp: CurrentPlugin, offs: bigint) => this.handleUpdateAsset(cp, offs),
|
||||
addAssetToAlbum: (cp: CurrentPlugin, offs: bigint) => this.handleAddAssetToAlbum(cp, offs),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Host function wrapper for updateAsset.
|
||||
* Reads the input from the plugin, parses it, and calls the actual update function.
|
||||
*/
|
||||
private async handleUpdateAsset(cp: CurrentPlugin, offs: bigint) {
|
||||
const input = JSON.parse(cp.read(offs)!.text());
|
||||
await this.updateAsset(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Host function wrapper for addAssetToAlbum.
|
||||
* Reads the input from the plugin, parses it, and calls the actual add function.
|
||||
*/
|
||||
private async handleAddAssetToAlbum(cp: CurrentPlugin, offs: bigint) {
|
||||
const input = JSON.parse(cp.read(offs)!.text());
|
||||
await this.addAssetToAlbum(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the JWT token and returns the auth context.
|
||||
*/
|
||||
private validateToken(authToken: string): { userId: string } {
|
||||
try {
|
||||
const auth = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.pluginJwtSecret);
|
||||
if (!auth.userId) {
|
||||
throw new UnauthorizedException('Invalid token: missing userId');
|
||||
}
|
||||
return auth;
|
||||
} catch (error) {
|
||||
this.logger.error('Token validation failed:', error);
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an asset with the given properties.
|
||||
*/
|
||||
async updateAsset(input: { authToken: string } & Updateable<AssetTable> & { id: string }) {
|
||||
const { authToken, id, ...assetData } = input;
|
||||
|
||||
// Validate token
|
||||
const auth = this.validateToken(authToken);
|
||||
|
||||
// Check access to the asset
|
||||
await requireAccess(this.accessRepository, {
|
||||
auth: { user: { id: auth.userId } } as any,
|
||||
permission: Permission.AssetUpdate,
|
||||
ids: [id],
|
||||
});
|
||||
|
||||
this.logger.log(`Updating asset ${id} -- ${JSON.stringify(assetData)}`);
|
||||
await this.assetRepository.update({ id, ...assetData });
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an asset to an album.
|
||||
*/
|
||||
async addAssetToAlbum(input: { authToken: string; assetId: string; albumId: string }) {
|
||||
const { authToken, assetId, albumId } = input;
|
||||
|
||||
// Validate token
|
||||
const auth = this.validateToken(authToken);
|
||||
|
||||
// Check access to both the asset and the album
|
||||
await requireAccess(this.accessRepository, {
|
||||
auth: { user: { id: auth.userId } } as any,
|
||||
permission: Permission.AssetRead,
|
||||
ids: [assetId],
|
||||
});
|
||||
|
||||
await requireAccess(this.accessRepository, {
|
||||
auth: { user: { id: auth.userId } } as any,
|
||||
permission: Permission.AlbumUpdate,
|
||||
ids: [albumId],
|
||||
});
|
||||
|
||||
this.logger.log(`Adding asset ${assetId} to album ${albumId}`);
|
||||
await this.albumRepository.addAssetIds(albumId, [assetId]);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,313 +1,34 @@
|
||||
import { Plugin as ExtismPlugin, newPlugin } from '@extism/extism';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { join } from 'node:path';
|
||||
import { Asset, WorkflowAction, WorkflowFilter } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { PluginManifestDto, PluginManifestSchema } from 'src/dtos/plugin-manifest.dto';
|
||||
import { mapPlugin, PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
|
||||
import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum';
|
||||
import { pluginTriggers } from 'src/plugins';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import {
|
||||
mapMethod,
|
||||
mapPlugin,
|
||||
PluginMethodResponseDto,
|
||||
PluginMethodSearchDto,
|
||||
PluginResponseDto,
|
||||
PluginSearchDto,
|
||||
} from 'src/dtos/plugin.dto';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { PluginHostFunctions } from 'src/services/plugin-host.functions';
|
||||
import { IWorkflowJob, JobItem, JobOf, WorkflowData } from 'src/types';
|
||||
|
||||
interface WorkflowContext {
|
||||
authToken: string;
|
||||
asset: Asset;
|
||||
}
|
||||
|
||||
interface PluginInput<T = unknown> {
|
||||
authToken: string;
|
||||
config: T;
|
||||
data: {
|
||||
asset: Asset;
|
||||
};
|
||||
}
|
||||
import { isMethodCompatible } from 'src/utils/workflow';
|
||||
|
||||
@Injectable()
|
||||
export class PluginService extends BaseService {
|
||||
private pluginJwtSecret!: string;
|
||||
private loadedPlugins: Map<string, ExtismPlugin> = new Map();
|
||||
private hostFunctions!: PluginHostFunctions;
|
||||
|
||||
@OnEvent({ name: 'AppBootstrap' })
|
||||
async onBootstrap() {
|
||||
this.pluginJwtSecret = this.cryptoRepository.randomBytesAsText(32);
|
||||
|
||||
await this.loadPluginsFromManifests();
|
||||
|
||||
this.hostFunctions = new PluginHostFunctions(
|
||||
this.assetRepository,
|
||||
this.albumRepository,
|
||||
this.accessRepository,
|
||||
this.cryptoRepository,
|
||||
this.logger,
|
||||
this.pluginJwtSecret,
|
||||
);
|
||||
|
||||
await this.loadPlugins();
|
||||
}
|
||||
|
||||
getTriggers(): PluginTriggerResponseDto[] {
|
||||
return pluginTriggers;
|
||||
}
|
||||
|
||||
//
|
||||
// CRUD operations for plugins
|
||||
//
|
||||
async getAll(): Promise<PluginResponseDto[]> {
|
||||
const plugins = await this.pluginRepository.getAllPlugins();
|
||||
async search(dto: PluginSearchDto): Promise<PluginResponseDto[]> {
|
||||
const plugins = await this.pluginRepository.search(dto);
|
||||
return plugins.map((plugin) => mapPlugin(plugin));
|
||||
}
|
||||
|
||||
async get(id: string): Promise<PluginResponseDto> {
|
||||
const plugin = await this.pluginRepository.getPlugin(id);
|
||||
const plugin = await this.pluginRepository.get(id);
|
||||
if (!plugin) {
|
||||
throw new BadRequestException('Plugin not found');
|
||||
}
|
||||
return mapPlugin(plugin);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////
|
||||
// Plugin Loader
|
||||
//////////////////////////////////////////
|
||||
async loadPluginsFromManifests(): Promise<void> {
|
||||
// Load core plugin
|
||||
const { resourcePaths, plugins } = this.configRepository.getEnv();
|
||||
const coreManifestPath = `${resourcePaths.corePlugin}/manifest.json`;
|
||||
|
||||
const coreManifest = await this.readAndValidateManifest(coreManifestPath);
|
||||
await this.loadPluginToDatabase(coreManifest, resourcePaths.corePlugin);
|
||||
|
||||
this.logger.log(`Successfully processed core plugin: ${coreManifest.name} (version ${coreManifest.version})`);
|
||||
|
||||
// Load external plugins
|
||||
if (plugins.external.allow && plugins.external.installFolder) {
|
||||
await this.loadExternalPlugins(plugins.external.installFolder);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadExternalPlugins(installFolder: string): Promise<void> {
|
||||
try {
|
||||
const entries = await this.pluginRepository.readDirectory(installFolder);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pluginFolder = join(installFolder, entry.name);
|
||||
const manifestPath = join(pluginFolder, 'manifest.json');
|
||||
try {
|
||||
const manifest = await this.readAndValidateManifest(manifestPath);
|
||||
await this.loadPluginToDatabase(manifest, pluginFolder);
|
||||
|
||||
this.logger.log(`Successfully processed external plugin: ${manifest.name} (version ${manifest.version})`);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to load external plugin from ${manifestPath}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to scan external plugins folder ${installFolder}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPluginToDatabase(manifest: PluginManifestDto, basePath: string): Promise<void> {
|
||||
const currentPlugin = await this.pluginRepository.getPluginByName(manifest.name);
|
||||
if (currentPlugin != null && currentPlugin.version === manifest.version) {
|
||||
this.logger.log(`Plugin ${manifest.name} is up to date (version ${manifest.version}). Skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { plugin, filters, actions } = await this.pluginRepository.loadPlugin(manifest, basePath);
|
||||
|
||||
this.logger.log(`Upserted plugin: ${plugin.name} (ID: ${plugin.id}, version: ${plugin.version})`);
|
||||
|
||||
for (const filter of filters) {
|
||||
this.logger.log(`Upserted plugin filter: ${filter.methodName} (ID: ${filter.id})`);
|
||||
}
|
||||
|
||||
for (const action of actions) {
|
||||
this.logger.log(`Upserted plugin action: ${action.methodName} (ID: ${action.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
private async readAndValidateManifest(manifestPath: string): Promise<PluginManifestDto> {
|
||||
const content = await this.storageRepository.readTextFile(manifestPath);
|
||||
const manifestData = JSON.parse(content);
|
||||
return PluginManifestSchema.parse(manifestData);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////
|
||||
// Plugin Execution
|
||||
///////////////////////////////////////////
|
||||
private async loadPlugins() {
|
||||
const plugins = await this.pluginRepository.getAllPlugins();
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
this.logger.debug(`Loading plugin: ${plugin.name} from ${plugin.wasmPath}`);
|
||||
|
||||
const extismPlugin = await newPlugin(plugin.wasmPath, {
|
||||
useWasi: true,
|
||||
functions: this.hostFunctions.getHostFunctions(),
|
||||
});
|
||||
|
||||
this.loadedPlugins.set(plugin.id, extismPlugin);
|
||||
this.logger.log(`Successfully loaded plugin: ${plugin.name}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to load plugin ${plugin.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AssetCreate' })
|
||||
async handleAssetCreate({ asset }: ArgOf<'AssetCreate'>) {
|
||||
await this.handleTrigger(PluginTriggerType.AssetCreate, {
|
||||
ownerId: asset.ownerId,
|
||||
event: { userId: asset.ownerId, asset },
|
||||
});
|
||||
}
|
||||
|
||||
private async handleTrigger<T extends PluginTriggerType>(
|
||||
triggerType: T,
|
||||
params: { ownerId: string; event: WorkflowData[T] },
|
||||
): Promise<void> {
|
||||
const workflows = await this.workflowRepository.getWorkflowByOwnerAndTrigger(params.ownerId, triggerType);
|
||||
if (workflows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jobs: JobItem[] = workflows.map((workflow) => ({
|
||||
name: JobName.WorkflowRun,
|
||||
data: {
|
||||
id: workflow.id,
|
||||
type: triggerType,
|
||||
event: params.event,
|
||||
} as IWorkflowJob<T>,
|
||||
}));
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
this.logger.debug(`Queued ${jobs.length} workflow execution jobs for trigger ${triggerType}`);
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.WorkflowRun, queue: QueueName.Workflow })
|
||||
async handleWorkflowRun({ id: workflowId, type, event }: JobOf<JobName.WorkflowRun>): Promise<JobStatus> {
|
||||
try {
|
||||
const workflow = await this.workflowRepository.getWorkflow(workflowId);
|
||||
if (!workflow) {
|
||||
this.logger.error(`Workflow ${workflowId} not found`);
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
const workflowFilters = await this.workflowRepository.getFilters(workflowId);
|
||||
const workflowActions = await this.workflowRepository.getActions(workflowId);
|
||||
|
||||
switch (type) {
|
||||
case PluginTriggerType.AssetCreate: {
|
||||
const data = event as WorkflowData[PluginTriggerType.AssetCreate];
|
||||
const asset = data.asset;
|
||||
|
||||
const authToken = this.cryptoRepository.signJwt({ userId: data.userId }, this.pluginJwtSecret);
|
||||
|
||||
const context = {
|
||||
authToken,
|
||||
asset,
|
||||
};
|
||||
|
||||
const filtersPassed = await this.executeFilters(workflowFilters, context);
|
||||
if (!filtersPassed) {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
await this.executeActions(workflowActions, context);
|
||||
this.logger.debug(`Workflow ${workflowId} executed successfully`);
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
case PluginTriggerType.PersonRecognized: {
|
||||
this.logger.error('unimplemented');
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
default: {
|
||||
this.logger.error(`Unknown workflow trigger type: ${type}`);
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error executing workflow ${workflowId}:`, error);
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
private async executeFilters(workflowFilters: WorkflowFilter[], context: WorkflowContext): Promise<boolean> {
|
||||
for (const workflowFilter of workflowFilters) {
|
||||
const filter = await this.pluginRepository.getFilter(workflowFilter.pluginFilterId);
|
||||
if (!filter) {
|
||||
this.logger.error(`Filter ${workflowFilter.pluginFilterId} not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const pluginInstance = this.loadedPlugins.get(filter.pluginId);
|
||||
if (!pluginInstance) {
|
||||
this.logger.error(`Plugin ${filter.pluginId} not loaded`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const filterInput: PluginInput = {
|
||||
authToken: context.authToken,
|
||||
config: workflowFilter.filterConfig,
|
||||
data: {
|
||||
asset: context.asset,
|
||||
},
|
||||
};
|
||||
|
||||
this.logger.debug(`Calling filter ${filter.methodName} with input: ${JSON.stringify(filterInput)}`);
|
||||
|
||||
const filterResult = await pluginInstance.call(
|
||||
filter.methodName,
|
||||
new TextEncoder().encode(JSON.stringify(filterInput)),
|
||||
);
|
||||
|
||||
if (!filterResult) {
|
||||
this.logger.error(`Filter ${filter.methodName} returned null`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = JSON.parse(filterResult.text());
|
||||
if (result.passed === false) {
|
||||
this.logger.debug(`Filter ${filter.methodName} returned false, stopping workflow execution`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async executeActions(workflowActions: WorkflowAction[], context: WorkflowContext): Promise<void> {
|
||||
for (const workflowAction of workflowActions) {
|
||||
const action = await this.pluginRepository.getAction(workflowAction.pluginActionId);
|
||||
if (!action) {
|
||||
throw new Error(`Action ${workflowAction.pluginActionId} not found`);
|
||||
}
|
||||
|
||||
const pluginInstance = this.loadedPlugins.get(action.pluginId);
|
||||
if (!pluginInstance) {
|
||||
throw new Error(`Plugin ${action.pluginId} not loaded`);
|
||||
}
|
||||
|
||||
const actionInput: PluginInput = {
|
||||
authToken: context.authToken,
|
||||
config: workflowAction.actionConfig,
|
||||
data: {
|
||||
asset: context.asset,
|
||||
},
|
||||
};
|
||||
|
||||
this.logger.debug(`Calling action ${action.methodName} with input: ${JSON.stringify(actionInput)}`);
|
||||
|
||||
await pluginInstance.call(action.methodName, JSON.stringify(actionInput));
|
||||
}
|
||||
async searchMethods(dto: PluginMethodSearchDto): Promise<PluginMethodResponseDto[]> {
|
||||
const methods = await this.pluginRepository.searchMethods(dto);
|
||||
return methods
|
||||
.filter((method) => !dto.trigger || isMethodCompatible(method, dto.trigger))
|
||||
.map((method) => mapMethod(method));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import { CurrentPlugin } from '@extism/extism';
|
||||
import { WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk';
|
||||
import { HttpException, UnauthorizedException } from '@nestjs/common';
|
||||
import _ from 'lodash';
|
||||
import { join } from 'node:path';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AlbumsAddAssetsDto } from 'src/dtos/album.dto';
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
|
||||
import {
|
||||
BootstrapEventPriority,
|
||||
DatabaseLock,
|
||||
ImmichWorker,
|
||||
JobName,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
WorkflowTrigger,
|
||||
WorkflowType,
|
||||
} from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { AlbumService } from 'src/services/album.service';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobOf } from 'src/types';
|
||||
|
||||
const dummy = () => {
|
||||
throw new Error(
|
||||
`Calling host functions is not allowed without setting methods[].hostFunctions=true in the plugin manifest`,
|
||||
);
|
||||
};
|
||||
|
||||
type ExecuteOptions<T extends WorkflowType> = {
|
||||
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>;
|
||||
write: (changes: WorkflowChanges<T>) => Promise<void>;
|
||||
};
|
||||
|
||||
export class WorkflowExecutionService extends BaseService {
|
||||
private jwtSecret!: string;
|
||||
|
||||
@OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.PluginSync, workers: [ImmichWorker.Microservices] })
|
||||
async onPluginSync() {
|
||||
await this.databaseRepository.withLock(DatabaseLock.PluginImport, async () => {
|
||||
// TODO avoid importing plugins in each worker
|
||||
// Can this use system metadata similar to geocoding?
|
||||
|
||||
const { resourcePaths, plugins } = this.configRepository.getEnv();
|
||||
await this.importFolder(resourcePaths.corePlugin, { force: true });
|
||||
|
||||
if (plugins.external.allow && plugins.external.installFolder) {
|
||||
await this.importFolders(plugins.external.installFolder);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.PluginLoad, workers: [ImmichWorker.Microservices] })
|
||||
async onPluginLoad() {
|
||||
this.jwtSecret = this.cryptoRepository.randomBytesAsText(32);
|
||||
|
||||
const albumService = BaseService.create(AlbumService, this);
|
||||
|
||||
const albumAddAssets = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
|
||||
albumService.addAssets(authDto, ...args),
|
||||
);
|
||||
|
||||
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
|
||||
albumService.addAssetsToAlbums(authDto, ...args),
|
||||
);
|
||||
|
||||
const functions = {
|
||||
albumAddAssets,
|
||||
addAssetsToAlbums,
|
||||
};
|
||||
|
||||
const stubs = {
|
||||
albumAddAssets: dummy,
|
||||
addAssetsToAlbums: dummy,
|
||||
};
|
||||
|
||||
const plugins = await this.pluginRepository.getForLoad();
|
||||
for (const { id, name, version, wasmBytes, methods } of plugins) {
|
||||
const method = methods.some(({ hostFunctions }) => !hostFunctions);
|
||||
if (method) {
|
||||
const label = `${name}@${version}`;
|
||||
const key = this.getPluginKey({ id, hostFunctions: false });
|
||||
try {
|
||||
await this.pluginRepository.load({ key, label, wasmBytes }, { runInWorker: false, functions: stubs });
|
||||
this.logger.log(`Loaded plugin: ${label}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Unable to load plugin ${label} (${id})`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const methodWithFunction = methods.some(({ hostFunctions }) => hostFunctions);
|
||||
if (methodWithFunction) {
|
||||
const label = `${name}@${version}/worker`;
|
||||
const key = this.getPluginKey({ id, hostFunctions: true });
|
||||
try {
|
||||
await this.pluginRepository.load({ key, label, wasmBytes }, { runInWorker: true, functions });
|
||||
this.logger.log(`Loaded plugin with host functions: ${label}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Unable to load plugin with host functions ${label} (${id})`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getPluginKey({ id, hostFunctions }: { id: string; hostFunctions: boolean }) {
|
||||
return id + (hostFunctions ? '/worker' : '');
|
||||
}
|
||||
|
||||
private wrap<T>(fn: (authDto: AuthDto, args: T) => Promise<unknown>) {
|
||||
return async (plugin: CurrentPlugin, offset: bigint) => {
|
||||
try {
|
||||
const handle = plugin.read(offset);
|
||||
if (!handle) {
|
||||
return plugin.store(
|
||||
JSON.stringify({ success: false, status: 400, message: 'Called host function without input' }),
|
||||
);
|
||||
}
|
||||
|
||||
const { authToken, args } = handle.json() as { authToken: string; args: T };
|
||||
if (!authToken) {
|
||||
throw new Error('authToken is required');
|
||||
}
|
||||
|
||||
const authDto = this.validate(authToken);
|
||||
const response = await fn(authDto, args);
|
||||
|
||||
return plugin.store(JSON.stringify({ success: true, response }));
|
||||
} catch (error: Error | any) {
|
||||
if (error instanceof HttpException) {
|
||||
this.logger.error(`Plugin host exception: ${error}`);
|
||||
return plugin.store(
|
||||
JSON.stringify({ success: false, status: error.getStatus(), message: error.getResponse() }),
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.error(`Plugin host exception: ${error}`, error?.stack);
|
||||
|
||||
return plugin.store(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
status: 500,
|
||||
message: `Internal server error: ${error}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async importFolders(installFolder: string): Promise<void> {
|
||||
try {
|
||||
const entries = await this.storageRepository.readdirWithTypes(installFolder);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.importFolder(join(installFolder, entry.name));
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to import plugins folder ${installFolder}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async importFolder(folder: string, options?: { force?: boolean }) {
|
||||
try {
|
||||
const manifestPath = join(folder, 'manifest.json');
|
||||
const dto = await this.storageRepository.readJsonFile(manifestPath);
|
||||
const result = PluginManifestDto.schema.safeParse(dto);
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues.map((issue) => ` - [${issue.path.join('.')}] ${issue.message}`).join('\n');
|
||||
this.logger.warn(`Invalid plugin manifest at ${manifestPath}:\n${issues}`);
|
||||
return;
|
||||
}
|
||||
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(
|
||||
{
|
||||
enabled: true,
|
||||
name: manifest.name,
|
||||
title: manifest.title,
|
||||
description: manifest.description,
|
||||
author: manifest.author,
|
||||
version: manifest.version,
|
||||
wasmBytes,
|
||||
},
|
||||
manifest.methods,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
this.logger.log(
|
||||
`Upgraded plugin ${manifest.name} (${plugin.methods.length} methods) from ${existing.version} to ${manifest.version} `,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`Imported plugin ${manifest.name}@${manifest.version} (${plugin.methods.length} methods) from ${folder}`,
|
||||
);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
} catch {
|
||||
this.logger.warn(`Failed to import plugin from ${folder}:`);
|
||||
}
|
||||
}
|
||||
|
||||
private validate(authToken: string): AuthDto {
|
||||
try {
|
||||
const jwt = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.jwtSecret);
|
||||
if (!jwt.userId) {
|
||||
throw new UnauthorizedException('Invalid token: missing userId');
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: jwt.userId,
|
||||
},
|
||||
} as AuthDto;
|
||||
} catch (error) {
|
||||
this.logger.error('Token validation failed:', error);
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
private sign(userId: string) {
|
||||
return this.cryptoRepository.signJwt({ userId }, this.jwtSecret);
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AssetCreate' })
|
||||
async onAssetCreate({ asset }: ArgOf<'AssetCreate'>) {
|
||||
const dto = { ownerId: asset.ownerId, trigger: WorkflowTrigger.AssetCreate };
|
||||
const items = await this.workflowRepository.search(dto);
|
||||
await this.jobRepository.queueAll(
|
||||
items.map((workflow) => ({
|
||||
name: JobName.WorkflowAssetCreate,
|
||||
data: { workflowId: workflow.id, assetId: asset.id },
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.WorkflowAssetCreate, queue: QueueName.Workflow })
|
||||
handleAssetCreate({ workflowId, assetId }: JobOf<JobName.WorkflowAssetCreate>) {
|
||||
return this.execute(workflowId, (type) => {
|
||||
switch (type) {
|
||||
case WorkflowType.AssetV1: {
|
||||
return {
|
||||
read: async () => {
|
||||
const asset = await this.workflowRepository.getForAssetV1(assetId);
|
||||
return {
|
||||
data: { asset } as any,
|
||||
authUserId: asset.ownerId,
|
||||
};
|
||||
},
|
||||
write: async (changes) => {
|
||||
if (changes.asset) {
|
||||
await this.assetRepository.update({
|
||||
id: assetId,
|
||||
..._.omitBy(
|
||||
{
|
||||
isFavorite: changes.asset?.isFavorite,
|
||||
visibility: changes.asset?.visibility,
|
||||
},
|
||||
_.isUndefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
} satisfies ExecuteOptions<typeof type>;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async execute<T extends WorkflowType>(
|
||||
workflowId: string,
|
||||
getHandler: (type: T) => ExecuteOptions<T> | undefined,
|
||||
) {
|
||||
const workflow = await this.workflowRepository.getForWorkflowRun(workflowId);
|
||||
if (!workflow) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO infer from steps
|
||||
const type = 'AssetV1' as T;
|
||||
const handler = getHandler(type);
|
||||
if (!handler) {
|
||||
this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { read, write } = handler;
|
||||
const readResult = await read(type);
|
||||
let data = readResult.data;
|
||||
for (const step of workflow.steps) {
|
||||
const payload: WorkflowEventPayload = {
|
||||
trigger: workflow.trigger,
|
||||
type,
|
||||
config: step.config ?? {},
|
||||
workflow: {
|
||||
id: workflowId,
|
||||
authToken: this.sign(readResult.authUserId),
|
||||
stepId: step.id,
|
||||
},
|
||||
data,
|
||||
};
|
||||
|
||||
if (step.methodName.startsWith('noop')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await this.pluginRepository.callMethod<WorkflowResponse<T>>(
|
||||
{
|
||||
pluginKey: this.getPluginKey({ id: step.pluginId, hostFunctions: step.hostFunctions }),
|
||||
methodName: step.methodName,
|
||||
},
|
||||
payload,
|
||||
);
|
||||
if (result?.changes) {
|
||||
await write(result.changes);
|
||||
({ data } = await read(type));
|
||||
}
|
||||
|
||||
const shouldContinue = result?.workflow?.continue ?? true;
|
||||
if (!shouldContinue) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug(`Workflow ${workflowId} executed successfully`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error executing workflow ${workflowId}:`, error);
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,159 +1,114 @@
|
||||
import { WorkflowStepConfig } from '@immich/plugin-sdk';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Workflow } from 'src/database';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
mapWorkflowAction,
|
||||
mapWorkflowFilter,
|
||||
mapWorkflow,
|
||||
mapWorkflowShare,
|
||||
WorkflowCreateDto,
|
||||
WorkflowResponseDto,
|
||||
WorkflowSearchDto,
|
||||
WorkflowShareResponseDto,
|
||||
WorkflowTriggerResponseDto,
|
||||
WorkflowUpdateDto,
|
||||
} from 'src/dtos/workflow.dto';
|
||||
import { Permission, PluginContext, PluginTriggerType } from 'src/enum';
|
||||
import { pluginTriggers } from 'src/plugins';
|
||||
|
||||
import { Permission, WorkflowTrigger } from 'src/enum';
|
||||
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { getWorkflowTriggers, isMethodCompatible, resolveMethod } from 'src/utils/workflow';
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowService extends BaseService {
|
||||
async create(auth: AuthDto, dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
|
||||
const context = this.getContextForTrigger(dto.triggerType);
|
||||
|
||||
const filterInserts = await this.validateAndMapFilters(dto.filters, context);
|
||||
const actionInserts = await this.validateAndMapActions(dto.actions, context);
|
||||
|
||||
const workflow = await this.workflowRepository.createWorkflow(
|
||||
{
|
||||
ownerId: auth.user.id,
|
||||
triggerType: dto.triggerType,
|
||||
name: dto.name,
|
||||
description: dto.description || '',
|
||||
enabled: dto.enabled ?? true,
|
||||
},
|
||||
filterInserts,
|
||||
actionInserts,
|
||||
);
|
||||
|
||||
return this.mapWorkflow(workflow);
|
||||
getTriggers(): WorkflowTriggerResponseDto[] {
|
||||
return getWorkflowTriggers();
|
||||
}
|
||||
|
||||
async getAll(auth: AuthDto): Promise<WorkflowResponseDto[]> {
|
||||
const workflows = await this.workflowRepository.getWorkflowsByOwner(auth.user.id);
|
||||
|
||||
return Promise.all(workflows.map((workflow) => this.mapWorkflow(workflow)));
|
||||
async search(auth: AuthDto, dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> {
|
||||
const workflows = await this.workflowRepository.search({ ...dto, ownerId: auth.user.id });
|
||||
return workflows.map((workflow) => mapWorkflow(workflow));
|
||||
}
|
||||
|
||||
async get(auth: AuthDto, id: string): Promise<WorkflowResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.WorkflowRead, ids: [id] });
|
||||
const workflow = await this.findOrFail(id);
|
||||
return this.mapWorkflow(workflow);
|
||||
return mapWorkflow(workflow);
|
||||
}
|
||||
|
||||
async share(auth: AuthDto, id: string): Promise<WorkflowShareResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.WorkflowRead, ids: [id] });
|
||||
const workflow = await this.findOrFail(id);
|
||||
return mapWorkflowShare(workflow);
|
||||
}
|
||||
|
||||
async create(auth: AuthDto, dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
|
||||
const { steps: stepsDto, ...workflowDto } = dto;
|
||||
const steps = await this.resolveAndValidateSteps(stepsDto ?? [], workflowDto.trigger);
|
||||
|
||||
const workflow = await this.workflowRepository.create(
|
||||
{
|
||||
...workflowDto,
|
||||
ownerId: auth.user.id,
|
||||
},
|
||||
steps.map((step) => ({
|
||||
enabled: step.enabled ?? true,
|
||||
config: step.config as WorkflowStepConfig,
|
||||
pluginMethodId: step.pluginMethod.id,
|
||||
})),
|
||||
);
|
||||
|
||||
return mapWorkflow({ ...workflow, steps: [] });
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: WorkflowUpdateDto): Promise<WorkflowResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.WorkflowUpdate, ids: [id] });
|
||||
|
||||
if (Object.values(dto).filter((prop) => prop !== undefined).length === 0) {
|
||||
throw new BadRequestException('No fields to update');
|
||||
}
|
||||
|
||||
const workflow = await this.findOrFail(id);
|
||||
const context = this.getContextForTrigger(dto.triggerType ?? workflow.triggerType);
|
||||
|
||||
const { filters, actions, ...workflowUpdate } = dto;
|
||||
const filterInserts = filters && (await this.validateAndMapFilters(filters, context));
|
||||
const actionInserts = actions && (await this.validateAndMapActions(actions, context));
|
||||
|
||||
const updatedWorkflow = await this.workflowRepository.updateWorkflow(
|
||||
const { steps: stepsDto, ...workflowDto } = dto;
|
||||
const current = await this.findOrFail(id);
|
||||
const steps = stepsDto ? await this.resolveAndValidateSteps(stepsDto, dto.trigger ?? current.trigger) : undefined;
|
||||
const workflow = await this.workflowRepository.update(
|
||||
id,
|
||||
workflowUpdate,
|
||||
filterInserts,
|
||||
actionInserts,
|
||||
workflowDto,
|
||||
steps?.map((step) => ({
|
||||
enabled: step.enabled ?? true,
|
||||
config: step.config as WorkflowStepConfig,
|
||||
pluginMethodId: step.pluginMethod.id,
|
||||
})),
|
||||
);
|
||||
|
||||
return this.mapWorkflow(updatedWorkflow);
|
||||
return mapWorkflow(workflow);
|
||||
}
|
||||
|
||||
async delete(auth: AuthDto, id: string): Promise<void> {
|
||||
await this.requireAccess({ auth, permission: Permission.WorkflowDelete, ids: [id] });
|
||||
await this.workflowRepository.deleteWorkflow(id);
|
||||
await this.workflowRepository.delete(id);
|
||||
}
|
||||
|
||||
private async validateAndMapFilters(
|
||||
filters: Array<{ pluginFilterId: string; filterConfig?: any }>,
|
||||
requiredContext: PluginContext,
|
||||
) {
|
||||
for (const dto of filters) {
|
||||
const filter = await this.pluginRepository.getFilter(dto.pluginFilterId);
|
||||
if (!filter) {
|
||||
throw new BadRequestException(`Invalid filter ID: ${dto.pluginFilterId}`);
|
||||
private async resolveAndValidateSteps<T extends { method: string }>(steps: T[], trigger: WorkflowTrigger) {
|
||||
const methods = await this.pluginRepository.getForValidation();
|
||||
const results: Array<T & { pluginMethod: PluginMethodSearchResponse }> = [];
|
||||
|
||||
for (const step of steps) {
|
||||
const pluginMethod = resolveMethod(methods, step.method);
|
||||
if (!pluginMethod) {
|
||||
throw new BadRequestException(`Unknown method ${step.method}`);
|
||||
}
|
||||
|
||||
if (!filter.supportedContexts.includes(requiredContext)) {
|
||||
throw new BadRequestException(
|
||||
`Filter "${filter.title}" does not support ${requiredContext} context. Supported contexts: ${filter.supportedContexts.join(', ')}`,
|
||||
);
|
||||
if (!isMethodCompatible(pluginMethod, trigger)) {
|
||||
throw new BadRequestException(`Method "${step.method}" is incompatible with workflow trigger: "${trigger}"`);
|
||||
}
|
||||
|
||||
results.push({ ...step, pluginMethod });
|
||||
}
|
||||
|
||||
return filters.map((dto, index) => ({
|
||||
pluginFilterId: dto.pluginFilterId,
|
||||
filterConfig: dto.filterConfig || null,
|
||||
order: index,
|
||||
}));
|
||||
}
|
||||
// TODO make sure all steps can use a common WorkflowType
|
||||
|
||||
private async validateAndMapActions(
|
||||
actions: Array<{ pluginActionId: string; actionConfig?: any }>,
|
||||
requiredContext: PluginContext,
|
||||
) {
|
||||
for (const dto of actions) {
|
||||
const action = await this.pluginRepository.getAction(dto.pluginActionId);
|
||||
if (!action) {
|
||||
throw new BadRequestException(`Invalid action ID: ${dto.pluginActionId}`);
|
||||
}
|
||||
if (!action.supportedContexts.includes(requiredContext)) {
|
||||
throw new BadRequestException(
|
||||
`Action "${action.title}" does not support ${requiredContext} context. Supported contexts: ${action.supportedContexts.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return actions.map((dto, index) => ({
|
||||
pluginActionId: dto.pluginActionId,
|
||||
actionConfig: dto.actionConfig || null,
|
||||
order: index,
|
||||
}));
|
||||
}
|
||||
|
||||
private getContextForTrigger(type: PluginTriggerType) {
|
||||
const trigger = pluginTriggers.find((t) => t.type === type);
|
||||
if (!trigger) {
|
||||
throw new BadRequestException(`Invalid trigger type: ${type}`);
|
||||
}
|
||||
return trigger.contextType;
|
||||
return results;
|
||||
}
|
||||
|
||||
private async findOrFail(id: string) {
|
||||
const workflow = await this.workflowRepository.getWorkflow(id);
|
||||
const workflow = await this.workflowRepository.get(id);
|
||||
if (!workflow) {
|
||||
throw new BadRequestException('Workflow not found');
|
||||
}
|
||||
return workflow;
|
||||
}
|
||||
|
||||
private async mapWorkflow(workflow: Workflow): Promise<WorkflowResponseDto> {
|
||||
const filters = await this.workflowRepository.getFilters(workflow.id);
|
||||
const actions = await this.workflowRepository.getActions(workflow.id);
|
||||
|
||||
return {
|
||||
id: workflow.id,
|
||||
ownerId: workflow.ownerId,
|
||||
triggerType: workflow.triggerType,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
createdAt: workflow.createdAt.toISOString(),
|
||||
enabled: workflow.enabled,
|
||||
filters: filters.map((f) => mapWorkflowFilter(f)),
|
||||
actions: actions.map((a) => mapWorkflowAction(a)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+27
-19
@@ -1,7 +1,7 @@
|
||||
import { ShallowDehydrateObject } from 'kysely';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { VECTOR_EXTENSIONS } from 'src/constants';
|
||||
import { Asset, AssetFile } from 'src/database';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
ImageFormat,
|
||||
JobName,
|
||||
MemoryType,
|
||||
PluginTriggerType,
|
||||
QueueName,
|
||||
StorageFolder,
|
||||
SyncEntityType,
|
||||
@@ -30,10 +29,13 @@ import {
|
||||
TranscodeTarget,
|
||||
UserMetadataKey,
|
||||
VideoCodec,
|
||||
WorkflowTrigger,
|
||||
WorkflowType,
|
||||
} from 'src/enum';
|
||||
|
||||
export type DeepPartial<T> =
|
||||
T extends Record<string, unknown>
|
||||
export type DeepPartial<T> = T extends Date
|
||||
? T
|
||||
: T extends Record<string, unknown>
|
||||
? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: T extends Array<infer R>
|
||||
? DeepPartial<R>[]
|
||||
@@ -288,22 +290,11 @@ export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob {
|
||||
recipientId: string;
|
||||
}
|
||||
|
||||
export interface WorkflowData {
|
||||
[PluginTriggerType.AssetCreate]: {
|
||||
userId: string;
|
||||
asset: Asset;
|
||||
};
|
||||
[PluginTriggerType.PersonRecognized]: {
|
||||
personId: string;
|
||||
assetId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IWorkflowJob<T extends PluginTriggerType = PluginTriggerType> {
|
||||
export type IWorkflowJob<T extends WorkflowType = WorkflowType> = {
|
||||
id: string;
|
||||
trigger: WorkflowTrigger;
|
||||
type: T;
|
||||
event: WorkflowData[T];
|
||||
}
|
||||
};
|
||||
|
||||
export interface JobCounts {
|
||||
active: number;
|
||||
@@ -413,7 +404,7 @@ export type JobItem =
|
||||
| { name: JobName.Ocr; data: IEntityJob }
|
||||
|
||||
// Workflow
|
||||
| { name: JobName.WorkflowRun; data: IWorkflowJob }
|
||||
| { name: JobName.WorkflowAssetCreate; data: { workflowId: string; assetId: string } }
|
||||
|
||||
// Editor
|
||||
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
|
||||
@@ -574,3 +565,20 @@ export interface UserMetadata extends Record<UserMetadataKey, Record<string, any
|
||||
}
|
||||
|
||||
export type MaybeDehydrated<T> = T | ShallowDehydrateObject<T>;
|
||||
|
||||
export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object';
|
||||
|
||||
export type JSONSchemaProperty = {
|
||||
type: JSONSchemaType;
|
||||
description?: string;
|
||||
default?: any;
|
||||
enum?: string[];
|
||||
array?: boolean;
|
||||
properties?: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
|
||||
export interface ClassConstructor<T = any> extends Function {
|
||||
new (...args: any[]): T;
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* JSON Schema types for plugin configuration schemas
|
||||
* Based on JSON Schema Draft 7
|
||||
*/
|
||||
|
||||
import z from 'zod';
|
||||
|
||||
const JSONSchemaTypeSchema = z
|
||||
.enum(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null'])
|
||||
.meta({ id: 'PluginJsonSchemaType' });
|
||||
|
||||
const JSONSchemaPropertySchema = z
|
||||
.object({
|
||||
type: JSONSchemaTypeSchema.optional(),
|
||||
description: z.string().optional(),
|
||||
default: z.any().optional(),
|
||||
enum: z.array(z.string()).optional(),
|
||||
|
||||
get items() {
|
||||
return JSONSchemaPropertySchema.optional();
|
||||
},
|
||||
|
||||
get properties() {
|
||||
return z.record(z.string(), JSONSchemaPropertySchema).optional();
|
||||
},
|
||||
|
||||
required: z.array(z.string()).optional(),
|
||||
|
||||
get additionalProperties() {
|
||||
return z.union([z.boolean(), JSONSchemaPropertySchema]).optional();
|
||||
},
|
||||
})
|
||||
.meta({ id: 'PluginJsonSchemaProperty' });
|
||||
|
||||
export type JSONSchemaProperty = z.infer<typeof JSONSchemaPropertySchema>;
|
||||
|
||||
export const JSONSchemaSchema = z
|
||||
.object({
|
||||
type: JSONSchemaTypeSchema.optional(),
|
||||
properties: z.record(z.string(), JSONSchemaPropertySchema).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
additionalProperties: z.boolean().optional(),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
.meta({ id: 'PluginJsonSchema' });
|
||||
export type JSONSchema = z.infer<typeof JSONSchemaSchema>;
|
||||
|
||||
type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue };
|
||||
|
||||
const ConfigValueSchema: z.ZodType<ConfigValue> = z.any().meta({ id: 'PluginConfigValue' });
|
||||
|
||||
export const FilterConfigSchema = z.record(z.string(), ConfigValueSchema).meta({ id: 'WorkflowFilterConfig' });
|
||||
export type FilterConfig = z.infer<typeof FilterConfigSchema>;
|
||||
|
||||
export const ActionConfigSchema = z.record(z.string(), ConfigValueSchema).meta({ id: 'WorkflowActionConfig' });
|
||||
export type ActionConfig = z.infer<typeof ActionConfigSchema>;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { WorkflowTrigger, WorkflowType } from 'src/enum';
|
||||
import { isMethodCompatible } from 'src/utils/workflow';
|
||||
|
||||
const tests: Array<{ trigger: WorkflowTrigger; types: WorkflowType[]; expected: boolean }> = [
|
||||
{
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
types: [WorkflowType.AssetV1],
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
types: [WorkflowType.AssetPersonV1],
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
trigger: WorkflowTrigger.PersonRecognized,
|
||||
types: [WorkflowType.AssetPersonV1],
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
trigger: WorkflowTrigger.PersonRecognized,
|
||||
types: [WorkflowType.AssetV1],
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
trigger: WorkflowTrigger.PersonRecognized,
|
||||
types: [WorkflowType.AssetV1, WorkflowType.AssetPersonV1],
|
||||
expected: true,
|
||||
},
|
||||
];
|
||||
|
||||
describe(isMethodCompatible.name, () => {
|
||||
it.each(tests)('should return $expected for trigger $trigger with types $types', ({ trigger, types, expected }) => {
|
||||
expect(isMethodCompatible({ types }, trigger)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { WorkflowTrigger, WorkflowType } from 'src/enum';
|
||||
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
|
||||
|
||||
export const triggerMap: Record<WorkflowTrigger, WorkflowType[]> = {
|
||||
[WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1],
|
||||
[WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1],
|
||||
};
|
||||
|
||||
export const getWorkflowTriggers = () =>
|
||||
Object.entries(triggerMap).map(([trigger, types]) => ({ trigger: trigger as WorkflowTrigger, types }));
|
||||
|
||||
/** some types extend other types and have implied compatibility */
|
||||
const inferredMap: Record<WorkflowType, WorkflowType[]> = {
|
||||
[WorkflowType.AssetV1]: [],
|
||||
[WorkflowType.AssetPersonV1]: [WorkflowType.AssetV1],
|
||||
};
|
||||
|
||||
const withImpliedItems = (type: WorkflowType): WorkflowType[] => {
|
||||
const childTypes = inferredMap[type];
|
||||
const results = [type];
|
||||
for (const child of childTypes) {
|
||||
results.push(...withImpliedItems(child));
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const isMethodCompatible = (pluginMethod: { types: WorkflowType[] }, trigger: WorkflowTrigger) => {
|
||||
const validTypes = triggerMap[trigger];
|
||||
const pluginCompatibility = pluginMethod.types.map((type) => withImpliedItems(type));
|
||||
for (const requested of validTypes) {
|
||||
for (const pluginCompatibilityGroup of pluginCompatibility) {
|
||||
if (pluginCompatibilityGroup.includes(requested)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const resolveMethod = (methods: PluginMethodSearchResponse[], method: string) => {
|
||||
const result = parseMethodString(method);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { pluginName, methodName } = result;
|
||||
|
||||
return methods.find((method) => method.pluginName === pluginName && method.name === methodName);
|
||||
};
|
||||
|
||||
export const asMethodString = (method: { pluginName: string; methodName: string }) => {
|
||||
return `${method.pluginName}#${method.methodName}`;
|
||||
};
|
||||
|
||||
const METHOD_REGEX = /^(?<name>[^@#\s]+)(?:@(?<version>[^#\s]*))?#(?<method>[^@#\s]+)$/;
|
||||
export const parseMethodString = (method: string) => {
|
||||
const matches = METHOD_REGEX.exec(method);
|
||||
if (!matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginName = matches.groups?.name;
|
||||
const version = matches.groups?.version;
|
||||
const methodName = matches.groups?.method;
|
||||
return { pluginName, version, methodName };
|
||||
};
|
||||
Reference in New Issue
Block a user