From a939561e70f437dc7ab3ee2d8b859cc654d6cb3e Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 12 Aug 2026 16:26:03 -0700 Subject: [PATCH] feat: workflow asset tag trigger/filter/action (#29043) * feat: workflow asset tag trigger and filter * feat(web): show tag names in workflow editor * fix(web): tag picker in schema config editor * fix: invalid plugin manifest * chore: update workflow method wrapper type * chore: update tag filter method declaration * feat: workflow action to add tags to assets --- i18n/en.json | 2 + open-api/immich-openapi-specs.json | 3 +- packages/plugin-core/manifest.json | 50 +++++++++++++++++++ packages/plugin-core/src/index.ts | 27 ++++++++++ packages/plugin-sdk/src/host-functions.ts | 9 ++++ packages/plugin-sdk/src/types.ts | 9 +++- packages/sdk/src/fetch-client.ts | 3 +- server/src/repositories/event.repository.ts | 2 +- .../src/repositories/workflow.repository.ts | 2 + server/src/services/tag.service.ts | 4 +- .../services/workflow-execution.service.ts | 13 +++++ server/src/utils/workflow.ts | 1 + .../lib/components/SchemaConfiguration.svelte | 3 ++ web/src/lib/components/SchemaTagPicker.svelte | 32 ++++++++++++ web/src/lib/types.ts | 2 +- web/src/lib/utils/workflow.ts | 9 ++++ .../[workflowId]/WorkflowStepCard.svelte | 12 ++++- 17 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 web/src/lib/components/SchemaTagPicker.svelte diff --git a/i18n/en.json b/i18n/en.json index 536b7677ac..1022a6f851 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2139,6 +2139,8 @@ "trigger": "Trigger", "trigger_asset_metadata_extraction": "Asset Metadata Extraction", "trigger_asset_metadata_extraction_description": "Triggered when the EXIF metadata of an asset is extracted", + "trigger_asset_tagged": "Asset Tagged", + "trigger_asset_tagged_description": "Triggered when a tag is added to an asset", "trigger_asset_uploaded": "Asset Upload", "trigger_asset_uploaded_description": "Triggered when a new asset is uploaded", "trigger_person_recognized": "Person Recognized", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bc3bf82094..3596eaf4b1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -28066,7 +28066,8 @@ "description": "Plugin trigger type", "enum": [ "AssetCreate", - "AssetMetadataExtraction" + "AssetMetadataExtraction", + "AssetTagged" ], "type": "string" }, diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json index a72d60c04d..d7f7830a0e 100644 --- a/packages/plugin-core/manifest.json +++ b/packages/plugin-core/manifest.json @@ -308,6 +308,56 @@ }, "uiHints": ["Filter"] }, + { + "name": "assetAddTags", + "title": "Add Tags", + "description": "Add tags to an asset", + "types": ["AssetV1"], + "hostFunctions": true, + "schema": { + "type": "object", + "properties": { + "tags": { + "title": "Tags", + "description": "Tags to add to the asset", + "type": "string", + "array": true, + "uiHint": { + "type": "TagId" + } + } + }, + "required": ["tags"] + } + }, + { + "name": "assetTagFilter", + "title": "Filter by tags", + "description": "Filter assets by which tags they have", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "tags": { + "title": "Tags", + "description": "Which tags the asset must have", + "type": "string", + "array": true, + "uiHint": { + "type": "TagId" + } + }, + "matching": { + "title": "Matching", + "description": "Whether assets must have all, any, or none of the listed tags", + "type": "string", + "enum": ["all", "any", "none"] + } + }, + "required": ["tags", "matching"] + }, + "uiHints": ["Filter"] + }, { "name": "assetExifFilter", "title": "Filter by EXIF metadata", diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index 7b91ed7111..251393ace5 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -39,6 +39,11 @@ const matchValueResult = (value: string, config: MatchValueConfig) => { }; const methods = wrapper({ + assetAddTags: ({ config, data, functions }) => { + functions.bulkTagAssets({ assetIds: [data.asset.id], tagIds: config.tags }); + return {}; + }, + assetAddToAlbums: ({ config, data, functions }) => { const assetId = data.asset.id; @@ -176,6 +181,24 @@ const methods = wrapper({ return { workflow: { continue: hasTimeZone === needsTimeZone } }; }, + assetTagFilter: ({ config, data }) => { + const assetTags = data.asset.tags.map((tag) => tag.id); + + for (const tag of config.tags) { + if (assetTags.includes(tag)) { + if (config.matching === 'any') { + break; + } else if (config.matching === 'none') { + return { workflow: { continue: false } }; + } + } else if (config.matching === 'all') { + return { workflow: { continue: false } }; + } + } + + return { workflow: { continue: true } }; + }, + assetTypeFilter: ({ config, data }) => { return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } }; }, @@ -208,6 +231,7 @@ const methods = wrapper({ }); const { + assetAddTags, assetAddToAlbums, assetArchive, assetFavorite, @@ -217,6 +241,7 @@ const { assetDateFilter, assetLock, assetMissingTimeZoneFilter, + assetTagFilter, assetTypeFilter, assetVisibility, webhook, @@ -226,6 +251,7 @@ const { } = methods; export { + assetAddTags, assetAddToAlbums, assetArchive, assetFavorite, @@ -235,6 +261,7 @@ export { assetDateFilter, assetLock, assetMissingTimeZoneFilter, + assetTagFilter, assetTypeFilter, assetVisibility, webhook, diff --git a/packages/plugin-sdk/src/host-functions.ts b/packages/plugin-sdk/src/host-functions.ts index 9d52630438..e0b6e34da9 100644 --- a/packages/plugin-sdk/src/host-functions.ts +++ b/packages/plugin-sdk/src/host-functions.ts @@ -4,6 +4,8 @@ import { type BulkIdResponseDto, type BulkIdsDto, type CreateAlbumDto, + type TagBulkAssetsDto, + type TagBulkAssetsResponseDto, } from '@immich/sdk'; declare module 'extism:host' { @@ -47,6 +49,7 @@ export const availableFunctions = [ 'addAssetsToAlbum', 'addAssetsToAlbums', 'httpRequest', + 'bulkTagAssets', ] as const; export const hostFunctions = (authToken: string) => { @@ -96,5 +99,11 @@ export const hostFunctions = (authToken: string) => { authToken, [url, options], ), + bulkTagAssets: (dto: TagBulkAssetsDto) => + call<[TagBulkAssetsDto], TagBulkAssetsResponseDto>( + 'bulkTagAssets', + authToken, + [dto], + ), } satisfies Record<(typeof availableFunctions)[number], unknown>; }; diff --git a/packages/plugin-sdk/src/types.ts b/packages/plugin-sdk/src/types.ts index 9756d652c3..94c968978d 100644 --- a/packages/plugin-sdk/src/types.ts +++ b/packages/plugin-sdk/src/types.ts @@ -1,4 +1,9 @@ -import type { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk'; +import type { + AssetTypeEnum, + AssetVisibility, + TagResponseDto, + WorkflowType, +} from '@immich/sdk'; type DeepPartial = T extends Date ? T @@ -18,6 +23,7 @@ export type WorkflowEventData = WorkflowEventMap[T]; export enum WorkflowTrigger { AssetCreate = 'AssetCreate', AssetMetadataExtraction = 'AssetMetadataExtraction', + AssetTagged = 'AssetTagged', // PersonRecognized = 'PersonRecognized', } @@ -88,6 +94,7 @@ export type AssetV1 = { duplicateId: string | null; visibility: AssetVisibility; isEdited: boolean; + tags: TagResponseDto[]; exifInfo: { make: string | null; model: string | null; diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 3ac958ce2d..af24b2d21b 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -7423,7 +7423,8 @@ export enum WorkflowType { } export enum WorkflowTrigger { AssetCreate = "AssetCreate", - AssetMetadataExtraction = "AssetMetadataExtraction" + AssetMetadataExtraction = "AssetMetadataExtraction", + AssetTagged = "AssetTagged" } export enum QueueJobStatus { Active = "active", diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 416f823952..7fedc4eb3a 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -43,7 +43,7 @@ type EventMap = { // asset events AssetCreate: [{ asset: Pick; file?: UploadFile }]; - AssetTag: [{ assetId: string }]; + AssetTag: [{ assetId: string; userId: string }]; AssetUntag: [{ assetId: string }]; AssetHide: [{ assetId: string; userId: string }]; AssetShow: [{ assetId: string; userId: string }]; diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts index 9c946fe583..888ca94440 100644 --- a/server/src/repositories/workflow.repository.ts +++ b/server/src/repositories/workflow.repository.ts @@ -8,6 +8,7 @@ import { WorkflowSearchDto } from 'src/dtos/workflow.dto'; import { DB } from 'src/schema'; import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; import { WorkflowTable } from 'src/schema/tables/workflow.table'; +import { withTags } from 'src/utils/database'; export type WorkflowStepUpsert = Omit, 'workflowId' | 'order'>; @@ -143,6 +144,7 @@ export class WorkflowRepository { .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') .select((eb) => [ ...columns.workflowAssetV1, + withTags, jsonObjectFrom( eb .selectFrom('asset_exif') diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index 3cd442b6a7..08a9b00104 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -93,7 +93,7 @@ export class TagService extends BaseService { const results = await this.tagRepository.upsertAssetIds(items); for (const assetId of new Set(results.map((item) => item.assetId))) { await this.updateTags(assetId); - await this.eventRepository.emit('AssetTag', { assetId }); + await this.eventRepository.emit('AssetTag', { assetId, userId: auth.user.id }); } return { count: results.length }; @@ -114,7 +114,7 @@ export class TagService extends BaseService { } await this.updateTags(assetId); - await this.eventRepository.emit('AssetTag', { assetId }); + await this.eventRepository.emit('AssetTag', { assetId, userId: auth.user.id }); } return results; diff --git a/server/src/services/workflow-execution.service.ts b/server/src/services/workflow-execution.service.ts index 318f36a4b6..4995eada9c 100644 --- a/server/src/services/workflow-execution.service.ts +++ b/server/src/services/workflow-execution.service.ts @@ -13,6 +13,7 @@ import { AlbumsAddAssetsDto, CreateAlbumDto, GetAlbumsDto } from 'src/dtos/album 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 { TagBulkAssetsDto } from 'src/dtos/tag.dto'; import { BootstrapEventPriority, DatabaseLock, @@ -27,6 +28,7 @@ import { ArgOf } from 'src/repositories/event.repository'; import { AlbumService } from 'src/services/album.service'; import { AssetService } from 'src/services/asset.service'; import { BaseService } from 'src/services/base.service'; +import { TagService } from 'src/services/tag.service'; import { JobOf } from 'src/types'; const dummy = () => { @@ -69,6 +71,7 @@ export class WorkflowExecutionService extends BaseService { this.jwtSecret = this.cryptoRepository.randomBytesAsText(32); const albumService = BaseService.create(AlbumService, this); + const tagService = BaseService.create(TagService, this); const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, ctx, args) => albumService.getAll(authDto, ...args)); const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, ctx, args) => albumService.create(authDto, ...args)); @@ -106,6 +109,9 @@ export class WorkflowExecutionService extends BaseService { throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest'); }); + const bulkTagAssets = this.wrap<[dto: TagBulkAssetsDto]>((authDto, ctx, args) => + tagService.bulkTagAssets(authDto, ...args), + ); const functions = { searchAlbums, @@ -113,6 +119,7 @@ export class WorkflowExecutionService extends BaseService { addAssetsToAlbum, addAssetsToAlbums, httpRequest, + bulkTagAssets, }; const stubs: typeof functions = { @@ -121,6 +128,7 @@ export class WorkflowExecutionService extends BaseService { addAssetsToAlbum: dummy, addAssetsToAlbums: dummy, httpRequest: dummy, + bulkTagAssets: dummy, }; const plugins = await this.pluginRepository.getForLoad(); @@ -309,6 +317,11 @@ export class WorkflowExecutionService extends BaseService { return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetMetadataExtraction }); } + @OnEvent({ name: 'AssetTag' }) + onAssetTagged({ assetId, userId }: ArgOf<'AssetTag'>) { + return this.onAssetTrigger({ userId, assetId, trigger: WorkflowTrigger.AssetTagged }); + } + private async onAssetTrigger({ userId, assetId, trigger }: AssetTrigger) { const items = await this.workflowRepository.search({ userId, trigger }); await this.jobRepository.queueAll( diff --git a/server/src/utils/workflow.ts b/server/src/utils/workflow.ts index c892239c80..a3a9d56c42 100644 --- a/server/src/utils/workflow.ts +++ b/server/src/utils/workflow.ts @@ -6,6 +6,7 @@ export const triggerMap: Record = { [WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1], // [WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1], [WorkflowTrigger.AssetMetadataExtraction]: [WorkflowType.AssetV1], + [WorkflowTrigger.AssetTagged]: [WorkflowType.AssetV1], }; export const getWorkflowTriggers = () => diff --git a/web/src/lib/components/SchemaConfiguration.svelte b/web/src/lib/components/SchemaConfiguration.svelte index 65d115179e..c4e20081dd 100644 --- a/web/src/lib/components/SchemaConfiguration.svelte +++ b/web/src/lib/components/SchemaConfiguration.svelte @@ -1,6 +1,7 @@ + + { + if (option && !tagIds.includes(option.value)) { + tagIds.push(option.value); + } + }} + label={$t('tags')} + defaultFirstOption + options={tags.map((tag) => ({ label: tag.value, value: tag.id }))} + placeholder={$t('search_tags')} +/> + +
+ {#each tagIds as id, index (id)} + t.id === id)?.name ?? id} onRemove={() => tagIds.splice(index, 1)} /> + {/each} +
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 09d882dd7d..f2451b1bc5 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -100,7 +100,7 @@ export type JSONSchemaProperty = { properties?: Record; required?: string[]; uiHint?: { - type?: 'AlbumId' | 'AssetId' | 'PersonId'; + type?: 'AlbumId' | 'AssetId' | 'PersonId' | 'TagId'; order?: number; }; }; diff --git a/web/src/lib/utils/workflow.ts b/web/src/lib/utils/workflow.ts index b8dc44cc67..3a2100e3a4 100644 --- a/web/src/lib/utils/workflow.ts +++ b/web/src/lib/utils/workflow.ts @@ -13,6 +13,9 @@ export const getTriggerName = ($t: MessageFormatter, type: WorkflowTrigger) => { case WorkflowTrigger.AssetMetadataExtraction: { return $t('trigger_asset_metadata_extraction'); } + case WorkflowTrigger.AssetTagged: { + return $t('trigger_asset_tagged'); + } default: { return type; } @@ -30,6 +33,12 @@ export const getTriggerDescription = ($t: MessageFormatter, type: WorkflowTrigge case WorkflowTrigger.AssetMetadataExtraction: { return $t('trigger_asset_metadata_extraction_description'); } + case WorkflowTrigger.AssetTagged: { + return $t('trigger_asset_tagged_description'); + } + default: { + return type; + } } }; diff --git a/web/src/routes/(user)/workflows/[workflowId]/WorkflowStepCard.svelte b/web/src/routes/(user)/workflows/[workflowId]/WorkflowStepCard.svelte index d3dec41f48..2b8c0d7ffe 100644 --- a/web/src/routes/(user)/workflows/[workflowId]/WorkflowStepCard.svelte +++ b/web/src/routes/(user)/workflows/[workflowId]/WorkflowStepCard.svelte @@ -1,6 +1,6 @@