diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index d9c34475a620c..91bb2f88c3e5c 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -399,6 +399,18 @@ export interface AssetBulkUpdateDto { * @memberof AssetBulkUpdateDto */ 'isFavorite'?: boolean; + /** + * + * @type {boolean} + * @memberof AssetBulkUpdateDto + */ + 'removeParent'?: boolean; + /** + * + * @type {string} + * @memberof AssetBulkUpdateDto + */ + 'stackParentId'?: string; } /** * @@ -748,6 +760,24 @@ export interface AssetResponseDto { * @memberof AssetResponseDto */ 'smartInfo'?: SmartInfoResponseDto; + /** + * + * @type {Array} + * @memberof AssetResponseDto + */ + 'stack'?: Array; + /** + * + * @type {number} + * @memberof AssetResponseDto + */ + 'stackCount': number; + /** + * + * @type {string} + * @memberof AssetResponseDto + */ + 'stackParentId'?: string | null; /** * * @type {Array} @@ -3981,6 +4011,25 @@ export interface UpdateLibraryDto { */ 'name'?: string; } +/** + * + * @export + * @interface UpdateStackParentDto + */ +export interface UpdateStackParentDto { + /** + * + * @type {string} + * @memberof UpdateStackParentDto + */ + 'newParentId': string; + /** + * + * @type {string} + * @memberof UpdateStackParentDto + */ + 'oldParentId': string; +} /** * * @export @@ -7135,6 +7184,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {UpdateStackParentDto} updateStackParentDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateStackParent: async (updateStackParentDto: UpdateStackParentDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'updateStackParentDto' is not null or undefined + assertParamExists('updateStackParent', 'updateStackParentDto', updateStackParentDto) + const localVarPath = `/asset/stack/parent`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(updateStackParentDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {File} assetData @@ -7601,6 +7694,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {UpdateStackParentDto} updateStackParentDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateStackParent(updateStackParentDto: UpdateStackParentDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateStackParent(updateStackParentDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {File} assetData @@ -7892,6 +7995,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {AssetApiUploadFileRequest} requestParameters Request parameters. @@ -8499,6 +8611,20 @@ export interface AssetApiUpdateAssetsRequest { readonly assetBulkUpdateDto: AssetBulkUpdateDto } +/** + * Request parameters for updateStackParent operation in AssetApi. + * @export + * @interface AssetApiUpdateStackParentRequest + */ +export interface AssetApiUpdateStackParentRequest { + /** + * + * @type {UpdateStackParentDto} + * @memberof AssetApiUpdateStackParent + */ + readonly updateStackParentDto: UpdateStackParentDto +} + /** * Request parameters for uploadFile operation in AssetApi. * @export @@ -8939,6 +9065,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiUploadFileRequest} requestParameters Request parameters. diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index b5a7e2efe2757..3aadda4b7f743 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -130,7 +130,9 @@ "control_bottom_app_bar_delete": "Delete", "control_bottom_app_bar_favorite": "Favorite", "control_bottom_app_bar_share": "Share", + "control_bottom_app_bar_stack": "Stack", "control_bottom_app_bar_unarchive": "Unarchive", + "control_bottom_app_bar_upload": "Upload", "create_album_page_untitled": "Untitled", "create_shared_album_page_create": "Create", "create_shared_album_page_share": "Share", @@ -275,6 +277,7 @@ "setting_pages_app_bar_settings": "Settings", "settings_require_restart": "Please restart Immich to apply this setting", "share_add": "Add", + "share_done": "Done", "share_add_photos": "Add photos", "share_add_title": "Add a title", "share_create_album": "Create album", @@ -337,5 +340,8 @@ "trash_page_select_assets_btn": "Select assets", "trash_page_empty_trash_btn": "Empty trash", "trash_page_empty_trash_dialog_ok": "Ok", - "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich" + "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", + "viewer_stack_use_as_main_asset": "Use as Main Asset", + "viewer_remove_from_stack": "Remove from Stack", + "viewer_unstack": "Un-Stack" } diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart index cb389059af4e1..23358f33513cb 100644 --- a/mobile/lib/modules/album/views/album_viewer_page.dart +++ b/mobile/lib/modules/album/views/album_viewer_page.dart @@ -16,6 +16,7 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; @@ -69,7 +70,8 @@ class AlbumViewerPage extends HookConsumerWidget { await AutoRouter.of(context).push( AssetSelectionRoute( existingAssets: albumInfo.assets, - isNewAlbum: false, + canDeselect: false, + query: getRemoteAssetQuery(ref), ), ); diff --git a/mobile/lib/modules/album/views/asset_selection_page.dart b/mobile/lib/modules/album/views/asset_selection_page.dart index afec5f8aea875..9c30ba80f6e73 100644 --- a/mobile/lib/modules/album/views/asset_selection_page.dart +++ b/mobile/lib/modules/album/views/asset_selection_page.dart @@ -4,26 +4,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/shared/models/asset.dart'; -import 'package:immich_mobile/shared/providers/asset.provider.dart'; -import 'package:immich_mobile/shared/providers/user.provider.dart'; +import 'package:isar/isar.dart'; class AssetSelectionPage extends HookConsumerWidget { const AssetSelectionPage({ Key? key, required this.existingAssets, - this.isNewAlbum = false, + this.canDeselect = false, + required this.query, }) : super(key: key); final Set existingAssets; - final bool isNewAlbum; + final QueryBuilder? query; + final bool canDeselect; @override Widget build(BuildContext context, WidgetRef ref) { - final currentUser = ref.watch(currentUserProvider); - final renderList = ref.watch(remoteAssetsProvider(currentUser?.isarId)); + final renderList = ref.watch(renderListQueryProvider(query)); final selected = useState>(existingAssets); final selectionEnabledHook = useState(true); @@ -39,8 +40,8 @@ class AssetSelectionPage extends HookConsumerWidget { selected.value = assets; }, selectionActive: true, - preselectedAssets: isNewAlbum ? selected.value : existingAssets, - canDeselect: isNewAlbum, + preselectedAssets: existingAssets, + canDeselect: canDeselect, showMultiSelectIndicator: false, ); } @@ -65,7 +66,7 @@ class AssetSelectionPage extends HookConsumerWidget { ), centerTitle: false, actions: [ - if (selected.value.isNotEmpty) + if (selected.value.isNotEmpty || canDeselect) TextButton( onPressed: () { var payload = @@ -74,7 +75,7 @@ class AssetSelectionPage extends HookConsumerWidget { .popForced(payload); }, child: Text( - "share_add", + canDeselect ? "share_done" : "share_add", style: TextStyle( fontWeight: FontWeight.bold, color: Theme.of(context).primaryColor, diff --git a/mobile/lib/modules/album/views/create_album_page.dart b/mobile/lib/modules/album/views/create_album_page.dart index e1f0d65e7b7ce..191ce1470522b 100644 --- a/mobile/lib/modules/album/views/create_album_page.dart +++ b/mobile/lib/modules/album/views/create_album_page.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart'; import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/asset.provider.dart'; // ignore: must_be_immutable class CreateAlbumPage extends HookConsumerWidget { @@ -31,7 +32,8 @@ class CreateAlbumPage extends HookConsumerWidget { final isAlbumTitleTextFieldFocus = useState(false); final isAlbumTitleEmpty = useState(true); final selectedAssets = useState>( - initialAssets != null ? Set.from(initialAssets!) : const {},); + initialAssets != null ? Set.from(initialAssets!) : const {}, + ); final isDarkTheme = Theme.of(context).brightness == Brightness.dark; showSelectUserPage() async { @@ -59,7 +61,8 @@ class CreateAlbumPage extends HookConsumerWidget { await AutoRouter.of(context).push( AssetSelectionRoute( existingAssets: selectedAssets.value, - isNewAlbum: true, + canDeselect: true, + query: getRemoteAssetQuery(ref), ), ); if (selectedAsset == null) { diff --git a/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart new file mode 100644 index 0000000000000..8f8e9bbe02ac3 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/providers/asset_stack.provider.dart @@ -0,0 +1,50 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/db.provider.dart'; +import 'package:isar/isar.dart'; + +class AssetStackNotifier extends StateNotifier> { + final Asset _asset; + final Ref _ref; + + AssetStackNotifier( + this._asset, + this._ref, + ) : super([]) { + fetchStackChildren(); + } + + void fetchStackChildren() async { + if (mounted) { + state = await _ref.read(assetStackProvider(_asset).future); + } + } + + removeChild(int index) { + if (index < state.length) { + state.removeAt(index); + } + } +} + +final assetStackStateProvider = StateNotifierProvider.autoDispose + .family, Asset>( + (ref, asset) => AssetStackNotifier(asset, ref), +); + +final assetStackProvider = + FutureProvider.autoDispose.family, Asset>((ref, asset) async { + // Guard [local asset] + if (asset.remoteId == null) { + return []; + } + + return await ref + .watch(dbProvider) + .assets + .filter() + .isArchivedEqualTo(false) + .isTrashedEqualTo(false) + .stackParentIdEqualTo(asset.remoteId) + .findAll(); +}); diff --git a/mobile/lib/modules/asset_viewer/providers/render_list.provider.dart b/mobile/lib/modules/asset_viewer/providers/render_list.provider.dart index 2273380843688..04532ce1b8b68 100644 --- a/mobile/lib/modules/asset_viewer/providers/render_list.provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/render_list.provider.dart @@ -3,6 +3,7 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structu import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:isar/isar.dart'; final renderListProvider = FutureProvider.family>((ref, assets) { @@ -13,3 +14,19 @@ final renderListProvider = GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)], ); }); + +final renderListQueryProvider = StreamProvider.family?>( + (ref, query) async* { + if (query == null) { + return; + } + final settings = ref.watch(appSettingsServiceProvider); + final groupBy = GroupAssetsBy + .values[settings.getSetting(AppSettingsEnum.groupAssetsBy)]; + yield await RenderList.fromQuery(query, groupBy); + await for (final _ in query.watchLazy()) { + yield await RenderList.fromQuery(query, groupBy); + } + }, +); diff --git a/mobile/lib/modules/asset_viewer/services/asset_stack.service.dart b/mobile/lib/modules/asset_viewer/services/asset_stack.service.dart new file mode 100644 index 0000000000000..8efee425c7df9 --- /dev/null +++ b/mobile/lib/modules/asset_viewer/services/asset_stack.service.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/providers/api.provider.dart'; +import 'package:immich_mobile/shared/services/api.service.dart'; +import 'package:openapi/api.dart'; + +class AssetStackService { + AssetStackService(this._api); + + final ApiService _api; + + updateStack( + Asset parentAsset, { + List? childrenToAdd, + List? childrenToRemove, + }) async { + // Guard [local asset] + if (parentAsset.remoteId == null) { + return; + } + + try { + if (childrenToAdd != null) { + final toAdd = childrenToAdd + .where((e) => e.isRemote) + .map((e) => e.remoteId!) + .toList(); + + await _api.assetApi.updateAssets( + AssetBulkUpdateDto(ids: toAdd, stackParentId: parentAsset.remoteId), + ); + } + + if (childrenToRemove != null) { + final toRemove = childrenToRemove + .where((e) => e.isRemote) + .map((e) => e.remoteId!) + .toList(); + await _api.assetApi.updateAssets( + AssetBulkUpdateDto(ids: toRemove, removeParent: true), + ); + } + } catch (error) { + debugPrint("Error while updating stack children: ${error.toString()}"); + } + } + + updateStackParent(Asset oldParent, Asset newParent) async { + // Guard [local asset] + if (oldParent.remoteId == null || newParent.remoteId == null) { + return; + } + + try { + await _api.assetApi.updateStackParent( + UpdateStackParentDto( + oldParentId: oldParent.remoteId!, + newParentId: newParent.remoteId!, + ), + ); + } catch (error) { + debugPrint("Error while updating stack parent: ${error.toString()}"); + } + } +} + +final assetStackServiceProvider = Provider( + (ref) => AssetStackService( + ref.watch(apiServiceProvider), + ), +); diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index bb818dcfcd116..cdc07a2a7947e 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -8,11 +8,13 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_controls_provider.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/video_player_value_provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/advanced_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; @@ -44,6 +46,7 @@ class GalleryViewerPage extends HookConsumerWidget { final int totalAssets; final int initialIndex; final int heroOffset; + final bool showStack; GalleryViewerPage({ super.key, @@ -51,6 +54,7 @@ class GalleryViewerPage extends HookConsumerWidget { required this.loadAsset, required this.totalAssets, this.heroOffset = 0, + this.showStack = false, }) : controller = PageController(initialPage: initialIndex); final PageController controller; @@ -77,8 +81,17 @@ class GalleryViewerPage extends HookConsumerWidget { final isFromTrash = isTrashEnabled && navStack.length > 2 && navStack.elementAt(navStack.length - 2).name == TrashRoute.name; + final stackIndex = useState(-1); + final stack = showStack && currentAsset.stackCount > 0 + ? ref.watch(assetStackStateProvider(currentAsset)) + : []; + final stackElements = showStack ? [currentAsset, ...stack] : []; - Asset asset() => currentAsset; + Asset asset() => stackIndex.value == -1 + ? currentAsset + : stackElements.elementAt(stackIndex.value); + + bool isParent = stackIndex.value == -1 || stackIndex.value == 0; useEffect( () { @@ -165,19 +178,28 @@ class GalleryViewerPage extends HookConsumerWidget { padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), - child: ExifBottomSheet(asset: currentAsset), + child: ExifBottomSheet(asset: asset()), ); }, ); } + void removeAssetFromStack() { + if (stackIndex.value > 0 && showStack) { + ref + .read(assetStackStateProvider(currentAsset).notifier) + .removeChild(stackIndex.value - 1); + stackIndex.value = stackIndex.value - 1; + } + } + void handleDelete(Asset deleteAsset) async { Future onDelete(bool force) async { final isDeleted = await ref.read(assetProvider.notifier).deleteAssets( {deleteAsset}, force: force, ); - if (isDeleted) { + if (isDeleted && isParent) { if (totalAssets == 1) { // Handle only one asset AutoRouter.of(context).pop(); @@ -195,14 +217,17 @@ class GalleryViewerPage extends HookConsumerWidget { // Asset is trashed if (isTrashEnabled && !isFromTrash) { final isDeleted = await onDelete(false); - // Can only trash assets stored in server. Local assets are always permanently removed for now - if (context.mounted && isDeleted && deleteAsset.isRemote) { - ImmichToast.show( - durationInSecond: 1, - context: context, - msg: 'Asset trashed', - gravity: ToastGravity.BOTTOM, - ); + if (isDeleted) { + // Can only trash assets stored in server. Local assets are always permanently removed for now + if (context.mounted && deleteAsset.isRemote && isParent) { + ImmichToast.show( + durationInSecond: 1, + context: context, + msg: 'Asset trashed', + gravity: ToastGravity.BOTTOM, + ); + } + removeAssetFromStack(); } return; } @@ -211,7 +236,14 @@ class GalleryViewerPage extends HookConsumerWidget { showDialog( context: context, builder: (BuildContext _) { - return DeleteDialog(onDelete: () => onDelete(true)); + return DeleteDialog( + onDelete: () async { + final isDeleted = await onDelete(true); + if (isDeleted) { + removeAssetFromStack(); + } + }, + ); }, ); } @@ -268,7 +300,11 @@ class GalleryViewerPage extends HookConsumerWidget { ref .watch(assetProvider.notifier) .toggleArchive([asset], !asset.isArchived); - AutoRouter.of(context).pop(); + if (isParent) { + AutoRouter.of(context).pop(); + return; + } + removeAssetFromStack(); } handleUpload(Asset asset) { @@ -385,7 +421,186 @@ class GalleryViewerPage extends HookConsumerWidget { ); } - buildBottomBar() { + Widget buildStackedChildren() { + return ListView.builder( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + itemCount: stackElements.length, + itemBuilder: (context, index) { + final assetId = stackElements.elementAt(index).remoteId; + return Padding( + padding: const EdgeInsets.only(right: 10), + child: GestureDetector( + onTap: () => stackIndex.value = index, + child: Container( + width: 40, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(6), + border: index == stackIndex.value + ? Border.all( + color: Colors.white, + width: 2, + ) + : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: CachedNetworkImage( + fit: BoxFit.cover, + imageUrl: + '${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$assetId', + httpHeaders: { + "Authorization": + "Bearer ${Store.get(StoreKey.accessToken)}", + }, + errorWidget: (context, url, error) => + const Icon(Icons.image_not_supported_outlined), + ), + ), + ), + ), + ); + }, + ); + } + + void showStackActionItems() { + showModalBottomSheet( + context: context, + enableDrag: false, + builder: (BuildContext ctx) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.only(top: 24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isParent) + ListTile( + leading: const Icon( + Icons.bookmark_border_outlined, + size: 24, + ), + onTap: () async { + await ref + .read(assetStackServiceProvider) + .updateStackParent( + currentAsset, + stackElements.elementAt(stackIndex.value), + ); + Navigator.pop(ctx); + AutoRouter.of(context).pop(); + }, + title: const Text( + "viewer_stack_use_as_main_asset", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), + ListTile( + leading: const Icon( + Icons.copy_all_outlined, + size: 24, + ), + onTap: () async { + if (isParent) { + await ref + .read(assetStackServiceProvider) + .updateStackParent( + currentAsset, + stackElements + .elementAt(1), // Next asset as parent + ); + // Remove itself from stack + await ref.read(assetStackServiceProvider).updateStack( + stackElements.elementAt(1), + childrenToRemove: [currentAsset], + ); + Navigator.pop(ctx); + AutoRouter.of(context).pop(); + } else { + await ref.read(assetStackServiceProvider).updateStack( + currentAsset, + childrenToRemove: [ + stackElements.elementAt(stackIndex.value), + ], + ); + removeAssetFromStack(); + Navigator.pop(ctx); + } + }, + title: const Text( + "viewer_remove_from_stack", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), + ListTile( + leading: const Icon( + Icons.filter_none_outlined, + size: 18, + ), + onTap: () async { + await ref.read(assetStackServiceProvider).updateStack( + currentAsset, + childrenToRemove: stack, + ); + Navigator.pop(ctx); + AutoRouter.of(context).pop(); + }, + title: const Text( + "viewer_unstack", + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + ), + ], + ), + ), + ); + }, + ); + } + + Widget buildBottomBar() { + // !!!! itemsList and actionlist should always be in sync + final itemsList = [ + BottomNavigationBarItem( + icon: Icon( + Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded, + ), + label: 'control_bottom_app_bar_share'.tr(), + tooltip: 'control_bottom_app_bar_share'.tr(), + ), + asset().isArchived + ? BottomNavigationBarItem( + icon: const Icon(Icons.unarchive_rounded), + label: 'control_bottom_app_bar_unarchive'.tr(), + tooltip: 'control_bottom_app_bar_unarchive'.tr(), + ) + : BottomNavigationBarItem( + icon: const Icon(Icons.archive_outlined), + label: 'control_bottom_app_bar_archive'.tr(), + tooltip: 'control_bottom_app_bar_archive'.tr(), + ), + if (stack.isNotEmpty) + BottomNavigationBarItem( + icon: const Icon(Icons.burst_mode_outlined), + label: 'control_bottom_app_bar_stack'.tr(), + tooltip: 'control_bottom_app_bar_stack'.tr(), + ), + BottomNavigationBarItem( + icon: const Icon(Icons.delete_outline), + label: 'control_bottom_app_bar_delete'.tr(), + tooltip: 'control_bottom_app_bar_delete'.tr(), + ), + ]; + + List actionslist = [ + (_) => shareAsset(), + (_) => handleArchive(asset()), + if (stack.isNotEmpty) (_) => showStackActionItems(), + (_) => handleDelete(asset()), + ]; + return IgnorePointer( ignoring: !ref.watch(showControlsProvider), child: AnimatedOpacity( @@ -393,6 +608,17 @@ class GalleryViewerPage extends HookConsumerWidget { opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0, child: Column( children: [ + if (stack.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + left: 10, + bottom: 30, + ), + child: SizedBox( + height: 40, + child: buildStackedChildren(), + ), + ), Visibility( visible: !asset().isImage && !isPlayingMotionVideo.value, child: Container( @@ -421,44 +647,10 @@ class GalleryViewerPage extends HookConsumerWidget { selectedLabelStyle: const TextStyle(color: Colors.black), showSelectedLabels: false, showUnselectedLabels: false, - items: [ - BottomNavigationBarItem( - icon: Icon( - Platform.isAndroid - ? Icons.share_rounded - : Icons.ios_share_rounded, - ), - label: 'control_bottom_app_bar_share'.tr(), - tooltip: 'control_bottom_app_bar_share'.tr(), - ), - asset().isArchived - ? BottomNavigationBarItem( - icon: const Icon(Icons.unarchive_rounded), - label: 'control_bottom_app_bar_unarchive'.tr(), - tooltip: 'control_bottom_app_bar_unarchive'.tr(), - ) - : BottomNavigationBarItem( - icon: const Icon(Icons.archive_outlined), - label: 'control_bottom_app_bar_archive'.tr(), - tooltip: 'control_bottom_app_bar_archive'.tr(), - ), - BottomNavigationBarItem( - icon: const Icon(Icons.delete_outline), - label: 'control_bottom_app_bar_delete'.tr(), - tooltip: 'control_bottom_app_bar_delete'.tr(), - ), - ], + items: itemsList, onTap: (index) { - switch (index) { - case 0: - shareAsset(); - break; - case 1: - handleArchive(asset()); - break; - case 2: - handleDelete(asset()); - break; + if (index < actionslist.length) { + actionslist[index].call(index); } }, ), @@ -504,6 +696,7 @@ class GalleryViewerPage extends HookConsumerWidget { final next = currentIndex.value < value ? value + 1 : value - 1; precacheNextImage(next); currentIndex.value = value; + stackIndex.value = -1; HapticFeedback.selectionClick(); }, loadingBuilder: (context, event, index) { @@ -544,10 +737,11 @@ class GalleryViewerPage extends HookConsumerWidget { : webPThumbnail; }, builder: (context, index) { - final asset = loadAsset(index); - final ImageProvider provider = finalImageProvider(asset); + final a = + index == currentIndex.value ? asset() : loadAsset(index); + final ImageProvider provider = finalImageProvider(a); - if (asset.isImage && !isPlayingMotionVideo.value) { + if (a.isImage && !isPlayingMotionVideo.value) { return PhotoViewGalleryPageOptions( onDragStart: (_, details, __) => localPosition = details.localPosition, @@ -558,13 +752,13 @@ class GalleryViewerPage extends HookConsumerWidget { }, imageProvider: provider, heroAttributes: PhotoViewHeroAttributes( - tag: asset.id + heroOffset, + tag: a.id + heroOffset, ), filterQuality: FilterQuality.high, tightMode: true, minScale: PhotoViewComputedScale.contained, errorBuilder: (context, error, stackTrace) => ImmichImage( - asset, + a, fit: BoxFit.contain, ), ); @@ -575,7 +769,7 @@ class GalleryViewerPage extends HookConsumerWidget { onDragUpdate: (_, details, __) => handleSwipeUpDown(details), heroAttributes: PhotoViewHeroAttributes( - tag: asset.id + heroOffset, + tag: a.id + heroOffset, ), filterQuality: FilterQuality.high, maxScale: 1.0, @@ -584,7 +778,7 @@ class GalleryViewerPage extends HookConsumerWidget { child: VideoViewerPage( onPlaying: () => isPlayingVideo.value = true, onPaused: () => isPlayingVideo.value = false, - asset: asset, + asset: a, isMotionVideo: isPlayingMotionVideo.value, placeholder: Image( image: provider, diff --git a/mobile/lib/modules/home/models/selection_state.dart b/mobile/lib/modules/home/models/selection_state.dart new file mode 100644 index 0000000000000..291b590689369 --- /dev/null +++ b/mobile/lib/modules/home/models/selection_state.dart @@ -0,0 +1,47 @@ +import 'package:immich_mobile/shared/models/asset.dart'; + +class SelectionAssetState { + final bool hasRemote; + final bool hasLocal; + final bool hasMerged; + + const SelectionAssetState({ + this.hasRemote = false, + this.hasLocal = false, + this.hasMerged = false, + }); + + SelectionAssetState copyWith({ + bool? hasRemote, + bool? hasLocal, + bool? hasMerged, + }) { + return SelectionAssetState( + hasRemote: hasRemote ?? this.hasRemote, + hasLocal: hasLocal ?? this.hasLocal, + hasMerged: hasMerged ?? this.hasMerged, + ); + } + + SelectionAssetState.fromSelection(Set selection) + : hasLocal = selection.any((e) => e.storage == AssetState.local), + hasMerged = selection.any((e) => e.storage == AssetState.merged), + hasRemote = selection.any((e) => e.storage == AssetState.remote); + + @override + String toString() => + 'SelectionAssetState(hasRemote: $hasRemote, hasMerged: $hasMerged, hasMerged: $hasMerged)'; + + @override + bool operator ==(covariant SelectionAssetState other) { + if (identical(this, other)) return true; + + return other.hasRemote == hasRemote && + other.hasLocal == hasLocal && + other.hasMerged == hasMerged; + } + + @override + int get hashCode => + hasRemote.hashCode ^ hasLocal.hashCode ^ hasMerged.hashCode; +} diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart index c4a6d527ed6ee..28b25f3422b04 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart @@ -32,6 +32,7 @@ class ImmichAssetGrid extends HookConsumerWidget { final Widget? topWidget; final bool shrinkWrap; final bool showDragScroll; + final bool showStack; const ImmichAssetGrid({ super.key, @@ -51,6 +52,7 @@ class ImmichAssetGrid extends HookConsumerWidget { this.topWidget, this.shrinkWrap = false, this.showDragScroll = true, + this.showStack = false, }); @override @@ -114,6 +116,7 @@ class ImmichAssetGrid extends HookConsumerWidget { heroOffset: heroOffset(), shrinkWrap: shrinkWrap, showDragScroll: showDragScroll, + showStack: showStack, ), ); } diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart index 8f50c28832bfb..b3f031c68aedb 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart @@ -37,6 +37,7 @@ class ImmichAssetGridView extends StatefulWidget { final int heroOffset; final bool shrinkWrap; final bool showDragScroll; + final bool showStack; const ImmichAssetGridView({ super.key, @@ -56,6 +57,7 @@ class ImmichAssetGridView extends StatefulWidget { this.heroOffset = 0, this.shrinkWrap = false, this.showDragScroll = true, + this.showStack = false, }); @override @@ -71,7 +73,7 @@ class ImmichAssetGridViewState extends State { bool _scrolling = false; final Set _selectedAssets = - HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id); + LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id); Set _getSelectedAssets() { return Set.from(_selectedAssets); @@ -90,7 +92,13 @@ class ImmichAssetGridViewState extends State { void _deselectAssets(List assets) { setState(() { - _selectedAssets.removeAll(assets); + _selectedAssets.removeAll( + assets.where( + (a) => + widget.canDeselect || + !(widget.preselectedAssets?.contains(a) ?? false), + ), + ); _callSelectionListener(_selectedAssets.isNotEmpty); }); } @@ -129,6 +137,7 @@ class ImmichAssetGridViewState extends State { useGrayBoxPlaceholder: true, showStorageIndicator: widget.showStorageIndicator, heroOffset: widget.heroOffset, + showStack: widget.showStack, ); } @@ -377,10 +386,6 @@ class ImmichAssetGridViewState extends State { setState(() { _selectedAssets.clear(); }); - } else if (widget.preselectedAssets != null) { - setState(() { - _selectedAssets.addAll(widget.preselectedAssets!); - }); } } diff --git a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart index 555475b4b3001..5b925c86b330f 100644 --- a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart @@ -12,6 +12,7 @@ class ThumbnailImage extends StatelessWidget { final Asset Function(int index) loadAsset; final int totalAssets; final bool showStorageIndicator; + final bool showStack; final bool useGrayBoxPlaceholder; final bool isSelected; final bool multiselectEnabled; @@ -26,6 +27,7 @@ class ThumbnailImage extends StatelessWidget { required this.loadAsset, required this.totalAssets, this.showStorageIndicator = true, + this.showStack = false, this.useGrayBoxPlaceholder = false, this.isSelected = false, this.multiselectEnabled = false, @@ -93,6 +95,35 @@ class ThumbnailImage extends StatelessWidget { ); } + Widget buildStackIcon() { + return Positioned( + top: 5, + right: 5, + child: Row( + children: [ + if (asset.stackCount > 1) + Text( + "${asset.stackCount}", + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + if (asset.stackCount > 1) + const SizedBox( + width: 3, + ), + const Icon( + Icons.burst_mode_rounded, + color: Colors.white, + size: 18, + ), + ], + ), + ); + } + Widget buildImage() { final image = SizedBox( width: 300, @@ -113,9 +144,9 @@ class ThumbnailImage extends StatelessWidget { decoration: BoxDecoration( border: Border.all( width: 0, - color: assetContainerColor, + color: onDeselect == null ? Colors.grey : assetContainerColor, ), - color: assetContainerColor, + color: onDeselect == null ? Colors.grey : assetContainerColor, ), child: ClipRRect( borderRadius: const BorderRadius.only( @@ -144,6 +175,7 @@ class ThumbnailImage extends StatelessWidget { loadAsset: loadAsset, totalAssets: totalAssets, heroOffset: heroOffset, + showStack: showStack, ), ); } @@ -196,6 +228,7 @@ class ThumbnailImage extends StatelessWidget { ), ), if (!asset.isImage) buildVideoIcon(), + if (asset.isImage && asset.stackCount > 0) buildStackIcon(), ], ), ); diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart index 6315cf1d4f797..3d8e165e2a713 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart @@ -4,9 +4,9 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart'; +import 'package:immich_mobile/modules/home/models/selection_state.dart'; import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; -import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:immich_mobile/shared/models/album.dart'; @@ -19,11 +19,12 @@ class ControlBottomAppBar extends ConsumerWidget { final Function(Album album) onAddToAlbum; final void Function() onCreateNewAlbum; final void Function() onUpload; + final void Function() onStack; final List albums; final List sharedAlbums; final bool enabled; - final AssetState selectionAssetState; + final SelectionAssetState selectionAssetState; const ControlBottomAppBar({ Key? key, @@ -36,19 +37,24 @@ class ControlBottomAppBar extends ConsumerWidget { required this.onAddToAlbum, required this.onCreateNewAlbum, required this.onUpload, - this.selectionAssetState = AssetState.remote, + required this.onStack, + this.selectionAssetState = const SelectionAssetState(), this.enabled = true, }) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { var isDarkMode = Theme.of(context).brightness == Brightness.dark; - var hasRemote = selectionAssetState == AssetState.remote; + var hasRemote = + selectionAssetState.hasRemote || selectionAssetState.hasMerged; + var hasLocal = selectionAssetState.hasLocal; final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash)); Widget renderActionButtons() { - return Row( + return Wrap( + spacing: 10, + runSpacing: 15, children: [ ControlBoxButton( iconData: Platform.isAndroid @@ -92,7 +98,7 @@ class ControlBottomAppBar extends ConsumerWidget { if (!hasRemote) ControlBoxButton( iconData: Icons.backup_outlined, - label: "Upload", + label: "control_bottom_app_bar_upload".tr(), onPressed: enabled ? () => showDialog( context: context, @@ -104,6 +110,12 @@ class ControlBottomAppBar extends ConsumerWidget { ) : null, ), + if (!hasLocal) + ControlBoxButton( + iconData: Icons.filter_none_rounded, + label: "control_bottom_app_bar_stack".tr(), + onPressed: enabled ? onStack : null, + ), ], ); } @@ -111,7 +123,7 @@ class ControlBottomAppBar extends ConsumerWidget { return DraggableScrollableSheet( initialChildSize: hasRemote ? 0.30 : 0.18, minChildSize: 0.18, - maxChildSize: hasRemote ? 0.57 : 0.18, + maxChildSize: hasRemote ? 0.60 : 0.18, snap: true, builder: ( BuildContext context, diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 5dfc8ed918b37..d20501caa3cd3 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -7,11 +7,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; +import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart'; +import 'package:immich_mobile/modules/asset_viewer/services/asset_stack.service.dart'; import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; +import 'package:immich_mobile/modules/home/models/selection_state.dart'; import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart'; @@ -36,7 +40,7 @@ class HomePage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final multiselectEnabled = ref.watch(multiselectProvider.notifier); final selectionEnabledHook = useState(false); - final selectionAssetState = useState(AssetState.remote); + final selectionAssetState = useState(const SelectionAssetState()); final selection = useState({}); final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); @@ -83,9 +87,8 @@ class HomePage extends HookConsumerWidget { ) { selectionEnabledHook.value = multiselect; selection.value = selectedAssets; - selectionAssetState.value = selectedAssets.any((e) => e.isRemote) - ? AssetState.remote - : AssetState.local; + selectionAssetState.value = + SelectionAssetState.fromSelection(selectedAssets); } void onShareAssets() { @@ -246,6 +249,55 @@ class HomePage extends HookConsumerWidget { } } + void onStack() async { + try { + processing.value = true; + if (!selectionEnabledHook.value) { + return; + } + + final selectedAsset = selection.value.elementAt(0); + + if (selection.value.length == 1) { + final stackChildren = + (await ref.read(assetStackProvider(selectedAsset).future)) + .toSet(); + AssetSelectionPageResult? returnPayload = + await AutoRouter.of(context).push( + AssetSelectionRoute( + existingAssets: stackChildren, + canDeselect: true, + query: getAssetStackSelectionQuery(ref, selectedAsset), + ), + ); + + if (returnPayload != null) { + Set selectedAssets = returnPayload.selectedAssets; + // Do not add itself as its stack child + selectedAssets.remove(selectedAsset); + final removedChildren = stackChildren.difference(selectedAssets); + final addedChildren = selectedAssets.difference(stackChildren); + await ref.read(assetStackServiceProvider).updateStack( + selectedAsset, + childrenToAdd: addedChildren.toList(), + childrenToRemove: removedChildren.toList(), + ); + } + } else { + // Merge assets + selection.value.remove(selectedAsset); + final selectedAssets = selection.value; + await ref.read(assetStackServiceProvider).updateStack( + selectedAsset, + childrenToAdd: selectedAssets.toList(), + ); + } + } finally { + processing.value = false; + selectionEnabledHook.value = false; + } + } + Future refreshAssets() async { final fullRefresh = refreshCount.value > 0; await ref.read(assetProvider.notifier).getAllAsset(clear: fullRefresh); @@ -322,6 +374,7 @@ class HomePage extends HookConsumerWidget { currentUser.memoryEnabled!) ? const MemoryLane() : const SizedBox(), + showStack: true, ), error: (error, _) => Center(child: Text(error.toString())), loading: buildLoadingIndicator, @@ -339,6 +392,7 @@ class HomePage extends HookConsumerWidget { onUpload: onUpload, enabled: !processing.value, selectionAssetState: selectionAssetState.value, + onStack: onStack, ), if (processing.value) const Center(child: ImmichLoadingIndicator()), ], diff --git a/mobile/lib/modules/trash/views/trash_page.dart b/mobile/lib/modules/trash/views/trash_page.dart index 797b08a75cee1..8c128b61dc02d 100644 --- a/mobile/lib/modules/trash/views/trash_page.dart +++ b/mobile/lib/modules/trash/views/trash_page.dart @@ -252,6 +252,7 @@ class TrashPage extends HookConsumerWidget { listener: selectionListener, selectionActive: selectionEnabledHook.value, showMultiSelectIndicator: false, + showStack: true, topWidget: Padding( padding: const EdgeInsets.only( top: 24, diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index c3f4c2c1ad8c1..b7971c9d19ee0 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -51,6 +51,7 @@ import 'package:immich_mobile/shared/views/app_log_detail_page.dart'; import 'package:immich_mobile/shared/views/app_log_page.dart'; import 'package:immich_mobile/shared/views/splash_screen.dart'; import 'package:immich_mobile/shared/views/tab_controller_page.dart'; +import 'package:isar/isar.dart'; import 'package:photo_manager/photo_manager.dart'; part 'router.gr.dart'; diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 6502d5585bb98..651bec033fba8 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -71,6 +71,7 @@ class _$AppRouter extends RootStackRouter { loadAsset: args.loadAsset, totalAssets: args.totalAssets, heroOffset: args.heroOffset, + showStack: args.showStack, ), ); }, @@ -153,7 +154,8 @@ class _$AppRouter extends RootStackRouter { child: AssetSelectionPage( key: args.key, existingAssets: args.existingAssets, - isNewAlbum: args.isNewAlbum, + canDeselect: args.canDeselect, + query: args.query, ), transitionsBuilder: TransitionsBuilders.slideBottom, opaque: true, @@ -711,6 +713,7 @@ class GalleryViewerRoute extends PageRouteInfo { required Asset Function(int) loadAsset, required int totalAssets, int heroOffset = 0, + bool showStack = false, }) : super( GalleryViewerRoute.name, path: '/gallery-viewer-page', @@ -720,6 +723,7 @@ class GalleryViewerRoute extends PageRouteInfo { loadAsset: loadAsset, totalAssets: totalAssets, heroOffset: heroOffset, + showStack: showStack, ), ); @@ -733,6 +737,7 @@ class GalleryViewerRouteArgs { required this.loadAsset, required this.totalAssets, this.heroOffset = 0, + this.showStack = false, }); final Key? key; @@ -745,9 +750,11 @@ class GalleryViewerRouteArgs { final int heroOffset; + final bool showStack; + @override String toString() { - return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset}'; + return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack}'; } } @@ -961,14 +968,16 @@ class AssetSelectionRoute extends PageRouteInfo { AssetSelectionRoute({ Key? key, required Set existingAssets, - bool isNewAlbum = false, + bool canDeselect = false, + required QueryBuilder? query, }) : super( AssetSelectionRoute.name, path: '/asset-selection-page', args: AssetSelectionRouteArgs( key: key, existingAssets: existingAssets, - isNewAlbum: isNewAlbum, + canDeselect: canDeselect, + query: query, ), ); @@ -979,18 +988,21 @@ class AssetSelectionRouteArgs { const AssetSelectionRouteArgs({ this.key, required this.existingAssets, - this.isNewAlbum = false, + this.canDeselect = false, + required this.query, }); final Key? key; final Set existingAssets; - final bool isNewAlbum; + final bool canDeselect; + + final QueryBuilder? query; @override String toString() { - return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, isNewAlbum: $isNewAlbum}'; + return 'AssetSelectionRouteArgs{key: $key, existingAssets: $existingAssets, canDeselect: $canDeselect, query: $query}'; } } diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index 74d9380be9422..66f2cc9f37c8b 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -31,7 +31,9 @@ class Asset { remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null, isFavorite = remote.isFavorite, isArchived = remote.isArchived, - isTrashed = remote.isTrashed; + isTrashed = remote.isTrashed, + stackParentId = remote.stackParentId, + stackCount = remote.stackCount; Asset.local(AssetEntity local, List hash) : localId = local.id, @@ -47,6 +49,7 @@ class Asset { isFavorite = local.isFavorite, isArchived = false, isTrashed = false, + stackCount = 0, fileCreatedAt = local.createDateTime { if (fileCreatedAt.year == 1970) { fileCreatedAt = fileModifiedAt; @@ -77,6 +80,8 @@ class Asset { required this.isFavorite, required this.isArchived, required this.isTrashed, + this.stackParentId, + required this.stackCount, }); @ignore @@ -146,6 +151,10 @@ class Asset { @ignore ExifInfo? exifInfo; + String? stackParentId; + + int stackCount; + /// `true` if this [Asset] is present on the device @ignore bool get isLocal => localId != null; @@ -200,7 +209,9 @@ class Asset { isFavorite == other.isFavorite && isLocal == other.isLocal && isArchived == other.isArchived && - isTrashed == other.isTrashed; + isTrashed == other.isTrashed && + stackCount == other.stackCount && + stackParentId == other.stackParentId; } @override @@ -223,7 +234,9 @@ class Asset { isFavorite.hashCode ^ isLocal.hashCode ^ isArchived.hashCode ^ - isTrashed.hashCode; + isTrashed.hashCode ^ + stackCount.hashCode ^ + stackParentId.hashCode; /// Returns `true` if this [Asset] can updated with values from parameter [a] bool canUpdate(Asset a) { @@ -236,9 +249,11 @@ class Asset { width == null && a.width != null || height == null && a.height != null || livePhotoVideoId == null && a.livePhotoVideoId != null || + stackParentId == null && a.stackParentId != null || isFavorite != a.isFavorite || isArchived != a.isArchived || - isTrashed != a.isTrashed; + isTrashed != a.isTrashed || + stackCount != a.stackCount; } /// Returns a new [Asset] with values from this and merged & updated with [a] @@ -267,6 +282,8 @@ class Asset { id: id, remoteId: remoteId, livePhotoVideoId: livePhotoVideoId, + stackParentId: stackParentId, + stackCount: stackCount, isFavorite: isFavorite, isArchived: isArchived, isTrashed: isTrashed, @@ -281,6 +298,8 @@ class Asset { width: a.width, height: a.height, livePhotoVideoId: a.livePhotoVideoId, + stackParentId: a.stackParentId, + stackCount: a.stackCount, // isFavorite + isArchived are not set by device-only assets isFavorite: a.isFavorite, isArchived: a.isArchived, @@ -318,6 +337,8 @@ class Asset { bool? isArchived, bool? isTrashed, ExifInfo? exifInfo, + String? stackParentId, + int? stackCount, }) => Asset( id: id ?? this.id, @@ -338,6 +359,8 @@ class Asset { isArchived: isArchived ?? this.isArchived, isTrashed: isTrashed ?? this.isTrashed, exifInfo: exifInfo ?? this.exifInfo, + stackParentId: stackParentId ?? this.stackParentId, + stackCount: stackCount ?? this.stackCount, ); Future put(Isar db) async { @@ -379,6 +402,8 @@ class Asset { "checksum": "$checksum", "ownerId": $ownerId, "livePhotoVideoId": "${livePhotoVideoId ?? "N/A"}", + "stackCount": "$stackCount", + "stackParentId": "${stackParentId ?? "N/A"}", "fileCreatedAt": "$fileCreatedAt", "fileModifiedAt": "$fileModifiedAt", "updatedAt": "$updatedAt", diff --git a/mobile/lib/shared/models/asset.g.dart b/mobile/lib/shared/models/asset.g.dart index f06e556eaf19c..4f485dfb024b0 100644 --- a/mobile/lib/shared/models/asset.g.dart +++ b/mobile/lib/shared/models/asset.g.dart @@ -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

( 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 { }); } + QueryBuilder stackCountEqualTo( + int value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'stackCount', + value: value, + )); + }); + } + + QueryBuilder stackCountGreaterThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'stackCount', + value: value, + )); + }); + } + + QueryBuilder stackCountLessThan( + int value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'stackCount', + value: value, + )); + }); + } + + QueryBuilder 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 stackParentIdIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'stackParentId', + )); + }); + } + + QueryBuilder stackParentIdIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'stackParentId', + )); + }); + } + + QueryBuilder stackParentIdEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'stackParentId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder 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 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 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 stackParentIdStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'stackParentId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackParentIdEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'stackParentId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackParentIdContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'stackParentId', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackParentIdMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'stackParentId', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder stackParentIdIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'stackParentId', + value: '', + )); + }); + } + + QueryBuilder stackParentIdIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'stackParentId', + value: '', + )); + }); + } + QueryBuilder typeEqualTo( AssetType value) { return QueryBuilder.apply(this, (query) { @@ -2137,6 +2360,30 @@ extension AssetQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByStackCount() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackCount', Sort.asc); + }); + } + + QueryBuilder sortByStackCountDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackCount', Sort.desc); + }); + } + + QueryBuilder sortByStackParentId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackParentId', Sort.asc); + }); + } + + QueryBuilder sortByStackParentIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackParentId', Sort.desc); + }); + } + QueryBuilder sortByType() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'type', Sort.asc); @@ -2343,6 +2590,30 @@ extension AssetQuerySortThenBy on QueryBuilder { }); } + QueryBuilder thenByStackCount() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackCount', Sort.asc); + }); + } + + QueryBuilder thenByStackCountDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackCount', Sort.desc); + }); + } + + QueryBuilder thenByStackParentId() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackParentId', Sort.asc); + }); + } + + QueryBuilder thenByStackParentIdDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'stackParentId', Sort.desc); + }); + } + QueryBuilder thenByType() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'type', Sort.asc); @@ -2465,6 +2736,20 @@ extension AssetQueryWhereDistinct on QueryBuilder { }); } + QueryBuilder distinctByStackCount() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'stackCount'); + }); + } + + QueryBuilder distinctByStackParentId( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'stackParentId', + caseSensitive: caseSensitive); + }); + } + QueryBuilder distinctByType() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'type'); @@ -2569,6 +2854,18 @@ extension AssetQueryProperty on QueryBuilder { }); } + QueryBuilder stackCountProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'stackCount'); + }); + } + + QueryBuilder stackParentIdProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'stackParentId'); + }); + } + QueryBuilder typeProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'type'); diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index d17c953558e2f..bc36f65b985ee 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -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((ref, userId) async* { - if (userId == null) return; - final query = ref +QueryBuilder? getRemoteAssetQuery(WidgetRef ref) { + final userId = ref.watch(currentUserProvider)?.isarId; + if (userId == null) { + return null; + } + return ref .watch(dbProvider) .assets .where() @@ -238,12 +242,34 @@ final remoteAssetsProvider = .filter() .ownerIdEqualTo(userId) .isTrashedEqualTo(false) + .stackParentIdIsNull() .sortByFileCreatedAtDesc(); - final settings = ref.watch(appSettingsServiceProvider); - final groupBy = - GroupAssetsBy.values[settings.getSetting(AppSettingsEnum.groupAssetsBy)]; - yield await RenderList.fromQuery(query, groupBy); - await for (final _ in query.watchLazy()) { - yield await RenderList.fromQuery(query, groupBy); +} + +QueryBuilder? getAssetStackSelectionQuery( + WidgetRef ref, + Asset parentAsset, +) { + final userId = ref.watch(currentUserProvider)?.isarId; + if (userId == null || !parentAsset.isRemote) { + return null; } -}); + return ref + .watch(dbProvider) + .assets + .where() + .remoteIdIsNotNull() + .filter() + .isArchivedEqualTo(false) + .ownerIdEqualTo(userId) + .not() + .remoteIdEqualTo(parentAsset.remoteId) + // Show existing stack children in selection page + .group( + (q) => q + .stackParentIdIsNull() + .or() + .stackParentIdEqualTo(parentAsset.remoteId), + ) + .sortByFileCreatedAtDesc(); +} diff --git a/mobile/lib/shared/providers/websocket.provider.dart b/mobile/lib/shared/providers/websocket.provider.dart index ed529a6d8d170..1dda262f509ea 100644 --- a/mobile/lib/shared/providers/websocket.provider.dart +++ b/mobile/lib/shared/providers/websocket.provider.dart @@ -133,6 +133,7 @@ class WebsocketNotifier extends StateNotifier { socket.on('on_asset_delete', _handleOnAssetDelete); socket.on('on_asset_trash', _handleServerUpdates); socket.on('on_asset_restore', _handleServerUpdates); + socket.on('on_asset_update', _handleServerUpdates); } catch (e) { debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}"); } diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index bf699a31336f2..85b96e64735f0 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -149,6 +149,7 @@ doc/TranscodePolicy.md doc/UpdateAlbumDto.md doc/UpdateAssetDto.md doc/UpdateLibraryDto.md +doc/UpdateStackParentDto.md doc/UpdateTagDto.md doc/UpdateUserDto.md doc/UsageByUserDto.md @@ -314,6 +315,7 @@ lib/model/transcode_policy.dart lib/model/update_album_dto.dart lib/model/update_asset_dto.dart lib/model/update_library_dto.dart +lib/model/update_stack_parent_dto.dart lib/model/update_tag_dto.dart lib/model/update_user_dto.dart lib/model/usage_by_user_dto.dart @@ -468,6 +470,7 @@ test/transcode_policy_test.dart test/update_album_dto_test.dart test/update_asset_dto_test.dart test/update_library_dto_test.dart +test/update_stack_parent_dto_test.dart test/update_tag_dto_test.dart test/update_user_dto_test.dart test/usage_by_user_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c8c913977d131..47d04b9bd2d6f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -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) diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 30914b10cea1b..075c32bd986ad 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -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('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('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) diff --git a/mobile/openapi/doc/AssetBulkUpdateDto.md b/mobile/openapi/doc/AssetBulkUpdateDto.md index b482684642010..74fd5ec45311a 100644 --- a/mobile/openapi/doc/AssetBulkUpdateDto.md +++ b/mobile/openapi/doc/AssetBulkUpdateDto.md @@ -11,6 +11,8 @@ Name | Type | Description | Notes **ids** | **List** | | [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) diff --git a/mobile/openapi/doc/AssetResponseDto.md b/mobile/openapi/doc/AssetResponseDto.md index a08be71ace26c..8c4d1db4a74fe 100644 --- a/mobile/openapi/doc/AssetResponseDto.md +++ b/mobile/openapi/doc/AssetResponseDto.md @@ -33,6 +33,9 @@ Name | Type | Description | Notes **people** | [**List**](PersonResponseDto.md) | | [optional] [default to const []] **resized** | **bool** | | **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional] +**stack** | [**List**](AssetResponseDto.md) | | [optional] [default to const []] +**stackCount** | **int** | | +**stackParentId** | **String** | | [optional] **tags** | [**List**](TagResponseDto.md) | | [optional] [default to const []] **thumbhash** | **String** | | **type** | [**AssetTypeEnum**](AssetTypeEnum.md) | | diff --git a/mobile/openapi/doc/UpdateStackParentDto.md b/mobile/openapi/doc/UpdateStackParentDto.md new file mode 100644 index 0000000000000..750daace0c1b8 --- /dev/null +++ b/mobile/openapi/doc/UpdateStackParentDto.md @@ -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) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 48745a162c93e..e72c1da168fb5 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -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'; diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 91429ce7e5131..5935f56769bf1 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -1654,6 +1654,45 @@ class AssetApi { } } + /// Performs an HTTP 'PUT /asset/stack/parent' operation and returns the [Response]. + /// Parameters: + /// + /// * [UpdateStackParentDto] updateStackParentDto (required): + Future updateStackParentWithHttpInfo(UpdateStackParentDto updateStackParentDto,) async { + // ignore: prefer_const_declarations + final path = r'/asset/stack/parent'; + + // ignore: prefer_final_locals + Object? postBody = updateStackParentDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [UpdateStackParentDto] updateStackParentDto (required): + Future 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: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 9a98b4997ab8e..34b9a431d44e1 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -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': diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index 7eb0e31afce8a..64c8d1e7e7cdf 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -16,6 +16,8 @@ class AssetBulkUpdateDto { this.ids = const [], this.isArchived, this.isFavorite, + this.removeParent, + this.stackParentId, }); List 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 toJson() { final json = {}; @@ -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(json, r'isArchived'), isFavorite: mapValueOfType(json, r'isFavorite'), + removeParent: mapValueOfType(json, r'removeParent'), + stackParentId: mapValueOfType(json, r'stackParentId'), ); } return null; diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index b2feb0ee8236f..e580ca5a2fbeb 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -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 stack; + + int stackCount; + + String? stackParentId; + List 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 toJson() { final json = {}; @@ -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(json, r'resized')!, smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']), + stack: AssetResponseDto.listFromJson(json[r'stack']), + stackCount: mapValueOfType(json, r'stackCount')!, + stackParentId: mapValueOfType(json, r'stackParentId'), tags: TagResponseDto.listFromJson(json[r'tags']), thumbhash: mapValueOfType(json, r'thumbhash'), type: AssetTypeEnum.fromJson(json[r'type'])!, @@ -347,6 +372,7 @@ class AssetResponseDto { 'originalPath', 'ownerId', 'resized', + 'stackCount', 'thumbhash', 'type', 'updatedAt', diff --git a/mobile/openapi/lib/model/update_stack_parent_dto.dart b/mobile/openapi/lib/model/update_stack_parent_dto.dart new file mode 100644 index 0000000000000..8940f748de9c9 --- /dev/null +++ b/mobile/openapi/lib/model/update_stack_parent_dto.dart @@ -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 toJson() { + final json = {}; + 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(); + + return UpdateStackParentDto( + newParentId: mapValueOfType(json, r'newParentId')!, + oldParentId: mapValueOfType(json, r'oldParentId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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 mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + 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 = { + 'newParentId', + 'oldParentId', + }; +} + diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 640652167daca..8e45a1e3cb744 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -174,6 +174,11 @@ void main() { // TODO }); + //Future updateStackParent(UpdateStackParentDto updateStackParentDto) async + test('test updateStackParent', () async { + // TODO + }); + //Future 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 diff --git a/mobile/openapi/test/asset_bulk_update_dto_test.dart b/mobile/openapi/test/asset_bulk_update_dto_test.dart index cb23751e084df..06f65de66600f 100644 --- a/mobile/openapi/test/asset_bulk_update_dto_test.dart +++ b/mobile/openapi/test/asset_bulk_update_dto_test.dart @@ -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 + }); + }); diff --git a/mobile/openapi/test/asset_response_dto_test.dart b/mobile/openapi/test/asset_response_dto_test.dart index f450aae2748e8..63668934a9574 100644 --- a/mobile/openapi/test/asset_response_dto_test.dart +++ b/mobile/openapi/test/asset_response_dto_test.dart @@ -142,6 +142,21 @@ void main() { // TODO }); + // List 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 tags (default value: const []) test('to test the property `tags`', () async { // TODO diff --git a/mobile/openapi/test/update_stack_parent_dto_test.dart b/mobile/openapi/test/update_stack_parent_dto_test.dart new file mode 100644 index 0000000000000..6af71854ec9b8 --- /dev/null +++ b/mobile/openapi/test/update_stack_parent_dto_test.dart @@ -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 + }); + + + }); + +} diff --git a/mobile/test/asset_grid_data_structure_test.dart b/mobile/test/asset_grid_data_structure_test.dart index a124f5214ae6b..6b8f080638a23 100644 --- a/mobile/test/asset_grid_data_structure_test.dart +++ b/mobile/test/asset_grid_data_structure_test.dart @@ -25,6 +25,7 @@ void main() { isFavorite: false, isArchived: false, isTrashed: false, + stackCount: 0, ), ); } diff --git a/mobile/test/sync_service_test.dart b/mobile/test/sync_service_test.dart index 9c03ec689b806..b2543c66354a2 100644 --- a/mobile/test/sync_service_test.dart +++ b/mobile/test/sync_service_test.dart @@ -35,6 +35,7 @@ void main() { isFavorite: false, isArchived: false, isTrashed: false, + stackCount: 0, ); } diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 50224f8a00ace..73ec9d1e7c880 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -1673,6 +1673,41 @@ ] } }, + "/asset/stack/parent": { + "put": { + "operationId": "updateStackParent", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStackParentDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Asset" + ] + } + }, "/asset/statistics": { "get": { "operationId": "getAssetStats", @@ -5696,6 +5731,13 @@ }, "isFavorite": { "type": "boolean" + }, + "removeParent": { + "type": "boolean" + }, + "stackParentId": { + "format": "uuid", + "type": "string" } }, "required": [ @@ -5941,6 +5983,19 @@ "smartInfo": { "$ref": "#/components/schemas/SmartInfoResponseDto" }, + "stack": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + }, + "stackCount": { + "type": "integer" + }, + "stackParentId": { + "nullable": true, + "type": "string" + }, "tags": { "items": { "$ref": "#/components/schemas/TagResponseDto" @@ -5961,6 +6016,7 @@ }, "required": [ "type", + "stackCount", "deviceAssetId", "deviceId", "ownerId", @@ -8521,6 +8577,23 @@ }, "type": "object" }, + "UpdateStackParentDto": { + "properties": { + "newParentId": { + "format": "uuid", + "type": "string" + }, + "oldParentId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "oldParentId", + "newParentId" + ], + "type": "object" + }, "UpdateTagDto": { "properties": { "name": { diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 20e86f159ddc7..763256d0db130 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -20,6 +20,7 @@ import { Readable } from 'stream'; import { JobName } from '../job'; import { AssetStats, + CommunicationEvent, IAssetRepository, ICommunicationRepository, ICryptoRepository, @@ -636,10 +637,89 @@ describe(AssetService.name, () => { await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); }); + + /// Stack related + + it('should require asset update access for parent', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'parent').mockResolvedValue(false); + await expect( + sut.updateAll(authStub.user1, { + ids: ['asset-1'], + stackParentId: 'parent', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should update parent asset when children are added', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.updateAll(authStub.user1, { + ids: [], + stackParentId: 'parent', + }), + expect(assetMock.updateAll).toHaveBeenCalledWith(['parent'], { stackParentId: null }); + }); + + it('should update parent asset when children are removed', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([{ id: 'child-1', stackParentId: 'parent' } as AssetEntity]); + + await sut.updateAll(authStub.user1, { + ids: ['child-1'], + removeParent: true, + }), + expect(assetMock.updateAll).toHaveBeenCalledWith(expect.arrayContaining(['parent']), { stackParentId: null }); + }); + + it('update parentId for new children', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.updateAll(authStub.user1, { + stackParentId: 'parent', + ids: ['child-1', 'child-2'], + }); + + expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' }); + }); + + it('nullify parentId for remove children', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.updateAll(authStub.user1, { + removeParent: true, + ids: ['child-1', 'child-2'], + }); + + expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: null }); + }); + + it('merge stacks if new child has children', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([ + { id: 'child-1', stack: [{ id: 'child-2' } as AssetEntity] } as AssetEntity, + ]); + + await sut.updateAll(authStub.user1, { + ids: ['child-1'], + stackParentId: 'parent', + }); + + expect(assetMock.updateAll).toBeCalledWith(['child-1', 'child-2'], { stackParentId: 'parent' }); + }); + + it('should send ws asset update event', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.updateAll(authStub.user1, { + ids: ['asset-1'], + stackParentId: 'parent', + }); + + expect(communicationMock.send).toHaveBeenCalledWith(CommunicationEvent.ASSET_UPDATE, authStub.user1.id, [ + 'asset-1', + ]); + }); }); describe('deleteAll', () => { - it('should required asset delete access for all ids', async () => { + it('should require asset delete access for all ids', async () => { accessMock.asset.hasOwnerAccess.mockResolvedValue(false); await expect( sut.deleteAll(authStub.user1, { @@ -677,7 +757,7 @@ describe(AssetService.name, () => { }); describe('restoreAll', () => { - it('should required asset restore access for all ids', async () => { + it('should require asset restore access for all ids', async () => { accessMock.asset.hasOwnerAccess.mockResolvedValue(false); await expect( sut.deleteAll(authStub.user1, { @@ -757,6 +837,21 @@ describe(AssetService.name, () => { expect(assetMock.remove).toHaveBeenCalledWith(assetWithFace); }); + it('should update stack parent if asset has stack children', async () => { + when(assetMock.getById) + .calledWith(assetStub.primaryImage.id) + .mockResolvedValue(assetStub.primaryImage as AssetEntity); + + await sut.handleAssetDeletion({ id: assetStub.primaryImage.id }); + + expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-2'], { + stackParentId: 'stack-child-asset-1', + }); + expect(assetMock.updateAll).toHaveBeenCalledWith(['stack-child-asset-1'], { + stackParentId: null, + }); + }); + it('should not schedule delete-files job for readonly assets', async () => { when(assetMock.getById) .calledWith(assetStub.readOnly.id) @@ -854,4 +949,70 @@ describe(AssetService.name, () => { expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VIDEO_CONVERSION, data: { id: 'asset-1' } }); }); }); + + describe('updateStackParent', () => { + it('should require asset update access for new parent', async () => { + when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(true); + when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(false); + accessMock.asset.hasOwnerAccess.mockResolvedValue(false); + await expect( + sut.updateStackParent(authStub.user1, { + oldParentId: 'old', + newParentId: 'new', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should require asset read access for old parent', async () => { + when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'old').mockResolvedValue(false); + when(accessMock.asset.hasOwnerAccess).calledWith(authStub.user1.id, 'new').mockResolvedValue(true); + await expect( + sut.updateStackParent(authStub.user1, { + oldParentId: 'old', + newParentId: 'new', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('make old parent the child of new parent', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + when(assetMock.getById) + .calledWith(assetStub.image.id) + .mockResolvedValue(assetStub.image as AssetEntity); + + await sut.updateStackParent(authStub.user1, { + oldParentId: assetStub.image.id, + newParentId: 'new', + }); + + expect(assetMock.updateAll).toBeCalledWith([assetStub.image.id], { stackParentId: 'new' }); + }); + + it('remove stackParentId of new parent', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.updateStackParent(authStub.user1, { + oldParentId: assetStub.primaryImage.id, + newParentId: 'new', + }); + + expect(assetMock.updateAll).toBeCalledWith(['new'], { stackParentId: null }); + }); + + it('update stackParentId of old parents children to new parent', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + when(assetMock.getById) + .calledWith(assetStub.primaryImage.id) + .mockResolvedValue(assetStub.primaryImage as AssetEntity); + + await sut.updateStackParent(authStub.user1, { + oldParentId: assetStub.primaryImage.id, + newParentId: 'new', + }); + + expect(assetMock.updateAll).toBeCalledWith( + [assetStub.primaryImage.id, 'stack-child-asset-1', 'stack-child-asset-2'], + { stackParentId: 'new' }, + ); + }); + }); }); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index abd0dbe0d9e7b..57623fa1b28bf 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -40,6 +40,7 @@ import { TimeBucketDto, TrashAction, UpdateAssetDto, + UpdateStackParentDto, mapStats, } from './dto'; import { @@ -208,7 +209,7 @@ export class AssetService { if (authUser.isShowMetadata) { return assets.map((asset) => mapAsset(asset)); } else { - return assets.map((asset) => mapAsset(asset, true)); + return assets.map((asset) => mapAsset(asset, { stripMetadata: true })); } } @@ -338,10 +339,29 @@ export class AssetService { } async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise { - const { ids, ...options } = dto; + const { ids, removeParent, ...options } = dto; await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids); + + if (removeParent) { + (options as Partial).stackParentId = null; + const assets = await this.assetRepository.getByIds(ids); + // This updates the updatedAt column of the parents to indicate that one of its children is removed + // All the unique parent's -> parent is set to null + ids.push(...new Set(assets.filter((a) => !!a.stackParentId).map((a) => a.stackParentId!))); + } else if (options.stackParentId) { + await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, options.stackParentId); + // Merge stacks + const assets = await this.assetRepository.getByIds(ids); + const assetsWithChildren = assets.filter((a) => a.stack && a.stack.length > 0); + ids.push(...assetsWithChildren.flatMap((child) => child.stack!.map((gChild) => gChild.id))); + + // This updates the updatedAt column of the parent to indicate that a new child has been added + await this.assetRepository.updateAll([options.stackParentId], { stackParentId: null }); + } + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); await this.assetRepository.updateAll(ids, options); + this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids); } async handleAssetDeletionCheck() { @@ -384,6 +404,14 @@ export class AssetService { ); } + // Replace the parent of the stack children with a new asset + if (asset.stack && asset.stack.length != 0) { + const stackIds = asset.stack.map((a) => a.id); + const newParentId = stackIds[0]; + await this.assetRepository.updateAll(stackIds.slice(1), { stackParentId: newParentId }); + await this.assetRepository.updateAll([newParentId], { stackParentId: null }); + } + await this.assetRepository.remove(asset); await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } }); this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id); @@ -454,6 +482,25 @@ export class AssetService { this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids); } + async updateStackParent(authUser: AuthUserDto, dto: UpdateStackParentDto): Promise { + const { oldParentId, newParentId } = dto; + await this.access.requirePermission(authUser, Permission.ASSET_READ, oldParentId); + await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, newParentId); + + const childIds: string[] = []; + const oldParent = await this.assetRepository.getById(oldParentId); + if (oldParent != null) { + childIds.push(oldParent.id); + // Get all children of old parent + childIds.push(...(oldParent.stack?.map((a) => a.id) ?? [])); + } + + this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, [...childIds, newParentId]); + await this.assetRepository.updateAll(childIds, { stackParentId: newParentId }); + // Remove ParentId of new parent if this was previously a child of some other asset + return this.assetRepository.updateAll([newParentId], { stackParentId: null }); + } + async run(authUser: AuthUserDto, dto: AssetJobsDto) { await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, dto.assetIds); diff --git a/server/src/domain/asset/dto/asset-stack.dto.ts b/server/src/domain/asset/dto/asset-stack.dto.ts new file mode 100644 index 0000000000000..80dabdb34b1fe --- /dev/null +++ b/server/src/domain/asset/dto/asset-stack.dto.ts @@ -0,0 +1,9 @@ +import { ValidateUUID } from '../../domain.util'; + +export class UpdateStackParentDto { + @ValidateUUID() + oldParentId!: string; + + @ValidateUUID() + newParentId!: string; +} diff --git a/server/src/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts index f5ada315c109e..0b3ce68d5c0d1 100644 --- a/server/src/domain/asset/dto/asset.dto.ts +++ b/server/src/domain/asset/dto/asset.dto.ts @@ -1,6 +1,6 @@ import { Type } from 'class-transformer'; import { IsBoolean, IsInt, IsPositive, IsString } from 'class-validator'; -import { Optional } from '../../domain.util'; +import { Optional, ValidateUUID } from '../../domain.util'; import { BulkIdsDto } from '../response-dto'; export class AssetBulkUpdateDto extends BulkIdsDto { @@ -11,6 +11,14 @@ export class AssetBulkUpdateDto extends BulkIdsDto { @Optional() @IsBoolean() isArchived?: boolean; + + @Optional() + @ValidateUUID() + stackParentId?: string; + + @Optional() + @IsBoolean() + removeParent?: boolean; } export class UpdateAssetDto { diff --git a/server/src/domain/asset/dto/index.ts b/server/src/domain/asset/dto/index.ts index 8e780869a56ba..281d924f326f2 100644 --- a/server/src/domain/asset/dto/index.ts +++ b/server/src/domain/asset/dto/index.ts @@ -1,4 +1,5 @@ export * from './asset-ids.dto'; +export * from './asset-stack.dto'; export * from './asset-statistics.dto'; export * from './asset.dto'; export * from './download.dto'; diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index e7d5061be1eb9..0e5784055378c 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-response.dto.ts @@ -42,9 +42,20 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { people?: PersonResponseDto[]; /**base64 encoded sha1 hash */ checksum!: string; + stackParentId?: string | null; + stack?: AssetResponseDto[]; + @ApiProperty({ type: 'integer' }) + stackCount!: number; } -export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetResponseDto { +export type AssetMapOptions = { + stripMetadata?: boolean; + withStack?: boolean; +}; + +export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { + const { stripMetadata = false, withStack = false } = options; + const sanitizedAssetResponse: SanitizedAssetResponseDto = { id: entity.id, type: entity.type, @@ -87,6 +98,9 @@ export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetRespo tags: entity.tags?.map(mapTag), people: entity.faces?.map(mapFace).filter((person) => !person.isHidden), checksum: entity.checksum.toString('base64'), + stackParentId: entity.stackParentId, + stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined, + stackCount: entity.stack?.length ?? 0, isExternal: entity.isExternal, isOffline: entity.isOffline, isReadOnly: entity.isReadOnly, diff --git a/server/src/domain/repositories/communication.repository.ts b/server/src/domain/repositories/communication.repository.ts index f49beeb502399..f4c06a1e9adeb 100644 --- a/server/src/domain/repositories/communication.repository.ts +++ b/server/src/domain/repositories/communication.repository.ts @@ -4,6 +4,7 @@ export enum CommunicationEvent { UPLOAD_SUCCESS = 'on_upload_success', ASSET_DELETE = 'on_asset_delete', ASSET_TRASH = 'on_asset_trash', + ASSET_UPDATE = 'on_asset_update', ASSET_RESTORE = 'on_asset_restore', PERSON_THUMBNAIL = 'on_person_thumbnail', SERVER_VERSION = 'on_server_version', diff --git a/server/src/domain/shared-link/shared-link-response.dto.ts b/server/src/domain/shared-link/shared-link-response.dto.ts index 52592d36fa950..4e35f65462e7d 100644 --- a/server/src/domain/shared-link/shared-link-response.dto.ts +++ b/server/src/domain/shared-link/shared-link-response.dto.ts @@ -58,7 +58,7 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): Shar type: sharedLink.type, createdAt: sharedLink.createdAt, expiresAt: sharedLink.expiresAt, - assets: assets.map((asset) => mapAsset(asset, true)) as AssetResponseDto[], + assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })) as AssetResponseDto[], album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, allowUpload: sharedLink.allowUpload, allowDownload: sharedLink.allowDownload, diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts index a41b18341a4c8..e0e239f6dda70 100644 --- a/server/src/immich/api-v1/asset/asset-repository.ts +++ b/server/src/immich/api-v1/asset/asset-repository.ts @@ -109,6 +109,7 @@ export class AssetRepository implements IAssetRepository { faces: { person: true, }, + stack: true, }, // We are specifically asking for this asset. Return it even if it is soft deleted withDeleted: true, @@ -131,6 +132,7 @@ export class AssetRepository implements IAssetRepository { relations: { exifInfo: true, tags: true, + stack: true, }, skip: dto.skip || 0, order: { diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index d3c1fe8764d0f..415fb380de582 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -196,7 +196,7 @@ export class AssetService { const includeMetadata = this.getExifPermission(authUser); const asset = await this._assetRepository.getById(assetId); if (includeMetadata) { - const data = mapAsset(asset); + const data = mapAsset(asset, { withStack: true }); if (data.ownerId !== authUser.id) { data.people = []; @@ -208,7 +208,7 @@ export class AssetService { return data; } else { - return mapAsset(asset, true); + return mapAsset(asset, { stripMetadata: true, withStack: true }); } } diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index f4f376e98daab..6a91bad30e4ec 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -21,6 +21,7 @@ import { TimeBucketResponseDto, TrashAction, UpdateAssetDto as UpdateDto, + UpdateStackParentDto, } from '@app/domain'; import { Body, @@ -137,6 +138,12 @@ export class AssetController { return this.service.handleTrashAction(authUser, TrashAction.RESTORE_ALL); } + @Put('stack/parent') + @HttpCode(HttpStatus.OK) + updateStackParent(@AuthUser() authUser: AuthUserDto, @Body() dto: UpdateStackParentDto): Promise { + return this.service.updateStackParent(authUser, dto); + } + @Put(':id') updateAsset( @AuthUser() authUser: AuthUserDto, diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts index 31935ae5f2610..937107f9d16e8 100644 --- a/server/src/infra/entities/asset.entity.ts +++ b/server/src/infra/entities/asset.entity.ts @@ -148,6 +148,16 @@ export class AssetEntity { @OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset) faces!: AssetFaceEntity[]; + + @Column({ nullable: true }) + stackParentId?: string | null; + + @ManyToOne(() => AssetEntity, (asset) => asset.stack, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' }) + @JoinColumn({ name: 'stackParentId' }) + stackParent?: AssetEntity | null; + + @OneToMany(() => AssetEntity, (asset) => asset.stackParent) + stack?: AssetEntity[]; } export enum AssetType { diff --git a/server/src/infra/migrations/1695354433573-AddStackParentIdToAssets.ts b/server/src/infra/migrations/1695354433573-AddStackParentIdToAssets.ts new file mode 100644 index 0000000000000..d5150d3a811f0 --- /dev/null +++ b/server/src/infra/migrations/1695354433573-AddStackParentIdToAssets.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddStackParentIdToAssets1695354433573 implements MigrationInterface { + name = 'AddStackParentIdToAssets1695354433573' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" ADD "stackParentId" uuid`); + await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "FK_b463c8edb01364bf2beba08ef19" FOREIGN KEY ("stackParentId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_b463c8edb01364bf2beba08ef19"`); + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "stackParentId"`); + } + +} diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 76e7d4d93087d..a740cf583a62c 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -112,6 +112,7 @@ export class AssetRepository implements IAssetRepository { faces: { person: true, }, + stack: true, }, withDeleted: true, }); @@ -192,6 +193,7 @@ export class AssetRepository implements IAssetRepository { person: true, }, library: true, + stack: true, }, // We are specifically asking for this asset. Return it even if it is soft deleted withDeleted: true, @@ -538,6 +540,12 @@ export class AssetRepository implements IAssetRepository { .andWhere('person.id = :personId', { personId }); } + // Hide stack children only in main timeline + // Uncomment after adding support for stacked assets in web client + // if (!isArchived && !isFavorite && !personId && !albumId && !isTrashed) { + // builder = builder.andWhere('asset.stackParent IS NULL'); + // } + return builder; } } diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts index c18268502b429..b9b10e1044a44 100644 --- a/server/test/e2e/asset.e2e-spec.ts +++ b/server/test/e2e/asset.e2e-spec.ts @@ -626,4 +626,167 @@ describe(`${AssetController.name} (e2e)`, () => { expect(body).toEqual([expect.objectContaining({ id: asset2.id })]); }); }); + + describe('PUT /asset', () => { + beforeEach(async () => { + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] }); + + expect(status).toBe(204); + }); + + it('should require authentication', async () => { + const { status, body } = await request(server).put('/asset'); + + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should require a valid parent id', async () => { + const { status, body } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: uuidStub.invalid, ids: [asset1.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest(['stackParentId must be a UUID'])); + }); + + it('should require access to the parent', async () => { + const { status, body } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: asset4.id, ids: [asset1.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.noPermission); + }); + + it('should add stack children', async () => { + const parent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01')); + const child = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01')); + + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: parent.id, ids: [child.id] }); + + expect(status).toBe(204); + + const asset = await api.assetApi.get(server, user1.accessToken, parent.id); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: child.id })])); + }); + + it('should remove stack children', async () => { + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ removeParent: true, ids: [asset2.id] }); + + expect(status).toBe(204); + + const asset = await api.assetApi.get(server, user1.accessToken, asset1.id); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })])); + }); + + it('should remove all stack children', async () => { + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ removeParent: true, ids: [asset2.id, asset3.id] }); + + expect(status).toBe(204); + + const asset = await api.assetApi.get(server, user1.accessToken, asset1.id); + expect(asset.stack).toHaveLength(0); + }); + + it('should merge stack children', async () => { + const newParent = await createAsset(assetRepository, user1, defaultLibrary.id, new Date('1970-01-01')); + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: newParent.id, ids: [asset1.id] }); + + expect(status).toBe(204); + + const asset = await api.assetApi.get(server, user1.accessToken, newParent.id); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: asset1.id }), + expect.objectContaining({ id: asset2.id }), + expect.objectContaining({ id: asset3.id }), + ]), + ); + }); + }); + + describe('PUT /asset/stack/parent', () => { + beforeEach(async () => { + const { status } = await request(server) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] }); + + expect(status).toBe(204); + }); + + it('should require authentication', async () => { + const { status, body } = await request(server).put('/asset/stack/parent'); + + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(server) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ oldParentId: uuidStub.invalid, newParentId: uuidStub.invalid }); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest()); + }); + + it('should require access', async () => { + const { status, body } = await request(server) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ oldParentId: asset4.id, newParentId: asset1.id }); + + expect(status).toBe(400); + expect(body).toEqual(errorStub.noPermission); + }); + + it('should make old parent child of new parent', async () => { + const { status } = await request(server) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ oldParentId: asset1.id, newParentId: asset2.id }); + + expect(status).toBe(200); + + const asset = await api.assetApi.get(server, user1.accessToken, asset2.id); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset1.id })])); + }); + + it('should make all childrens of old parent, a child of new parent', async () => { + const { status } = await request(server) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ oldParentId: asset1.id, newParentId: asset2.id }); + + expect(status).toBe(200); + + const asset = await api.assetApi.get(server, user1.accessToken, asset2.id); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })])); + }); + }); }); diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 5fef9f6d1e734..3454818438750 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -41,6 +41,7 @@ export const assetStub = { libraryId: 'library-id', library: libraryStub.uploadLibrary1, }), + noWebpPath: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -80,6 +81,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, }), + noThumbhash: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -116,6 +118,7 @@ export const assetStub = { sidecarPath: null, deletedAt: null, }), + primaryImage: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -154,7 +157,9 @@ export const assetStub = { exifInfo: { fileSizeInByte: 5_000, } as ExifEntity, + stack: [{ id: 'stack-child-asset-1' } as AssetEntity, { id: 'stack-child-asset-2' } as AssetEntity], }), + image: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -194,6 +199,7 @@ export const assetStub = { fileSizeInByte: 5_000, } as ExifEntity, }), + external: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -233,6 +239,7 @@ export const assetStub = { fileSizeInByte: 5_000, } as ExifEntity, }), + offline: Object.freeze({ id: 'asset-id', deviceAssetId: 'device-asset-id', @@ -272,6 +279,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, }), + image1: Object.freeze({ id: 'asset-id-1', deviceAssetId: 'device-asset-id', @@ -311,6 +319,7 @@ export const assetStub = { fileSizeInByte: 5_000, } as ExifEntity, }), + imageFrom2015: Object.freeze({ id: 'asset-id-1', deviceAssetId: 'device-asset-id', @@ -350,6 +359,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, }), + video: Object.freeze({ id: 'asset-id', originalFileName: 'asset-id.ext', @@ -389,6 +399,7 @@ export const assetStub = { } as ExifEntity, deletedAt: null, }), + livePhotoMotionAsset: Object.freeze({ id: 'live-photo-motion-asset', originalPath: fileStub.livePhotoMotion.originalPath, @@ -497,10 +508,41 @@ export const assetStub = { sidecarPath: '/original/path.ext.xmp', deletedAt: null, }), - readOnly: Object.freeze({ + + readOnly: Object.freeze({ id: 'read-only-asset', + deviceAssetId: 'device-asset-id', + fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), + fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), + owner: userStub.user1, + ownerId: 'user-id', + deviceId: 'device-id', + originalPath: '/original/path.ext', + resizePath: '/uploads/user-id/thumbs/path.ext', + thumbhash: null, + checksum: Buffer.from('file hash', 'utf8'), + type: AssetType.IMAGE, + webpPath: null, + encodedVideoPath: null, + createdAt: new Date('2023-02-23T05:06:29.716Z'), + updatedAt: new Date('2023-02-23T05:06:29.716Z'), + localDateTime: new Date('2023-02-23T05:06:29.716Z'), + isFavorite: true, + isArchived: false, isReadOnly: true, + isExternal: false, + isOffline: false, libraryId: 'library-id', library: libraryStub.uploadLibrary1, + duration: null, + isVisible: true, + livePhotoVideo: null, + livePhotoVideoId: null, + tags: [], + sharedLinks: [], + originalFileName: 'asset-id.ext', + faces: [], + sidecarPath: '/original/path.ext.xmp', + deletedAt: null, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index acb14c6b2d135..dd5771cf99678 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -72,6 +72,7 @@ const assetResponse: AssetResponseDto = { isTrashed: false, libraryId: 'library-id', hasMetadata: true, + stackCount: 0, }; const assetResponseWithoutMetadata = { diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index d9c34475a620c..91bb2f88c3e5c 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -399,6 +399,18 @@ export interface AssetBulkUpdateDto { * @memberof AssetBulkUpdateDto */ 'isFavorite'?: boolean; + /** + * + * @type {boolean} + * @memberof AssetBulkUpdateDto + */ + 'removeParent'?: boolean; + /** + * + * @type {string} + * @memberof AssetBulkUpdateDto + */ + 'stackParentId'?: string; } /** * @@ -748,6 +760,24 @@ export interface AssetResponseDto { * @memberof AssetResponseDto */ 'smartInfo'?: SmartInfoResponseDto; + /** + * + * @type {Array} + * @memberof AssetResponseDto + */ + 'stack'?: Array; + /** + * + * @type {number} + * @memberof AssetResponseDto + */ + 'stackCount': number; + /** + * + * @type {string} + * @memberof AssetResponseDto + */ + 'stackParentId'?: string | null; /** * * @type {Array} @@ -3981,6 +4011,25 @@ export interface UpdateLibraryDto { */ 'name'?: string; } +/** + * + * @export + * @interface UpdateStackParentDto + */ +export interface UpdateStackParentDto { + /** + * + * @type {string} + * @memberof UpdateStackParentDto + */ + 'newParentId': string; + /** + * + * @type {string} + * @memberof UpdateStackParentDto + */ + 'oldParentId': string; +} /** * * @export @@ -7135,6 +7184,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {UpdateStackParentDto} updateStackParentDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateStackParent: async (updateStackParentDto: UpdateStackParentDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'updateStackParentDto' is not null or undefined + assertParamExists('updateStackParent', 'updateStackParentDto', updateStackParentDto) + const localVarPath = `/asset/stack/parent`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(updateStackParentDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {File} assetData @@ -7601,6 +7694,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {UpdateStackParentDto} updateStackParentDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateStackParent(updateStackParentDto: UpdateStackParentDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateStackParent(updateStackParentDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {File} assetData @@ -7892,6 +7995,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {AssetApiUploadFileRequest} requestParameters Request parameters. @@ -8499,6 +8611,20 @@ export interface AssetApiUpdateAssetsRequest { readonly assetBulkUpdateDto: AssetBulkUpdateDto } +/** + * Request parameters for updateStackParent operation in AssetApi. + * @export + * @interface AssetApiUpdateStackParentRequest + */ +export interface AssetApiUpdateStackParentRequest { + /** + * + * @type {UpdateStackParentDto} + * @memberof AssetApiUpdateStackParent + */ + readonly updateStackParentDto: UpdateStackParentDto +} + /** * Request parameters for uploadFile operation in AssetApi. * @export @@ -8939,6 +9065,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiUpdateStackParentRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public updateStackParent(requestParameters: AssetApiUpdateStackParentRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).updateStackParent(requestParameters.updateStackParentDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiUploadFileRequest} requestParameters Request parameters.