From cf08ac753803d1746cd4c58582a3faa5f704f73e Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Sun, 22 Oct 2023 02:38:07 +0000 Subject: [PATCH] feat: manual stack assets (#4198) --- cli/src/api/open-api/api.ts | 137 ++++++++ mobile/assets/i18n/en-US.json | 8 +- .../album/views/album_viewer_page.dart | 4 +- .../album/views/asset_selection_page.dart | 21 +- .../album/views/create_album_page.dart | 7 +- .../providers/asset_stack.provider.dart | 50 +++ .../providers/render_list.provider.dart | 17 + .../services/asset_stack.service.dart | 72 ++++ .../asset_viewer/views/gallery_viewer.dart | 310 ++++++++++++++---- .../modules/home/models/selection_state.dart | 47 +++ .../home/ui/asset_grid/immich_asset_grid.dart | 3 + .../ui/asset_grid/immich_asset_grid_view.dart | 17 +- .../home/ui/asset_grid/thumbnail_image.dart | 37 ++- .../home/ui/control_bottom_app_bar.dart | 26 +- mobile/lib/modules/home/views/home_page.dart | 62 +++- .../lib/modules/trash/views/trash_page.dart | 1 + mobile/lib/routing/router.dart | 1 + mobile/lib/routing/router.gr.dart | 26 +- mobile/lib/shared/models/asset.dart | 33 +- mobile/lib/shared/models/asset.g.dart | Bin 74000 -> 82576 bytes .../lib/shared/providers/asset.provider.dart | 48 ++- .../shared/providers/websocket.provider.dart | 1 + mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | Bin 21697 -> 21859 bytes mobile/openapi/doc/AssetApi.md | Bin 66689 -> 68763 bytes mobile/openapi/doc/AssetBulkUpdateDto.md | Bin 524 -> 617 bytes mobile/openapi/doc/AssetResponseDto.md | Bin 1737 -> 1916 bytes mobile/openapi/doc/UpdateStackParentDto.md | Bin 0 -> 456 bytes mobile/openapi/lib/api.dart | Bin 7073 -> 7116 bytes mobile/openapi/lib/api/asset_api.dart | Bin 59142 -> 60368 bytes mobile/openapi/lib/api_client.dart | Bin 21186 -> 21278 bytes .../lib/model/asset_bulk_update_dto.dart | Bin 4268 -> 5754 bytes .../openapi/lib/model/asset_response_dto.dart | Bin 11867 -> 12759 bytes .../lib/model/update_stack_parent_dto.dart | Bin 0 -> 3206 bytes mobile/openapi/test/asset_api_test.dart | Bin 5986 -> 6135 bytes .../test/asset_bulk_update_dto_test.dart | Bin 806 -> 1024 bytes .../openapi/test/asset_response_dto_test.dart | Bin 3620 -> 3970 bytes .../test/update_stack_parent_dto_test.dart | Bin 0 -> 697 bytes .../test/asset_grid_data_structure_test.dart | 1 + mobile/test/sync_service_test.dart | 1 + server/immich-openapi-specs.json | 73 +++++ server/src/domain/asset/asset.service.spec.ts | 165 +++++++++- server/src/domain/asset/asset.service.ts | 51 ++- .../src/domain/asset/dto/asset-stack.dto.ts | 9 + server/src/domain/asset/dto/asset.dto.ts | 10 +- server/src/domain/asset/dto/index.ts | 1 + .../asset/response-dto/asset-response.dto.ts | 16 +- .../repositories/communication.repository.ts | 1 + .../shared-link/shared-link-response.dto.ts | 2 +- .../immich/api-v1/asset/asset-repository.ts | 2 + .../src/immich/api-v1/asset/asset.service.ts | 4 +- .../immich/controllers/asset.controller.ts | 7 + server/src/infra/entities/asset.entity.ts | 10 + .../1695354433573-AddStackParentIdToAssets.ts | 16 + .../infra/repositories/asset.repository.ts | 8 + server/test/e2e/asset.e2e-spec.ts | 163 +++++++++ server/test/fixtures/asset.stub.ts | 44 ++- server/test/fixtures/shared-link.stub.ts | 1 + web/src/api/open-api/api.ts | 137 ++++++++ 59 files changed, 1530 insertions(+), 123 deletions(-) create mode 100644 mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart create mode 100644 mobile/lib/modules/asset_viewer/services/asset_stack.service.dart create mode 100644 mobile/lib/modules/home/models/selection_state.dart create mode 100644 mobile/openapi/doc/UpdateStackParentDto.md create mode 100644 mobile/openapi/lib/model/update_stack_parent_dto.dart create mode 100644 mobile/openapi/test/update_stack_parent_dto_test.dart create mode 100644 server/src/domain/asset/dto/asset-stack.dto.ts create mode 100644 server/src/infra/migrations/1695354433573-AddStackParentIdToAssets.ts diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index d9c34475a6..91bb2f88c3 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -399,6 +399,18 @@ export interface AssetBulkUpdateDto { * @memberof AssetBulkUpdateDto */ 'isFavorite'?: boolean; + /** + * + * @type {boolean} + * @memberof AssetBulkUpdateDto + */ + 'removeParent'?: boolean; + /** + * + * @type {string} + * @memberof AssetBulkUpdateDto + */ + 'stackParentId'?: string; } /** * @@ -748,6 +760,24 @@ export interface AssetResponseDto { * @memberof AssetResponseDto */ 'smartInfo'?: SmartInfoResponseDto; + /** + * + * @type {Array} + * @memberof AssetResponseDto + */ + 'stack'?: Array; + /** + * + * @type {number} + * @memberof AssetResponseDto + */ + 'stackCount': number; + /** + * + * @type {string} + * @memberof AssetResponseDto + */ + 'stackParentId'?: string | null; /** * * @type {Array} @@ -3981,6 +4011,25 @@ export interface UpdateLibraryDto { */ 'name'?: string; } +/** + * + * @export + * @interface UpdateStackParentDto + */ +export interface UpdateStackParentDto { + /** + * + * @type {string} + * @memberof UpdateStackParentDto + */ + 'newParentId': string; + /** + * + * @type {string} + * @memberof UpdateStackParentDto + */ + 'oldParentId': string; +} /** * * @export @@ -7135,6 +7184,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {UpdateStackParentDto} updateStackParentDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateStackParent: async (updateStackParentDto: UpdateStackParentDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'updateStackParentDto' is not null or undefined + assertParamExists('updateStackParent', 'updateStackParentDto', updateStackParentDto) + const localVarPath = `/asset/stack/parent`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(updateStackParentDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {File} assetData @@ -7601,6 +7694,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {UpdateStackParentDto} updateStackParentDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateStackParent(updateStackParentDto: UpdateStackParentDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateStackParent(updateStackParentDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {File} assetData @@ -7892,6 +7995,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {AssetApiUploadFileRequest} requestParameters Request parameters. @@ -8499,6 +8611,20 @@ export interface AssetApiUpdateAssetsRequest { readonly assetBulkUpdateDto: AssetBulkUpdateDto } +/** + * Request parameters for updateStackParent operation in AssetApi. + * @export + * @interface AssetApiUpdateStackParentRequest + */ +export interface AssetApiUpdateStackParentRequest { + /** + * + * @type {UpdateStackParentDto} + * @memberof AssetApiUpdateStackParent + */ + readonly updateStackParentDto: UpdateStackParentDto +} + /** * Request parameters for uploadFile operation in AssetApi. * @export @@ -8939,6 +9065,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiUploadFileRequest} requestParameters Request parameters. diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index b5a7e2efe2..3aadda4b7f 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -130,7 +130,9 @@ "control_bottom_app_bar_delete": "Delete", "control_bottom_app_bar_favorite": "Favorite", "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_stack": "Stack", "control_bottom_app_bar_unarchive": "Unarchive", + "control_bottom_app_bar_upload": "Upload", "create_album_page_untitled": "Untitled", "create_shared_album_page_create": "Create", "create_shared_album_page_share": "Share", @@ -275,6 +277,7 @@ "setting_pages_app_bar_settings": "Settings", "settings_require_restart": "Please restart Immich to apply this setting", "share_add": "Add", + "share_done": "Done", "share_add_photos": "Add photos", "share_add_title": "Add a title", "share_create_album": "Create album", @@ -337,5 +340,8 @@ "trash_page_select_assets_btn": "Select assets", "trash_page_empty_trash_btn": "Empty trash", "trash_page_empty_trash_dialog_ok": "Ok", - "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich" + "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", + "viewer_stack_use_as_main_asset": "Use as Main Asset", + "viewer_remove_from_stack": "Remove from Stack", + "viewer_unstack": "Un-Stack" } diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart index cb389059af..23358f3351 100644 --- a/mobile/lib/modules/album/views/album_viewer_page.dart +++ b/mobile/lib/modules/album/views/album_viewer_page.dart @@ -16,6 +16,7 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; @@ -69,7 +70,8 @@ class AlbumViewerPage extends HookConsumerWidget { await AutoRouter.of(context).push( AssetSelectionRoute( existingAssets: albumInfo.assets, - isNewAlbum: false, + canDeselect: false, + query: getRemoteAssetQuery(ref), ), ); diff --git a/mobile/lib/modules/album/views/asset_selection_page.dart b/mobile/lib/modules/album/views/asset_selection_page.dart index afec5f8aea..9c30ba80f6 100644 --- a/mobile/lib/modules/album/views/asset_selection_page.dart +++ b/mobile/lib/modules/album/views/asset_selection_page.dart @@ -4,26 +4,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/providers/asset.provider.dart'; -import 'package:immich_mobile/shared/providers/user.provider.dart'; +import 'package:isar/isar.dart'; class AssetSelectionPage extends HookConsumerWidget { const AssetSelectionPage({ Key? key, required this.existingAssets, - this.isNewAlbum = false, + this.canDeselect = false, + required this.query, }) : super(key: key); final Set existingAssets; - final bool isNewAlbum; + final QueryBuilder? query; + final bool canDeselect; @override Widget build(BuildContext context, WidgetRef ref) { - final currentUser = ref.watch(currentUserProvider); - final renderList = ref.watch(remoteAssetsProvider(currentUser?.isarId)); + final renderList = ref.watch(renderListQueryProvider(query)); final selected = useState>(existingAssets); final selectionEnabledHook = useState(true); @@ -39,8 +40,8 @@ class AssetSelectionPage extends HookConsumerWidget { selected.value = assets; }, selectionActive: true, - preselectedAssets: isNewAlbum ? selected.value : existingAssets, - canDeselect: isNewAlbum, + preselectedAssets: existingAssets, + canDeselect: canDeselect, showMultiSelectIndicator: false, ); } @@ -65,7 +66,7 @@ class AssetSelectionPage extends HookConsumerWidget { ), centerTitle: false, actions: [ - if (selected.value.isNotEmpty) + if (selected.value.isNotEmpty || canDeselect) TextButton( onPressed: () { var payload = @@ -74,7 +75,7 @@ class AssetSelectionPage extends HookConsumerWidget { .popForced(payload); }, child: Text( - "share_add", + canDeselect ? "share_done" : "share_add", style: TextStyle( fontWeight: FontWeight.bold, color: Theme.of(context).primaryColor, diff --git a/mobile/lib/modules/album/views/create_album_page.dart b/mobile/lib/modules/album/views/create_album_page.dart index e1f0d65e7b..191ce14705 100644 --- a/mobile/lib/modules/album/views/create_album_page.dart +++ b/mobile/lib/modules/album/views/create_album_page.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart'; import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/asset.provider.dart'; // ignore: must_be_immutable class CreateAlbumPage extends HookConsumerWidget { @@ -31,7 +32,8 @@ class CreateAlbumPage extends HookConsumerWidget { final isAlbumTitleTextFieldFocus = useState(false); final isAlbumTitleEmpty = useState(true); final selectedAssets = useState>( - initialAssets != null ? Set.from(initialAssets!) : const {},); + initialAssets != null ? Set.from(initialAssets!) : const {}, + ); final isDarkTheme = Theme.of(context).brightness == Brightness.dark; showSelectUserPage() async { @@ -59,7 +61,8 @@ class CreateAlbumPage extends HookConsumerWidget { await AutoRouter.of(context).push( AssetSelectionRoute( existingAssets: selectedAssets.value, - isNewAlbum: true, + canDeselect: true, + query: getRemoteAssetQuery(ref), ), ); if (selectedAsset == null) { diff --git a/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart new file mode 100644 index 0000000000..8f8e9bbe02 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart @@ -0,0 +1,50 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +class AssetStackNotifier extends StateNotifier> { + final Asset _asset; + final Ref _ref; + + AssetStackNotifier( + this._asset, + this._ref, + ) : super([]) { + fetchStackChildren(); + } + + void fetchStackChildren() async { + if (mounted) { + state = await _ref.read(assetStackProvider(_asset).future); + } + } + + removeChild(int index) { + if (index < state.length) { + state.removeAt(index); + } + } +} + +final assetStackStateProvider = StateNotifierProvider.autoDispose + .family, Asset>( + (ref, asset) => AssetStackNotifier(asset, ref), +); + +final assetStackProvider = + FutureProvider.autoDispose.family, Asset>((ref, asset) async { + // Guard [local asset] + if (asset.remoteId == null) { + return []; + } + + return await ref + .watch(dbProvider) + .assets + .filter() + .isArchivedEqualTo(false) + .isTrashedEqualTo(false) + .stackParentIdEqualTo(asset.remoteId) + .findAll(); +}); diff --git a/mobile/lib/modules/asset_viewer/providers/render_list.provider.dart b/mobile/lib/modules/asset_viewer/providers/render_list.provider.dart index 2273380843..04532ce1b8 100644 --- a/mobile/lib/modules/asset_viewer/providers/render_list.provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/render_list.provider.dart @@ -3,6 +3,7 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:isar/isar.dart'; final renderListProvider = FutureProvider.family>((ref, assets) { @@ -13,3 +14,19 @@ final renderListProvider = GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)], ); }); + +final renderListQueryProvider = StreamProvider.family?>( + (ref, query) async* { + if (query == null) { + return; + } + final settings = ref.watch(appSettingsServiceProvider); + final groupBy = GroupAssetsBy + .values[settings.getSetting(AppSettingsEnum.groupAssetsBy)]; + yield await RenderList.fromQuery(query, groupBy); + await for (final _ in query.watchLazy()) { + yield await RenderList.fromQuery(query, groupBy); + } + }, +); diff --git a/mobile/lib/modules/asset_viewer/services/asset_stack.service.dart b/mobile/lib/modules/asset_viewer/services/asset_stack.service.dart new file mode 100644 index 0000000000..8efee425c7 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/services/asset_stack.service.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/api.provider.dart'; +import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:openapi/api.dart'; + +class AssetStackService { + AssetStackService(this._api); + + final ApiService _api; + + updateStack( + Asset parentAsset, { + List? childrenToAdd, + List? childrenToRemove, + }) async { + // Guard [local asset] + if (parentAsset.remoteId == null) { + return; + } + + try { + if (childrenToAdd != null) { + final toAdd = childrenToAdd + .where((e) => e.isRemote) + .map((e) => e.remoteId!) + .toList(); + + await _api.assetApi.updateAssets( + AssetBulkUpdateDto(ids: toAdd, stackParentId: parentAsset.remoteId), + ); + } + + if (childrenToRemove != null) { + final toRemove = childrenToRemove + .where((e) => e.isRemote) + .map((e) => e.remoteId!) + .toList(); + await _api.assetApi.updateAssets( + AssetBulkUpdateDto(ids: toRemove, removeParent: true), + ); + } + } catch (error) { + debugPrint("Error while updating stack children: ${error.toString()}"); + } + } + + updateStackParent(Asset oldParent, Asset newParent) async { + // Guard [local asset] + if (oldParent.remoteId == null || newParent.remoteId == null) { + return; + } + + try { + await _api.assetApi.updateStackParent( + UpdateStackParentDto( + oldParentId: oldParent.remoteId!, + newParentId: newParent.remoteId!, + ), + ); + } catch (error) { + debugPrint("Error while updating stack parent: ${error.toString()}"); + } + } +} + +final assetStackServiceProvider = Provider( + (ref) => AssetStackService( + ref.watch(apiServiceProvider), + ), +); diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index bb818dcfcd..cdc07a2a79 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -8,11 +8,13 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; @@ -44,6 +46,7 @@ class GalleryViewerPage extends HookConsumerWidget { final int totalAssets; final int initialIndex; final int heroOffset; + final bool showStack; GalleryViewerPage({ super.key, @@ -51,6 +54,7 @@ class GalleryViewerPage extends HookConsumerWidget { required this.loadAsset, required this.totalAssets, this.heroOffset = 0, + this.showStack = false, }) : controller = PageController(initialPage: initialIndex); final PageController controller; @@ -77,8 +81,17 @@ class GalleryViewerPage extends HookConsumerWidget { final isFromTrash = isTrashEnabled && navStack.length > 2 && navStack.elementAt(navStack.length - 2).name == TrashRoute.name; + final stackIndex = useState(-1); + final stack = showStack && currentAsset.stackCount > 0 + ? ref.watch(assetStackStateProvider(currentAsset)) + : []; + final stackElements = showStack ? [currentAsset, ...stack] : []; - Asset asset() => currentAsset; + Asset asset() => stackIndex.value == -1 + ? currentAsset + : stackElements.elementAt(stackIndex.value); + + bool isParent = stackIndex.value == -1 || stackIndex.value == 0; useEffect( () { @@ -165,19 +178,28 @@ class GalleryViewerPage extends HookConsumerWidget { padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), - child: ExifBottomSheet(asset: currentAsset), + child: ExifBottomSheet(asset: asset()), ); }, ); } + void removeAssetFromStack() { + if (stackIndex.value > 0 && showStack) { + ref + .read(assetStackStateProvider(currentAsset).notifier) + .removeChild(stackIndex.value - 1); + stackIndex.value = stackIndex.value - 1; + } + } + void handleDelete(Asset deleteAsset) async { Future onDelete(bool force) async { final isDeleted = await ref.read(assetProvider.notifier).deleteAssets( {deleteAsset}, force: force, ); - if (isDeleted) { + if (isDeleted && isParent) { if (totalAssets == 1) { // Handle only one asset AutoRouter.of(context).pop(); @@ -195,14 +217,17 @@ class GalleryViewerPage extends HookConsumerWidget { // Asset is trashed if (isTrashEnabled && !isFromTrash) { final isDeleted = await onDelete(false); - // Can only trash assets stored in server. Local assets are always permanently removed for now - if (context.mounted && isDeleted && deleteAsset.isRemote) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'Asset trashed', - gravity: ToastGravity.BOTTOM, - ); + if (isDeleted) { + // Can only trash assets stored in server. Local assets are always permanently removed for now + if (context.mounted && deleteAsset.isRemote && isParent) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'Asset trashed', + gravity: ToastGravity.BOTTOM, + ); + } + removeAssetFromStack(); } return; } @@ -211,7 +236,14 @@ class GalleryViewerPage extends HookConsumerWidget { showDialog( context: context, builder: (BuildContext _) { - return DeleteDialog(onDelete: () => onDelete(true)); + return DeleteDialog( + onDelete: () async { + final isDeleted = await onDelete(true); + if (isDeleted) { + removeAssetFromStack(); + } + }, + ); }, ); } @@ -268,7 +300,11 @@ class GalleryViewerPage extends HookConsumerWidget { ref .watch(assetProvider.notifier) .toggleArchive([asset], !asset.isArchived); - AutoRouter.of(context).pop(); + if (isParent) { + AutoRouter.of(context).pop(); + return; + } + removeAssetFromStack(); } handleUpload(Asset asset) { @@ -385,7 +421,186 @@ class GalleryViewerPage extends HookConsumerWidget { ); } - buildBottomBar() { + Widget buildStackedChildren() { + return ListView.builder( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + itemCount: stackElements.length, + itemBuilder: (context, index) { + final assetId = stackElements.elementAt(index).remoteId; + return Padding( + padding: const EdgeInsets.only(right: 10), + child: GestureDetector( + onTap: () => stackIndex.value = index, + child: Container( + width: 40, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: index == stackIndex.value + ? Border.all( + color: Colors.white, + width: 2, + ) + : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: CachedNetworkImage( + fit: BoxFit.cover, + imageUrl: + '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$assetId', + httpHeaders: { + "Authorization": + "Bearer ${Store.get(StoreKey.accessToken)}", + }, + errorWidget: (context, url, error) => + const Icon(Icons.image_not_supported_outlined), + ), + ), + ), + ), + ); + }, + ); + } + + void showStackActionItems() { + showModalBottomSheet( + context: context, + enableDrag: false, + builder: (BuildContext ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isParent) + ListTile( + leading: const Icon( + Icons.bookmark_border_outlined, + size: 24, + ), + onTap: () async { + await ref + .read(assetStackServiceProvider) + .updateStackParent( + currentAsset, + stackElements.elementAt(stackIndex.value), + ); + Navigator.pop(ctx); + AutoRouter.of(context).pop(); + }, + title: const Text( + "viewer_stack_use_as_main_asset", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), + ListTile( + leading: const Icon( + Icons.copy_all_outlined, + size: 24, + ), + onTap: () async { + if (isParent) { + await ref + .read(assetStackServiceProvider) + .updateStackParent( + currentAsset, + stackElements + .elementAt(1), // Next asset as parent + ); + // Remove itself from stack + await ref.read(assetStackServiceProvider).updateStack( + stackElements.elementAt(1), + childrenToRemove: [currentAsset], + ); + Navigator.pop(ctx); + AutoRouter.of(context).pop(); + } else { + await ref.read(assetStackServiceProvider).updateStack( + currentAsset, + childrenToRemove: [ + stackElements.elementAt(stackIndex.value), + ], + ); + removeAssetFromStack(); + Navigator.pop(ctx); + } + }, + title: const Text( + "viewer_remove_from_stack", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), + ListTile( + leading: const Icon( + Icons.filter_none_outlined, + size: 18, + ), + onTap: () async { + await ref.read(assetStackServiceProvider).updateStack( + currentAsset, + childrenToRemove: stack, + ); + Navigator.pop(ctx); + AutoRouter.of(context).pop(); + }, + title: const Text( + "viewer_unstack", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), + ], + ), + ), + ); + }, + ); + } + + Widget buildBottomBar() { + // !!!! itemsList and actionlist should always be in sync + final itemsList = [ + BottomNavigationBarItem( + icon: Icon( + Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, + ), + label: 'control_bottom_app_bar_share'.tr(), + tooltip: 'control_bottom_app_bar_share'.tr(), + ), + asset().isArchived + ? BottomNavigationBarItem( + icon: const Icon(Icons.unarchive_rounded), + label: 'control_bottom_app_bar_unarchive'.tr(), + tooltip: 'control_bottom_app_bar_unarchive'.tr(), + ) + : BottomNavigationBarItem( + icon: const Icon(Icons.archive_outlined), + label: 'control_bottom_app_bar_archive'.tr(), + tooltip: 'control_bottom_app_bar_archive'.tr(), + ), + if (stack.isNotEmpty) + BottomNavigationBarItem( + icon: const Icon(Icons.burst_mode_outlined), + label: 'control_bottom_app_bar_stack'.tr(), + tooltip: 'control_bottom_app_bar_stack'.tr(), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.delete_outline), + label: 'control_bottom_app_bar_delete'.tr(), + tooltip: 'control_bottom_app_bar_delete'.tr(), + ), + ]; + + List actionslist = [ + (_) => shareAsset(), + (_) => handleArchive(asset()), + if (stack.isNotEmpty) (_) => showStackActionItems(), + (_) => handleDelete(asset()), + ]; + return IgnorePointer( ignoring: !ref.watch(showControlsProvider), child: AnimatedOpacity( @@ -393,6 +608,17 @@ class GalleryViewerPage extends HookConsumerWidget { opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, child: Column( children: [ + if (stack.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: 10, + bottom: 30, + ), + child: SizedBox( + height: 40, + child: buildStackedChildren(), + ), + ), Visibility( visible: !asset().isImage && !isPlayingMotionVideo.value, child: Container( @@ -421,44 +647,10 @@ class GalleryViewerPage extends HookConsumerWidget { selectedLabelStyle: const TextStyle(color: Colors.black), showSelectedLabels: false, showUnselectedLabels: false, - items: [ - BottomNavigationBarItem( - icon: Icon( - Platform.isAndroid - ? Icons.share_rounded - : Icons.ios_share_rounded, - ), - label: 'control_bottom_app_bar_share'.tr(), - tooltip: 'control_bottom_app_bar_share'.tr(), - ), - asset().isArchived - ? BottomNavigationBarItem( - icon: const Icon(Icons.unarchive_rounded), - label: 'control_bottom_app_bar_unarchive'.tr(), - tooltip: 'control_bottom_app_bar_unarchive'.tr(), - ) - : BottomNavigationBarItem( - icon: const Icon(Icons.archive_outlined), - label: 'control_bottom_app_bar_archive'.tr(), - tooltip: 'control_bottom_app_bar_archive'.tr(), - ), - BottomNavigationBarItem( - icon: const Icon(Icons.delete_outline), - label: 'control_bottom_app_bar_delete'.tr(), - tooltip: 'control_bottom_app_bar_delete'.tr(), - ), - ], + items: itemsList, onTap: (index) { - switch (index) { - case 0: - shareAsset(); - break; - case 1: - handleArchive(asset()); - break; - case 2: - handleDelete(asset()); - break; + if (index < actionslist.length) { + actionslist[index].call(index); } }, ), @@ -504,6 +696,7 @@ class GalleryViewerPage extends HookConsumerWidget { final next = currentIndex.value < value ? value + 1 : value - 1; precacheNextImage(next); currentIndex.value = value; + stackIndex.value = -1; HapticFeedback.selectionClick(); }, loadingBuilder: (context, event, index) { @@ -544,10 +737,11 @@ class GalleryViewerPage extends HookConsumerWidget { : webPThumbnail; }, builder: (context, index) { - final asset = loadAsset(index); - final ImageProvider provider = finalImageProvider(asset); + final a = + index == currentIndex.value ? asset() : loadAsset(index); + final ImageProvider provider = finalImageProvider(a); - if (asset.isImage && !isPlayingMotionVideo.value) { + if (a.isImage && !isPlayingMotionVideo.value) { return PhotoViewGalleryPageOptions( onDragStart: (_, details, __) => localPosition = details.localPosition, @@ -558,13 +752,13 @@ class GalleryViewerPage extends HookConsumerWidget { }, imageProvider: provider, heroAttributes: PhotoViewHeroAttributes( - tag: asset.id + heroOffset, + tag: a.id + heroOffset, ), filterQuality: FilterQuality.high, tightMode: true, minScale: PhotoViewComputedScale.contained, errorBuilder: (context, error, stackTrace) => ImmichImage( - asset, + a, fit: BoxFit.contain, ), ); @@ -575,7 +769,7 @@ class GalleryViewerPage extends HookConsumerWidget { onDragUpdate: (_, details, __) => handleSwipeUpDown(details), heroAttributes: PhotoViewHeroAttributes( - tag: asset.id + heroOffset, + tag: a.id + heroOffset, ), filterQuality: FilterQuality.high, maxScale: 1.0, @@ -584,7 +778,7 @@ class GalleryViewerPage extends HookConsumerWidget { child: VideoViewerPage( onPlaying: () => isPlayingVideo.value = true, onPaused: () => isPlayingVideo.value = false, - asset: asset, + asset: a, isMotionVideo: isPlayingMotionVideo.value, placeholder: Image( image: provider, diff --git a/mobile/lib/modules/home/models/selection_state.dart b/mobile/lib/modules/home/models/selection_state.dart new file mode 100644 index 0000000000..291b590689 --- /dev/null +++ b/mobile/lib/modules/home/models/selection_state.dart @@ -0,0 +1,47 @@ +import 'package:immich_mobile/shared/models/asset.dart'; + +class SelectionAssetState { + final bool hasRemote; + final bool hasLocal; + final bool hasMerged; + + const SelectionAssetState({ + this.hasRemote = false, + this.hasLocal = false, + this.hasMerged = false, + }); + + SelectionAssetState copyWith({ + bool? hasRemote, + bool? hasLocal, + bool? hasMerged, + }) { + return SelectionAssetState( + hasRemote: hasRemote ?? this.hasRemote, + hasLocal: hasLocal ?? this.hasLocal, + hasMerged: hasMerged ?? this.hasMerged, + ); + } + + SelectionAssetState.fromSelection(Set selection) + : hasLocal = selection.any((e) => e.storage == AssetState.local), + hasMerged = selection.any((e) => e.storage == AssetState.merged), + hasRemote = selection.any((e) => e.storage == AssetState.remote); + + @override + String toString() => + 'SelectionAssetState(hasRemote: $hasRemote, hasMerged: $hasMerged, hasMerged: $hasMerged)'; + + @override + bool operator ==(covariant SelectionAssetState other) { + if (identical(this, other)) return true; + + return other.hasRemote == hasRemote && + other.hasLocal == hasLocal && + other.hasMerged == hasMerged; + } + + @override + int get hashCode => + hasRemote.hashCode ^ hasLocal.hashCode ^ hasMerged.hashCode; +} diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart index c4a6d527ed..28b25f3422 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart @@ -32,6 +32,7 @@ class ImmichAssetGrid extends HookConsumerWidget { final Widget? topWidget; final bool shrinkWrap; final bool showDragScroll; + final bool showStack; const ImmichAssetGrid({ super.key, @@ -51,6 +52,7 @@ class ImmichAssetGrid extends HookConsumerWidget { this.topWidget, this.shrinkWrap = false, this.showDragScroll = true, + this.showStack = false, }); @override @@ -114,6 +116,7 @@ class ImmichAssetGrid extends HookConsumerWidget { heroOffset: heroOffset(), shrinkWrap: shrinkWrap, showDragScroll: showDragScroll, + showStack: showStack, ), ); } diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart index 8f50c28832..b3f031c68a 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart @@ -37,6 +37,7 @@ class ImmichAssetGridView extends StatefulWidget { final int heroOffset; final bool shrinkWrap; final bool showDragScroll; + final bool showStack; const ImmichAssetGridView({ super.key, @@ -56,6 +57,7 @@ class ImmichAssetGridView extends StatefulWidget { this.heroOffset = 0, this.shrinkWrap = false, this.showDragScroll = true, + this.showStack = false, }); @override @@ -71,7 +73,7 @@ class ImmichAssetGridViewState extends State { bool _scrolling = false; final Set _selectedAssets = - HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id); + LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id); Set _getSelectedAssets() { return Set.from(_selectedAssets); @@ -90,7 +92,13 @@ class ImmichAssetGridViewState extends State { void _deselectAssets(List assets) { setState(() { - _selectedAssets.removeAll(assets); + _selectedAssets.removeAll( + assets.where( + (a) => + widget.canDeselect || + !(widget.preselectedAssets?.contains(a) ?? false), + ), + ); _callSelectionListener(_selectedAssets.isNotEmpty); }); } @@ -129,6 +137,7 @@ class ImmichAssetGridViewState extends State { useGrayBoxPlaceholder: true, showStorageIndicator: widget.showStorageIndicator, heroOffset: widget.heroOffset, + showStack: widget.showStack, ); } @@ -377,10 +386,6 @@ class ImmichAssetGridViewState extends State { setState(() { _selectedAssets.clear(); }); - } else if (widget.preselectedAssets != null) { - setState(() { - _selectedAssets.addAll(widget.preselectedAssets!); - }); } } diff --git a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart index 555475b4b3..5b925c86b3 100644 --- a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart @@ -12,6 +12,7 @@ class ThumbnailImage extends StatelessWidget { final Asset Function(int index) loadAsset; final int totalAssets; final bool showStorageIndicator; + final bool showStack; final bool useGrayBoxPlaceholder; final bool isSelected; final bool multiselectEnabled; @@ -26,6 +27,7 @@ class ThumbnailImage extends StatelessWidget { required this.loadAsset, required this.totalAssets, this.showStorageIndicator = true, + this.showStack = false, this.useGrayBoxPlaceholder = false, this.isSelected = false, this.multiselectEnabled = false, @@ -93,6 +95,35 @@ class ThumbnailImage extends StatelessWidget { ); } + Widget buildStackIcon() { + return Positioned( + top: 5, + right: 5, + child: Row( + children: [ + if (asset.stackCount > 1) + Text( + "${asset.stackCount}", + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + if (asset.stackCount > 1) + const SizedBox( + width: 3, + ), + const Icon( + Icons.burst_mode_rounded, + color: Colors.white, + size: 18, + ), + ], + ), + ); + } + Widget buildImage() { final image = SizedBox( width: 300, @@ -113,9 +144,9 @@ class ThumbnailImage extends StatelessWidget { decoration: BoxDecoration( border: Border.all( width: 0, - color: assetContainerColor, + color: onDeselect == null ? Colors.grey : assetContainerColor, ), - color: assetContainerColor, + color: onDeselect == null ? Colors.grey : assetContainerColor, ), child: ClipRRect( borderRadius: const BorderRadius.only( @@ -144,6 +175,7 @@ class ThumbnailImage extends StatelessWidget { loadAsset: loadAsset, totalAssets: totalAssets, heroOffset: heroOffset, + showStack: showStack, ), ); } @@ -196,6 +228,7 @@ class ThumbnailImage extends StatelessWidget { ), ), if (!asset.isImage) buildVideoIcon(), + if (asset.isImage && asset.stackCount > 0) buildStackIcon(), ], ), ); diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart index 6315cf1d4f..3d8e165e2a 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart @@ -4,9 +4,9 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart'; +import 'package:immich_mobile/modules/home/models/selection_state.dart'; import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; -import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:immich_mobile/shared/models/album.dart'; @@ -19,11 +19,12 @@ class ControlBottomAppBar extends ConsumerWidget { final Function(Album album) onAddToAlbum; final void Function() onCreateNewAlbum; final void Function() onUpload; + final void Function() onStack; final List albums; final List sharedAlbums; final bool enabled; - final AssetState selectionAssetState; + final SelectionAssetState selectionAssetState; const ControlBottomAppBar({ Key? key, @@ -36,19 +37,24 @@ class ControlBottomAppBar extends ConsumerWidget { required this.onAddToAlbum, required this.onCreateNewAlbum, required this.onUpload, - this.selectionAssetState = AssetState.remote, + required this.onStack, + this.selectionAssetState = const SelectionAssetState(), this.enabled = true, }) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { var isDarkMode = Theme.of(context).brightness == Brightness.dark; - var hasRemote = selectionAssetState == AssetState.remote; + var hasRemote = + selectionAssetState.hasRemote || selectionAssetState.hasMerged; + var hasLocal = selectionAssetState.hasLocal; final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); Widget renderActionButtons() { - return Row( + return Wrap( + spacing: 10, + runSpacing: 15, children: [ ControlBoxButton( iconData: Platform.isAndroid @@ -92,7 +98,7 @@ class ControlBottomAppBar extends ConsumerWidget { if (!hasRemote) ControlBoxButton( iconData: Icons.backup_outlined, - label: "Upload", + label: "control_bottom_app_bar_upload".tr(), onPressed: enabled ? () => showDialog( context: context, @@ -104,6 +110,12 @@ class ControlBottomAppBar extends ConsumerWidget { ) : null, ), + if (!hasLocal) + ControlBoxButton( + iconData: Icons.filter_none_rounded, + label: "control_bottom_app_bar_stack".tr(), + onPressed: enabled ? onStack : null, + ), ], ); } @@ -111,7 +123,7 @@ class ControlBottomAppBar extends ConsumerWidget { return DraggableScrollableSheet( initialChildSize: hasRemote ? 0.30 : 0.18, minChildSize: 0.18, - maxChildSize: hasRemote ? 0.57 : 0.18, + maxChildSize: hasRemote ? 0.60 : 0.18, snap: true, builder: ( BuildContext context, diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 5dfc8ed918..d20501caa3 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -7,11 +7,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; +import 'package:immich_mobile/modules/home/models/selection_state.dart'; import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart'; @@ -36,7 +40,7 @@ class HomePage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final multiselectEnabled = ref.watch(multiselectProvider.notifier); final selectionEnabledHook = useState(false); - final selectionAssetState = useState(AssetState.remote); + final selectionAssetState = useState(const SelectionAssetState()); final selection = useState({}); final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); @@ -83,9 +87,8 @@ class HomePage extends HookConsumerWidget { ) { selectionEnabledHook.value = multiselect; selection.value = selectedAssets; - selectionAssetState.value = selectedAssets.any((e) => e.isRemote) - ? AssetState.remote - : AssetState.local; + selectionAssetState.value = + SelectionAssetState.fromSelection(selectedAssets); } void onShareAssets() { @@ -246,6 +249,55 @@ class HomePage extends HookConsumerWidget { } } + void onStack() async { + try { + processing.value = true; + if (!selectionEnabledHook.value) { + return; + } + + final selectedAsset = selection.value.elementAt(0); + + if (selection.value.length == 1) { + final stackChildren = + (await ref.read(assetStackProvider(selectedAsset).future)) + .toSet(); + AssetSelectionPageResult? returnPayload = + await AutoRouter.of(context).push( + AssetSelectionRoute( + existingAssets: stackChildren, + canDeselect: true, + query: getAssetStackSelectionQuery(ref, selectedAsset), + ), + ); + + if (returnPayload != null) { + Set selectedAssets = returnPayload.selectedAssets; + // Do not add itself as its stack child + selectedAssets.remove(selectedAsset); + final removedChildren = stackChildren.difference(selectedAssets); + final addedChildren = selectedAssets.difference(stackChildren); + await ref.read(assetStackServiceProvider).updateStack( + selectedAsset, + childrenToAdd: addedChildren.toList(), + childrenToRemove: removedChildren.toList(), + ); + } + } else { + // Merge assets + selection.value.remove(selectedAsset); + final selectedAssets = selection.value; + await ref.read(assetStackServiceProvider).updateStack( + selectedAsset, + childrenToAdd: selectedAssets.toList(), + ); + } + } finally { + processing.value = false; + selectionEnabledHook.value = false; + } + } + Future refreshAssets() async { final fullRefresh = refreshCount.value > 0; await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh); @@ -322,6 +374,7 @@ class HomePage extends HookConsumerWidget { currentUser.memoryEnabled!) ? const MemoryLane() : const SizedBox(), + showStack: true, ), error: (error, _) => Center(child: Text(error.toString())), loading: buildLoadingIndicator, @@ -339,6 +392,7 @@ class HomePage extends HookConsumerWidget { onUpload: onUpload, enabled: !processing.value, selectionAssetState: selectionAssetState.value, + onStack: onStack, ), if (processing.value) const Center(child: ImmichLoadingIndicator()), ], diff --git a/mobile/lib/modules/trash/views/trash_page.dart b/mobile/lib/modules/trash/views/trash_page.dart index 797b08a75c..8c128b61dc 100644 --- a/mobile/lib/modules/trash/views/trash_page.dart +++ b/mobile/lib/modules/trash/views/trash_page.dart @@ -252,6 +252,7 @@ class TrashPage extends HookConsumerWidget { listener: selectionListener, selectionActive: selectionEnabledHook.value, showMultiSelectIndicator: false, + showStack: true, topWidget: Padding( padding: const EdgeInsets.only( top: 24, diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index c3f4c2c1ad..b7971c9d19 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -51,6 +51,7 @@ import 'package:immich_mobile/shared/views/app_log_detail_page.dart'; import 'package:immich_mobile/shared/views/app_log_page.dart'; import 'package:immich_mobile/shared/views/splash_screen.dart'; import 'package:immich_mobile/shared/views/tab_controller_page.dart'; +import 'package:isar/isar.dart'; import 'package:photo_manager/photo_manager.dart'; part 'router.gr.dart'; diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 6502d5585b..651bec033f 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -71,6 +71,7 @@ class _$AppRouter extends RootStackRouter { loadAsset: args.loadAsset, totalAssets: args.totalAssets, heroOffset: args.heroOffset, + showStack: args.showStack, ), ); }, @@ -153,7 +154,8 @@ class _$AppRouter extends RootStackRouter { child: AssetSelectionPage( key: args.key, existingAssets: args.existingAssets, - isNewAlbum: args.isNewAlbum, + canDeselect: args.canDeselect, + query: args.query, ), transitionsBuilder: TransitionsBuilders.slideBottom, opaque: true, @@ -711,6 +713,7 @@ class GalleryViewerRoute extends PageRouteInfo { required Asset Function(int) loadAsset, required int totalAssets, int heroOffset = 0, + bool showStack = false, }) : super( GalleryViewerRoute.name, path: '/gallery-viewer-page', @@ -720,6 +723,7 @@ class GalleryViewerRoute extends PageRouteInfo { loadAsset: loadAsset, totalAssets: totalAssets, heroOffset: heroOffset, + showStack: showStack, ), ); @@ -733,6 +737,7 @@ class GalleryViewerRouteArgs { required this.loadAsset, required this.totalAssets, this.heroOffset = 0, + this.showStack = false, }); final Key? key; @@ -745,9 +750,11 @@ class GalleryViewerRouteArgs { final int heroOffset; + final bool showStack; + @override String toString() { - return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset}'; + return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack}'; } } @@ -961,14 +968,16 @@ class AssetSelectionRoute extends PageRouteInfo { AssetSelectionRoute({ Key? key, required Set existingAssets, - bool isNewAlbum = false, + bool canDeselect = false, + required QueryBuilder? query, }) : super( AssetSelectionRoute.name, path: '/asset-selection-page', args: AssetSelectionRouteArgs( key: key, existingAssets: existingAssets, - isNewAlbum: isNewAlbum, + canDeselect: canDeselect, + query: query, ), ); @@ -979,18 +988,21 @@ class AssetSelectionRouteArgs { const AssetSelectionRouteArgs({ this.key, required this.existingAssets, - this.isNewAlbum = false, + this.canDeselect = false, + required this.query, }); final Key? key; final Set existingAssets; - final bool isNewAlbum; + final bool canDeselect; + + final QueryBuilder? query; @override String toString() { - return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, isNewAlbum: $isNewAlbum}'; + return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, canDeselect: $canDeselect, query: $query}'; } } diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index 74d9380be9..66f2cc9f37 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -31,7 +31,9 @@ class Asset { remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null, isFavorite = remote.isFavorite, isArchived = remote.isArchived, - isTrashed = remote.isTrashed; + isTrashed = remote.isTrashed, + stackParentId = remote.stackParentId, + stackCount = remote.stackCount; Asset.local(AssetEntity local, List hash) : localId = local.id, @@ -47,6 +49,7 @@ class Asset { isFavorite = local.isFavorite, isArchived = false, isTrashed = false, + stackCount = 0, fileCreatedAt = local.createDateTime { if (fileCreatedAt.year == 1970) { fileCreatedAt = fileModifiedAt; @@ -77,6 +80,8 @@ class Asset { required this.isFavorite, required this.isArchived, required this.isTrashed, + this.stackParentId, + required this.stackCount, }); @ignore @@ -146,6 +151,10 @@ class Asset { @ignore ExifInfo? exifInfo; + String? stackParentId; + + int stackCount; + /// `true` if this [Asset] is present on the device @ignore bool get isLocal => localId != null; @@ -200,7 +209,9 @@ class Asset { isFavorite == other.isFavorite && isLocal == other.isLocal && isArchived == other.isArchived && - isTrashed == other.isTrashed; + isTrashed == other.isTrashed && + stackCount == other.stackCount && + stackParentId == other.stackParentId; } @override @@ -223,7 +234,9 @@ class Asset { isFavorite.hashCode ^ isLocal.hashCode ^ isArchived.hashCode ^ - isTrashed.hashCode; + isTrashed.hashCode ^ + stackCount.hashCode ^ + stackParentId.hashCode; /// Returns `true` if this [Asset] can updated with values from parameter [a] bool canUpdate(Asset a) { @@ -236,9 +249,11 @@ class Asset { width == null && a.width != null || height == null && a.height != null || livePhotoVideoId == null && a.livePhotoVideoId != null || + stackParentId == null && a.stackParentId != null || isFavorite != a.isFavorite || isArchived != a.isArchived || - isTrashed != a.isTrashed; + isTrashed != a.isTrashed || + stackCount != a.stackCount; } /// Returns a new [Asset] with values from this and merged & updated with [a] @@ -267,6 +282,8 @@ class Asset { id: id, remoteId: remoteId, livePhotoVideoId: livePhotoVideoId, + stackParentId: stackParentId, + stackCount: stackCount, isFavorite: isFavorite, isArchived: isArchived, isTrashed: isTrashed, @@ -281,6 +298,8 @@ class Asset { width: a.width, height: a.height, livePhotoVideoId: a.livePhotoVideoId, + stackParentId: a.stackParentId, + stackCount: a.stackCount, // isFavorite + isArchived are not set by device-only assets isFavorite: a.isFavorite, isArchived: a.isArchived, @@ -318,6 +337,8 @@ class Asset { bool? isArchived, bool? isTrashed, ExifInfo? exifInfo, + String? stackParentId, + int? stackCount, }) => Asset( id: id ?? this.id, @@ -338,6 +359,8 @@ class Asset { isArchived: isArchived ?? this.isArchived, isTrashed: isTrashed ?? this.isTrashed, exifInfo: exifInfo ?? this.exifInfo, + stackParentId: stackParentId ?? this.stackParentId, + stackCount: stackCount ?? this.stackCount, ); Future put(Isar db) async { @@ -379,6 +402,8 @@ class Asset { "checksum": "$checksum", "ownerId": $ownerId, "livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}", + "stackCount": "$stackCount", + "stackParentId": "${stackParentId ?? "N/A"}", "fileCreatedAt": "$fileCreatedAt", "fileModifiedAt": "$fileModifiedAt", "updatedAt": "$updatedAt", diff --git a/mobile/lib/shared/models/asset.g.dart b/mobile/lib/shared/models/asset.g.dart index f06e556eaf19cd245d18fe690d1447cae1faeb7f..4f485dfb024b0656b4c0d2f3bbd7c04990b688aa 100644 GIT binary patch delta 1319 zcmbPmh-E@6>xMW^&f=29!f8Scq`vT~D^(Evc3E7D=+<;$h^#oc!J-aPo$5fytc{jd+nf=$SHk z;X*$Yl@AxqB%nfb=W!Gr6LJrU!*iG8-C(46eE858Nv`mIIC5h7_}zW7 z!xJ_hux3cM^PIksm(gqTf?J#@b_Klh<3>_7`N8uDWckgBZ-OL{>_K(I#jkkX;7gSK z9+S`iXT#@)$%$_~L29;dW??MhLvf7P^j;oDUPP!&SL9}N;Y0F|XNtyTfw@^=Wqie; zpoE4-iu&Y09_h&r5xmp$1Q?yRs|z!J;hw%%mQfrj9H;BcGA5v!t}&fgmQk7q*{JD> UqKt{CB7%I3y4w}x89y)r00RgB9RL6T delta 160 zcmbQx$~xf?%Z514$xd8KliN52Czo>7PW}$0Q=SV=&Sq7cY|X7aIfYAbb3XS@_RR~0 zJ((wmi}NuWPfimzXEd2SPu!5vbn^vq4d%)5(l$WhInq8r;g{07Kw)v2o6M7Q6jCNj zD#%QZl~tS^r64r((ref, userId) async* { - if (userId == null) return; - final query = ref +QueryBuilder? getRemoteAssetQuery(WidgetRef ref) { + final userId = ref.watch(currentUserProvider)?.isarId; + if (userId == null) { + return null; + } + return ref .watch(dbProvider) .assets .where() @@ -238,12 +242,34 @@ final remoteAssetsProvider = .filter() .ownerIdEqualTo(userId) .isTrashedEqualTo(false) + .stackParentIdIsNull() .sortByFileCreatedAtDesc(); - final settings = ref.watch(appSettingsServiceProvider); - final groupBy = - GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)]; - yield await RenderList.fromQuery(query, groupBy); - await for (final _ in query.watchLazy()) { - yield await RenderList.fromQuery(query, groupBy); +} + +QueryBuilder? getAssetStackSelectionQuery( + WidgetRef ref, + Asset parentAsset, +) { + final userId = ref.watch(currentUserProvider)?.isarId; + if (userId == null || !parentAsset.isRemote) { + return null; } -}); + return ref + .watch(dbProvider) + .assets + .where() + .remoteIdIsNotNull() + .filter() + .isArchivedEqualTo(false) + .ownerIdEqualTo(userId) + .not() + .remoteIdEqualTo(parentAsset.remoteId) + // Show existing stack children in selection page + .group( + (q) => q + .stackParentIdIsNull() + .or() + .stackParentIdEqualTo(parentAsset.remoteId), + ) + .sortByFileCreatedAtDesc(); +} diff --git a/mobile/lib/shared/providers/websocket.provider.dart b/mobile/lib/shared/providers/websocket.provider.dart index ed529a6d8d..1dda262f50 100644 --- a/mobile/lib/shared/providers/websocket.provider.dart +++ b/mobile/lib/shared/providers/websocket.provider.dart @@ -133,6 +133,7 @@ class WebsocketNotifier extends StateNotifier { socket.on('on_asset_delete', _handleOnAssetDelete); socket.on('on_asset_trash', _handleServerUpdates); socket.on('on_asset_restore', _handleServerUpdates); + socket.on('on_asset_update', _handleServerUpdates); } catch (e) { debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}"); } diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index bf699a3133..85b96e6473 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -149,6 +149,7 @@ doc/TranscodePolicy.md doc/UpdateAlbumDto.md doc/UpdateAssetDto.md doc/UpdateLibraryDto.md +doc/UpdateStackParentDto.md doc/UpdateTagDto.md doc/UpdateUserDto.md doc/UsageByUserDto.md @@ -314,6 +315,7 @@ lib/model/transcode_policy.dart lib/model/update_album_dto.dart lib/model/update_asset_dto.dart lib/model/update_library_dto.dart +lib/model/update_stack_parent_dto.dart lib/model/update_tag_dto.dart lib/model/update_user_dto.dart lib/model/usage_by_user_dto.dart @@ -468,6 +470,7 @@ test/transcode_policy_test.dart test/update_album_dto_test.dart test/update_asset_dto_test.dart test/update_library_dto_test.dart +test/update_stack_parent_dto_test.dart test/update_tag_dto_test.dart test/update_user_dto_test.dart test/usage_by_user_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c8c913977d131eba128d38ac2c8e55e447220610..47d04b9bd2d6f15c66ed5071d20a712233aedf9f 100644 GIT binary patch delta 131 zcmX@OlJW5>#to*D{3(egslg?Q$=LykMX7lulLduD4N40@qQxN50uZ&?1hJ7WI13$ZQA^9}FxXl$q8_*u0PiqvSwY0FgK}#!E14Dds I;ui%00NdbxeEuZ diff --git a/mobile/openapi/doc/AssetBulkUpdateDto.md b/mobile/openapi/doc/AssetBulkUpdateDto.md index b48268464201011b2492698dbf5b53493bead375..74fd5ec45311a6c4c35df8d5949165cccd117299 100644 GIT binary patch delta 57 zcmeBSdC9UNosmyVt0*-$zbrK%u_!gKWU?%yqEvB7VsbWw@0p^drBI`wr4?LKl$n=4 IIhb)L05i@Lga7~l delta 11 ScmaFK(!;VLopJIJ#-#uozyy;3 diff --git a/mobile/openapi/doc/AssetResponseDto.md b/mobile/openapi/doc/AssetResponseDto.md index a08be71ace26c9790f78a1c32dfb745809198756..8c4d1db4a74fe02e8f0cc91bbf7aee4cadd16ee6 100644 GIT binary patch delta 117 zcmX@f`-g9XEvrs(Nn&!gmX<<|LbR5aPiAq6jbm|fYDrLPaY24wajHv6zMYmuyjCTQEFa^=j1>pvB`PNe4A}qdl&&K CRVF+D delta 12 TcmeyvcanF5E$ikFtUZhXBy|M{ diff --git a/mobile/openapi/doc/UpdateStackParentDto.md b/mobile/openapi/doc/UpdateStackParentDto.md new file mode 100644 index 0000000000000000000000000000000000000000..750daace0c1b8c2951bc35faee84b97cdbe3a6ed GIT binary patch literal 456 zcma)&K}!QM5QXpg6$5*y4J5tmsj@v3Y+0ySUsq@gg5!- zP3BcV38PEF4&<@7kI6g69lP$OaTz1mOiEQ1+>!IZ&j=F-NdR|5)wZp7nN=7PWro?g zetmXb6z2lWZVEa%HCY}r2OQPb4G16b@Se(5xgfs zHP&8h;kDjz4)Hh%(SlI#e@E1Qe=FMp6KqOG(1(!^hbwCr_p4>SS=qr?p1RjT;&SEp j=83YIavJCG)xB)i|Lm323`U delta 12 TcmX?OzR-L_wB+VCNjD|{BQ*q? diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 91429ce7e513132cd2e7ad201280a9a854191cc2..5935f56769bf158822e844cd71eb6641142ff5ad 100644 GIT binary patch delta 344 zcmZoW$9&;B^9IigllPqF!vy#TsBlMgQW zI$7b|W}+P>Ihp@Fk0Fx7h<1$d|}qo8I$jCRo@(PVUrjDE7FQw delta 18 acmca`ow@BC^9Iiglh3c@-Yj=@lNbP8WeHCJ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 9a98b4997ab8eaf952fccb0f7380a85315e66851..34b9a431d44e16475b25069c81f2e32723fe50f2 100644 GIT binary patch delta 52 ycmX@KlyTlN#to0%xr0j*ld}U7i&FDSCOgWA3x*b?B$lKiizZ4bZ@%mPlnVeVITXbJ delta 14 WcmbQYjPcM?#to0%H`{yM=K=sUcLu8f diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index 7eb0e31afce8a999e467e19213606ce938026212..64c8d1e7e7cdf0ce823e54847a74e333cbe1a572 100644 GIT binary patch delta 686 zcmZ3Z_)BNQX+~`Yg_4ZSV!fi&-2AfCfW)HIyb>KQ1t0(m7ndX^XG26iQ#K!GWMSSM z$(+o{gRFdW2y+snXmCkUW?s6z0*a2w$5_rsD5$A{4OGZ4$w)0iu}Q(!Rsq>4G^HqJ zfz+Z1OrFYms$M}GY_SZJrXE^7lq-w%+qL>M?1W7B3 z9FksZE}*RrzyO<^!Io*IgJOlP3bI>tP~B{+f@0+6i)?j_qM2z58W@o<`8K!SJ_Qti zPd4L{4@Pl`CL|$&5)+DUD}~&|g0RG#(p3MnkjjEo8%Wx*)5t2$&jSXgI*RJe^SJof E0NZZrHvj+t delta 38 wcmV+>0NMZgEUY20%K@YM0Rxle2g;KN3Cfd43Uaga3Vs2zlMPt{vvv>!22RNjJpcdz diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index b2feb0ee8236f75a66f1a1602e1c0655d0f5fd28..e580ca5a2fbeb860bec6c0b225481f6957447900 100644 GIT binary patch delta 717 zcmcZ|b3J*39}8=7Nn&#LWKI@&B{0i5zcjBzhf4tnN-{Ew^}vDwiAAY-C7vmpomu{{ zxi}UVrE4G|17Hoietb#343|K=#Mj@*>KQFpS9pQ2`bzn;%iZjy`G%%xZ@<(w= zh!bI!qL}~_p8Q`%o(GgR-~t9Ra+_Ubw{x(8ZMT~IQCohptQtEcbk$=uA*lwGauCk5 zQpim#2usWV(sLO%F29T<6zEE-~2(L0fVvt=nS2$QBU EG4#3da5X=_y!iVB z&B*di#*~R)ML)k9(Wh8fQt>30D#=C3&!DLq&9j7;e8VdfcHd%COJxQ2l9hTj;Le)+%@COS6~?nVF;1mPn$jjA*- zsQ4C^L$)grw#<}A=rm*zP&9xJ0x*Tp5yo@rF0*i8Tb7x>y+6fz0d9;E)tYMQHvMJK z$1cB-U%65U9(vwNS<>+pGg3hu2lLy{JVj7U!PP0mQ*a=stPH5Wu-p0e&HV-92T(nK zZp(L8{;@ENUnv|Ldc~5V_j#{&Q5jfs0|nE??=s_9txegBl(bp#lxxk@joausEct5FWjb=_^^4egcf)s-5-HPY&=Bv(#sY<^F9#K1E=vb?_)K#`L0;~s|z1O zw!=OV=@g87r)7otoQ85yu@XMx62x%N`fmT)abYc%YV4&Suh8Rr<$Y)F)pRiT7M;1W zJz-PO!k&crpL+#Zm4O=_8idjCm6Z+GkSn=q<1OtHwpWe@W9q>rLbM8@YD&~H33n^B z?K)2hwc3wb^xXku{u&K^tCf zaiCk%!+yuB<2)YZDkACHQ{e$xpa`skR!(ppB3OP{x)ta+5z8{CD9_Wu+`)YWdh2B* z9WCe}JA>{o8^v~J%ZNW*25}=%4f4{J>)=5vfAS25>c)^y0p@>@0ZUcv82VAe;FM`! zC1-#`-aQ}^yu#@3 zM>E*^JB?BS50H$mePiL#@soz2MM}Ecdn-(VOVm^JycuHSr87m(cE;kBf-dydqlkw& zc;#I`q^jZenvxT34{9ZZr}Z$#@Ky9cy8ol4C(7h0l56L$Hy<8V6rSyv@F7+rqO6C| z6IR%_@S&N*UTsb+N0R{4c_Nv>qTszA`WGikPvhm!kn#qla?1g)NsVc#H5k0RnP1c6 kwVf6A?(Cndf0A$i*qGi~NRD0>?uHrrR|YNNV+1GAzj{v=RR910 literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 640652167daca77bef745ac3dd7ac28d5a7e7b73..8e45a1e3cb74491e3a6912e565db6ebc5b0ab983 100644 GIT binary patch delta 89 zcmaE)_g#O(I#COS(t?!4lGNam#N_ON#G=%^5{*!F5tovD3}urqh$wO+>6+ZgWwd#{ H=w?O$N)sal delta 16 Ycmeya|447cI?>6wg0h070h)nE(I) diff --git a/mobile/openapi/test/asset_bulk_update_dto_test.dart b/mobile/openapi/test/asset_bulk_update_dto_test.dart index cb23751e084df51ec7770e01d42fb9558d75edc1..06f65de66600fce926c32a7769dae151e00960e5 100644 GIT binary patch delta 91 zcmZ3+*1)krhIz6d6Aw>OYHog6YCvL9YF^3Y(@ct}0{u+7BEcm^nR)37#U+W!*${P} SDPWbzB9r@=)F;a@a{&Or_aFfP delta 18 ZcmZqRSjM(NhM9{?L7`UDnv1KJ3jird1JnQj diff --git a/mobile/openapi/test/asset_response_dto_test.dart b/mobile/openapi/test/asset_response_dto_test.dart index f450aae2748e89bf6a5bb7fb54e714cf50fbe990..63668934a95743ab92f9d93299eabd177d0e532f 100644 GIT binary patch delta 118 zcmZ1?(WD7CmCKd(5|r6k`@p|~V5IeT&=k2NcVwUASnFEg(MBJP}D snpZMekVlCVqzb}Y$Z5h0RvM64l$uxKnF5mM0m;J!fM#sY;Mv0n0NiLQ8~^|S delta 12 TcmZpYUm~+1muK^7-tCM4AK?Vf diff --git a/mobile/openapi/test/update_stack_parent_dto_test.dart b/mobile/openapi/test/update_stack_parent_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..6af71854ec9b894b1b81e4f464d4eec1a24830d6 GIT binary patch literal 697 zcma)&QES356oudWEAF1E;OyqpPzDoPhp^c(>%*R6Xm4v+O|m4N$nd}KHk}}>4?Z-_ zg>%k#ZlWlNBA9Qo^!7PfCGYbriD9vPNk$N-uu3y{P2=U_jOVprW?l$el^07UGhDhnj88%G?v1R z?YHMjTF0ehq9C9s1sZR+y|7wHw5(RLBAMH9$sX=lzT?IQLDvyN&GS!Q;Z$jqT#Jal zal3Um`zLW|UDO_DK?`kX3-lzg(FJ$7b`F%i literal 0 HcmV?d00001 diff --git a/mobile/test/asset_grid_data_structure_test.dart b/mobile/test/asset_grid_data_structure_test.dart index a124f5214a..6b8f080638 100644 --- a/mobile/test/asset_grid_data_structure_test.dart +++ b/mobile/test/asset_grid_data_structure_test.dart @@ -25,6 +25,7 @@ void main() { isFavorite: false, isArchived: false, isTrashed: false, + stackCount: 0, ), ); } diff --git a/mobile/test/sync_service_test.dart b/mobile/test/sync_service_test.dart index 9c03ec689b..b2543c6635 100644 --- a/mobile/test/sync_service_test.dart +++ b/mobile/test/sync_service_test.dart @@ -35,6 +35,7 @@ void main() { isFavorite: false, isArchived: false, isTrashed: false, + stackCount: 0, ); } diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 50224f8a00..73ec9d1e7c 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -1673,6 +1673,41 @@ ] } }, + "/asset/stack/parent": { + "put": { + "operationId": "updateStackParent", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStackParentDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Asset" + ] + } + }, "/asset/statistics": { "get": { "operationId": "getAssetStats", @@ -5696,6 +5731,13 @@ }, "isFavorite": { "type": "boolean" + }, + "removeParent": { + "type": "boolean" + }, + "stackParentId": { + "format": "uuid", + "type": "string" } }, "required": [ @@ -5941,6 +5983,19 @@ "smartInfo": { "$ref": "#/components/schemas/SmartInfoResponseDto" }, + "stack": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + }, + "stackCount": { + "type": "integer" + }, + "stackParentId": { + "nullable": true, + "type": "string" + }, "tags": { "items": { "$ref": "#/components/schemas/TagResponseDto" @@ -5961,6 +6016,7 @@ }, "required": [ "type", + "stackCount", "deviceAssetId", "deviceId", "ownerId", @@ -8521,6 +8577,23 @@ }, "type": "object" }, + "UpdateStackParentDto": { + "properties": { + "newParentId": { + "format": "uuid", + "type": "string" + }, + "oldParentId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "oldParentId", + "newParentId" + ], + "type": "object" + }, "UpdateTagDto": { "properties": { "name": { diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 20e86f159d..763256d0db 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -20,6 +20,7 @@ import { Readable } from 'stream'; import { JobName } from '../job'; import { AssetStats, + CommunicationEvent, IAssetRepository, ICommunicationRepository, ICryptoRepository, @@ -636,10 +637,89 @@ describe(AssetService.name, () => { await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); }); + + /// Stack related + + it('should require asset update access for parent', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'parent').mockResolvedValue(false); + await expect( + sut.updateAll(authStub.user1, { + ids: ['asset-1'], + stackParentId: 'parent', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should update parent asset when children are added', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.updateAll(authStub.user1, { + ids: [], + stackParentId: 'parent', + }), + expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { stackParentId: null }); + }); + + it('should update parent asset when children are removed', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([{ id: 'child-1', stackParentId: 'parent' } as AssetEntity]); + + await sut.updateAll(authStub.user1, { + ids: ['child-1'], + removeParent: true, + }), + expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), { stackParentId: null }); + }); + + it('update parentId for new children', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.updateAll(authStub.user1, { + stackParentId: 'parent', + ids: ['child-1', 'child-2'], + }); + + expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' }); + }); + + it('nullify parentId for remove children', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.updateAll(authStub.user1, { + removeParent: true, + ids: ['child-1', 'child-2'], + }); + + expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: null }); + }); + + it('merge stacks if new child has children', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([ + { id: 'child-1', stack: [{ id: 'child-2' } as AssetEntity] } as AssetEntity, + ]); + + await sut.updateAll(authStub.user1, { + ids: ['child-1'], + stackParentId: 'parent', + }); + + expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' }); + }); + + it('should send ws asset update event', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.updateAll(authStub.user1, { + ids: ['asset-1'], + stackParentId: 'parent', + }); + + expect(communicationMock.send).toHaveBeenCalledWith(CommunicationEvent.ASSET_UPDATE, authStub.user1.id, [ + 'asset-1', + ]); + }); }); describe('deleteAll', () => { - it('should required asset delete access for all ids', async () => { + it('should require asset delete access for all ids', async () => { accessMock.asset.hasOwnerAccess.mockResolvedValue(false); await expect( sut.deleteAll(authStub.user1, { @@ -677,7 +757,7 @@ describe(AssetService.name, () => { }); describe('restoreAll', () => { - it('should required asset restore access for all ids', async () => { + it('should require asset restore access for all ids', async () => { accessMock.asset.hasOwnerAccess.mockResolvedValue(false); await expect( sut.deleteAll(authStub.user1, { @@ -757,6 +837,21 @@ describe(AssetService.name, () => { expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace); }); + it('should update stack parent if asset has stack children', async () => { + when(assetMock.getById) + .calledWith(assetStub.primaryImage.id) + .mockResolvedValue(assetStub.primaryImage as AssetEntity); + + await sut.handleAssetDeletion({ id: assetStub.primaryImage.id }); + + expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-2'], { + stackParentId: 'stack-child-asset-1', + }); + expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-1'], { + stackParentId: null, + }); + }); + it('should not schedule delete-files job for readonly assets', async () => { when(assetMock.getById) .calledWith(assetStub.readOnly.id) @@ -854,4 +949,70 @@ describe(AssetService.name, () => { expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }); }); }); + + describe('updateStackParent', () => { + it('should require asset update access for new parent', async () => { + when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(true); + when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(false); + accessMock.asset.hasOwnerAccess.mockResolvedValue(false); + await expect( + sut.updateStackParent(authStub.user1, { + oldParentId: 'old', + newParentId: 'new', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should require asset read access for old parent', async () => { + when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(false); + when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(true); + await expect( + sut.updateStackParent(authStub.user1, { + oldParentId: 'old', + newParentId: 'new', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('make old parent the child of new parent', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + when(assetMock.getById) + .calledWith(assetStub.image.id) + .mockResolvedValue(assetStub.image as AssetEntity); + + await sut.updateStackParent(authStub.user1, { + oldParentId: assetStub.image.id, + newParentId: 'new', + }); + + expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id], { stackParentId: 'new' }); + }); + + it('remove stackParentId of new parent', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.updateStackParent(authStub.user1, { + oldParentId: assetStub.primaryImage.id, + newParentId: 'new', + }); + + expect(assetMock.updateAll).toBeCalledWith(['new'], { stackParentId: null }); + }); + + it('update stackParentId of old parents children to new parent', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + when(assetMock.getById) + .calledWith(assetStub.primaryImage.id) + .mockResolvedValue(assetStub.primaryImage as AssetEntity); + + await sut.updateStackParent(authStub.user1, { + oldParentId: assetStub.primaryImage.id, + newParentId: 'new', + }); + + expect(assetMock.updateAll).toBeCalledWith( + [assetStub.primaryImage.id, 'stack-child-asset-1', 'stack-child-asset-2'], + { stackParentId: 'new' }, + ); + }); + }); }); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index abd0dbe0d9..57623fa1b2 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -40,6 +40,7 @@ import { TimeBucketDto, TrashAction, UpdateAssetDto, + UpdateStackParentDto, mapStats, } from './dto'; import { @@ -208,7 +209,7 @@ export class AssetService { if (authUser.isShowMetadata) { return assets.map((asset) => mapAsset(asset)); } else { - return assets.map((asset) => mapAsset(asset, true)); + return assets.map((asset) => mapAsset(asset, { stripMetadata: true })); } } @@ -338,10 +339,29 @@ export class AssetService { } async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise { - const { ids, ...options } = dto; + const { ids, removeParent, ...options } = dto; await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids); + + if (removeParent) { + (options as Partial).stackParentId = null; + const assets = await this.assetRepository.getByIds(ids); + // This updates the updatedAt column of the parents to indicate that one of its children is removed + // All the unique parent's -> parent is set to null + ids.push(...new Set(assets.filter((a) => !!a.stackParentId).map((a) => a.stackParentId!))); + } else if (options.stackParentId) { + await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, options.stackParentId); + // Merge stacks + const assets = await this.assetRepository.getByIds(ids); + const assetsWithChildren = assets.filter((a) => a.stack && a.stack.length > 0); + ids.push(...assetsWithChildren.flatMap((child) => child.stack!.map((gChild) => gChild.id))); + + // This updates the updatedAt column of the parent to indicate that a new child has been added + await this.assetRepository.updateAll([options.stackParentId], { stackParentId: null }); + } + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); await this.assetRepository.updateAll(ids, options); + this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids); } async handleAssetDeletionCheck() { @@ -384,6 +404,14 @@ export class AssetService { ); } + // Replace the parent of the stack children with a new asset + if (asset.stack && asset.stack.length != 0) { + const stackIds = asset.stack.map((a) => a.id); + const newParentId = stackIds[0]; + await this.assetRepository.updateAll(stackIds.slice(1), { stackParentId: newParentId }); + await this.assetRepository.updateAll([newParentId], { stackParentId: null }); + } + await this.assetRepository.remove(asset); await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } }); this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id); @@ -454,6 +482,25 @@ export class AssetService { this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids); } + async updateStackParent(authUser: AuthUserDto, dto: UpdateStackParentDto): Promise { + const { oldParentId, newParentId } = dto; + await this.access.requirePermission(authUser, Permission.ASSET_READ, oldParentId); + await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, newParentId); + + const childIds: string[] = []; + const oldParent = await this.assetRepository.getById(oldParentId); + if (oldParent != null) { + childIds.push(oldParent.id); + // Get all children of old parent + childIds.push(...(oldParent.stack?.map((a) => a.id) ?? [])); + } + + this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, [...childIds, newParentId]); + await this.assetRepository.updateAll(childIds, { stackParentId: newParentId }); + // Remove ParentId of new parent if this was previously a child of some other asset + return this.assetRepository.updateAll([newParentId], { stackParentId: null }); + } + async run(authUser: AuthUserDto, dto: AssetJobsDto) { await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds); diff --git a/server/src/domain/asset/dto/asset-stack.dto.ts b/server/src/domain/asset/dto/asset-stack.dto.ts new file mode 100644 index 0000000000..80dabdb34b --- /dev/null +++ b/server/src/domain/asset/dto/asset-stack.dto.ts @@ -0,0 +1,9 @@ +import { ValidateUUID } from '../../domain.util'; + +export class UpdateStackParentDto { + @ValidateUUID() + oldParentId!: string; + + @ValidateUUID() + newParentId!: string; +} diff --git a/server/src/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts index f5ada315c1..0b3ce68d5c 100644 --- a/server/src/domain/asset/dto/asset.dto.ts +++ b/server/src/domain/asset/dto/asset.dto.ts @@ -1,6 +1,6 @@ import { Type } from 'class-transformer'; import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator'; -import { Optional } from '../../domain.util'; +import { Optional, ValidateUUID } from '../../domain.util'; import { BulkIdsDto } from '../response-dto'; export class AssetBulkUpdateDto extends BulkIdsDto { @@ -11,6 +11,14 @@ export class AssetBulkUpdateDto extends BulkIdsDto { @Optional() @IsBoolean() isArchived?: boolean; + + @Optional() + @ValidateUUID() + stackParentId?: string; + + @Optional() + @IsBoolean() + removeParent?: boolean; } export class UpdateAssetDto { diff --git a/server/src/domain/asset/dto/index.ts b/server/src/domain/asset/dto/index.ts index 8e780869a5..281d924f32 100644 --- a/server/src/domain/asset/dto/index.ts +++ b/server/src/domain/asset/dto/index.ts @@ -1,4 +1,5 @@ export * from './asset-ids.dto'; +export * from './asset-stack.dto'; export * from './asset-statistics.dto'; export * from './asset.dto'; export * from './download.dto'; diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index e7d5061be1..0e57840553 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-response.dto.ts @@ -42,9 +42,20 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { people?: PersonResponseDto[]; /**base64 encoded sha1 hash */ checksum!: string; + stackParentId?: string | null; + stack?: AssetResponseDto[]; + @ApiProperty({ type: 'integer' }) + stackCount!: number; } -export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetResponseDto { +export type AssetMapOptions = { + stripMetadata?: boolean; + withStack?: boolean; +}; + +export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { + const { stripMetadata = false, withStack = false } = options; + const sanitizedAssetResponse: SanitizedAssetResponseDto = { id: entity.id, type: entity.type, @@ -87,6 +98,9 @@ export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetRespo tags: entity.tags?.map(mapTag), people: entity.faces?.map(mapFace).filter((person) => !person.isHidden), checksum: entity.checksum.toString('base64'), + stackParentId: entity.stackParentId, + stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined, + stackCount: entity.stack?.length ?? 0, isExternal: entity.isExternal, isOffline: entity.isOffline, isReadOnly: entity.isReadOnly, diff --git a/server/src/domain/repositories/communication.repository.ts b/server/src/domain/repositories/communication.repository.ts index f49beeb502..f4c06a1e9a 100644 --- a/server/src/domain/repositories/communication.repository.ts +++ b/server/src/domain/repositories/communication.repository.ts @@ -4,6 +4,7 @@ export enum CommunicationEvent { UPLOAD_SUCCESS = 'on_upload_success', ASSET_DELETE = 'on_asset_delete', ASSET_TRASH = 'on_asset_trash', + ASSET_UPDATE = 'on_asset_update', ASSET_RESTORE = 'on_asset_restore', PERSON_THUMBNAIL = 'on_person_thumbnail', SERVER_VERSION = 'on_server_version', diff --git a/server/src/domain/shared-link/shared-link-response.dto.ts b/server/src/domain/shared-link/shared-link-response.dto.ts index 52592d36fa..4e35f65462 100644 --- a/server/src/domain/shared-link/shared-link-response.dto.ts +++ b/server/src/domain/shared-link/shared-link-response.dto.ts @@ -58,7 +58,7 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): Shar type: sharedLink.type, createdAt: sharedLink.createdAt, expiresAt: sharedLink.expiresAt, - assets: assets.map((asset) => mapAsset(asset, true)) as AssetResponseDto[], + assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })) as AssetResponseDto[], album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, allowUpload: sharedLink.allowUpload, allowDownload: sharedLink.allowDownload, diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts index a41b18341a..e0e239f6dd 100644 --- a/server/src/immich/api-v1/asset/asset-repository.ts +++ b/server/src/immich/api-v1/asset/asset-repository.ts @@ -109,6 +109,7 @@ export class AssetRepository implements IAssetRepository { faces: { person: true, }, + stack: true, }, // We are specifically asking for this asset. Return it even if it is soft deleted withDeleted: true, @@ -131,6 +132,7 @@ export class AssetRepository implements IAssetRepository { relations: { exifInfo: true, tags: true, + stack: true, }, skip: dto.skip || 0, order: { diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index d3c1fe8764..415fb380de 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -196,7 +196,7 @@ export class AssetService { const includeMetadata = this.getExifPermission(authUser); const asset = await this._assetRepository.getById(assetId); if (includeMetadata) { - const data = mapAsset(asset); + const data = mapAsset(asset, { withStack: true }); if (data.ownerId !== authUser.id) { data.people = []; @@ -208,7 +208,7 @@ export class AssetService { return data; } else { - return mapAsset(asset, true); + return mapAsset(asset, { stripMetadata: true, withStack: true }); } } diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index f4f376e98d..6a91bad30e 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -21,6 +21,7 @@ import { TimeBucketResponseDto, TrashAction, UpdateAssetDto as UpdateDto, + UpdateStackParentDto, } from '@app/domain'; import { Body, @@ -137,6 +138,12 @@ export class AssetController { return this.service.handleTrashAction(authUser, TrashAction.RESTORE_ALL); } + @Put('stack/parent') + @HttpCode(HttpStatus.OK) + updateStackParent(@AuthUser() authUser: AuthUserDto, @Body() dto: UpdateStackParentDto): Promise { + return this.service.updateStackParent(authUser, dto); + } + @Put(':id') updateAsset( @AuthUser() authUser: AuthUserDto, diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts index 31935ae5f2..937107f9d1 100644 --- a/server/src/infra/entities/asset.entity.ts +++ b/server/src/infra/entities/asset.entity.ts @@ -148,6 +148,16 @@ export class AssetEntity { @OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset) faces!: AssetFaceEntity[]; + + @Column({ nullable: true }) + stackParentId?: string | null; + + @ManyToOne(() => AssetEntity, (asset) => asset.stack, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' }) + @JoinColumn({ name: 'stackParentId' }) + stackParent?: AssetEntity | null; + + @OneToMany(() => AssetEntity, (asset) => asset.stackParent) + stack?: AssetEntity[]; } export enum AssetType { diff --git a/server/src/infra/migrations/1695354433573-AddStackParentIdToAssets.ts b/server/src/infra/migrations/1695354433573-AddStackParentIdToAssets.ts new file mode 100644 index 0000000000..d5150d3a81 --- /dev/null +++ b/server/src/infra/migrations/1695354433573-AddStackParentIdToAssets.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddStackParentIdToAssets1695354433573 implements MigrationInterface { + name = 'AddStackParentIdToAssets1695354433573' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" ADD "stackParentId" uuid`); + await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "FK_b463c8edb01364bf2beba08ef19" FOREIGN KEY ("stackParentId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_b463c8edb01364bf2beba08ef19"`); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "stackParentId"`); + } + +} diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 76e7d4d930..a740cf583a 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -112,6 +112,7 @@ export class AssetRepository implements IAssetRepository { faces: { person: true, }, + stack: true, }, withDeleted: true, }); @@ -192,6 +193,7 @@ export class AssetRepository implements IAssetRepository { person: true, }, library: true, + stack: true, }, // We are specifically asking for this asset. Return it even if it is soft deleted withDeleted: true, @@ -538,6 +540,12 @@ export class AssetRepository implements IAssetRepository { .andWhere('person.id = :personId', { personId }); } + // Hide stack children only in main timeline + // Uncomment after adding support for stacked assets in web client + // if (!isArchived && !isFavorite && !personId && !albumId && !isTrashed) { + // builder = builder.andWhere('asset.stackParent IS NULL'); + // } + return builder; } } diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts index c18268502b..b9b10e1044 100644 --- a/server/test/e2e/asset.e2e-spec.ts +++ b/server/test/e2e/asset.e2e-spec.ts @@ -626,4 +626,167 @@ describe(`${AssetController.name} (e2e)`, () => { expect(body).toEqual([expect.objectContaining({ id: asset2.id })]); }); }); + + describe('PUT /asset', () => { + beforeEach(async () => { + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] }); + + expect(status).toBe(204); + }); + + it('should require authentication', async () => { + const { status, body } = await request(server).put('/asset'); + + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should require a valid parent id', async () => { + const { status, body } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: uuidStub.invalid, ids: [asset1.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest(['stackParentId must be a UUID'])); + }); + + it('should require access to the parent', async () => { + const { status, body } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: asset4.id, ids: [asset1.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.noPermission); + }); + + it('should add stack children', async () => { + const parent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01')); + const child = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01')); + + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: parent.id, ids: [child.id] }); + + expect(status).toBe(204); + + const asset = await api.assetApi.get(server, user1.accessToken, parent.id); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: child.id })])); + }); + + it('should remove stack children', async () => { + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ removeParent: true, ids: [asset2.id] }); + + expect(status).toBe(204); + + const asset = await api.assetApi.get(server, user1.accessToken, asset1.id); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })])); + }); + + it('should remove all stack children', async () => { + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ removeParent: true, ids: [asset2.id, asset3.id] }); + + expect(status).toBe(204); + + const asset = await api.assetApi.get(server, user1.accessToken, asset1.id); + expect(asset.stack).toHaveLength(0); + }); + + it('should merge stack children', async () => { + const newParent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01')); + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: newParent.id, ids: [asset1.id] }); + + expect(status).toBe(204); + + const asset = await api.assetApi.get(server, user1.accessToken, newParent.id); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: asset1.id }), + expect.objectContaining({ id: asset2.id }), + expect.objectContaining({ id: asset3.id }), + ]), + ); + }); + }); + + describe('PUT /asset/stack/parent', () => { + beforeEach(async () => { + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] }); + + expect(status).toBe(204); + }); + + it('should require authentication', async () => { + const { status, body } = await request(server).put('/asset/stack/parent'); + + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(server) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ oldParentId: uuidStub.invalid, newParentId: uuidStub.invalid }); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest()); + }); + + it('should require access', async () => { + const { status, body } = await request(server) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ oldParentId: asset4.id, newParentId: asset1.id }); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.noPermission); + }); + + it('should make old parent child of new parent', async () => { + const { status } = await request(server) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ oldParentId: asset1.id, newParentId: asset2.id }); + + expect(status).toBe(200); + + const asset = await api.assetApi.get(server, user1.accessToken, asset2.id); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset1.id })])); + }); + + it('should make all childrens of old parent, a child of new parent', async () => { + const { status } = await request(server) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ oldParentId: asset1.id, newParentId: asset2.id }); + + expect(status).toBe(200); + + const asset = await api.assetApi.get(server, user1.accessToken, asset2.id); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })])); + }); + }); }); diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 5fef9f6d1e..3454818438 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -41,6 +41,7 @@ export const assetStub = { libraryId: 'library-id', library: libraryStub.uploadLibrary1, }), + noWebpPath: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -80,6 +81,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, }), + noThumbhash: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -116,6 +118,7 @@ export const assetStub = { sidecarPath: null, deletedAt: null, }), + primaryImage: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -154,7 +157,9 @@ export const assetStub = { exifInfo: { fileSizeInByte: 5_000, } as ExifEntity, + stack: [{ id: 'stack-child-asset-1' } as AssetEntity, { id: 'stack-child-asset-2' } as AssetEntity], }), + image: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -194,6 +199,7 @@ export const assetStub = { fileSizeInByte: 5_000, } as ExifEntity, }), + external: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -233,6 +239,7 @@ export const assetStub = { fileSizeInByte: 5_000, } as ExifEntity, }), + offline: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -272,6 +279,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, }), + image1: Object.freeze({ id: 'asset-id-1', deviceAssetId: 'device-asset-id', @@ -311,6 +319,7 @@ export const assetStub = { fileSizeInByte: 5_000, } as ExifEntity, }), + imageFrom2015: Object.freeze({ id: 'asset-id-1', deviceAssetId: 'device-asset-id', @@ -350,6 +359,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, }), + video: Object.freeze({ id: 'asset-id', originalFileName: 'asset-id.ext', @@ -389,6 +399,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, }), + livePhotoMotionAsset: Object.freeze({ id: 'live-photo-motion-asset', originalPath: fileStub.livePhotoMotion.originalPath, @@ -497,10 +508,41 @@ export const assetStub = { sidecarPath: '/original/path.ext.xmp', deletedAt: null, }), - readOnly: Object.freeze({ + + readOnly: Object.freeze({ id: 'read-only-asset', + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.ext', + resizePath: '/uploads/user-id/thumbs/path.ext', + thumbhash: null, + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + webpPath: null, + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + localDateTime: new Date('2023-02-23T05:06:29.716Z'), + isFavorite: true, + isArchived: false, isReadOnly: true, + isExternal: false, + isOffline: false, libraryId: 'library-id', library: libraryStub.uploadLibrary1, + duration: null, + isVisible: true, + livePhotoVideo: null, + livePhotoVideoId: null, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.ext', + faces: [], + sidecarPath: '/original/path.ext.xmp', + deletedAt: null, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index acb14c6b2d..dd5771cf99 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -72,6 +72,7 @@ const assetResponse: AssetResponseDto = { isTrashed: false, libraryId: 'library-id', hasMetadata: true, + stackCount: 0, }; const assetResponseWithoutMetadata = { diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index d9c34475a6..91bb2f88c3 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -399,6 +399,18 @@ export interface AssetBulkUpdateDto { * @memberof AssetBulkUpdateDto */ 'isFavorite'?: boolean; + /** + * + * @type {boolean} + * @memberof AssetBulkUpdateDto + */ + 'removeParent'?: boolean; + /** + * + * @type {string} + * @memberof AssetBulkUpdateDto + */ + 'stackParentId'?: string; } /** * @@ -748,6 +760,24 @@ export interface AssetResponseDto { * @memberof AssetResponseDto */ 'smartInfo'?: SmartInfoResponseDto; + /** + * + * @type {Array} + * @memberof AssetResponseDto + */ + 'stack'?: Array; + /** + * + * @type {number} + * @memberof AssetResponseDto + */ + 'stackCount': number; + /** + * + * @type {string} + * @memberof AssetResponseDto + */ + 'stackParentId'?: string | null; /** * * @type {Array} @@ -3981,6 +4011,25 @@ export interface UpdateLibraryDto { */ 'name'?: string; } +/** + * + * @export + * @interface UpdateStackParentDto + */ +export interface UpdateStackParentDto { + /** + * + * @type {string} + * @memberof UpdateStackParentDto + */ + 'newParentId': string; + /** + * + * @type {string} + * @memberof UpdateStackParentDto + */ + 'oldParentId': string; +} /** * * @export @@ -7135,6 +7184,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {UpdateStackParentDto} updateStackParentDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateStackParent: async (updateStackParentDto: UpdateStackParentDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'updateStackParentDto' is not null or undefined + assertParamExists('updateStackParent', 'updateStackParentDto', updateStackParentDto) + const localVarPath = `/asset/stack/parent`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(updateStackParentDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {File} assetData @@ -7601,6 +7694,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {UpdateStackParentDto} updateStackParentDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateStackParent(updateStackParentDto: UpdateStackParentDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateStackParent(updateStackParentDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {File} assetData @@ -7892,6 +7995,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {AssetApiUploadFileRequest} requestParameters Request parameters. @@ -8499,6 +8611,20 @@ export interface AssetApiUpdateAssetsRequest { readonly assetBulkUpdateDto: AssetBulkUpdateDto } +/** + * Request parameters for updateStackParent operation in AssetApi. + * @export + * @interface AssetApiUpdateStackParentRequest + */ +export interface AssetApiUpdateStackParentRequest { + /** + * + * @type {UpdateStackParentDto} + * @memberof AssetApiUpdateStackParent + */ + readonly updateStackParentDto: UpdateStackParentDto +} + /** * Request parameters for uploadFile operation in AssetApi. * @export @@ -8939,6 +9065,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiUploadFileRequest} requestParameters Request parameters.