From c2ac10d65f722ec9a49a59dac2d381804764a7d3 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler Date: Fri, 26 Jun 2026 14:37:12 +0200 Subject: [PATCH] feat: ultimate plugin type safety --- packages/plugin-core/src/index.ts | 298 ++++++++++++++++-------------- packages/plugin-sdk/src/sdk.ts | 90 ++++----- 2 files changed, 208 insertions(+), 180 deletions(-) diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index cf438b583e70c..90083ab689c1e 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -1,175 +1,201 @@ -import { getWrapper } from '@immich/plugin-sdk'; +import { wrapper } from '@immich/plugin-sdk'; import { AssetVisibility } from '@immich/sdk'; import type { Manifest } from '../dist/index.d.ts'; -const wrapper = getWrapper(); +const methods = wrapper({ + assetAddToAlbums: ({ config, data, functions }) => { + const assetId = data.asset.id; -export const assetFileFilter = wrapper<'assetFileFilter'>(({ data, config }) => { - const { pattern, matchType = 'contains', caseSensitive = false } = config; + if (config.albumIds.length === 0) { + if (!config.albumName) { + return {}; + } - const { asset } = data; + const [existing] = functions.searchAlbums({ name: config.albumName }); + if (!existing) { + const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] }); + config.albumIds.push(created.id); + return {}; + } - const fileName = asset.originalFileName || ''; - const searchName = caseSensitive ? fileName : fileName.toLowerCase(); - const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); - - switch (matchType) { - case 'contains': { - return { workflow: { continue: searchName.includes(searchPattern) } }; + config.albumIds.push(existing.id); } - case 'exact': { - return { workflow: { continue: searchName === searchPattern } }; + if (config.albumIds.length === 1) { + functions.addAssetsToAlbum(config.albumIds[0], [assetId]); + return {}; } - case 'startsWith': { - return { workflow: { continue: searchName.startsWith(searchPattern) } }; - } + functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] }); + return {}; + }, - case 'regex': { - const flags = caseSensitive ? '' : 'i'; - const regex = new RegExp(searchPattern, flags); - return { workflow: { continue: regex.test(fileName) } }; + assetArchive: ({ config, data }) => { + if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) { + return { changes: { asset: { visibility: AssetVisibility.Archive } } }; } - default: { - return {}; + if (config.inverse && data.asset.visibility === AssetVisibility.Archive) { + return { changes: { asset: { visibility: AssetVisibility.Timeline } } }; } - } -}); - -export const assetMissingTimeZoneFilter = wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => { - const hasTimeZone = !!data.asset?.exifInfo?.timeZone; - const needsTimeZone = config.inverse ? true : false; - return { workflow: { continue: hasTimeZone === needsTimeZone } }; -}); -export const assetLocationFilter = wrapper<'assetLocationFilter'>(({ config, data }) => { - if ( - (config.region?.country && config.region.country !== data.asset.exifInfo?.country) || - (config.region?.state && config.region.state !== data.asset.exifInfo?.state) || - (config.region?.city && config.region.city !== data.asset.exifInfo?.city) - ) { - return { workflow: { continue: false } }; - } - - const configLat = Number.parseFloat(config.coordinate?.latitude ?? ''); - const configLon = Number.parseFloat(config.coordinate?.longitude ?? ''); - - if (Number.isNaN(configLat) || Number.isNaN(configLat)) { - return { workflow: { continue: true } }; - } - - const assetLat = data.asset.exifInfo?.latitude; - const assetLon = data.asset.exifInfo?.longitude; - - if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) { - return { workflow: { continue: false } }; - } - - const earthDiameter = 12742; - const deg = Math.PI / 180; - const delta = Math.asin( - Math.sqrt( - Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) + - Math.cos(assetLat * deg) * - Math.cos(configLat * deg) * - Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2), - ), - ); - - return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } }; -}); + return {}; + }, + + assetFavorite: ({ config, data }) => { + const target = config.inverse ? false : true; + if (target !== data.asset.isFavorite) { + return { + changes: { + asset: { isFavorite: target }, + }, + }; + } + }, -export const assetTypeFilter = wrapper<'assetTypeFilter'>(({ config, data }) => { - return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } }; -}); + assetFileFilter: ({ data, config }) => { + const { pattern, matchType = 'contains', caseSensitive = false } = config; -export const assetFavorite = wrapper<'assetFavorite'>(({ config, data }) => { - const target = config.inverse ? false : true; - if (target !== data.asset.isFavorite) { - return { - changes: { - asset: { isFavorite: target }, - }, - }; - } -}); + const { asset } = data; -export const assetVisibility = wrapper<'assetVisibility'>(({ config }) => ({ - changes: { asset: { visibility: config.visibility as AssetVisibility } }, -})); + const fileName = asset.originalFileName || ''; + const searchName = caseSensitive ? fileName : fileName.toLowerCase(); + const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); -export const assetArchive = wrapper<'assetArchive'>(({ config, data }) => { - if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) { - return { changes: { asset: { visibility: AssetVisibility.Archive } } }; - } + switch (matchType) { + case 'contains': { + return { workflow: { continue: searchName.includes(searchPattern) } }; + } - if (config.inverse && data.asset.visibility === AssetVisibility.Archive) { - return { changes: { asset: { visibility: AssetVisibility.Timeline } } }; - } + case 'exact': { + return { workflow: { continue: searchName === searchPattern } }; + } - return {}; -}); + case 'startsWith': { + return { workflow: { continue: searchName.startsWith(searchPattern) } }; + } -export const assetLock = wrapper<'assetLock'>(({ config, data }) => { - if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) { - return { changes: { asset: { visibility: AssetVisibility.Locked } } }; - } + case 'regex': { + const flags = caseSensitive ? '' : 'i'; + const regex = new RegExp(searchPattern, flags); + return { workflow: { continue: regex.test(fileName) } }; + } - if (config.inverse && data.asset.visibility === AssetVisibility.Locked) { - return { changes: { asset: { visibility: AssetVisibility.Timeline } } }; - } + default: { + return {}; + } + } + }, + + assetLocationFilter: ({ config, data }) => { + if ( + (config.region?.country && config.region.country !== data.asset.exifInfo?.country) || + (config.region?.state && config.region.state !== data.asset.exifInfo?.state) || + (config.region?.city && config.region.city !== data.asset.exifInfo?.city) + ) { + return { workflow: { continue: false } }; + } - return {}; -}); + const configLat = Number.parseFloat(config.coordinate?.latitude ?? ''); + const configLon = Number.parseFloat(config.coordinate?.longitude ?? ''); -// export const assetTrash = () => { -// // TODO use trash/untrash host functions -// return wrapper(() => ({})); -// }; + if (Number.isNaN(configLat) || Number.isNaN(configLat)) { + return { workflow: { continue: true } }; + } -export const assetAddToAlbums = wrapper<'assetAddToAlbums'>(({ config, data, functions }) => { - const assetId = data.asset.id; + const assetLat = data.asset.exifInfo?.latitude; + const assetLon = data.asset.exifInfo?.longitude; - if (config.albumIds.length === 0) { - if (!config.albumName) { - return {}; + if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) { + return { workflow: { continue: false } }; } - const [existing] = functions.searchAlbums({ name: config.albumName }); - if (!existing) { - const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] }); - config.albumIds.push(created.id); - return {}; + const earthDiameter = 12742; + const deg = Math.PI / 180; + const delta = Math.asin( + Math.sqrt( + Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) + + Math.cos(assetLat * deg) * + Math.cos(configLat * deg) * + Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2), + ), + ); + + return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } }; + }, + + assetLock: ({ config, data }) => { + if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) { + return { changes: { asset: { visibility: AssetVisibility.Locked } } }; } - config.albumIds.push(existing.id); - } + if (config.inverse && data.asset.visibility === AssetVisibility.Locked) { + return { changes: { asset: { visibility: AssetVisibility.Timeline } } }; + } - if (config.albumIds.length === 1) { - functions.addAssetsToAlbum(config.albumIds[0], [assetId]); return {}; - } + }, - functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] }); - return {}; -}); + assetMissingTimeZoneFilter: ({ config, data }) => { + const hasTimeZone = !!data.asset?.exifInfo?.timeZone; + const needsTimeZone = config.inverse ? true : false; + return { workflow: { continue: hasTimeZone === needsTimeZone } }; + }, + + assetTypeFilter: ({ config, data }) => { + return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } }; + }, -export const webhook = wrapper<'webhook'>(({ config, data, functions }) => { - const headers: Record = { - 'Content-Type': 'application/json', - }; + assetVisibility: ({ config }) => ({ + changes: { asset: { visibility: config.visibility as AssetVisibility } }, + }), - if (config.headerName && config.headerValue) { - headers[config.headerName] = config.headerValue; - } + webhook: ({ config, data, functions }) => { + const headers: Record = { + 'Content-Type': 'application/json', + }; - functions.httpRequest(config.url, { - method: config.method ?? 'POST', - body: JSON.stringify(data.asset), - headers, - }); + if (config.headerName && config.headerValue) { + headers[config.headerName] = config.headerValue; + } - return {}; + functions.httpRequest(config.url, { + method: config.method ?? 'POST', + body: JSON.stringify(data.asset), + headers, + }); + + return {}; + }, }); + +const { + assetAddToAlbums, + assetArchive, + assetFavorite, + assetFileFilter, + assetLocationFilter, + assetLock, + assetMissingTimeZoneFilter, + assetTypeFilter, + assetVisibility, + webhook, + + // should be empty. ensures that every field is destructured + ...rest +} = methods; + +export { + assetAddToAlbums, + assetArchive, + assetFavorite, + assetFileFilter, + assetLocationFilter, + assetLock, + assetMissingTimeZoneFilter, + assetTypeFilter, + assetVisibility, + webhook, +}; + +'All methods must be destructured and exported' satisfies string & typeof rest; diff --git a/packages/plugin-sdk/src/sdk.ts b/packages/plugin-sdk/src/sdk.ts index 8069c2cbece0e..1840cf10fb192 100644 --- a/packages/plugin-sdk/src/sdk.ts +++ b/packages/plugin-sdk/src/sdk.ts @@ -1,4 +1,3 @@ -import type { WorkflowType } from '@immich/sdk'; import { hostFunctions } from 'src/host-functions.js'; import type { WorkflowEventPayload, @@ -53,53 +52,56 @@ type ConfigValue< 'required' extends keyof T ? T['required'] : undefined >['properties']; -export const getWrapper = - >() => - < - K extends T['methods'][number]['name'], - L extends WorkflowType = (T['methods'][number] & { - name: K; - })['types'][number], - TConfig = ConfigValue<(T['methods'][number] & { name: K })['schema']>, - >( - fn: ( - payload: WorkflowEventPayload & { - functions: ReturnType; - }, - ) => WorkflowResponse | undefined, - ) => - () => { - const input = Host.inputString(); +export const wrapper = >(methods: { + [K in T['methods'][number] as K['name']]: ( + payload: WorkflowEventPayload< + K['types'][number], + ConfigValue + > & { + functions: ReturnType; + }, + ) => WorkflowResponse | undefined; +}) => { + const result: { [K in keyof typeof methods]: () => void } = {} as never; + for (const name of Object.keys(methods) as (keyof typeof methods)[]) { + result[name] = () => { + const input = Host.inputString(); - try { - const payload = JSON.parse(input) as WorkflowEventPayload; - const event = { - ...payload, - functions: hostFunctions(payload.workflow.authToken), - }; + try { + const payload = JSON.parse(input) as WorkflowEventPayload< + typeof name, + (T['methods'][number]['name'] & { name: typeof name })['schema'] + >; + const event = { + ...payload, + functions: hostFunctions(payload.workflow.authToken), + }; - const eventConfigBefore = JSON.stringify(event.config); + const eventConfigBefore = JSON.stringify(event.config); - console.debug( - `Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`, - ); + console.debug( + `Inputs: trigger=${event.trigger}, event=${String(event.type)}, config=${eventConfigBefore}`, + ); - const response = fn(event) ?? {}; + const response = methods[name](event) ?? {}; - // if config changed, notify host - const eventConfigAfter = JSON.stringify(event.config); - if (!response.config && eventConfigBefore !== eventConfigAfter) { - response.config = event.config as WorkflowStepConfig; - } + // if config changed, notify host + const eventConfigAfter = JSON.stringify(event.config); + if (!response.config && eventConfigBefore !== eventConfigAfter) { + response.config = event.config as WorkflowStepConfig; + } - console.debug( - `Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`, - ); + console.debug( + `Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`, + ); - const output = JSON.stringify(response); - Host.outputString(output); - } catch (error: Error | any) { - console.error(`Unhandled plugin exception: ${error.message || error}`); - throw error; - } - }; + const output = JSON.stringify(response); + Host.outputString(output); + } catch (error: Error | any) { + console.error(`Unhandled plugin exception: ${error.message || error}`); + throw error; + } + }; + } + return result; +};