From 297316c7b85d3da3944f77534c573fe7bffbcdd0 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:19:30 +0530 Subject: [PATCH 1/5] refactor: owned action assets filter (#29382) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../models/asset/remote_asset.model.dart | 4 + .../presentation/actions/favorite.action.dart | 20 +- mobile/lib/utils/asset_filter.dart | 22 ++ .../unit/factories/remote_asset_factory.dart | 11 +- mobile/test/unit/utils/asset_filter_test.dart | 200 ++++++++++++++++++ 5 files changed, 245 insertions(+), 12 deletions(-) create mode 100644 mobile/lib/utils/asset_filter.dart create mode 100644 mobile/test/unit/utils/asset_filter_test.dart diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index 3709627cfb..40e681d47d 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -70,6 +70,10 @@ class RemoteAsset extends BaseAsset { bool get isTrashed => deletedAt != null; + bool get isStacked => stackId != null; + + bool get isArchived => visibility == .archive; + @override String toString() { return '''Asset { diff --git a/mobile/lib/presentation/actions/favorite.action.dart b/mobile/lib/presentation/actions/favorite.action.dart index 33d4bb3b6c..a480e10a5f 100644 --- a/mobile/lib/presentation/actions/favorite.action.dart +++ b/mobile/lib/presentation/actions/favorite.action.dart @@ -3,25 +3,23 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/utils/asset_filter.dart'; import 'package:immich_ui/immich_ui.dart'; class FavoriteAction extends AssetAction { - final bool shouldFavorite; + final bool favorite; - FavoriteAction({required super.assets}) : shouldFavorite = assets.any((asset) => !asset.isFavorite); + FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite); @override - IconData get icon => shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded; + IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded; @override - String label(ActionScope scope) => shouldFavorite ? scope.context.t.favorite : scope.context.t.unfavorite; + String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite; @override - Iterable filter(ActionScope scope) => assets - .where( - (asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite, - ) - .cast(); + Iterable filter(ActionScope scope) => + AssetFilter(assets).owned(scope.authUser.id).favorite(isFavorite: !favorite); @override bool isVisible(ActionScope scope) => filter(scope).isNotEmpty; @@ -31,8 +29,8 @@ class FavoriteAction extends AssetAction { final ActionScope(:ref) = scope; final assets = filter(scope).map((asset) => asset.id).toList(growable: false); - await ref.read(assetServiceProvider).updateFavorite(assets, shouldFavorite); - final message = shouldFavorite + await ref.read(assetServiceProvider).updateFavorite(assets, favorite); + final message = favorite ? StaticTranslations.instance.favorite_action_prompt(count: assets.length) : StaticTranslations.instance.unfavorite_action_prompt(count: assets.length); snackbar.success(message); diff --git a/mobile/lib/utils/asset_filter.dart b/mobile/lib/utils/asset_filter.dart new file mode 100644 index 0000000000..34879fa778 --- /dev/null +++ b/mobile/lib/utils/asset_filter.dart @@ -0,0 +1,22 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +extension type const AssetFilter(Iterable assets) implements Iterable { + AssetFilter where(bool Function(T asset) test) => AssetFilter(assets.where(test)); + AssetFilter whereNot(bool Function(T asset) test) => AssetFilter(assets.where((asset) => !test(asset))); + + AssetFilter type(AssetType type) => where((asset) => asset.type == type); + AssetFilter favorite({bool isFavorite = true}) => where((asset) => asset.isFavorite == isFavorite); + + AssetFilter remote() => AssetFilter(assets.whereType()); + AssetFilter owned(String ownerId) => remote().where((asset) => asset.ownerId == ownerId); + AssetFilter visibility(AssetVisibility visibility) => + remote().where((asset) => asset.visibility == visibility); + AssetFilter notVisibility(AssetVisibility visibility) => + remote().where((asset) => asset.visibility != visibility); + AssetFilter archived({bool isArchived = true}) => + remote().where((asset) => asset.isArchived == isArchived); + AssetFilter stacked({bool isStacked = true}) => remote().where((asset) => asset.isStacked == isStacked); + + AssetFilter local() => AssetFilter(assets.whereType()); + AssetFilter backedUp() => local().where((asset) => asset.remoteAssetId != null); +} diff --git a/mobile/test/unit/factories/remote_asset_factory.dart b/mobile/test/unit/factories/remote_asset_factory.dart index 669eb3998a..3ab76ae15e 100644 --- a/mobile/test/unit/factories/remote_asset_factory.dart +++ b/mobile/test/unit/factories/remote_asset_factory.dart @@ -5,7 +5,14 @@ import '../../utils.dart'; class RemoteAssetFactory { const RemoteAssetFactory(); - static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) { + static RemoteAsset create({ + String? id, + String? name, + String? ownerId, + bool isFavorite = false, + AssetVisibility visibility = AssetVisibility.timeline, + String? stackId, + }) { id = TestUtils.uuid(id); return RemoteAsset( @@ -17,6 +24,8 @@ class RemoteAssetFactory { createdAt: TestUtils.yesterday(), updatedAt: TestUtils.now(), isFavorite: isFavorite, + visibility: visibility, + stackId: stackId, isEdited: false, ); } diff --git a/mobile/test/unit/utils/asset_filter_test.dart b/mobile/test/unit/utils/asset_filter_test.dart new file mode 100644 index 0000000000..ceb28ba932 --- /dev/null +++ b/mobile/test/unit/utils/asset_filter_test.dart @@ -0,0 +1,200 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/utils/asset_filter.dart'; + +import '../factories/local_asset_factory.dart'; +import '../factories/remote_asset_factory.dart'; + +void main() { + group('AssetFilter', () { + group('type promotion', () { + test('a bare filter retains every BaseAsset', () { + final remoteAsset = RemoteAssetFactory.create(); + final localAsset = LocalAssetFactory.create(); + + final AssetFilter filter = AssetFilter([remoteAsset, localAsset]); + + expect(filter.toList(), [remoteAsset, localAsset]); + }); + + test('remote keeps only remote assets', () { + final remoteAsset = RemoteAssetFactory.create(); + final localAsset = LocalAssetFactory.create(); + + final AssetFilter remoteOnly = AssetFilter([remoteAsset, localAsset]).remote(); + + expect(remoteOnly.toList(), [remoteAsset]); + }); + + test('local keeps only local assets', () { + final remoteAsset = RemoteAssetFactory.create(); + final localAsset = LocalAssetFactory.create(); + + final AssetFilter localOnly = AssetFilter([remoteAsset, localAsset]).local(); + + expect(localOnly.toList(), [localAsset]); + }); + + test('owned promotes to RemoteAsset and drops local assets', () { + final remoteAsset = RemoteAssetFactory.create(); + final localAsset = LocalAssetFactory.create(); + + final AssetFilter remoteOnly = AssetFilter([ + remoteAsset, + localAsset, + ]).owned(remoteAsset.ownerId); + + expect(remoteOnly.toList(), [remoteAsset]); + }); + + test('backedUp promotes to LocalAsset and drops remote assets', () { + final syncedPhoto = LocalAssetFactory.create().copyWith(remoteId: 'remote'); + final offlinePhoto = LocalAssetFactory.create(); + final remotePhoto = RemoteAssetFactory.create(); + + final AssetFilter syncedPhotos = AssetFilter([ + syncedPhoto, + offlinePhoto, + remotePhoto, + ]).backedUp(); + + expect(syncedPhotos.toList(), [syncedPhoto]); + }); + }); + + group('named filters', () { + test('owned keeps only assets of the given owner', () { + final asset1 = RemoteAssetFactory.create(); + final asset2 = RemoteAssetFactory.create(); + + final alexPhotos = AssetFilter([asset1, asset2]).owned(asset1.ownerId); + + expect(alexPhotos.toList(), [asset1]); + }); + + test('favorites keeps only favorite assets', () { + final asset1 = RemoteAssetFactory.create(isFavorite: true); + final asset2 = RemoteAssetFactory.create(ownerId: asset1.ownerId); + + final favorites = AssetFilter([asset1, asset2]).favorite(); + + expect(favorites.toList(), [asset1]); + }); + + test('type keeps only assets of the given type', () { + final image = RemoteAssetFactory.create(); + final video = RemoteAssetFactory.create(ownerId: image.ownerId).copyWith(type: .video); + + final videos = AssetFilter([image, video]).type(.video); + + expect(videos.toList(), [video]); + }); + + test('visibility keeps only assets with the given visibility', () { + final locked = RemoteAssetFactory.create(visibility: AssetVisibility.locked); + final onTimeline = RemoteAssetFactory.create(ownerId: locked.ownerId); + + final lockedPhotos = AssetFilter([locked, onTimeline]).visibility(.locked); + + expect(lockedPhotos.toList(), [locked]); + }); + + test('archived keeps only archived assets', () { + final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive); + final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId); + + final archivedPhotos = AssetFilter([archived, onTimeline]).archived(); + + expect(archivedPhotos.toList(), [archived]); + }); + + test('stacked keeps only assets belonging to a stack', () { + final stacked = RemoteAssetFactory.create(stackId: 'stack'); + final loose = RemoteAssetFactory.create(ownerId: stacked.ownerId); + + final stackedPhotos = AssetFilter([stacked, loose]).stacked(); + + expect(stackedPhotos.toList(), [stacked]); + }); + }); + + group('inversion', () { + test('notArchived keeps every non-archived visibility', () { + final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive); + final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.timeline); + final hidden = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.hidden); + final locked = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.locked); + + final visiblePhotos = AssetFilter([archived, onTimeline, hidden, locked]).archived(isArchived: false); + + expect(visiblePhotos.toSet(), {onTimeline, hidden, locked}); + }); + + test('notVisibility keeps every asset not at the target visibility', () { + final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive); + final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.timeline); + final locked = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.locked); + + final toArchive = AssetFilter([archived, onTimeline, locked]).notVisibility(.archive); + + expect(toArchive.toSet(), {onTimeline, locked}); + }); + + test('notStacked keeps only assets without a stack', () { + final stacked = RemoteAssetFactory.create(stackId: 'stack'); + final loose = RemoteAssetFactory.create(ownerId: stacked.ownerId); + + final loosePhotos = AssetFilter([stacked, loose]).stacked(isStacked: false); + + expect(loosePhotos.toList(), [loose]); + }); + + test('whereNot inverts an arbitrary predicate', () { + final favorite = RemoteAssetFactory.create(isFavorite: true); + final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId); + + final nonFavorites = AssetFilter([favorite, regular]).whereNot((asset) => asset.isFavorite); + + expect(nonFavorites.toList(), [regular]); + }); + + test('notFavorites keeps only non-favorite assets', () { + final favorite = RemoteAssetFactory.create(isFavorite: true); + final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId); + + final nonFavorites = AssetFilter([favorite, regular]).favorite(isFavorite: false); + + expect(nonFavorites.toList(), [regular]); + }); + }); + + group('chaining', () { + test('combines predicates across owner, visibility and stack', () { + final asset = RemoteAssetFactory.create(); + final wrongOwner = RemoteAssetFactory.create(); + final archived = RemoteAssetFactory.create(ownerId: asset.ownerId, visibility: AssetVisibility.archive); + final stacked = RemoteAssetFactory.create(ownerId: asset.ownerId, stackId: 'stack-1'); + final localPhoto = LocalAssetFactory.create(); + + final result = AssetFilter([ + asset, + wrongOwner, + archived, + stacked, + localPhoto, + ]).owned(asset.ownerId).archived(isArchived: false).stacked(isStacked: false); + + expect(result.toList(), [asset]); + }); + + test('a base filter after a promotion retains the promoted type', () { + final favorite = RemoteAssetFactory.create(isFavorite: true); + final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId); + + final AssetFilter result = AssetFilter([favorite, regular]).owned(favorite.ownerId).favorite(); + + expect(result.toList(), [favorite]); + }); + }); + }); +} From f19f30ec6610622a5dbb22d4e50f8bb1a4a6afd4 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:19:55 +0530 Subject: [PATCH 2/5] fix: reset override for kebab buttons (#29380) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../widgets/asset_viewer/viewer_top_app_bar.widget.dart | 2 +- mobile/packages/ui/lib/src/color_override.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart index 8d51b8cd2e..5997e15bf0 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/viewer_top_app_bar.widget.dart @@ -63,7 +63,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget { ActionIconButtonWidget(action: FavoriteAction(assets: assetForAction)), - ViewerKebabMenu(originalTheme: originalTheme), + ImmichColorOverride(color: null, child: ViewerKebabMenu(originalTheme: originalTheme)), ]; final lockedViewActions = [ViewerKebabMenu(originalTheme: originalTheme)]; diff --git a/mobile/packages/ui/lib/src/color_override.dart b/mobile/packages/ui/lib/src/color_override.dart index c0b8358bb5..873f5e42ef 100644 --- a/mobile/packages/ui/lib/src/color_override.dart +++ b/mobile/packages/ui/lib/src/color_override.dart @@ -3,7 +3,7 @@ import 'package:flutter/widgets.dart'; class ImmichColorOverride extends InheritedWidget { const ImmichColorOverride({super.key, required this.color, required super.child}); - final Color color; + final Color? color; static Color? maybeOf(BuildContext context) => context.dependOnInheritedWidgetOfExactType()?.color; From f2c00c107d4f88cc8251a0c9b8084ae509bf775f Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Fri, 17 Jul 2026 02:30:40 -0700 Subject: [PATCH 3/5] fix(server): return workflow steps in ascending order (#29999) --- server/src/queries/workflow.repository.sql | 4 ++++ server/src/repositories/workflow.repository.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/queries/workflow.repository.sql b/server/src/queries/workflow.repository.sql index 86c26f5360..f44ba54066 100644 --- a/server/src/queries/workflow.repository.sql +++ b/server/src/queries/workflow.repository.sql @@ -25,6 +25,8 @@ select inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId" where "workflow"."id" = "workflow_step"."workflowId" + order by + "workflow_step"."order" asc ) as agg ) as "steps" from @@ -57,6 +59,8 @@ select inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId" where "workflow"."id" = "workflow_step"."workflowId" + order by + "workflow_step"."order" asc ) as agg ) as "steps" from diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts index 7a39e78ce9..9c946fe583 100644 --- a/server/src/repositories/workflow.repository.ts +++ b/server/src/repositories/workflow.repository.ts @@ -39,7 +39,8 @@ export class WorkflowRepository { 'plugin_method.name as methodName', 'workflow_step.config', 'workflow_step.enabled', - ]), + ]) + .orderBy('workflow_step.order', 'asc'), ).as('steps'), ]); } From 12fc8bac183f40bc7b5e0c7837545edfd8d74caf Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Fri, 17 Jul 2026 02:46:48 -0700 Subject: [PATCH 4/5] feat: workflow filter assets by upload path (#30000) Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- packages/plugin-core/manifest.json | 6 ++++++ packages/plugin-core/src/index.ts | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json index 66a12c36f2..d55ad43e70 100644 --- a/packages/plugin-core/manifest.json +++ b/packages/plugin-core/manifest.json @@ -103,6 +103,12 @@ "default": false, "title": "Case sensitive", "description": "Whether matching should be case-sensitive" + }, + "usePath": { + "type": "boolean", + "default": false, + "title": "Use path", + "description": "Match the full path on the server instead of the original filename" } }, "required": ["pattern"] diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index 5dc358a2f0..d412ad7708 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -54,11 +54,11 @@ const methods = wrapper({ }, assetFileFilter: ({ data, config }) => { - const { pattern, matchType = 'contains', caseSensitive = false } = config; + const { pattern, matchType = 'contains', caseSensitive = false, usePath = false } = config; const { asset } = data; - const fileName = asset.originalFileName || ''; + const fileName = usePath ? asset.originalPath : asset.originalFileName; const searchName = caseSensitive ? fileName : fileName.toLowerCase(); const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); From b5401eb1200a005d2a93135ca8221d9c8b77d1a2 Mon Sep 17 00:00:00 2001 From: Luis Nachtigall <31982496+LeLunZ@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:15:20 +0200 Subject: [PATCH 5/5] fix(mobile): update album creation to use user-defined name from dialog (#30002) fix: update album creation to use user-defined name from dialog --- .../presentation/widgets/album/album_selector.widget.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index 5a174bfc5e..44abfba47e 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -745,10 +745,15 @@ class AddToAlbumHeader extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { Future onCreateAlbum() async { + final albumName = await showDialog(context: context, builder: (context) => const NewAlbumNameModal()); + if (albumName == null) { + return; + } + final selectedAssets = ref.read(multiSelectProvider).selectedAssets; final newAlbum = await ref .read(remoteAlbumProvider.notifier) - .createAlbumWithAssets(title: "Untitled Album", assets: selectedAssets); + .createAlbumWithAssets(title: albumName, assets: selectedAssets); if (newAlbum == null) { ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.tr());