diff --git a/packages/plugin-core/package.json b/packages/plugin-core/package.json index baddf4a6eb..5e4812bb1c 100644 --- a/packages/plugin-core/package.json +++ b/packages/plugin-core/package.json @@ -5,8 +5,8 @@ "main": "src/index.ts", "scripts": { "build": "pnpm build:tsc && pnpm build:wasm", - "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" + "build:tsc": "plugin-sdk prepareBuild && tsc --noEmit && node esbuild.js", + "build:wasm": "extism-js dist/index.js -i dist/index.d.ts -o dist/plugin.wasm" }, "keywords": [], "author": "", diff --git a/packages/plugin-core/src/index.d.ts b/packages/plugin-core/src/index.d.ts deleted file mode 100644 index 636cb03047..0000000000 --- a/packages/plugin-core/src/index.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -// keep in sync with plugin-sdk/host-functions.ts'; -declare module 'extism:host' { - interface user { - searchAlbums(ptr: PTR): I64; - createAlbum(ptr: PTR): I64; - addAssetsToAlbum(ptr: PTR): I64; - addAssetsToAlbums(ptr: PTR): I64; - } -} - -// keep in sync with manifest.json -declare module 'main' { - // filters - export function assetFileFilter(): I32; - export function assetMissingTimeZoneFilter(): I32; - export function assetLocationFilter(): I32; - export function assetTypeFilter(): I32; - - // updates - export function assetFavorite(): I32; - export function assetVisibility(): I32; - export function assetArchive(): I32; - export function assetLock(): I32; - export function assetTimeline(): I32; - // export function assetTrash(): I32; - export function assetAddToAlbums(): I32; -} diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index 12eaab404b..164f33d72b 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -1,175 +1,157 @@ import { getWrapper } from '@immich/plugin-sdk'; import { AssetVisibility } from '@immich/sdk'; -import type manifestType from '../dist/manifest'; +import type { Manifest } from '../dist/index.d.ts'; -const wrapper = getWrapper(); +const wrapper = getWrapper(); -export const assetFileFilter = () => { - return wrapper<'assetFileFilter'>(({ data, config }) => { - const { pattern, matchType = 'contains', caseSensitive = false } = config; +export const assetFileFilter = wrapper<'assetFileFilter'>(({ data, config }) => { + const { pattern, matchType = 'contains', caseSensitive = false } = config; - const { asset } = data; + const { asset } = data; - const fileName = asset.originalFileName || ''; - const searchName = caseSensitive ? fileName : fileName.toLowerCase(); - const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); + 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) } }; - } - - case 'exact': { - return { workflow: { continue: searchName === searchPattern } }; - } - - case 'startsWith': { - return { workflow: { continue: searchName.startsWith(searchPattern) } }; - } - - case 'regex': { - const flags = caseSensitive ? '' : 'i'; - const regex = new RegExp(searchPattern, flags); - return { workflow: { continue: regex.test(fileName) } }; - } - - default: { - return {}; - } - } - }); -}; - -export const assetMissingTimeZoneFilter = () => { - return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => { - const hasTimeZone = !!data.asset?.exifInfo?.timeZone; - const needsTimeZone = config.inverse ? true : false; - return { workflow: { continue: hasTimeZone === needsTimeZone } }; - }); -}; - -export const assetLocationFilter = () => { - 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) || - (config.region?.city && config.region.city !== data.asset.exifInfo?.city) - ) { - return { workflow: { continue: false } }; + switch (matchType) { + case 'contains': { + return { workflow: { continue: searchName.includes(searchPattern) } }; } - 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 } }; + case 'exact': { + return { workflow: { continue: searchName === searchPattern } }; } - 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 } }; + case 'startsWith': { + return { workflow: { continue: searchName.startsWith(searchPattern) } }; } - 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) } }; - }); -}; - -export const assetTypeFilter = () => { - return wrapper<'assetTypeFilter'>(({ config, data }) => { - return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } }; - }); -}; - -export const assetFavorite = () => { - return wrapper<'assetFavorite'>(({ config, data }) => { - const target = config.inverse ? false : true; - if (target !== data.asset.isFavorite) { - return { - changes: { - asset: { isFavorite: target }, - }, - }; - } - }); -}; - -export const assetVisibility = () => { - return wrapper<'assetVisibility'>(({ config }) => ({ - changes: { asset: { visibility: config.visibility as AssetVisibility } }, - })); -}; - -export const assetArchive = () => { - return wrapper<'assetArchive'>(({ config, data }) => { - if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) { - return { changes: { asset: { visibility: AssetVisibility.Archive } } }; + 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.Archive) { - return { changes: { asset: { visibility: AssetVisibility.Timeline } } }; + default: { + return {}; } + } +}); - return {}; - }); -}; +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 assetLock = () => { - return wrapper<'assetLock'>(({ config, data }) => { - if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) { - return { changes: { asset: { visibility: AssetVisibility.Locked } } }; - } +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 } }; + } - if (config.inverse && data.asset.visibility === AssetVisibility.Locked) { - return { changes: { asset: { visibility: AssetVisibility.Timeline } } }; - } + const configLat = Number.parseFloat(config.coordinate?.latitude ?? ''); + const configLon = Number.parseFloat(config.coordinate?.longitude ?? ''); - return {}; - }); -}; + 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) } }; +}); + +export const assetTypeFilter = wrapper<'assetTypeFilter'>(({ config, data }) => { + return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } }; +}); + +export const assetFavorite = wrapper<'assetFavorite'>(({ config, data }) => { + const target = config.inverse ? false : true; + if (target !== data.asset.isFavorite) { + return { + changes: { + asset: { isFavorite: target }, + }, + }; + } +}); + +export const assetVisibility = wrapper<'assetVisibility'>(({ config }) => ({ + changes: { asset: { visibility: config.visibility as AssetVisibility } }, +})); + +export const assetArchive = wrapper<'assetArchive'>(({ config, data }) => { + if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) { + return { changes: { asset: { visibility: AssetVisibility.Archive } } }; + } + + if (config.inverse && data.asset.visibility === AssetVisibility.Archive) { + return { changes: { asset: { visibility: AssetVisibility.Timeline } } }; + } + + return {}; +}); + +export const assetLock = wrapper<'assetLock'>(({ config, data }) => { + if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) { + return { changes: { asset: { visibility: AssetVisibility.Locked } } }; + } + + if (config.inverse && data.asset.visibility === AssetVisibility.Locked) { + return { changes: { asset: { visibility: AssetVisibility.Timeline } } }; + } + + return {}; +}); // export const assetTrash = () => { // // TODO use trash/untrash host functions // return wrapper(() => ({})); // }; -export const assetAddToAlbums = () => { - return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => { - const assetId = data.asset.id; +export const assetAddToAlbums = wrapper<'assetAddToAlbums'>(({ config, data, functions }) => { + const assetId = data.asset.id; - if (config.albumIds.length === 0) { - if (!config.albumName) { - return {}; - } - - const [existing] = functions.searchAlbums({ name: config.albumName }); - if (!existing) { - const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] }); - config.albumIds.push(created.id); - return {}; - } - - config.albumIds.push(existing.id); - } - - if (config.albumIds.length === 1) { - functions.addAssetsToAlbum(config.albumIds[0], [assetId]); + if (config.albumIds.length === 0) { + if (!config.albumName) { return {}; } - functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] }); + const [existing] = functions.searchAlbums({ name: config.albumName }); + if (!existing) { + const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] }); + config.albumIds.push(created.id); + return {}; + } + + config.albumIds.push(existing.id); + } + + if (config.albumIds.length === 1) { + functions.addAssetsToAlbum(config.albumIds[0], [assetId]); return {}; - }); -}; + } + + functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] }); + return {}; +}); diff --git a/packages/plugin-core/tsconfig.json b/packages/plugin-core/tsconfig.json index 23ab692016..24aab4851c 100644 --- a/packages/plugin-core/tsconfig.json +++ b/packages/plugin-core/tsconfig.json @@ -13,7 +13,7 @@ "skipLibCheck": true, // Skip type checking of declaration files "strict": true, // Enable all strict type-checking options "target": "es2020", // Specify ECMAScript target version - "types": ["./src/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation + "types": ["./dist/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation }, "exclude": [ "node_modules" // Exclude the node_modules directory diff --git a/packages/plugin-sdk/esbuild.js b/packages/plugin-sdk/esbuild.js index d2e036e5c7..61590b6b41 100644 --- a/packages/plugin-sdk/esbuild.js +++ b/packages/plugin-sdk/esbuild.js @@ -1,11 +1,12 @@ import esbuild from 'esbuild'; esbuild.build({ - entryPoints: ['src/index.ts'], + entryPoints: ['src/index.ts', 'src/cli.ts'], outdir: 'dist', bundle: true, sourcemap: false, minify: false, format: 'esm', + platform: 'node', target: ['es2020'], }); diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 3d0c75b0b7..37fe066ea6 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -21,6 +21,9 @@ "files": [ "dist" ], + "bin": { + "plugin-sdk": "./plugin-sdk.mjs" + }, "keywords": [], "author": "", "license": "GNU Affero General Public License version 3", @@ -35,5 +38,8 @@ }, "peerDependencies": { "@extism/js-pdk": "^1.1.1" + }, + "dependencies": { + "commander": "^15.0.0" } } diff --git a/packages/plugin-sdk/plugin-sdk.mjs b/packages/plugin-sdk/plugin-sdk.mjs new file mode 100755 index 0000000000..f1d46c5b32 --- /dev/null +++ b/packages/plugin-sdk/plugin-sdk.mjs @@ -0,0 +1,2 @@ +#!/usr/bin/env node +import "./dist/cli.js"; diff --git a/packages/plugin-sdk/src/cli.ts b/packages/plugin-sdk/src/cli.ts new file mode 100644 index 0000000000..9035911134 --- /dev/null +++ b/packages/plugin-sdk/src/cli.ts @@ -0,0 +1,43 @@ +import { Command } from 'commander'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { availableFunctions } from 'src/host-functions.js'; + +const program = new Command('plugin-sdk'); + +program + .command('prepareBuild') + .description('Generate .d.ts file required for extism') + .argument( + '[manifest]', + "Path to the plugins's manifest file", + 'manifest.json', + ) + .option('-o --output', 'Output file for generated types', 'dist/index.d.ts') + .action((manifest: string, { output }) => { + const content = readFileSync(manifest, { encoding: 'utf-8' }); + const methods = ( + JSON.parse(content) as { methods: { name: string }[] } + ).methods.map(({ name }) => name); + mkdirSync(dirname(output), { recursive: true }); + + writeFileSync( + output, + ` +declare module 'extism:host' { + interface user { +${availableFunctions.map((functionName) => ` ${functionName}(ptr: PTR): I64;`).join('\n')} + } +} + +declare module 'main' { +${methods.map((method) => ` export function ${method}(): I32;`).join('\n')} +} + +export type Manifest = ${content}; + + `, + ); + }); + +program.parse(); diff --git a/packages/plugin-sdk/src/host-functions.ts b/packages/plugin-sdk/src/host-functions.ts index 281e27c83c..9bfe073a69 100644 --- a/packages/plugin-sdk/src/host-functions.ts +++ b/packages/plugin-sdk/src/host-functions.ts @@ -6,14 +6,11 @@ import { type CreateAlbumDto, } from '@immich/sdk'; -// keep in sync with plugin-core/src/index.d.ts'; declare module 'extism:host' { - interface user { - searchAlbums(ptr: PTR): I64; - createAlbum(ptr: PTR): I64; - addAssetsToAlbum(ptr: PTR): I64; - addAssetsToAlbums(ptr: PTR): I64; - } + interface user extends Record< + (typeof availableFunctions)[number], + (ptr: PTR) => I64 + > {} } type AlbumsToAssets = { @@ -34,6 +31,13 @@ type HostFunctionResult = type QueryParams any> = Parameters[0]; type AlbumSearchDto = QueryParams; +export const availableFunctions = [ + 'searchAlbums', + 'createAlbum', + 'addAssetsToAlbum', + 'addAssetsToAlbums', +] as const; + export const hostFunctions = (authToken: string) => { const host = Host.getFunctions(); type HostFunctionName = keyof typeof host; @@ -75,5 +79,5 @@ export const hostFunctions = (authToken: string) => { ), addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) => call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]), - }; + } satisfies Record<(typeof availableFunctions)[number], unknown>; }; diff --git a/packages/plugin-sdk/src/sdk.ts b/packages/plugin-sdk/src/sdk.ts index e428eafed5..8069c2cbec 100644 --- a/packages/plugin-sdk/src/sdk.ts +++ b/packages/plugin-sdk/src/sdk.ts @@ -67,7 +67,8 @@ export const getWrapper = functions: ReturnType; }, ) => WorkflowResponse | undefined, - ) => { + ) => + () => { const input = Host.inputString(); try { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15ad20d587..4022416c2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -336,6 +336,10 @@ importers: version: 6.0.3 packages/plugin-sdk: + dependencies: + commander: + specifier: ^15.0.0 + version: 15.0.0 devDependencies: '@extism/js-pdk': specifier: ^1.1.1