You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	feat: manual stack assets (#4198)
This commit is contained in:
		
							
								
								
									
										137
									
								
								cli/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										137
									
								
								cli/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -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<AssetResponseDto>} | ||||
|      * @memberof AssetResponseDto | ||||
|      */ | ||||
|     'stack'?: Array<AssetResponseDto>; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {number} | ||||
|      * @memberof AssetResponseDto | ||||
|      */ | ||||
|     'stackCount': number; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof AssetResponseDto | ||||
|      */ | ||||
|     'stackParentId'?: string | null; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {Array<TagResponseDto>} | ||||
| @@ -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<RequestArgs> => { | ||||
|             // 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<void>> { | ||||
|             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<void> { | ||||
|             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<void> { | ||||
|             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. | ||||
|   | ||||
| @@ -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" | ||||
| } | ||||
|   | ||||
| @@ -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<AssetSelectionPageResult?>( | ||||
|         AssetSelectionRoute( | ||||
|           existingAssets: albumInfo.assets, | ||||
|           isNewAlbum: false, | ||||
|           canDeselect: false, | ||||
|           query: getRemoteAssetQuery(ref), | ||||
|         ), | ||||
|       ); | ||||
|  | ||||
|   | ||||
| @@ -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<Asset> existingAssets; | ||||
|   final bool isNewAlbum; | ||||
|   final QueryBuilder<Asset, Asset, QAfterSortBy>? 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<Set<Asset>>(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<AssetSelectionPageResult>(payload); | ||||
|               }, | ||||
|               child: Text( | ||||
|                 "share_add", | ||||
|                 canDeselect ? "share_done" : "share_add", | ||||
|                 style: TextStyle( | ||||
|                   fontWeight: FontWeight.bold, | ||||
|                   color: Theme.of(context).primaryColor, | ||||
|   | ||||
| @@ -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<Set<Asset>>( | ||||
|         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<AssetSelectionPageResult?>( | ||||
|         AssetSelectionRoute( | ||||
|           existingAssets: selectedAssets.value, | ||||
|           isNewAlbum: true, | ||||
|           canDeselect: true, | ||||
|           query: getRemoteAssetQuery(ref), | ||||
|         ), | ||||
|       ); | ||||
|       if (selectedAsset == null) { | ||||
|   | ||||
| @@ -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<List<Asset>> { | ||||
|   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<AssetStackNotifier, List<Asset>, Asset>( | ||||
|   (ref, asset) => AssetStackNotifier(asset, ref), | ||||
| ); | ||||
|  | ||||
| final assetStackProvider = | ||||
|     FutureProvider.autoDispose.family<List<Asset>, 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(); | ||||
| }); | ||||
| @@ -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<RenderList, List<Asset>>((ref, assets) { | ||||
| @@ -13,3 +14,19 @@ final renderListProvider = | ||||
|     GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)], | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| final renderListQueryProvider = StreamProvider.family<RenderList, | ||||
|     QueryBuilder<Asset, Asset, QAfterSortBy>?>( | ||||
|   (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); | ||||
|     } | ||||
|   }, | ||||
| ); | ||||
|   | ||||
| @@ -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<Asset>? childrenToAdd, | ||||
|     List<Asset>? 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), | ||||
|   ), | ||||
| ); | ||||
| @@ -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)) | ||||
|         : <Asset>[]; | ||||
|     final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[]; | ||||
|  | ||||
|     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<bool> 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<void>( | ||||
|         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<Function(int)> 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, | ||||
|   | ||||
							
								
								
									
										47
									
								
								mobile/lib/modules/home/models/selection_state.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								mobile/lib/modules/home/models/selection_state.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -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<Asset> 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; | ||||
| } | ||||
| @@ -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, | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|   | ||||
| @@ -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<ImmichAssetGridView> { | ||||
|  | ||||
|   bool _scrolling = false; | ||||
|   final Set<Asset> _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<Asset> _getSelectedAssets() { | ||||
|     return Set.from(_selectedAssets); | ||||
| @@ -90,7 +92,13 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> { | ||||
|  | ||||
|   void _deselectAssets(List<Asset> 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<ImmichAssetGridView> { | ||||
|       useGrayBoxPlaceholder: true, | ||||
|       showStorageIndicator: widget.showStorageIndicator, | ||||
|       heroOffset: widget.heroOffset, | ||||
|       showStack: widget.showStack, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| @@ -377,10 +386,6 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> { | ||||
|       setState(() { | ||||
|         _selectedAssets.clear(); | ||||
|       }); | ||||
|     } else if (widget.preselectedAssets != null) { | ||||
|       setState(() { | ||||
|         _selectedAssets.addAll(widget.preselectedAssets!); | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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(), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   | ||||
| @@ -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<Album> albums; | ||||
|   final List<Album> 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, | ||||
|   | ||||
| @@ -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(<Asset>{}); | ||||
|     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<AssetSelectionPageResult?>( | ||||
|               AssetSelectionRoute( | ||||
|                 existingAssets: stackChildren, | ||||
|                 canDeselect: true, | ||||
|                 query: getAssetStackSelectionQuery(ref, selectedAsset), | ||||
|               ), | ||||
|             ); | ||||
|  | ||||
|             if (returnPayload != null) { | ||||
|               Set<Asset> 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<void> 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()), | ||||
|           ], | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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'; | ||||
|   | ||||
| @@ -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<GalleryViewerRouteArgs> { | ||||
|     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<GalleryViewerRouteArgs> { | ||||
|             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<AssetSelectionRouteArgs> { | ||||
|   AssetSelectionRoute({ | ||||
|     Key? key, | ||||
|     required Set<Asset> existingAssets, | ||||
|     bool isNewAlbum = false, | ||||
|     bool canDeselect = false, | ||||
|     required QueryBuilder<Asset, Asset, QAfterSortBy>? 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<Asset> existingAssets; | ||||
|  | ||||
|   final bool isNewAlbum; | ||||
|   final bool canDeselect; | ||||
|  | ||||
|   final QueryBuilder<Asset, Asset, QAfterSortBy>? query; | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, isNewAlbum: $isNewAlbum}'; | ||||
|     return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, canDeselect: $canDeselect, query: $query}'; | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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<int> 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<void> 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",  | ||||
|   | ||||
| @@ -82,19 +82,29 @@ const AssetSchema = CollectionSchema( | ||||
|       name: r'remoteId', | ||||
|       type: IsarType.string, | ||||
|     ), | ||||
|     r'type': PropertySchema( | ||||
|     r'stackCount': PropertySchema( | ||||
|       id: 13, | ||||
|       name: r'stackCount', | ||||
|       type: IsarType.long, | ||||
|     ), | ||||
|     r'stackParentId': PropertySchema( | ||||
|       id: 14, | ||||
|       name: r'stackParentId', | ||||
|       type: IsarType.string, | ||||
|     ), | ||||
|     r'type': PropertySchema( | ||||
|       id: 15, | ||||
|       name: r'type', | ||||
|       type: IsarType.byte, | ||||
|       enumMap: _AssettypeEnumValueMap, | ||||
|     ), | ||||
|     r'updatedAt': PropertySchema( | ||||
|       id: 14, | ||||
|       id: 16, | ||||
|       name: r'updatedAt', | ||||
|       type: IsarType.dateTime, | ||||
|     ), | ||||
|     r'width': PropertySchema( | ||||
|       id: 15, | ||||
|       id: 17, | ||||
|       name: r'width', | ||||
|       type: IsarType.int, | ||||
|     ) | ||||
| @@ -184,6 +194,12 @@ int _assetEstimateSize( | ||||
|       bytesCount += 3 + value.length * 3; | ||||
|     } | ||||
|   } | ||||
|   { | ||||
|     final value = object.stackParentId; | ||||
|     if (value != null) { | ||||
|       bytesCount += 3 + value.length * 3; | ||||
|     } | ||||
|   } | ||||
|   return bytesCount; | ||||
| } | ||||
|  | ||||
| @@ -206,9 +222,11 @@ void _assetSerialize( | ||||
|   writer.writeString(offsets[10], object.localId); | ||||
|   writer.writeLong(offsets[11], object.ownerId); | ||||
|   writer.writeString(offsets[12], object.remoteId); | ||||
|   writer.writeByte(offsets[13], object.type.index); | ||||
|   writer.writeDateTime(offsets[14], object.updatedAt); | ||||
|   writer.writeInt(offsets[15], object.width); | ||||
|   writer.writeLong(offsets[13], object.stackCount); | ||||
|   writer.writeString(offsets[14], object.stackParentId); | ||||
|   writer.writeByte(offsets[15], object.type.index); | ||||
|   writer.writeDateTime(offsets[16], object.updatedAt); | ||||
|   writer.writeInt(offsets[17], object.width); | ||||
| } | ||||
|  | ||||
| Asset _assetDeserialize( | ||||
| @@ -232,10 +250,12 @@ Asset _assetDeserialize( | ||||
|     localId: reader.readStringOrNull(offsets[10]), | ||||
|     ownerId: reader.readLong(offsets[11]), | ||||
|     remoteId: reader.readStringOrNull(offsets[12]), | ||||
|     type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[13])] ?? | ||||
|     stackCount: reader.readLong(offsets[13]), | ||||
|     stackParentId: reader.readStringOrNull(offsets[14]), | ||||
|     type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[15])] ?? | ||||
|         AssetType.other, | ||||
|     updatedAt: reader.readDateTime(offsets[14]), | ||||
|     width: reader.readIntOrNull(offsets[15]), | ||||
|     updatedAt: reader.readDateTime(offsets[16]), | ||||
|     width: reader.readIntOrNull(offsets[17]), | ||||
|   ); | ||||
|   return object; | ||||
| } | ||||
| @@ -274,11 +294,15 @@ P _assetDeserializeProp<P>( | ||||
|     case 12: | ||||
|       return (reader.readStringOrNull(offset)) as P; | ||||
|     case 13: | ||||
|       return (reader.readLong(offset)) as P; | ||||
|     case 14: | ||||
|       return (reader.readStringOrNull(offset)) as P; | ||||
|     case 15: | ||||
|       return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ?? | ||||
|           AssetType.other) as P; | ||||
|     case 14: | ||||
|     case 16: | ||||
|       return (reader.readDateTime(offset)) as P; | ||||
|     case 15: | ||||
|     case 17: | ||||
|       return (reader.readIntOrNull(offset)) as P; | ||||
|     default: | ||||
|       throw IsarError('Unknown property with id $propertyId'); | ||||
| @@ -1801,6 +1825,205 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountEqualTo( | ||||
|       int value) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.equalTo( | ||||
|         property: r'stackCount', | ||||
|         value: value, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountGreaterThan( | ||||
|     int value, { | ||||
|     bool include = false, | ||||
|   }) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.greaterThan( | ||||
|         include: include, | ||||
|         property: r'stackCount', | ||||
|         value: value, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountLessThan( | ||||
|     int value, { | ||||
|     bool include = false, | ||||
|   }) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.lessThan( | ||||
|         include: include, | ||||
|         property: r'stackCount', | ||||
|         value: value, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> stackCountBetween( | ||||
|     int lower, | ||||
|     int upper, { | ||||
|     bool includeLower = true, | ||||
|     bool includeUpper = true, | ||||
|   }) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.between( | ||||
|         property: r'stackCount', | ||||
|         lower: lower, | ||||
|         includeLower: includeLower, | ||||
|         upper: upper, | ||||
|         includeUpper: includeUpper, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsNull() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(const FilterCondition.isNull( | ||||
|         property: r'stackParentId', | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsNotNull() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(const FilterCondition.isNotNull( | ||||
|         property: r'stackParentId', | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdEqualTo( | ||||
|     String? value, { | ||||
|     bool caseSensitive = true, | ||||
|   }) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.equalTo( | ||||
|         property: r'stackParentId', | ||||
|         value: value, | ||||
|         caseSensitive: caseSensitive, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdGreaterThan( | ||||
|     String? value, { | ||||
|     bool include = false, | ||||
|     bool caseSensitive = true, | ||||
|   }) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.greaterThan( | ||||
|         include: include, | ||||
|         property: r'stackParentId', | ||||
|         value: value, | ||||
|         caseSensitive: caseSensitive, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdLessThan( | ||||
|     String? value, { | ||||
|     bool include = false, | ||||
|     bool caseSensitive = true, | ||||
|   }) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.lessThan( | ||||
|         include: include, | ||||
|         property: r'stackParentId', | ||||
|         value: value, | ||||
|         caseSensitive: caseSensitive, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdBetween( | ||||
|     String? lower, | ||||
|     String? upper, { | ||||
|     bool includeLower = true, | ||||
|     bool includeUpper = true, | ||||
|     bool caseSensitive = true, | ||||
|   }) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.between( | ||||
|         property: r'stackParentId', | ||||
|         lower: lower, | ||||
|         includeLower: includeLower, | ||||
|         upper: upper, | ||||
|         includeUpper: includeUpper, | ||||
|         caseSensitive: caseSensitive, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdStartsWith( | ||||
|     String value, { | ||||
|     bool caseSensitive = true, | ||||
|   }) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.startsWith( | ||||
|         property: r'stackParentId', | ||||
|         value: value, | ||||
|         caseSensitive: caseSensitive, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdEndsWith( | ||||
|     String value, { | ||||
|     bool caseSensitive = true, | ||||
|   }) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.endsWith( | ||||
|         property: r'stackParentId', | ||||
|         value: value, | ||||
|         caseSensitive: caseSensitive, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdContains( | ||||
|       String value, | ||||
|       {bool caseSensitive = true}) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.contains( | ||||
|         property: r'stackParentId', | ||||
|         value: value, | ||||
|         caseSensitive: caseSensitive, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdMatches( | ||||
|       String pattern, | ||||
|       {bool caseSensitive = true}) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.matches( | ||||
|         property: r'stackParentId', | ||||
|         wildcard: pattern, | ||||
|         caseSensitive: caseSensitive, | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsEmpty() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.equalTo( | ||||
|         property: r'stackParentId', | ||||
|         value: '', | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> stackParentIdIsNotEmpty() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addFilterCondition(FilterCondition.greaterThan( | ||||
|         property: r'stackParentId', | ||||
|         value: '', | ||||
|       )); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterFilterCondition> typeEqualTo( | ||||
|       AssetType value) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
| @@ -2137,6 +2360,30 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackCount() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addSortBy(r'stackCount', Sort.asc); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackCountDesc() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addSortBy(r'stackCount', Sort.desc); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackParentId() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addSortBy(r'stackParentId', Sort.asc); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterSortBy> sortByStackParentIdDesc() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addSortBy(r'stackParentId', Sort.desc); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterSortBy> sortByType() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addSortBy(r'type', Sort.asc); | ||||
| @@ -2343,6 +2590,30 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackCount() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addSortBy(r'stackCount', Sort.asc); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackCountDesc() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addSortBy(r'stackCount', Sort.desc); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackParentId() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addSortBy(r'stackParentId', Sort.asc); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterSortBy> thenByStackParentIdDesc() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addSortBy(r'stackParentId', Sort.desc); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QAfterSortBy> thenByType() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addSortBy(r'type', Sort.asc); | ||||
| @@ -2465,6 +2736,20 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QDistinct> distinctByStackCount() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addDistinctBy(r'stackCount'); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QDistinct> distinctByStackParentId( | ||||
|       {bool caseSensitive = true}) { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addDistinctBy(r'stackParentId', | ||||
|           caseSensitive: caseSensitive); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, Asset, QDistinct> distinctByType() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addDistinctBy(r'type'); | ||||
| @@ -2569,6 +2854,18 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, int, QQueryOperations> stackCountProperty() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addPropertyName(r'stackCount'); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, String?, QQueryOperations> stackParentIdProperty() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addPropertyName(r'stackParentId'); | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   QueryBuilder<Asset, AssetType, QQueryOperations> typeProperty() { | ||||
|     return QueryBuilder.apply(this, (query) { | ||||
|       return query.addPropertyName(r'type'); | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import 'package:immich_mobile/shared/models/exif_info.dart'; | ||||
| import 'package:immich_mobile/shared/models/store.dart'; | ||||
| import 'package:immich_mobile/shared/models/user.dart'; | ||||
| import 'package:immich_mobile/shared/providers/db.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/user.provider.dart'; | ||||
| import 'package:immich_mobile/shared/services/asset.service.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; | ||||
| import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; | ||||
| @@ -217,6 +218,7 @@ final assetsProvider = | ||||
|       .filter() | ||||
|       .isArchivedEqualTo(false) | ||||
|       .isTrashedEqualTo(false) | ||||
|       .stackParentIdIsNull() | ||||
|       .sortByFileCreatedAtDesc(); | ||||
|   final settings = ref.watch(appSettingsServiceProvider); | ||||
|   final groupBy = | ||||
| @@ -227,10 +229,12 @@ final assetsProvider = | ||||
|   } | ||||
| }); | ||||
|  | ||||
| final remoteAssetsProvider = | ||||
|     StreamProvider.family<RenderList, int?>((ref, userId) async* { | ||||
|   if (userId == null) return; | ||||
|   final query = ref | ||||
| QueryBuilder<Asset, Asset, QAfterSortBy>? 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<Asset, Asset, QAfterSortBy>? 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(); | ||||
| } | ||||
|   | ||||
| @@ -133,6 +133,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> { | ||||
|         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()}"); | ||||
|       } | ||||
|   | ||||
							
								
								
									
										3
									
								
								mobile/openapi/.openapi-generator/FILES
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/.openapi-generator/FILES
									
									
									
										generated
									
									
									
								
							| @@ -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 | ||||
|   | ||||
							
								
								
									
										2
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							| @@ -116,6 +116,7 @@ Class | Method | HTTP request | Description | ||||
| *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} |  | ||||
| *AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} |  | ||||
| *AssetApi* | [**updateAssets**](doc//AssetApi.md#updateassets) | **PUT** /asset |  | ||||
| *AssetApi* | [**updateStackParent**](doc//AssetApi.md#updatestackparent) | **PUT** /asset/stack/parent |  | ||||
| *AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload |  | ||||
| *AuditApi* | [**fixAuditFiles**](doc//AuditApi.md#fixauditfiles) | **POST** /audit/file-report/fix |  | ||||
| *AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes |  | ||||
| @@ -330,6 +331,7 @@ Class | Method | HTTP request | Description | ||||
|  - [UpdateAlbumDto](doc//UpdateAlbumDto.md) | ||||
|  - [UpdateAssetDto](doc//UpdateAssetDto.md) | ||||
|  - [UpdateLibraryDto](doc//UpdateLibraryDto.md) | ||||
|  - [UpdateStackParentDto](doc//UpdateStackParentDto.md) | ||||
|  - [UpdateTagDto](doc//UpdateTagDto.md) | ||||
|  - [UpdateUserDto](doc//UpdateUserDto.md) | ||||
|  - [UsageByUserDto](doc//UsageByUserDto.md) | ||||
|   | ||||
							
								
								
									
										55
									
								
								mobile/openapi/doc/AssetApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										55
									
								
								mobile/openapi/doc/AssetApi.md
									
									
									
										generated
									
									
									
								
							| @@ -38,6 +38,7 @@ Method | HTTP request | Description | ||||
| [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} |  | ||||
| [**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} |  | ||||
| [**updateAssets**](AssetApi.md#updateassets) | **PUT** /asset |  | ||||
| [**updateStackParent**](AssetApi.md#updatestackparent) | **PUT** /asset/stack/parent |  | ||||
| [**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload |  | ||||
| 
 | ||||
| 
 | ||||
| @@ -1696,6 +1697,60 @@ void (empty response body) | ||||
| 
 | ||||
| [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) | ||||
| 
 | ||||
| # **updateStackParent** | ||||
| > updateStackParent(updateStackParentDto) | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| ### Example | ||||
| ```dart | ||||
| import 'package:openapi/api.dart'; | ||||
| // TODO Configure API key authorization: cookie | ||||
| //defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY'; | ||||
| // uncomment below to setup prefix (e.g. Bearer) for API key, if needed | ||||
| //defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer'; | ||||
| // TODO Configure API key authorization: api_key | ||||
| //defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY'; | ||||
| // uncomment below to setup prefix (e.g. Bearer) for API key, if needed | ||||
| //defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer'; | ||||
| // TODO Configure HTTP Bearer authorization: bearer | ||||
| // Case 1. Use String Token | ||||
| //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); | ||||
| // Case 2. Use Function which generate token. | ||||
| // String yourTokenGeneratorFunction() { ... } | ||||
| //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction); | ||||
| 
 | ||||
| final api_instance = AssetApi(); | ||||
| final updateStackParentDto = UpdateStackParentDto(); // UpdateStackParentDto |  | ||||
| 
 | ||||
| try { | ||||
|     api_instance.updateStackParent(updateStackParentDto); | ||||
| } catch (e) { | ||||
|     print('Exception when calling AssetApi->updateStackParent: $e\n'); | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| ### Parameters | ||||
| 
 | ||||
| Name | Type | Description  | Notes | ||||
| ------------- | ------------- | ------------- | ------------- | ||||
|  **updateStackParentDto** | [**UpdateStackParentDto**](UpdateStackParentDto.md)|  |  | ||||
| 
 | ||||
| ### Return type | ||||
| 
 | ||||
| void (empty response body) | ||||
| 
 | ||||
| ### Authorization | ||||
| 
 | ||||
| [cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) | ||||
| 
 | ||||
| ### HTTP request headers | ||||
| 
 | ||||
|  - **Content-Type**: application/json | ||||
|  - **Accept**: Not defined | ||||
| 
 | ||||
| [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) | ||||
| 
 | ||||
| # **uploadFile** | ||||
| > AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isExternal, isOffline, isReadOnly, isVisible, libraryId, livePhotoData, sidecarData) | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										2
									
								
								mobile/openapi/doc/AssetBulkUpdateDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/doc/AssetBulkUpdateDto.md
									
									
									
										generated
									
									
									
								
							| @@ -11,6 +11,8 @@ Name | Type | Description | Notes | ||||
| **ids** | **List<String>** |  | [default to const []] | ||||
| **isArchived** | **bool** |  | [optional]  | ||||
| **isFavorite** | **bool** |  | [optional]  | ||||
| **removeParent** | **bool** |  | [optional]  | ||||
| **stackParentId** | **String** |  | [optional]  | ||||
| 
 | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										3
									
								
								mobile/openapi/doc/AssetResponseDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/doc/AssetResponseDto.md
									
									
									
										generated
									
									
									
								
							| @@ -33,6 +33,9 @@ Name | Type | Description | Notes | ||||
| **people** | [**List<PersonResponseDto>**](PersonResponseDto.md) |  | [optional] [default to const []] | ||||
| **resized** | **bool** |  |  | ||||
| **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) |  | [optional]  | ||||
| **stack** | [**List<AssetResponseDto>**](AssetResponseDto.md) |  | [optional] [default to const []] | ||||
| **stackCount** | **int** |  |  | ||||
| **stackParentId** | **String** |  | [optional]  | ||||
| **tags** | [**List<TagResponseDto>**](TagResponseDto.md) |  | [optional] [default to const []] | ||||
| **thumbhash** | **String** |  |  | ||||
| **type** | [**AssetTypeEnum**](AssetTypeEnum.md) |  |  | ||||
|   | ||||
							
								
								
									
										16
									
								
								mobile/openapi/doc/UpdateStackParentDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								mobile/openapi/doc/UpdateStackParentDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| # openapi.model.UpdateStackParentDto | ||||
| 
 | ||||
| ## Load the model package | ||||
| ```dart | ||||
| import 'package:openapi/api.dart'; | ||||
| ``` | ||||
| 
 | ||||
| ## Properties | ||||
| Name | Type | Description | Notes | ||||
| ------------ | ------------- | ------------- | ------------- | ||||
| **newParentId** | **String** |  |  | ||||
| **oldParentId** | **String** |  |  | ||||
| 
 | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
| 
 | ||||
| 
 | ||||
							
								
								
									
										1
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							| @@ -176,6 +176,7 @@ part 'model/transcode_policy.dart'; | ||||
| part 'model/update_album_dto.dart'; | ||||
| part 'model/update_asset_dto.dart'; | ||||
| part 'model/update_library_dto.dart'; | ||||
| part 'model/update_stack_parent_dto.dart'; | ||||
| part 'model/update_tag_dto.dart'; | ||||
| part 'model/update_user_dto.dart'; | ||||
| part 'model/usage_by_user_dto.dart'; | ||||
|   | ||||
							
								
								
									
										39
									
								
								mobile/openapi/lib/api/asset_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										39
									
								
								mobile/openapi/lib/api/asset_api.dart
									
									
									
										generated
									
									
									
								
							| @@ -1654,6 +1654,45 @@ class AssetApi { | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /// Performs an HTTP 'PUT /asset/stack/parent' operation and returns the [Response]. | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [UpdateStackParentDto] updateStackParentDto (required): | ||||
|   Future<Response> updateStackParentWithHttpInfo(UpdateStackParentDto updateStackParentDto,) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final path = r'/asset/stack/parent'; | ||||
| 
 | ||||
|     // ignore: prefer_final_locals | ||||
|     Object? postBody = updateStackParentDto; | ||||
| 
 | ||||
|     final queryParams = <QueryParam>[]; | ||||
|     final headerParams = <String, String>{}; | ||||
|     final formParams = <String, String>{}; | ||||
| 
 | ||||
|     const contentTypes = <String>['application/json']; | ||||
| 
 | ||||
| 
 | ||||
|     return apiClient.invokeAPI( | ||||
|       path, | ||||
|       'PUT', | ||||
|       queryParams, | ||||
|       postBody, | ||||
|       headerParams, | ||||
|       formParams, | ||||
|       contentTypes.isEmpty ? null : contentTypes.first, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [UpdateStackParentDto] updateStackParentDto (required): | ||||
|   Future<void> updateStackParent(UpdateStackParentDto updateStackParentDto,) async { | ||||
|     final response = await updateStackParentWithHttpInfo(updateStackParentDto,); | ||||
|     if (response.statusCode >= HttpStatus.badRequest) { | ||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /// Performs an HTTP 'POST /asset/upload' operation and returns the [Response]. | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   | ||||
							
								
								
									
										2
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							| @@ -443,6 +443,8 @@ class ApiClient { | ||||
|           return UpdateAssetDto.fromJson(value); | ||||
|         case 'UpdateLibraryDto': | ||||
|           return UpdateLibraryDto.fromJson(value); | ||||
|         case 'UpdateStackParentDto': | ||||
|           return UpdateStackParentDto.fromJson(value); | ||||
|         case 'UpdateTagDto': | ||||
|           return UpdateTagDto.fromJson(value); | ||||
|         case 'UpdateUserDto': | ||||
|   | ||||
							
								
								
									
										40
									
								
								mobile/openapi/lib/model/asset_bulk_update_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										40
									
								
								mobile/openapi/lib/model/asset_bulk_update_dto.dart
									
									
									
										generated
									
									
									
								
							| @@ -16,6 +16,8 @@ class AssetBulkUpdateDto { | ||||
|     this.ids = const [], | ||||
|     this.isArchived, | ||||
|     this.isFavorite, | ||||
|     this.removeParent, | ||||
|     this.stackParentId, | ||||
|   }); | ||||
| 
 | ||||
|   List<String> ids; | ||||
| @@ -36,21 +38,41 @@ class AssetBulkUpdateDto { | ||||
|   /// | ||||
|   bool? isFavorite; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   bool? removeParent; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   String? stackParentId; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto && | ||||
|      other.ids == ids && | ||||
|      other.isArchived == isArchived && | ||||
|      other.isFavorite == isFavorite; | ||||
|      other.isFavorite == isFavorite && | ||||
|      other.removeParent == removeParent && | ||||
|      other.stackParentId == stackParentId; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (ids.hashCode) + | ||||
|     (isArchived == null ? 0 : isArchived!.hashCode) + | ||||
|     (isFavorite == null ? 0 : isFavorite!.hashCode); | ||||
|     (isFavorite == null ? 0 : isFavorite!.hashCode) + | ||||
|     (removeParent == null ? 0 : removeParent!.hashCode) + | ||||
|     (stackParentId == null ? 0 : stackParentId!.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'AssetBulkUpdateDto[ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite]'; | ||||
|   String toString() => 'AssetBulkUpdateDto[ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, removeParent=$removeParent, stackParentId=$stackParentId]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
| @@ -65,6 +87,16 @@ class AssetBulkUpdateDto { | ||||
|     } else { | ||||
|     //  json[r'isFavorite'] = null; | ||||
|     } | ||||
|     if (this.removeParent != null) { | ||||
|       json[r'removeParent'] = this.removeParent; | ||||
|     } else { | ||||
|     //  json[r'removeParent'] = null; | ||||
|     } | ||||
|     if (this.stackParentId != null) { | ||||
|       json[r'stackParentId'] = this.stackParentId; | ||||
|     } else { | ||||
|     //  json[r'stackParentId'] = null; | ||||
|     } | ||||
|     return json; | ||||
|   } | ||||
| 
 | ||||
| @@ -81,6 +113,8 @@ class AssetBulkUpdateDto { | ||||
|             : const [], | ||||
|         isArchived: mapValueOfType<bool>(json, r'isArchived'), | ||||
|         isFavorite: mapValueOfType<bool>(json, r'isFavorite'), | ||||
|         removeParent: mapValueOfType<bool>(json, r'removeParent'), | ||||
|         stackParentId: mapValueOfType<String>(json, r'stackParentId'), | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   | ||||
							
								
								
									
										28
									
								
								mobile/openapi/lib/model/asset_response_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										28
									
								
								mobile/openapi/lib/model/asset_response_dto.dart
									
									
									
										generated
									
									
									
								
							| @@ -38,6 +38,9 @@ class AssetResponseDto { | ||||
|     this.people = const [], | ||||
|     required this.resized, | ||||
|     this.smartInfo, | ||||
|     this.stack = const [], | ||||
|     required this.stackCount, | ||||
|     this.stackParentId, | ||||
|     this.tags = const [], | ||||
|     required this.thumbhash, | ||||
|     required this.type, | ||||
| @@ -113,6 +116,12 @@ class AssetResponseDto { | ||||
|   /// | ||||
|   SmartInfoResponseDto? smartInfo; | ||||
| 
 | ||||
|   List<AssetResponseDto> stack; | ||||
| 
 | ||||
|   int stackCount; | ||||
| 
 | ||||
|   String? stackParentId; | ||||
| 
 | ||||
|   List<TagResponseDto> tags; | ||||
| 
 | ||||
|   String? thumbhash; | ||||
| @@ -148,6 +157,9 @@ class AssetResponseDto { | ||||
|      other.people == people && | ||||
|      other.resized == resized && | ||||
|      other.smartInfo == smartInfo && | ||||
|      other.stack == stack && | ||||
|      other.stackCount == stackCount && | ||||
|      other.stackParentId == stackParentId && | ||||
|      other.tags == tags && | ||||
|      other.thumbhash == thumbhash && | ||||
|      other.type == type && | ||||
| @@ -181,13 +193,16 @@ class AssetResponseDto { | ||||
|     (people.hashCode) + | ||||
|     (resized.hashCode) + | ||||
|     (smartInfo == null ? 0 : smartInfo!.hashCode) + | ||||
|     (stack.hashCode) + | ||||
|     (stackCount.hashCode) + | ||||
|     (stackParentId == null ? 0 : stackParentId!.hashCode) + | ||||
|     (tags.hashCode) + | ||||
|     (thumbhash == null ? 0 : thumbhash!.hashCode) + | ||||
|     (type.hashCode) + | ||||
|     (updatedAt.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]'; | ||||
|   String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, stack=$stack, stackCount=$stackCount, stackParentId=$stackParentId, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
| @@ -231,6 +246,13 @@ class AssetResponseDto { | ||||
|       json[r'smartInfo'] = this.smartInfo; | ||||
|     } else { | ||||
|     //  json[r'smartInfo'] = null; | ||||
|     } | ||||
|       json[r'stack'] = this.stack; | ||||
|       json[r'stackCount'] = this.stackCount; | ||||
|     if (this.stackParentId != null) { | ||||
|       json[r'stackParentId'] = this.stackParentId; | ||||
|     } else { | ||||
|     //  json[r'stackParentId'] = null; | ||||
|     } | ||||
|       json[r'tags'] = this.tags; | ||||
|     if (this.thumbhash != null) { | ||||
| @@ -276,6 +298,9 @@ class AssetResponseDto { | ||||
|         people: PersonResponseDto.listFromJson(json[r'people']), | ||||
|         resized: mapValueOfType<bool>(json, r'resized')!, | ||||
|         smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']), | ||||
|         stack: AssetResponseDto.listFromJson(json[r'stack']), | ||||
|         stackCount: mapValueOfType<int>(json, r'stackCount')!, | ||||
|         stackParentId: mapValueOfType<String>(json, r'stackParentId'), | ||||
|         tags: TagResponseDto.listFromJson(json[r'tags']), | ||||
|         thumbhash: mapValueOfType<String>(json, r'thumbhash'), | ||||
|         type: AssetTypeEnum.fromJson(json[r'type'])!, | ||||
| @@ -347,6 +372,7 @@ class AssetResponseDto { | ||||
|     'originalPath', | ||||
|     'ownerId', | ||||
|     'resized', | ||||
|     'stackCount', | ||||
|     'thumbhash', | ||||
|     'type', | ||||
|     'updatedAt', | ||||
|   | ||||
							
								
								
									
										106
									
								
								mobile/openapi/lib/model/update_stack_parent_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										106
									
								
								mobile/openapi/lib/model/update_stack_parent_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,106 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.12 | ||||
| 
 | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
| 
 | ||||
| part of openapi.api; | ||||
| 
 | ||||
| class UpdateStackParentDto { | ||||
|   /// Returns a new [UpdateStackParentDto] instance. | ||||
|   UpdateStackParentDto({ | ||||
|     required this.newParentId, | ||||
|     required this.oldParentId, | ||||
|   }); | ||||
| 
 | ||||
|   String newParentId; | ||||
| 
 | ||||
|   String oldParentId; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is UpdateStackParentDto && | ||||
|      other.newParentId == newParentId && | ||||
|      other.oldParentId == oldParentId; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (newParentId.hashCode) + | ||||
|     (oldParentId.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'UpdateStackParentDto[newParentId=$newParentId, oldParentId=$oldParentId]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|       json[r'newParentId'] = this.newParentId; | ||||
|       json[r'oldParentId'] = this.oldParentId; | ||||
|     return json; | ||||
|   } | ||||
| 
 | ||||
|   /// Returns a new [UpdateStackParentDto] instance and imports its values from | ||||
|   /// [value] if it's a [Map], null otherwise. | ||||
|   // ignore: prefer_constructors_over_static_methods | ||||
|   static UpdateStackParentDto? fromJson(dynamic value) { | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
| 
 | ||||
|       return UpdateStackParentDto( | ||||
|         newParentId: mapValueOfType<String>(json, r'newParentId')!, | ||||
|         oldParentId: mapValueOfType<String>(json, r'oldParentId')!, | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   static List<UpdateStackParentDto> listFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final result = <UpdateStackParentDto>[]; | ||||
|     if (json is List && json.isNotEmpty) { | ||||
|       for (final row in json) { | ||||
|         final value = UpdateStackParentDto.fromJson(row); | ||||
|         if (value != null) { | ||||
|           result.add(value); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return result.toList(growable: growable); | ||||
|   } | ||||
| 
 | ||||
|   static Map<String, UpdateStackParentDto> mapFromJson(dynamic json) { | ||||
|     final map = <String, UpdateStackParentDto>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = UpdateStackParentDto.fromJson(entry.value); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   // maps a json object with a list of UpdateStackParentDto-objects as value to a dart map | ||||
|   static Map<String, List<UpdateStackParentDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final map = <String, List<UpdateStackParentDto>>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       // ignore: parameter_assignments | ||||
|       json = json.cast<String, dynamic>(); | ||||
|       for (final entry in json.entries) { | ||||
|         map[entry.key] = UpdateStackParentDto.listFromJson(entry.value, growable: growable,); | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'newParentId', | ||||
|     'oldParentId', | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
							
								
								
									
										5
									
								
								mobile/openapi/test/asset_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/asset_api_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -174,6 +174,11 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future updateStackParent(UpdateStackParentDto updateStackParentDto) async | ||||
|     test('test updateStackParent', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future<AssetFileUploadResponseDto> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, String duration, bool isArchived, bool isExternal, bool isOffline, bool isReadOnly, bool isVisible, String libraryId, MultipartFile livePhotoData, MultipartFile sidecarData }) async | ||||
|     test('test uploadFile', () async { | ||||
|       // TODO | ||||
|   | ||||
							
								
								
									
										10
									
								
								mobile/openapi/test/asset_bulk_update_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										10
									
								
								mobile/openapi/test/asset_bulk_update_dto_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -31,6 +31,16 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // bool removeParent | ||||
|     test('to test the property `removeParent`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String stackParentId | ||||
|     test('to test the property `stackParentId`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
| 
 | ||||
|   }); | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										15
									
								
								mobile/openapi/test/asset_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										15
									
								
								mobile/openapi/test/asset_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -142,6 +142,21 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // List<AssetResponseDto> stack (default value: const []) | ||||
|     test('to test the property `stack`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // int stackCount | ||||
|     test('to test the property `stackCount`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String stackParentId | ||||
|     test('to test the property `stackParentId`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // List<TagResponseDto> tags (default value: const []) | ||||
|     test('to test the property `tags`', () async { | ||||
|       // TODO | ||||
|   | ||||
							
								
								
									
										32
									
								
								mobile/openapi/test/update_stack_parent_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								mobile/openapi/test/update_stack_parent_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.12 | ||||
| 
 | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
| 
 | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:test/test.dart'; | ||||
| 
 | ||||
| // tests for UpdateStackParentDto | ||||
| void main() { | ||||
|   // final instance = UpdateStackParentDto(); | ||||
| 
 | ||||
|   group('test UpdateStackParentDto', () { | ||||
|     // String newParentId | ||||
|     test('to test the property `newParentId`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String oldParentId | ||||
|     test('to test the property `oldParentId`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
| 
 | ||||
|   }); | ||||
| 
 | ||||
| } | ||||
| @@ -25,6 +25,7 @@ void main() { | ||||
|         isFavorite: false, | ||||
|         isArchived: false, | ||||
|         isTrashed: false, | ||||
|         stackCount: 0, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -35,6 +35,7 @@ void main() { | ||||
|       isFavorite: false, | ||||
|       isArchived: false, | ||||
|       isTrashed: false, | ||||
|       stackCount: 0, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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": { | ||||
|   | ||||
| @@ -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' }, | ||||
|       ); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -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<void> { | ||||
|     const { ids, ...options } = dto; | ||||
|     const { ids, removeParent, ...options } = dto; | ||||
|     await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids); | ||||
|  | ||||
|     if (removeParent) { | ||||
|       (options as Partial<AssetEntity>).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<void> { | ||||
|     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); | ||||
|  | ||||
|   | ||||
							
								
								
									
										9
									
								
								server/src/domain/asset/dto/asset-stack.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								server/src/domain/asset/dto/asset-stack.dto.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| import { ValidateUUID } from '../../domain.util'; | ||||
|  | ||||
| export class UpdateStackParentDto { | ||||
|   @ValidateUUID() | ||||
|   oldParentId!: string; | ||||
|  | ||||
|   @ValidateUUID() | ||||
|   newParentId!: string; | ||||
| } | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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'; | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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', | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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: { | ||||
|   | ||||
| @@ -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 }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -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<void> { | ||||
|     return this.service.updateStackParent(authUser, dto); | ||||
|   } | ||||
|  | ||||
|   @Put(':id') | ||||
|   updateAsset( | ||||
|     @AuthUser() authUser: AuthUserDto, | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -0,0 +1,16 @@ | ||||
| import { MigrationInterface, QueryRunner } from "typeorm"; | ||||
|  | ||||
| export class AddStackParentIdToAssets1695354433573 implements MigrationInterface { | ||||
|     name = 'AddStackParentIdToAssets1695354433573' | ||||
|  | ||||
|     public async up(queryRunner: QueryRunner): Promise<void> { | ||||
|         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<void> { | ||||
|         await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_b463c8edb01364bf2beba08ef19"`); | ||||
|         await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "stackParentId"`); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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 })])); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
							
								
								
									
										44
									
								
								server/test/fixtures/asset.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										44
									
								
								server/test/fixtures/asset.stub.ts
									
									
									
									
										vendored
									
									
								
							| @@ -41,6 +41,7 @@ export const assetStub = { | ||||
|     libraryId: 'library-id', | ||||
|     library: libraryStub.uploadLibrary1, | ||||
|   }), | ||||
|  | ||||
|   noWebpPath: Object.freeze<AssetEntity>({ | ||||
|     id: 'asset-id', | ||||
|     deviceAssetId: 'device-asset-id', | ||||
| @@ -80,6 +81,7 @@ export const assetStub = { | ||||
|     } as ExifEntity, | ||||
|     deletedAt: null, | ||||
|   }), | ||||
|  | ||||
|   noThumbhash: Object.freeze<AssetEntity>({ | ||||
|     id: 'asset-id', | ||||
|     deviceAssetId: 'device-asset-id', | ||||
| @@ -116,6 +118,7 @@ export const assetStub = { | ||||
|     sidecarPath: null, | ||||
|     deletedAt: null, | ||||
|   }), | ||||
|  | ||||
|   primaryImage: Object.freeze<AssetEntity>({ | ||||
|     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<AssetEntity>({ | ||||
|     id: 'asset-id', | ||||
|     deviceAssetId: 'device-asset-id', | ||||
| @@ -194,6 +199,7 @@ export const assetStub = { | ||||
|       fileSizeInByte: 5_000, | ||||
|     } as ExifEntity, | ||||
|   }), | ||||
|  | ||||
|   external: Object.freeze<AssetEntity>({ | ||||
|     id: 'asset-id', | ||||
|     deviceAssetId: 'device-asset-id', | ||||
| @@ -233,6 +239,7 @@ export const assetStub = { | ||||
|       fileSizeInByte: 5_000, | ||||
|     } as ExifEntity, | ||||
|   }), | ||||
|  | ||||
|   offline: Object.freeze<AssetEntity>({ | ||||
|     id: 'asset-id', | ||||
|     deviceAssetId: 'device-asset-id', | ||||
| @@ -272,6 +279,7 @@ export const assetStub = { | ||||
|     } as ExifEntity, | ||||
|     deletedAt: null, | ||||
|   }), | ||||
|  | ||||
|   image1: Object.freeze<AssetEntity>({ | ||||
|     id: 'asset-id-1', | ||||
|     deviceAssetId: 'device-asset-id', | ||||
| @@ -311,6 +319,7 @@ export const assetStub = { | ||||
|       fileSizeInByte: 5_000, | ||||
|     } as ExifEntity, | ||||
|   }), | ||||
|  | ||||
|   imageFrom2015: Object.freeze<AssetEntity>({ | ||||
|     id: 'asset-id-1', | ||||
|     deviceAssetId: 'device-asset-id', | ||||
| @@ -350,6 +359,7 @@ export const assetStub = { | ||||
|     } as ExifEntity, | ||||
|     deletedAt: null, | ||||
|   }), | ||||
|  | ||||
|   video: Object.freeze<AssetEntity>({ | ||||
|     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<AssetEntity>({ | ||||
|     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, | ||||
|   }), | ||||
| }; | ||||
|   | ||||
							
								
								
									
										1
									
								
								server/test/fixtures/shared-link.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								server/test/fixtures/shared-link.stub.ts
									
									
									
									
										vendored
									
									
								
							| @@ -72,6 +72,7 @@ const assetResponse: AssetResponseDto = { | ||||
|   isTrashed: false, | ||||
|   libraryId: 'library-id', | ||||
|   hasMetadata: true, | ||||
|   stackCount: 0, | ||||
| }; | ||||
|  | ||||
| const assetResponseWithoutMetadata = { | ||||
|   | ||||
							
								
								
									
										137
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										137
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -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<AssetResponseDto>} | ||||
|      * @memberof AssetResponseDto | ||||
|      */ | ||||
|     'stack'?: Array<AssetResponseDto>; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {number} | ||||
|      * @memberof AssetResponseDto | ||||
|      */ | ||||
|     'stackCount': number; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof AssetResponseDto | ||||
|      */ | ||||
|     'stackParentId'?: string | null; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {Array<TagResponseDto>} | ||||
| @@ -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<RequestArgs> => { | ||||
|             // 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<void>> { | ||||
|             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<void> { | ||||
|             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<void> { | ||||
|             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. | ||||
|   | ||||
		Reference in New Issue
	
	Block a user