Compare commits

..

8 Commits

Author SHA1 Message Date
Daniel Dietzler fd4d9c6897 feat: plugin wrapper type safety 2026-06-23 22:39:11 +02:00
Mees Frensel 06f3b4f259 refactor(web): simple actions (#29257) 2026-06-23 17:08:46 +02:00
Ben Beckford 99f94a363d chore(web): workflow property ordering (#29261)
* chore(web): workflow property ordering

* chore(web): extract schema property sorting to method
2026-06-23 13:03:33 +00:00
Daniel Dietzler c3092b1c2c chore: basque was missing on mobile (#29284) 2026-06-23 08:57:05 -04:00
renovate[bot] 0656e7e231 chore(deps): update dependency typescript to v6 (#28772)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-06-23 14:01:09 +02:00
renovate[bot] 1692b81b7c chore(deps): lock file maintenance (pub) (#28733) 2026-06-23 12:43:35 +02:00
renovate[bot] ff2028c4c8 chore(deps): update prom/prometheus docker digest to a75c5a3 (#29271) 2026-06-23 12:43:16 +02:00
Timon f22836e1bf refactor(server): describe check upload id as string (#29274) 2026-06-23 12:42:42 +02:00
28 changed files with 255 additions and 270 deletions
+1 -1
View File
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:69f5241418838263316593f7274a304b095c40bcf22e57272865da91bd60a8ac
image: prom/prometheus@sha256:a75c5a35bc21d7afe69551eefa3cb1e1fb1775fe759408007a66b54ec3de1f29
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
+1
View File
@@ -5,6 +5,7 @@ const Map<String, Locale> locales = {
'English (en)': Locale('en'),
// Additional locales
'Arabic (ar)': Locale('ar'),
'Basque (eu)': Locale('eu'),
'Bosnian (bl)': Locale('bn'),
'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'),
'Bulgarian (bg)': Locale('bg'),
@@ -280,7 +280,8 @@ class SyncStreamService {
return;
// SyncCompleteV1 is used to signal the completion of the sync process. Cleanup stale assets and signal completion
case SyncEntityType.syncCompleteV1:
return _syncStreamRepository.pruneAssets();
return;
// return _syncStreamRepository.pruneAssets();
// Request to reset the client state. Clear everything related to remote entities
case SyncEntityType.syncResetV1:
return _syncStreamRepository.reset();
@@ -31,7 +31,7 @@ class AssetBulkUploadCheckResult {
///
Optional<String?> assetId;
/// Asset ID
/// Client-side identifier echoed from the request to match results to inputs
String id;
/// Whether existing asset is trashed
+34 -34
View File
@@ -69,10 +69,10 @@ packages:
dependency: "direct main"
description:
name: background_downloader
sha256: "4cb23d9ad4f5060944f38164e7b90d4bf99b57b2472a3bd4676e59b2db4afd06"
sha256: aceacec2b2a72ec3a8862ab5895fcbbc71ab33765f3619d57963f3110dd268e3
url: "https://pub.dev"
source: hosted
version: "9.5.4"
version: "9.5.5"
bonsoir:
dependency: "direct overridden"
description:
@@ -229,10 +229,10 @@ packages:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.0.0"
version: "1.2.1"
code_builder:
dependency: transitive
description:
@@ -326,18 +326,18 @@ packages:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
version: "0.7.13"
desktop_webview_window:
dependency: transitive
description:
name: desktop_webview_window
sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0"
sha256: b6fdae2cbf9571879b1761c12f27facaf82e22d0bdc74d049907c2a09a432957
url: "https://pub.dev"
source: hosted
version: "0.2.3"
version: "0.3.0"
device_info_plus:
dependency: "direct main"
description:
@@ -549,18 +549,18 @@ packages:
dependency: "direct dev"
description:
name: flutter_native_splash
sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002"
sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff"
url: "https://pub.dev"
source: hosted
version: "2.4.7"
version: "2.4.8"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.34"
version: "2.0.35"
flutter_riverpod:
dependency: transitive
description:
@@ -642,10 +642,10 @@ packages:
dependency: "direct main"
description:
name: flutter_web_auth_2
sha256: d354998934ddc338e69b999b2abaeb33c6fd09999d3a5f92ead1a6b49b49712e
sha256: "8f9303471dcd96670878c9b7c0c4e14c37595b2add67465f6a868f17a5872dfc"
url: "https://pub.dev"
source: hosted
version: "5.0.2"
version: "5.0.3"
flutter_web_auth_2_platform_interface:
dependency: transitive
description:
@@ -780,10 +780,10 @@ packages:
dependency: transitive
description:
name: hooks
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "1.0.3"
version: "2.0.2"
hooks_riverpod:
dependency: "direct main"
description:
@@ -844,10 +844,10 @@ packages:
dependency: transitive
description:
name: image
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
url: "https://pub.dev"
source: hosted
version: "4.8.0"
version: "4.9.1"
image_picker:
dependency: "direct main"
description:
@@ -860,10 +860,10 @@ packages:
dependency: transitive
description:
name: image_picker_android
sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f
sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a"
url: "https://pub.dev"
source: hosted
version: "0.8.13+17"
version: "0.8.13+19"
image_picker_for_web:
dependency: transitive
description:
@@ -1120,10 +1120,10 @@ packages:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
url: "https://pub.dev"
source: hosted
version: "0.17.6"
version: "0.19.1"
native_video_player:
dependency: "direct main"
description:
@@ -1161,10 +1161,10 @@ packages:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
version: "9.4.1"
octo_image:
dependency: "direct main"
description:
@@ -1297,10 +1297,10 @@ packages:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
sha256: e20daf680eef1ca62ffe8c8c526b778cc386d50137c77ac71c8ec9c88c13fb9d
url: "https://pub.dev"
source: hosted
version: "9.4.7"
version: "9.4.9"
permission_handler_html:
dependency: transitive
description:
@@ -1529,10 +1529,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
sha256: a2c49fc1fed7140cadd892d765bd47edbe4ac0b9c7e7e3c493dcb58126f99cf0
url: "https://pub.dev"
source: hosted
version: "2.4.23"
version: "2.4.25"
shared_preferences_foundation:
dependency: transitive
description:
@@ -1791,10 +1791,10 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
url: "https://pub.dev"
source: hosted
version: "6.3.30"
version: "6.3.32"
url_launcher_ios:
dependency: transitive
description:
@@ -1871,10 +1871,10 @@ packages:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: b9b3f391857781aa96acacef96066f2f49b4cd03cf9fce3ca4d8da2ef5ea129e
sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172"
url: "https://pub.dev"
source: hosted
version: "1.2.3"
version: "1.2.5"
vector_math:
dependency: transitive
description:
@@ -1991,10 +1991,10 @@ packages:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
version: "7.0.1"
xxh3:
dependency: transitive
description:
@@ -1,64 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import '../repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late SyncStreamRepository sut;
setUp(() {
ctx = MediumRepositoryContext();
sut = SyncStreamRepository(ctx.db);
});
tearDown(() async {
await ctx.dispose();
});
group('pruneAssets', () {
test('deletes foreign orphans and keeps owned, partner, and in-album assets', () async {
final me = await ctx.newUser();
final partner = await ctx.newUser();
final stranger = await ctx.newUser();
await ctx.newAuthUser(id: me.id);
await ctx.newPartner(sharedById: partner.id, sharedWithId: me.id);
final own = await ctx.newRemoteAsset(ownerId: me.id);
final fromPartner = await ctx.newRemoteAsset(ownerId: partner.id);
final shared = await ctx.newRemoteAsset(ownerId: stranger.id);
await ctx.newRemoteAsset(ownerId: stranger.id);
final album = await ctx.newRemoteAlbum(ownerId: me.id);
await ctx.newRemoteAlbumAsset(albumId: album.id, assetId: shared.id);
await sut.pruneAssets();
final remaining = await ctx.db.select(ctx.db.remoteAssetEntity).get();
expect(remaining.map((a) => a.id), unorderedEquals([own.id, fromPartner.id, shared.id]));
});
test('does nothing when there is no authenticated user', () async {
final stranger = await ctx.newUser();
final orphan = await ctx.newRemoteAsset(ownerId: stranger.id);
await sut.pruneAssets();
final remaining = await ctx.db.select(ctx.db.remoteAssetEntity).get();
expect(remaining.map((a) => a.id), [orphan.id]);
});
test('prunes every stale foreign asset in a large data set', () async {
final stranger = await ctx.newUser();
await ctx.newAuthUser();
for (var i = 0; i < 600; i++) {
await ctx.newRemoteAsset(ownerId: stranger.id);
}
await sut.pruneAssets();
final remaining = await ctx.db.select(ctx.db.remoteAssetEntity).get();
expect(remaining, isEmpty);
});
});
}
@@ -6,7 +6,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
@@ -73,20 +72,6 @@ class MediumRepositoryContext {
);
}
Future<AuthUserEntityData> newAuthUser({String? id, String? email, AvatarColor? avatarColor}) async {
id ??= TestUtils.uuid();
return await db
.into(db.authUserEntity)
.insertReturning(
AuthUserEntityCompanion(
id: .new(id),
email: .new(email ?? '$id@test.com'),
name: .new('user_$id'),
avatarColor: .new(avatarColor ?? TestUtils.randElement(AvatarColor.values)),
),
);
}
Future<void> newPartner({required String sharedById, required String sharedWithId, bool? inTimeline}) {
return db
.into(db.partnerEntity)
+3 -3
View File
@@ -17012,12 +17012,12 @@
},
"assetId": {
"description": "Existing asset ID if duplicate",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"id": {
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"description": "Client-side identifier echoed from the request to match results to inputs",
"type": "string"
},
"isTrashed": {
+11 -3
View File
@@ -278,7 +278,9 @@
"title": "Album IDs",
"array": true,
"description": "Target album IDs",
"uiHint": "AlbumId"
"uiHint": {
"type": "AlbumId"
}
},
"albumName": {
"type": "string",
@@ -368,14 +370,20 @@
"type": "string",
"title": "Album ID",
"description": "Target album ID",
"uiHint": "AlbumId"
"uiHint": {
"type": "AlbumId",
"order": 1
}
},
"albumIds": {
"type": "string",
"title": "Album IDs",
"description": "Target album IDs",
"array": true,
"uiHint": "AlbumId"
"uiHint": {
"type": "AlbumId",
"order": 2
}
}
}
}
+1 -1
View File
@@ -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": [],
+21 -21
View File
@@ -1,5 +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 Foo = (manifestType['methods'][number] & {
name: 'assetMissingTimeZoneFilter';
})['schema']['properties']['inverse']['type'];
type AssetFileFilterConfig = {
pattern: string;
@@ -7,7 +13,7 @@ type AssetFileFilterConfig = {
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 +49,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 +57,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 +96,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 +115,13 @@ export const assetFavorite = () => {
};
export const assetVisibility = () => {
return wrapper<WorkflowType.AssetV1, { visibility: AssetVisibility }>(({ config }) => ({
return wrapper<'assetVisibility'>(({ config }) => ({
changes: { asset: { visibility: config.visibility } },
}));
};
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 +135,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 +148,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 -1
View File
@@ -31,7 +31,7 @@
"@types/node": "^24.13.2",
"esbuild": "^0.28.0",
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3"
"typescript": "^6.0.0"
},
"peerDependencies": {
"@extism/js-pdk": "^1.1.1"
+86 -44
View File
@@ -1,53 +1,95 @@
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 = 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' | 'object';
array?: boolean;
enum?: 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
: object;
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'][0],
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;
}
};
+6 -5
View File
@@ -11,7 +11,7 @@ type DeepPartial<T> = T extends Date
export type WorkflowEventMap = {
[WorkflowType.AssetV1]: AssetV1;
// [WorkflowType.AssetPersonV1]: AssetPersonV1;
};
} & { [K in WorkflowType]: unknown };
export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
@@ -22,7 +22,7 @@ export enum WorkflowTrigger {
}
export type WorkflowEventPayload<
T extends WorkflowType = WorkflowType,
T extends WorkflowType,
TConfig = WorkflowStepConfig,
> = {
trigger: WorkflowTrigger;
@@ -37,10 +37,11 @@ export type WorkflowEventPayload<
};
};
export type WorkflowChanges<T extends WorkflowType = WorkflowType> =
DeepPartial<WorkflowEventData<T>>;
export type WorkflowChanges<T extends WorkflowType> = DeepPartial<
WorkflowEventData<T>
>;
export type WorkflowResponse<T extends WorkflowType = WorkflowType> = {
export type WorkflowResponse<T extends WorkflowType> = {
workflow?: {
/** stop the workflow */
continue?: boolean;
+1
View File
@@ -20,6 +20,7 @@
"sourceMap": false,
"strict": true,
"target": "esnext",
"typeRoots": ["./node_modules/@types", "./node_modules"],
"types": ["node", "@extism/js-pdk"],
"verbatimModuleSyntax": true
}
+1 -1
View File
@@ -707,7 +707,7 @@ export type AssetBulkUploadCheckResult = {
action: AssetUploadAction;
/** Existing asset ID if duplicate */
assetId?: string;
/** Asset ID */
/** Client-side identifier echoed from the request to match results to inputs */
id: string;
/** Whether existing asset is trashed */
isTrashed?: boolean;
+2 -2
View File
@@ -353,8 +353,8 @@ importers:
specifier: ^1.8.16
version: 1.8.17
typescript:
specifier: ^5.9.3
version: 5.9.3
specifier: ^6.0.0
version: 6.0.3
packages/sdk:
dependencies:
+2 -2
View File
@@ -34,10 +34,10 @@ const AssetRejectReasonSchema = z
const AssetBulkUploadCheckResultSchema = z
.object({
id: z.uuidv4().describe('Asset ID'),
id: z.string().describe('Client-side identifier echoed from the request to match results to inputs'),
action: AssetUploadActionSchema,
reason: AssetRejectReasonSchema.optional(),
assetId: z.string().optional().describe('Existing asset ID if duplicate'),
assetId: z.uuidv4().optional().describe('Existing asset ID if duplicate'),
isTrashed: z.boolean().optional().describe('Whether existing asset is trashed'),
})
.meta({ id: 'AssetBulkUploadCheckResult' });
+6 -1
View File
@@ -14,7 +14,12 @@ const JsonSchemaPropertySchema = z
enum: z.array(z.string()).optional().describe('Valid choices for enum types'),
array: z.boolean().optional().describe('Type is an array type'),
required: z.array(z.string()).optional().describe('A list of required properties'),
uiHint: z.string().optional(),
uiHint: z
.object({
type: z.string().optional(),
order: z.int().optional(),
})
.optional(),
get properties() {
return z.record(z.string(), JsonSchemaPropertySchema).optional();
},
@@ -369,7 +369,7 @@ export class WorkflowExecutionService extends BaseService {
const readResult = await read(type);
let data = readResult.data;
for (const step of workflow.steps) {
const payload: WorkflowEventPayload = {
const payload: WorkflowEventPayload<typeof type> = {
trigger: workflow.trigger,
type,
config: step.config ?? {},
@@ -51,6 +51,8 @@
};
const setUiHintValue = (values: string[]) => setValue(schema.array ? values : values[0]);
const getSchemaProperties = (schema: JSONSchemaProperty) =>
Object.entries(schema.properties ?? {}).sort((a, b) => (a[1].uiHint?.order ?? 0) - (b[1].uiHint?.order ?? 0));
const getBoolean = (defaultValue = false) => getValue<boolean>(defaultValue);
const getString = () => getValue<string>();
@@ -72,11 +74,11 @@
</div>
{/if}
<div class="flex flex-col gap-4 {root ? '' : 'border-l-4 border-gray-200 ps-2'}">
{#each Object.entries(schema.properties ?? {}) as [childKey, childSchema] (childKey)}
{#each getSchemaProperties(schema) as [childKey, childSchema] (childKey)}
<Self schema={childSchema} key={childKey} bind:config={getValue, setValue} />
{/each}
</div>
{:else if schema.uiHint === 'AlbumId'}
{:else if schema.uiHint?.type === 'AlbumId'}
<SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} />
{:else if schema.enum && schema.array}
<Field {label} {description}>
@@ -110,11 +110,11 @@
let sharedLink = getSharedLink();
let fullscreenElement = $state<Element>();
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
let isPlayingOriginalVideo = $state($alwaysLoadOriginalVideo);
let slideshowStartAssetId = $state<string>();
const setPlayOriginalVideo = (value: boolean) => {
playOriginalVideo = value;
isPlayingOriginalVideo = value;
};
const refreshStack = async () => {
@@ -504,7 +504,7 @@
{onUndoDelete}
onClose={onClose ? () => onClose(stack?.primaryAssetId ?? asset.id) : undefined}
{onRemoveFromAlbum}
{playOriginalVideo}
{isPlayingOriginalVideo}
{setPlayOriginalVideo}
/>
</div>
@@ -542,7 +542,7 @@
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
playOriginalVideo={isPlayingOriginalVideo}
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
@@ -554,7 +554,7 @@
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
{playOriginalVideo}
playOriginalVideo={isPlayingOriginalVideo}
/>
{:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer {asset} />
@@ -574,7 +574,7 @@
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
{playOriginalVideo}
playOriginalVideo={isPlayingOriginalVideo}
/>
{/if}
@@ -1,5 +1,4 @@
<script lang="ts">
import { goto } from '$app/navigation';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToStackAction from '$lib/components/asset-viewer/actions/AddToStackAction.svelte';
@@ -10,19 +9,15 @@
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/RemoveAssetFromStack.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/RestoreAction.svelte';
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/SetPersonFeaturedAction.svelte';
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/SetProfilePictureAction.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/SetVisibilityAction.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/UnstackAction.svelte';
import LoadingDots from '$lib/components/LoadingDots.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import RemoveFromAlbumAction from '$lib/components/timeline/actions/RemoveFromAlbumAction.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
import { Route } from '$lib/route';
import { getAlbumAssetActions } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service';
@@ -38,7 +33,7 @@
type StackResponseDto,
} from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui';
import { mdiArrowLeft, mdiArrowRight, mdiCompare, mdiDotsVertical, mdiImageSearch, mdiVideoOutline } from '@mdi/js';
import { mdiArrowLeft, mdiArrowRight, mdiDotsVertical, mdiVideoOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
@@ -51,7 +46,7 @@
onUndoDelete?: OnUndoDelete;
onClose?: () => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
playOriginalVideo: boolean;
isPlayingOriginalVideo: boolean;
setPlayOriginalVideo: (value: boolean) => void;
}
@@ -65,14 +60,13 @@
onUndoDelete = undefined,
onClose,
onRemoveFromAlbum,
playOriginalVideo = false,
isPlayingOriginalVideo = false,
setPlayOriginalVideo,
}: Props = $props();
const isOwner = $derived(authManager.authenticated && asset.ownerId === authManager.user.id);
const isAlbumOwner = $derived(authManager.authenticated && album?.albumUsers[0].user.id === authManager.user.id);
const isLocked = $derived(asset.visibility === AssetVisibility.Locked);
const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
const { Cast } = $derived(getGlobalActions($t));
@@ -84,7 +78,14 @@
shortcuts: [{ key: 'Escape' }],
});
const Actions = $derived(getAssetActions($t, asset));
const PlayOriginalVideo: ActionItem = $derived({
title: isPlayingOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video'),
icon: mdiVideoOutline,
$if: () => asset.type === AssetTypeEnum.Video,
onAction: () => setPlayOriginalVideo(!isPlayingOriginalVideo),
});
const Actions = $derived(getAssetActions($t, { ...asset, stackPrimaryAssetId: stack?.primaryAssetId }));
const sharedLink = getSharedLink();
</script>
@@ -169,41 +170,21 @@
{#if person}
<SetFeaturedPhotoAction {asset} {person} {onAction} />
{/if}
{#if asset.type === AssetTypeEnum.Image && !isLocked}
<SetProfilePictureAction {asset} />
{/if}
{#if !isLocked}
{#if isOwner}
<ArchiveAction {asset} {onAction} {preAction} />
{#if !asset.isArchived && !asset.isTrashed}
<MenuOption
icon={mdiImageSearch}
onClick={() => goto(Route.photos({ at: stack?.primaryAssetId ?? asset.id }))}
text={$t('view_in_timeline')}
/>
{/if}
{/if}
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
<MenuOption
icon={mdiCompare}
onClick={() => goto(Route.search({ queryAssetId: stack?.primaryAssetId ?? asset.id }))}
text={$t('view_similar_photos')}
/>
{/if}
<ActionMenuItem action={Actions.SetProfilePicture} />
{#if isOwner && !isLocked}
<ArchiveAction {asset} {onAction} {preAction} />
{/if}
<ActionMenuItem action={Actions.ViewInTimeline} />
<ActionMenuItem action={Actions.ViewSimilar} />
{#if !asset.isTrashed && isOwner}
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
{/if}
{#if asset.type === AssetTypeEnum.Video}
<MenuOption
icon={mdiVideoOutline}
onClick={() => setPlayOriginalVideo(!playOriginalVideo)}
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
/>
{/if}
<ActionMenuItem action={PlayOriginalVideo} />
{#if isOwner}
<hr />
<ActionMenuItem action={Actions.RefreshFacesJob} />
@@ -1,20 +0,0 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelte';
import type { AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiAccountCircleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
}
let { asset }: Props = $props();
</script>
<MenuOption
icon={mdiAccountCircleOutline}
onClick={() => modalManager.show(ProfileImageCropperModal, { asset })}
text={$t('set_as_profile_picture')}
/>
@@ -31,6 +31,12 @@ vitest.mock('$lib/utils', async () => {
};
});
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: {} } as never,
};
});
describe('AssetService', () => {
describe('getAssetActions', () => {
beforeEach(() => {
+34 -1
View File
@@ -11,8 +11,10 @@ import {
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiAccountCircleOutline,
mdiAlertOutline,
mdiCogRefreshOutline,
mdiCompare,
mdiContentCopy,
mdiDatabaseRefreshOutline,
mdiDownload,
@@ -22,6 +24,7 @@ import {
mdiHeart,
mdiHeartOutline,
mdiImageRefreshOutline,
mdiImageSearch,
mdiInformationOutline,
mdiMagnifyMinusOutline,
mdiMagnifyPlusOutline,
@@ -34,14 +37,18 @@ import {
mdiTune,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { ProjectionType } from '$lib/constants';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { Route } from '$lib/route';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils';
@@ -92,10 +99,11 @@ export const getAssetBulkActions = ($t: MessageFormatter) => {
return { AddToAlbum, RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob };
};
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto & { stackPrimaryAssetId?: string }) => {
const sharedLink = getSharedLink();
const authUser = authManager.authenticated ? authManager.user : undefined;
const isOwner = !!(authUser && authUser.id === asset.ownerId);
const smartSearchEnabled = featureFlagsManager.value.smartSearch;
const Share: ActionItem = {
title: $t('share'),
@@ -242,6 +250,28 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
shortcuts: [{ key: 'e' }],
};
const SetProfilePicture: ActionItem = {
title: $t('set_as_profile_picture'),
icon: mdiAccountCircleOutline,
$if: () => asset.type === AssetTypeEnum.Image && asset.visibility !== AssetVisibility.Locked,
onAction: () => modalManager.show(ProfileImageCropperModal, { asset }),
};
const ViewInTimeline: ActionItem = {
title: $t('view_in_timeline'),
icon: mdiImageSearch,
$if: () => isOwner && asset.visibility !== AssetVisibility.Locked && !asset.isArchived && !asset.isTrashed,
onAction: () => goto(Route.photos({ at: asset.stackPrimaryAssetId ?? asset.id })),
};
const ViewSimilar: ActionItem = {
title: $t('view_similar_photos'),
icon: mdiCompare,
$if: () =>
asset.visibility !== AssetVisibility.Locked && !asset.isArchived && !asset.isTrashed && smartSearchEnabled,
onAction: () => goto(Route.search({ queryAssetId: asset.stackPrimaryAssetId ?? asset.id })),
};
const RefreshFacesJob: ActionItem = {
title: $t('refresh_faces'),
icon: mdiHeadSyncOutline,
@@ -286,6 +316,9 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
Tag,
TagPeople,
Edit,
SetProfilePicture,
ViewInTimeline,
ViewSimilar,
RefreshFacesJob,
RefreshMetadataJob,
RegenerateThumbnailJob,
+4 -1
View File
@@ -96,7 +96,10 @@ export type JSONSchemaProperty = {
array?: boolean;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
uiHint?: 'AlbumId' | 'AssetId' | 'PersonId';
uiHint?: {
type?: 'AlbumId' | 'AssetId' | 'PersonId';
order?: number;
};
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -55,7 +55,7 @@
);
const isGhost = $derived(step.id === 'ghost');
const getUiHint = (key: string) => schema?.properties?.[key]?.uiHint;
const getUiHint = (key: string) => schema?.properties?.[key]?.uiHint?.type;
const toIds = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : [String(value)]);
let dragImage = $state<Element>();
let isDropTarget = $state(false);