mirror of
https://github.com/immich-app/immich.git
synced 2026-06-26 00:14:27 -07:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83dfe24c36 | |||
| 1ba7c15e37 | |||
| fa379f95e4 | |||
| 688241a462 | |||
| 28981a68f8 | |||
| cff8065e5f | |||
| 961ab7b150 | |||
| 1037fcc07e | |||
| db9dc73006 | |||
| c80303d4d5 | |||
| 11f61f23ba | |||
| 226fab849c | |||
| d39bd2e6cc | |||
| e4cf79263b |
@@ -28,7 +28,7 @@ while getopts 's:m:' flag; do
|
||||
done
|
||||
|
||||
CURRENT_SERVER=$(jq -r '.version' package.json)
|
||||
if ! NEXT_SERVER=$(pnpm --silent pump "$SERVER_PUMP"); then
|
||||
if ! NEXT_SERVER=$(pnpm --silent pump "$CURRENT_SERVER" "$SERVER_PUMP"); then
|
||||
echo "Fatal: failed to pump server version: $NEXT_SERVER" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -45,21 +45,25 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
|
||||
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix server
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/cli
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix web
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix e2e
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/sdk
|
||||
|
||||
# copy version to open-api spec
|
||||
mise run //:open-api
|
||||
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
||||
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
|
||||
|
||||
uv version --directory machine-learning "$NEXT_SERVER"
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix server
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/cli
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix web
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix e2e
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/sdk
|
||||
|
||||
./misc/release/archive-version.js "$NEXT_SERVER"
|
||||
# copy version to open-api spec
|
||||
mise run //:open-api
|
||||
|
||||
uv version --directory machine-learning "$NEXT_SERVER"
|
||||
|
||||
./misc/release/archive-version.js "$NEXT_SERVER"
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
|
||||
echo "Pumping Mobile: $CURRENT_MOBILE => $NEXT_MOBILE"
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
import { pump, PumpInvalidError, PumpUsageError } from './pump.js';
|
||||
import { pump } from './pump.js';
|
||||
|
||||
const [type] = process.argv.slice(2);
|
||||
const [versionRaw, type] = process.argv.slice(2);
|
||||
const { message, exitCode } = pump(versionRaw, type);
|
||||
|
||||
try {
|
||||
const nextVersion = pump(type);
|
||||
console.log(nextVersion);
|
||||
} catch (error) {
|
||||
if (error instanceof PumpUsageError) {
|
||||
console.log(
|
||||
'Usage: ./pump-wrapper.js <minor|patch|premajor|preminor|prepatch|prerelease|release>',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (error instanceof PumpInvalidError) {
|
||||
console.log(
|
||||
`Invalid pump: ${type}. Pumping from ${error.version} to ${error.newVersion} is not allowed.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
console.log(message);
|
||||
process.exit(exitCode);
|
||||
|
||||
+26
-77
@@ -1,74 +1,44 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import semver, { SemVer } from 'semver';
|
||||
|
||||
const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
|
||||
const Files = {
|
||||
PackageJson: join(PROJECT_ROOT, 'package.json'),
|
||||
ExampleEnv: join(PROJECT_ROOT, 'docker/example.env'),
|
||||
Docs: {
|
||||
Env: join(PROJECT_ROOT, 'docs/docs/install/environment-variables.md'),
|
||||
Upgrading: join(PROJECT_ROOT, 'docs/docs/install/upgrading.md'),
|
||||
},
|
||||
const printUsage = () => {
|
||||
return {
|
||||
message:
|
||||
'Usage: ./pump_cli.js <semver> <minor|patch|premajor|preminor|prepatch|prerelease|release>',
|
||||
exitCode: 1,
|
||||
};
|
||||
};
|
||||
|
||||
export class PumpUsageError extends Error {}
|
||||
export class PumpInvalidError extends Error {
|
||||
constructor(options) {
|
||||
super(`Invalid pump`);
|
||||
|
||||
this.version = options.version;
|
||||
this.newVersion = options.newVersion;
|
||||
}
|
||||
}
|
||||
const isPrerelease = (version) => version.prerelease.length > 0;
|
||||
|
||||
/**
|
||||
* @param {string} type
|
||||
* @param {SemVer} version
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const pump = (type) => {
|
||||
const currentVersionRaw = getCurrentVersion();
|
||||
const nextVersionRaw = getNextVersion(currentVersionRaw, type);
|
||||
const nextVersion = semver.parse(normalize(nextVersionRaw));
|
||||
const inc = (version, type) => `v${semver.inc(version, type, {}, 'rc')}`;
|
||||
|
||||
if (nextVersion && type === 'release') {
|
||||
const major = `v${nextVersion.major}`;
|
||||
|
||||
// sync major tag references in docs and example env file
|
||||
findAndReplace(
|
||||
Files.ExampleEnv,
|
||||
/^IMMICH_VERSION=v\d+$/m,
|
||||
`IMMICH_VERSION=${major}`,
|
||||
);
|
||||
findAndReplace(
|
||||
Files.Docs.Env,
|
||||
/(`IMMICH_VERSION`.*?)`v\d+`/,
|
||||
`$1\`${major}\``,
|
||||
);
|
||||
findAndReplace(Files.Docs.Upgrading, /:v\d+/, `:${major}`);
|
||||
/** @param {string} version */
|
||||
const normalize = (version) => {
|
||||
if (version.startsWith('v')) {
|
||||
version = version.slice(1);
|
||||
}
|
||||
|
||||
return nextVersionRaw;
|
||||
return version;
|
||||
};
|
||||
|
||||
const getCurrentVersion = () =>
|
||||
JSON.parse(readFileSync(Files.PackageJson, 'utf8')).version;
|
||||
|
||||
/**
|
||||
* @param {string} versionRaw
|
||||
* @param {string} type
|
||||
*/
|
||||
export const getNextVersion = (versionRaw, type) => {
|
||||
export const pump = (versionRaw, type) => {
|
||||
if (!versionRaw) {
|
||||
throw new PumpUsageError();
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
versionRaw = normalize(versionRaw);
|
||||
|
||||
const version = semver.parse(versionRaw);
|
||||
if (!version) {
|
||||
throw new PumpUsageError();
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
let newVersionRaw;
|
||||
@@ -102,19 +72,19 @@ export const getNextVersion = (versionRaw, type) => {
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new PumpUsageError();
|
||||
return printUsage();
|
||||
}
|
||||
}
|
||||
|
||||
if (!newVersionRaw) {
|
||||
throw new PumpUsageError();
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
newVersionRaw = normalize(newVersionRaw);
|
||||
|
||||
const newVersion = semver.parse(newVersionRaw);
|
||||
if (!newVersion) {
|
||||
throw new PumpUsageError();
|
||||
return printUsage();
|
||||
}
|
||||
|
||||
const invalidUpgrade =
|
||||
@@ -125,32 +95,11 @@ export const getNextVersion = (versionRaw, type) => {
|
||||
version.patch !== newVersion.patch);
|
||||
|
||||
if (!valid || invalidUpgrade) {
|
||||
throw new PumpInvalidError({
|
||||
type,
|
||||
version: versionRaw,
|
||||
newVersion: newVersionRaw,
|
||||
});
|
||||
return {
|
||||
message: `Invalid pump: ${type}. Pumping from ${versionRaw} to ${newVersionRaw} is not allowed.`,
|
||||
exitCode: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return newVersionRaw;
|
||||
};
|
||||
|
||||
const findAndReplace = (path, pattern, replacement) =>
|
||||
writeFileSync(path, readFileSync(path, 'utf8').replace(pattern, replacement));
|
||||
|
||||
const isPrerelease = (version) => version.prerelease.length > 0;
|
||||
|
||||
/**
|
||||
* @param {SemVer} version
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const inc = (version, type) => `v${semver.inc(version, type, {}, 'rc')}`;
|
||||
|
||||
/** @param {string} version */
|
||||
const normalize = (version) => {
|
||||
if (version.startsWith('v')) {
|
||||
version = version.slice(1);
|
||||
}
|
||||
|
||||
return version;
|
||||
return { message: newVersionRaw, exitCode: 0 };
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getNextVersion, PumpInvalidError, PumpUsageError } from './pump';
|
||||
import { pump } from './pump';
|
||||
|
||||
describe(getNextVersion.name, () => {
|
||||
describe(pump.name, () => {
|
||||
describe('usage', () => {
|
||||
it.each([
|
||||
[],
|
||||
@@ -10,7 +10,10 @@ describe(getNextVersion.name, () => {
|
||||
['invalid', 'patch'],
|
||||
['2.7.5', 'major'],
|
||||
])('should not accept $0, $1 as inputs', (version, type) => {
|
||||
expect(() => getNextVersion(version, type)).toThrow(PumpUsageError);
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: expect.stringContaining('Usage: '),
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +58,10 @@ describe(getNextVersion.name, () => {
|
||||
it.each(group.items)(
|
||||
'should allow a $0 from $1 to $2',
|
||||
(type, version, next) => {
|
||||
expect(getNextVersion(version, type)).toEqual(next);
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: next,
|
||||
exitCode: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -71,7 +77,10 @@ describe(getNextVersion.name, () => {
|
||||
['prerelease', 'v3.0.0'],
|
||||
['release', 'v3.0.0'],
|
||||
])('should not allow a $0 on $1', (type, version) => {
|
||||
expect(() => getNextVersion(version, type)).toThrow(PumpInvalidError);
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: expect.stringContaining('Invalid pump'),
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -300,6 +300,41 @@
|
||||
"required": ["albumIds"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "webhook",
|
||||
"title": "Trigger Webhook",
|
||||
"description": "POST/PUT event data to any URL",
|
||||
"types": ["AssetV1"],
|
||||
"hostFunctions": true,
|
||||
"allowedHosts": ["*"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"title": "URL",
|
||||
"description": "Event data will be PUT/POSTed to this URL as a JSON object"
|
||||
},
|
||||
"headerName": {
|
||||
"type": "string",
|
||||
"title": "Header name",
|
||||
"description": "The name of an additional header to include with the request (e.g. authentication)"
|
||||
},
|
||||
"headerValue": {
|
||||
"type": "string",
|
||||
"title": "Header value",
|
||||
"description": "The value of the additional header"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"title": "Method",
|
||||
"description": "The HTTP method to use in the request",
|
||||
"enum": ["POST", "PUT"]
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "noop1",
|
||||
"title": "DEV: Nested properties",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
Vendored
-27
@@ -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;
|
||||
}
|
||||
+143
-143
@@ -1,175 +1,175 @@
|
||||
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<manifestType>();
|
||||
const wrapper = getWrapper<Manifest>();
|
||||
|
||||
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<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
|
||||
// };
|
||||
|
||||
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 {};
|
||||
});
|
||||
|
||||
export const webhook = wrapper<'webhook'>(({ config, data, functions }) => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (config.headerName && config.headerValue) {
|
||||
headers[config.headerName] = config.headerValue;
|
||||
}
|
||||
|
||||
functions.httpRequest(config.url, {
|
||||
method: config.method ?? 'POST',
|
||||
body: JSON.stringify(data.asset),
|
||||
headers,
|
||||
});
|
||||
};
|
||||
|
||||
return {};
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"esModuleInterop": true, // Enables compatibility with Babel-style module imports
|
||||
"lib": ["es2020"], // Specify a list of library files to be included in the compilation
|
||||
"lib": ["es2020", "DOM"], // Specify a list of library files to be included in the compilation
|
||||
"module": "nodenext", // Specify module code generation
|
||||
"moduleResolution": "nodenext",
|
||||
"noEmit": true, // Do not emit outputs (no .js or .d.ts files)
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
import "./dist/cli.js";
|
||||
@@ -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();
|
||||
@@ -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 = {
|
||||
@@ -33,6 +30,24 @@ type HostFunctionResult<T> =
|
||||
|
||||
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
|
||||
type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
|
||||
type HttpRequestOptions = {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
};
|
||||
type HttpResponse = {
|
||||
ok: string;
|
||||
status: number;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export const availableFunctions = [
|
||||
'searchAlbums',
|
||||
'createAlbum',
|
||||
'addAssetsToAlbum',
|
||||
'addAssetsToAlbums',
|
||||
'httpRequest',
|
||||
] as const;
|
||||
|
||||
export const hostFunctions = (authToken: string) => {
|
||||
const host = Host.getFunctions();
|
||||
@@ -75,5 +90,11 @@ export const hostFunctions = (authToken: string) => {
|
||||
),
|
||||
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
|
||||
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
|
||||
};
|
||||
httpRequest: (url: string, options?: HttpRequestOptions) =>
|
||||
call<[string, HttpRequestOptions | undefined], HttpResponse>(
|
||||
'httpRequest',
|
||||
authToken,
|
||||
[url, options],
|
||||
),
|
||||
} satisfies Record<(typeof availableFunctions)[number], unknown>;
|
||||
};
|
||||
|
||||
@@ -67,7 +67,8 @@ export const getWrapper =
|
||||
functions: ReturnType<typeof hostFunctions>;
|
||||
},
|
||||
) => WorkflowResponse<L> | undefined,
|
||||
) => {
|
||||
) =>
|
||||
() => {
|
||||
const input = Host.inputString();
|
||||
|
||||
try {
|
||||
|
||||
Generated
+4
@@ -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
|
||||
|
||||
@@ -368,6 +368,7 @@ export const columns = {
|
||||
'plugin_method.types',
|
||||
'plugin_method.schema',
|
||||
'plugin_method.hostFunctions',
|
||||
'plugin_method.allowedHosts',
|
||||
'plugin_method.uiHints',
|
||||
],
|
||||
syncAsset: [
|
||||
|
||||
@@ -18,6 +18,11 @@ const PluginManifestMethodSchema = z
|
||||
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'),
|
||||
allowedHosts: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.default([])
|
||||
.describe('Hostnames the method can access (use * for wildcards)'),
|
||||
schema: PluginManifestMethodSchemaSchema.describe('Schema'),
|
||||
uiHints: z.array(z.string()).optional().describe('Ui hints, for example "filter"'),
|
||||
})
|
||||
|
||||
@@ -48,6 +48,7 @@ select
|
||||
"plugin_method"."types",
|
||||
"plugin_method"."schema",
|
||||
"plugin_method"."hostFunctions",
|
||||
"plugin_method"."allowedHosts",
|
||||
"plugin_method"."uiHints",
|
||||
"plugin"."name" as "pluginName"
|
||||
from
|
||||
@@ -84,6 +85,7 @@ select
|
||||
"plugin_method"."types",
|
||||
"plugin_method"."schema",
|
||||
"plugin_method"."hostFunctions",
|
||||
"plugin_method"."allowedHosts",
|
||||
"plugin_method"."uiHints",
|
||||
"plugin"."name" as "pluginName"
|
||||
from
|
||||
@@ -120,6 +122,7 @@ select
|
||||
"plugin_method"."types",
|
||||
"plugin_method"."schema",
|
||||
"plugin_method"."hostFunctions",
|
||||
"plugin_method"."allowedHosts",
|
||||
"plugin_method"."uiHints",
|
||||
"plugin"."name" as "pluginName"
|
||||
from
|
||||
@@ -156,6 +159,7 @@ select
|
||||
"plugin_method"."types",
|
||||
"plugin_method"."schema",
|
||||
"plugin_method"."hostFunctions",
|
||||
"plugin_method"."allowedHosts",
|
||||
"plugin_method"."uiHints",
|
||||
"plugin"."name" as "pluginName"
|
||||
from
|
||||
@@ -190,6 +194,7 @@ select
|
||||
"plugin_method"."types",
|
||||
"plugin_method"."schema",
|
||||
"plugin_method"."hostFunctions",
|
||||
"plugin_method"."allowedHosts",
|
||||
"plugin_method"."uiHints"
|
||||
from
|
||||
"plugin_method"
|
||||
|
||||
@@ -80,7 +80,8 @@ select
|
||||
"plugin_method"."pluginId" as "pluginId",
|
||||
"plugin_method"."name" as "methodName",
|
||||
"plugin_method"."types" as "types",
|
||||
"plugin_method"."hostFunctions"
|
||||
"plugin_method"."hostFunctions",
|
||||
"plugin_method"."allowedHosts"
|
||||
from
|
||||
"workflow_step"
|
||||
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
|
||||
|
||||
@@ -190,6 +190,7 @@ export class PluginRepository {
|
||||
description: ref('excluded.description'),
|
||||
types: ref('excluded.types'),
|
||||
hostFunctions: ref('excluded.hostFunctions'),
|
||||
allowedHosts: ref('excluded.allowedHosts'),
|
||||
uiHints: ref('excluded.uiHints'),
|
||||
schema: ref('excluded.schema'),
|
||||
})),
|
||||
@@ -240,7 +241,7 @@ export class PluginRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async callMethod<T>({ pluginKey, methodName }: PluginMethod, input: unknown) {
|
||||
async callMethod<T>({ pluginKey, methodName }: PluginMethod, input: unknown, context?: unknown) {
|
||||
const item = this.pluginMap.get(pluginKey);
|
||||
if (!item) {
|
||||
throw new Error(`No loaded plugin found for ${pluginKey}`);
|
||||
@@ -251,7 +252,7 @@ export class PluginRepository {
|
||||
try {
|
||||
const plugin = await pool.acquire();
|
||||
try {
|
||||
const result = await plugin.call(methodName, JSON.stringify(input));
|
||||
const result = await plugin.call(methodName, JSON.stringify(input), context);
|
||||
return (result ? result.json() : result) as T;
|
||||
} finally {
|
||||
await pool.release(plugin);
|
||||
|
||||
@@ -79,6 +79,7 @@ export class WorkflowRepository {
|
||||
'plugin_method.name as methodName',
|
||||
'plugin_method.types as types',
|
||||
'plugin_method.hostFunctions',
|
||||
'plugin_method.allowedHosts',
|
||||
]),
|
||||
).as('steps'),
|
||||
])
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "plugin_method" ADD "allowedHosts" character varying[] NOT NULL DEFAULT '{}';`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "plugin_method" DROP COLUMN "allowedHosts";`.execute(db);
|
||||
}
|
||||
@@ -27,6 +27,9 @@ export class PluginMethodTable {
|
||||
@Column({ type: 'boolean', default: false })
|
||||
hostFunctions!: Generated<boolean>;
|
||||
|
||||
@Column({ type: 'character varying', default: [], array: true })
|
||||
allowedHosts!: Generated<string[]>;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
schema!: JsonSchemaDto | null;
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ type ExecuteOptions<T extends WorkflowType> = {
|
||||
|
||||
type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger };
|
||||
|
||||
type HostContext = {
|
||||
allowedHosts: string[];
|
||||
};
|
||||
|
||||
export class WorkflowExecutionService extends BaseService {
|
||||
private jwtSecret!: string;
|
||||
|
||||
@@ -66,20 +70,48 @@ export class WorkflowExecutionService extends BaseService {
|
||||
|
||||
const albumService = BaseService.create(AlbumService, this);
|
||||
|
||||
const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, args) => albumService.getAll(authDto, ...args));
|
||||
const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, args) => albumService.create(authDto, ...args));
|
||||
const addAssetsToAlbum = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
|
||||
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));
|
||||
const addAssetsToAlbum = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, ctx, args) =>
|
||||
albumService.addAssets(authDto, ...args),
|
||||
);
|
||||
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
|
||||
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, ctx, args) =>
|
||||
albumService.addAssetsToAlbums(authDto, ...args),
|
||||
);
|
||||
const httpRequest = this.wrap<
|
||||
[
|
||||
url: string,
|
||||
options?: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
},
|
||||
]
|
||||
>(async (authDto, context, args) => {
|
||||
const hostname = new URL(args[0]).hostname;
|
||||
|
||||
for (const pattern of context.allowedHosts) {
|
||||
const regex = new RegExp(pattern.replaceAll('.', String.raw`\.`).replaceAll('*', '.*'));
|
||||
if (regex.test(hostname)) {
|
||||
const res = await fetch(...args);
|
||||
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
body: await res.text(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest');
|
||||
});
|
||||
|
||||
const functions = {
|
||||
searchAlbums,
|
||||
createAlbum,
|
||||
addAssetsToAlbum,
|
||||
addAssetsToAlbums,
|
||||
httpRequest,
|
||||
};
|
||||
|
||||
const stubs: typeof functions = {
|
||||
@@ -87,6 +119,7 @@ export class WorkflowExecutionService extends BaseService {
|
||||
createAlbum: dummy,
|
||||
addAssetsToAlbum: dummy,
|
||||
addAssetsToAlbums: dummy,
|
||||
httpRequest: dummy,
|
||||
};
|
||||
|
||||
const plugins = await this.pluginRepository.getForLoad();
|
||||
@@ -121,7 +154,7 @@ export class WorkflowExecutionService extends BaseService {
|
||||
return id + (hostFunctions ? '/worker' : '');
|
||||
}
|
||||
|
||||
private wrap<T>(fn: (authDto: AuthDto, args: T) => Promise<unknown>) {
|
||||
private wrap<T>(fn: (authDto: AuthDto, context: HostContext, args: T) => Promise<unknown>) {
|
||||
return async (plugin: CurrentPlugin, offset: bigint) => {
|
||||
try {
|
||||
const handle = plugin.read(offset);
|
||||
@@ -136,8 +169,9 @@ export class WorkflowExecutionService extends BaseService {
|
||||
throw new Error('authToken is required');
|
||||
}
|
||||
|
||||
const context = plugin.hostContext<HostContext>();
|
||||
const authDto = this.validate(authToken);
|
||||
const response = await fn(authDto, args);
|
||||
const response = await fn(authDto, context, args);
|
||||
|
||||
return plugin.store(JSON.stringify({ success: true, response }));
|
||||
} catch (error: Error | any) {
|
||||
@@ -381,6 +415,10 @@ export class WorkflowExecutionService extends BaseService {
|
||||
data,
|
||||
};
|
||||
|
||||
const context: HostContext = {
|
||||
allowedHosts: step.allowedHosts,
|
||||
};
|
||||
|
||||
if (step.methodName.startsWith('noop')) {
|
||||
continue;
|
||||
}
|
||||
@@ -391,6 +429,7 @@ export class WorkflowExecutionService extends BaseService {
|
||||
methodName: step.methodName,
|
||||
},
|
||||
payload,
|
||||
context,
|
||||
);
|
||||
if (result?.changes) {
|
||||
await write(
|
||||
|
||||
Reference in New Issue
Block a user