mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
feat: plugin wrapper type safety (#29300)
This commit is contained in:
parent
0931a19c5c
commit
9751530af8
6 changed files with 127 additions and 74 deletions
|
|
@ -222,7 +222,16 @@
|
|||
"name": "assetLock",
|
||||
"title": "Move to locked folder",
|
||||
"description": "Change visibility to locked",
|
||||
"types": ["AssetV1"]
|
||||
"types": ["AssetV1"],
|
||||
"schema": {
|
||||
"properties": {
|
||||
"inverse": {
|
||||
"title": "Inverse",
|
||||
"description": "When true will unarchive any archived assets",
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "assetTimeline",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "pnpm build:tsc && pnpm build:wasm",
|
||||
"build:tsc": "tsc --noEmit && node esbuild.js",
|
||||
"build:tsc": "mkdir -p dist && echo \"type Manifest = $(cat manifest.json); \nexport default Manifest;\" > dist/manifest.d.ts && tsc --noEmit && node esbuild.js",
|
||||
"build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
|
||||
},
|
||||
"keywords": [],
|
||||
|
|
|
|||
2
packages/plugin-core/src/index.d.ts
vendored
2
packages/plugin-core/src/index.d.ts
vendored
|
|
@ -22,6 +22,6 @@ declare module 'main' {
|
|||
export function assetArchive(): I32;
|
||||
export function assetLock(): I32;
|
||||
export function assetTimeline(): I32;
|
||||
export function assetTrash(): I32;
|
||||
// export function assetTrash(): I32;
|
||||
export function assetAddToAlbums(): I32;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import { wrapper } from '@immich/plugin-sdk';
|
||||
import { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk';
|
||||
import { getWrapper } from '@immich/plugin-sdk';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import type manifestType from '../dist/manifest';
|
||||
|
||||
const wrapper = getWrapper<manifestType>();
|
||||
|
||||
type AssetFileFilterConfig = {
|
||||
pattern: string;
|
||||
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith';
|
||||
caseSensitive?: boolean;
|
||||
};
|
||||
export const assetFileFilter = () => {
|
||||
return wrapper<WorkflowType.AssetV1, AssetFileFilterConfig>(({ data, config }) => {
|
||||
return wrapper<'assetFileFilter'>(({ data, config }) => {
|
||||
const { pattern, matchType = 'contains', caseSensitive = false } = config;
|
||||
|
||||
const { asset } = data;
|
||||
|
|
@ -43,7 +41,7 @@ export const assetFileFilter = () => {
|
|||
};
|
||||
|
||||
export const assetMissingTimeZoneFilter = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
|
||||
return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
|
||||
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
|
||||
const needsTimeZone = config.inverse ? true : false;
|
||||
return { workflow: { continue: hasTimeZone === needsTimeZone } };
|
||||
|
|
@ -51,13 +49,7 @@ export const assetMissingTimeZoneFilter = () => {
|
|||
};
|
||||
|
||||
export const assetLocationFilter = () => {
|
||||
return wrapper<
|
||||
WorkflowType.AssetV1,
|
||||
{
|
||||
region?: { country?: string; state?: string; city?: string };
|
||||
coordinate?: { latitude?: string; longitude?: string; radius?: number };
|
||||
}
|
||||
>(({ config, data }) => {
|
||||
return 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) ||
|
||||
|
|
@ -96,13 +88,13 @@ export const assetLocationFilter = () => {
|
|||
};
|
||||
|
||||
export const assetTypeFilter = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { allowedTypes: AssetTypeEnum[] }>(({ config, data }) => {
|
||||
return wrapper<'assetTypeFilter'>(({ config, data }) => {
|
||||
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
|
||||
});
|
||||
};
|
||||
|
||||
export const assetFavorite = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
|
||||
return wrapper<'assetFavorite'>(({ config, data }) => {
|
||||
const target = config.inverse ? false : true;
|
||||
if (target !== data.asset.isFavorite) {
|
||||
return {
|
||||
|
|
@ -115,13 +107,13 @@ export const assetFavorite = () => {
|
|||
};
|
||||
|
||||
export const assetVisibility = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { visibility: AssetVisibility }>(({ config }) => ({
|
||||
changes: { asset: { visibility: config.visibility } },
|
||||
return wrapper<'assetVisibility'>(({ config }) => ({
|
||||
changes: { asset: { visibility: config.visibility as AssetVisibility } },
|
||||
}));
|
||||
};
|
||||
|
||||
export const assetArchive = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
|
||||
return wrapper<'assetArchive'>(({ config, data }) => {
|
||||
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
|
||||
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
|
||||
}
|
||||
|
|
@ -135,7 +127,7 @@ export const assetArchive = () => {
|
|||
};
|
||||
|
||||
export const assetLock = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
|
||||
return wrapper<'assetLock'>(({ config, data }) => {
|
||||
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
|
||||
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
|
||||
}
|
||||
|
|
@ -148,13 +140,13 @@ export const assetLock = () => {
|
|||
});
|
||||
};
|
||||
|
||||
export const assetTrash = () => {
|
||||
// TODO use trash/untrash host functions
|
||||
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
|
||||
};
|
||||
// export const assetTrash = () => {
|
||||
// // TODO use trash/untrash host functions
|
||||
// return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
|
||||
// };
|
||||
|
||||
export const assetAddToAlbums = () => {
|
||||
return wrapper<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
|
||||
return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
|
||||
const assetId = data.asset.id;
|
||||
|
||||
if (config.albumIds.length === 0) {
|
||||
|
|
|
|||
|
|
@ -1,53 +1,104 @@
|
|||
import type { WorkflowType } from '@immich/sdk';
|
||||
import { hostFunctions } from 'src/host-functions.js';
|
||||
import type {
|
||||
ConfigValue,
|
||||
WorkflowEventPayload,
|
||||
WorkflowResponse,
|
||||
WorkflowStepConfig,
|
||||
} from 'src/types.js';
|
||||
|
||||
export const wrapper = <
|
||||
T extends WorkflowType,
|
||||
TConfig extends ConfigValue = ConfigValue,
|
||||
>(
|
||||
fn: (
|
||||
payload: WorkflowEventPayload<T, TConfig> & {
|
||||
functions: ReturnType<typeof hostFunctions>;
|
||||
},
|
||||
) => WorkflowResponse<T> | undefined,
|
||||
) => {
|
||||
const input = Host.inputString();
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
|
||||
const event = {
|
||||
...payload,
|
||||
functions: hostFunctions(payload.workflow.authToken),
|
||||
};
|
||||
|
||||
const eventConfigBefore = JSON.stringify(event.config);
|
||||
|
||||
console.debug(
|
||||
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
|
||||
);
|
||||
|
||||
const response = fn(event) ?? {};
|
||||
|
||||
// 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)}`,
|
||||
);
|
||||
|
||||
const output = JSON.stringify(response);
|
||||
Host.outputString(output);
|
||||
} catch (error: Error | any) {
|
||||
console.error(`Unhandled plugin exception: ${error.message || error}`);
|
||||
throw error;
|
||||
}
|
||||
type Property = {
|
||||
type: 'string' | 'boolean' | 'number';
|
||||
array?: boolean;
|
||||
enum?: string[];
|
||||
} & {
|
||||
type: 'object';
|
||||
properties: { [K: string]: Property };
|
||||
required?: string[];
|
||||
};
|
||||
|
||||
type RequiredProperties<
|
||||
Properties extends { [K: string]: unknown },
|
||||
Required extends string[] | undefined,
|
||||
RequiredKeys extends string = Required extends undefined
|
||||
? never
|
||||
: NonNullable<Required>[number],
|
||||
> = {
|
||||
properties: Pick<Properties, RequiredKeys> &
|
||||
Partial<Omit<Properties, RequiredKeys>>;
|
||||
};
|
||||
|
||||
type GetConfigType<T extends Property> = 'enum' extends keyof T
|
||||
? NonNullable<T['enum']>[number]
|
||||
: T['type'] extends 'boolean'
|
||||
? boolean
|
||||
: T['type'] extends 'number'
|
||||
? number
|
||||
: T['type'] extends 'string'
|
||||
? string
|
||||
: T['type'] extends 'object'
|
||||
? ConfigValue<T>
|
||||
: never;
|
||||
|
||||
type ConfigValue<
|
||||
T extends { properties: { [K: string]: Property }; required?: string[] },
|
||||
Properties extends { [K: string]: Property } = T['properties'],
|
||||
> = T extends never
|
||||
? never
|
||||
: RequiredProperties<
|
||||
{
|
||||
[K in keyof Properties]: Properties[K]['array'] extends true
|
||||
? Array<GetConfigType<Properties[K]>>
|
||||
: GetConfigType<Properties[K]>;
|
||||
},
|
||||
'required' extends keyof T ? T['required'] : undefined
|
||||
>['properties'];
|
||||
|
||||
export const getWrapper =
|
||||
<T extends Record<string, any>>() =>
|
||||
<
|
||||
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<L, TConfig> & {
|
||||
functions: ReturnType<typeof hostFunctions>;
|
||||
},
|
||||
) => WorkflowResponse<L> | undefined,
|
||||
) => {
|
||||
const input = Host.inputString();
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(input) as WorkflowEventPayload<K, TConfig>;
|
||||
const event = {
|
||||
...payload,
|
||||
functions: hostFunctions(payload.workflow.authToken),
|
||||
};
|
||||
|
||||
const eventConfigBefore = JSON.stringify(event.config);
|
||||
|
||||
console.debug(
|
||||
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
|
||||
);
|
||||
|
||||
const response = fn(event) ?? {};
|
||||
|
||||
// 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)}`,
|
||||
);
|
||||
|
||||
const output = JSON.stringify(response);
|
||||
Host.outputString(output);
|
||||
} catch (error: Error | any) {
|
||||
console.error(`Unhandled plugin exception: ${error.message || error}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue