diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 8ade1d5fde..03d260a6b1 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -344,6 +344,31 @@ export interface AllJobStatusResponseDto { */ 'videoConversion': JobStatusDto; } +/** + * + * @export + * @interface AssetBulkUpdateDto + */ +export interface AssetBulkUpdateDto { + /** + * + * @type {Array} + * @memberof AssetBulkUpdateDto + */ + 'ids': Array; + /** + * + * @type {boolean} + * @memberof AssetBulkUpdateDto + */ + 'isArchived'?: boolean; + /** + * + * @type {boolean} + * @memberof AssetBulkUpdateDto + */ + 'isFavorite'?: boolean; +} /** * * @export @@ -5871,6 +5896,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {AssetBulkUpdateDto} assetBulkUpdateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateAssets: async (assetBulkUpdateDto: AssetBulkUpdateDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'assetBulkUpdateDto' is not null or undefined + assertParamExists('updateAssets', 'assetBulkUpdateDto', assetBulkUpdateDto) + const localVarPath = `/asset`; + // 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(assetBulkUpdateDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {File} assetData @@ -6259,6 +6328,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.updateAsset(id, updateAssetDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {AssetBulkUpdateDto} assetBulkUpdateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateAssets(assetBulkUpdateDto: AssetBulkUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {File} assetData @@ -6495,6 +6574,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath updateAsset(requestParameters: AssetApiUpdateAssetRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetApiUpdateAssetsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {AssetApiUploadFileRequest} requestParameters Request parameters. @@ -7011,6 +7099,20 @@ export interface AssetApiUpdateAssetRequest { readonly updateAssetDto: UpdateAssetDto } +/** + * Request parameters for updateAssets operation in AssetApi. + * @export + * @interface AssetApiUpdateAssetsRequest + */ +export interface AssetApiUpdateAssetsRequest { + /** + * + * @type {AssetBulkUpdateDto} + * @memberof AssetApiUpdateAssets + */ + readonly assetBulkUpdateDto: AssetBulkUpdateDto +} + /** * Request parameters for uploadFile operation in AssetApi. * @export @@ -7366,6 +7468,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiUpdateAssetsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiUploadFileRequest} requestParameters Request parameters. diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index bf485ef08b..d8e4b1fad8 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -15,6 +15,7 @@ doc/AlbumCountResponseDto.md doc/AlbumResponseDto.md doc/AllJobStatusResponseDto.md doc/AssetApi.md +doc/AssetBulkUpdateDto.md doc/AssetBulkUploadCheckDto.md doc/AssetBulkUploadCheckItem.md doc/AssetBulkUploadCheckResponseDto.md @@ -158,6 +159,7 @@ lib/model/api_key_create_dto.dart lib/model/api_key_create_response_dto.dart lib/model/api_key_response_dto.dart lib/model/api_key_update_dto.dart +lib/model/asset_bulk_update_dto.dart lib/model/asset_bulk_upload_check_dto.dart lib/model/asset_bulk_upload_check_item.dart lib/model/asset_bulk_upload_check_response_dto.dart @@ -270,6 +272,7 @@ test/api_key_create_response_dto_test.dart test/api_key_response_dto_test.dart test/api_key_update_dto_test.dart test/asset_api_test.dart +test/asset_bulk_update_dto_test.dart test/asset_bulk_upload_check_dto_test.dart test/asset_bulk_upload_check_item_test.dart test/asset_bulk_upload_check_response_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 9167613b25..042d69591f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -110,6 +110,7 @@ Class | Method | HTTP request | Description *AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search | *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* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload | *AuthenticationApi* | [**adminSignUp**](doc//AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | @@ -187,6 +188,7 @@ Class | Method | HTTP request | Description - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md) - [AlbumResponseDto](doc//AlbumResponseDto.md) - [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md) + - [AssetBulkUpdateDto](doc//AssetBulkUpdateDto.md) - [AssetBulkUploadCheckDto](doc//AssetBulkUploadCheckDto.md) - [AssetBulkUploadCheckItem](doc//AssetBulkUploadCheckItem.md) - [AssetBulkUploadCheckResponseDto](doc//AssetBulkUploadCheckResponseDto.md) diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 5c1c7bb4bf..6981d9ec36 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -32,6 +32,7 @@ Method | HTTP request | Description [**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search | [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} | [**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} | +[**updateAssets**](AssetApi.md#updateassets) | **PUT** /asset | [**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload | @@ -1366,6 +1367,60 @@ Name | Type | Description | Notes [[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) +# **updateAssets** +> updateAssets(assetBulkUpdateDto) + + + +### 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 assetBulkUpdateDto = AssetBulkUpdateDto(); // AssetBulkUpdateDto | + +try { + api_instance.updateAssets(assetBulkUpdateDto); +} catch (e) { + print('Exception when calling AssetApi->updateAssets: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **assetBulkUpdateDto** | [**AssetBulkUpdateDto**](AssetBulkUpdateDto.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, isReadOnly, isVisible, livePhotoData, sidecarData) diff --git a/mobile/openapi/doc/AssetBulkUpdateDto.md b/mobile/openapi/doc/AssetBulkUpdateDto.md new file mode 100644 index 0000000000..b482684642 --- /dev/null +++ b/mobile/openapi/doc/AssetBulkUpdateDto.md @@ -0,0 +1,17 @@ +# openapi.model.AssetBulkUpdateDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**ids** | **List** | | [default to const []] +**isArchived** | **bool** | | [optional] +**isFavorite** | **bool** | | [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/lib/api.dart b/mobile/openapi/lib/api.dart index a38d8784e3..e83c7b868b 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -52,6 +52,7 @@ part 'model/admin_signup_response_dto.dart'; part 'model/album_count_response_dto.dart'; part 'model/album_response_dto.dart'; part 'model/all_job_status_response_dto.dart'; +part 'model/asset_bulk_update_dto.dart'; part 'model/asset_bulk_upload_check_dto.dart'; part 'model/asset_bulk_upload_check_item.dart'; part 'model/asset_bulk_upload_check_response_dto.dart'; diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index ba9f1d5a1f..2c73a4ba8c 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -1404,6 +1404,45 @@ class AssetApi { return null; } + /// Performs an HTTP 'PUT /asset' operation and returns the [Response]. + /// Parameters: + /// + /// * [AssetBulkUpdateDto] assetBulkUpdateDto (required): + Future updateAssetsWithHttpInfo(AssetBulkUpdateDto assetBulkUpdateDto,) async { + // ignore: prefer_const_declarations + final path = r'/asset'; + + // ignore: prefer_final_locals + Object? postBody = assetBulkUpdateDto; + + 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: + /// + /// * [AssetBulkUpdateDto] assetBulkUpdateDto (required): + Future updateAssets(AssetBulkUpdateDto assetBulkUpdateDto,) async { + final response = await updateAssetsWithHttpInfo(assetBulkUpdateDto,); + 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 eb76c12c58..a46580899d 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -199,6 +199,8 @@ class ApiClient { return AlbumResponseDto.fromJson(value); case 'AllJobStatusResponseDto': return AllJobStatusResponseDto.fromJson(value); + case 'AssetBulkUpdateDto': + return AssetBulkUpdateDto.fromJson(value); case 'AssetBulkUploadCheckDto': return AssetBulkUploadCheckDto.fromJson(value); case 'AssetBulkUploadCheckItem': diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart new file mode 100644 index 0000000000..7eb0e31afc --- /dev/null +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -0,0 +1,134 @@ +// +// 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 AssetBulkUpdateDto { + /// Returns a new [AssetBulkUpdateDto] instance. + AssetBulkUpdateDto({ + this.ids = const [], + this.isArchived, + this.isFavorite, + }); + + List ids; + + /// + /// 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? isArchived; + + /// + /// 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? isFavorite; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto && + other.ids == ids && + other.isArchived == isArchived && + other.isFavorite == isFavorite; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (ids.hashCode) + + (isArchived == null ? 0 : isArchived!.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode); + + @override + String toString() => 'AssetBulkUpdateDto[ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite]'; + + Map toJson() { + final json = {}; + json[r'ids'] = this.ids; + if (this.isArchived != null) { + json[r'isArchived'] = this.isArchived; + } else { + // json[r'isArchived'] = null; + } + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } + return json; + } + + /// Returns a new [AssetBulkUpdateDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetBulkUpdateDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return AssetBulkUpdateDto( + ids: json[r'ids'] is List + ? (json[r'ids'] as List).cast() + : const [], + isArchived: mapValueOfType(json, r'isArchived'), + isFavorite: mapValueOfType(json, r'isFavorite'), + ); + } + 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 = AssetBulkUpdateDto.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 = AssetBulkUpdateDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetBulkUpdateDto-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] = AssetBulkUpdateDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'ids', + }; +} + diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index ebb472c4a0..2a4847e029 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -146,6 +146,11 @@ void main() { // TODO }); + //Future updateAssets(AssetBulkUpdateDto assetBulkUpdateDto) async + test('test updateAssets', () async { + // TODO + }); + //Future uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, String duration, bool isArchived, bool isReadOnly, bool isVisible, 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 new file mode 100644 index 0000000000..cb23751e08 --- /dev/null +++ b/mobile/openapi/test/asset_bulk_update_dto_test.dart @@ -0,0 +1,37 @@ +// +// 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 AssetBulkUpdateDto +void main() { + // final instance = AssetBulkUpdateDto(); + + group('test AssetBulkUpdateDto', () { + // List ids (default value: const []) + test('to test the property `ids`', () async { + // TODO + }); + + // bool isArchived + test('to test the property `isArchived`', () async { + // TODO + }); + + // bool isFavorite + test('to test the property `isFavorite`', () async { + // TODO + }); + + + }); + +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index f193eb25e4..4078e687d6 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -808,6 +808,39 @@ "tags": [ "Asset" ] + }, + "put": { + "operationId": "updateAssets", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetBulkUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Asset" + ] } }, "/asset/assetById/{id}": { @@ -4841,6 +4874,27 @@ ], "type": "object" }, + "AssetBulkUpdateDto": { + "properties": { + "ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "isArchived": { + "type": "boolean" + }, + "isFavorite": { + "type": "boolean" + } + }, + "required": [ + "ids" + ], + "type": "object" + }, "AssetBulkUploadCheckDto": { "properties": { "assets": { diff --git a/server/src/domain/asset/asset.repository.ts b/server/src/domain/asset/asset.repository.ts index faf21361e2..d1e7a8fe64 100644 --- a/server/src/domain/asset/asset.repository.ts +++ b/server/src/domain/asset/asset.repository.ts @@ -79,6 +79,7 @@ export interface IAssetRepository { getLastUpdatedAssetForAlbumId(albumId: string): Promise; deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; + updateAll(ids: string[], options: Partial): Promise; save(asset: Partial): Promise; findLivePhotoMatch(options: LivePhotoSearchOptions): Promise; getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise; diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 4c91ce3425..88c82994fb 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -514,4 +514,22 @@ describe(AssetService.name, () => { expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, {}); }); }); + + describe('updateAll', () => { + it('should require asset write access for all ids', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(false); + await expect( + sut.updateAll(authStub.admin, { + ids: ['asset-1'], + isArchived: false, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should update all assets', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true }); + expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true }); + }); + }); }); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 2d4051b0fa..7d00aa6b0c 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -11,6 +11,7 @@ import { HumanReadableSize, usePagination } from '../domain.util'; import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage'; import { IAssetRepository } from './asset.repository'; import { + AssetBulkUpdateDto, AssetIdsDto, DownloadArchiveInfo, DownloadInfoDto, @@ -268,4 +269,10 @@ export class AssetService { const stats = await this.assetRepository.getStatistics(authUser.id, dto); return mapStats(stats); } + + async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto) { + const { ids, ...options } = dto; + await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids); + await this.assetRepository.updateAll(ids, options); + } } diff --git a/server/src/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts new file mode 100644 index 0000000000..1e4c3faa98 --- /dev/null +++ b/server/src/domain/asset/dto/asset.dto.ts @@ -0,0 +1,12 @@ +import { IsBoolean, IsOptional } from 'class-validator'; +import { BulkIdsDto } from '../response-dto'; + +export class AssetBulkUpdateDto extends BulkIdsDto { + @IsOptional() + @IsBoolean() + isFavorite?: boolean; + + @IsOptional() + @IsBoolean() + isArchived?: boolean; +} diff --git a/server/src/domain/asset/dto/index.ts b/server/src/domain/asset/dto/index.ts index 8e9440f027..8e780869a5 100644 --- a/server/src/domain/asset/dto/index.ts +++ b/server/src/domain/asset/dto/index.ts @@ -1,5 +1,6 @@ export * from './asset-ids.dto'; export * from './asset-statistics.dto'; +export * from './asset.dto'; export * from './download.dto'; export * from './map-marker.dto'; export * from './memory-lane.dto'; diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index b55cbb870d..70168cb816 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -1,4 +1,5 @@ import { + AssetBulkUpdateDto, AssetIdsDto, AssetResponseDto, AssetService, @@ -15,7 +16,7 @@ import { } from '@app/domain'; import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto'; import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.dto'; -import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, StreamableFile } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put, Query, StreamableFile } from '@nestjs/common'; import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { Authenticated, AuthUser, SharedLinkRoute } from '../app.guard'; import { asStreamableFile, UseValidation } from '../app.utils'; @@ -76,4 +77,10 @@ export class AssetController { getByTimeBucket(@AuthUser() authUser: AuthUserDto, @Query() dto: TimeBucketAssetDto): Promise { return this.service.getByTimeBucket(authUser, dto); } + + @Put() + @HttpCode(HttpStatus.NO_CONTENT) + updateAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetBulkUpdateDto): Promise { + return this.service.updateAll(authUser, dto); + } } diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 74f7f7497e..2d4a0e91ca 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -129,6 +129,10 @@ export class AssetRepository implements IAssetRepository { }); } + async updateAll(ids: string[], options: Partial): Promise { + await this.repository.update({ id: In(ids) }, options); + } + async save(asset: Partial): Promise { const { id } = await this.repository.save(asset); return this.repository.findOneOrFail({ diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 7f1eac8319..ecd5d5c105 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -11,6 +11,7 @@ export const newAssetRepositoryMock = (): jest.Mocked => { getFirstAssetForAlbumId: jest.fn(), getLastUpdatedAssetForAlbumId: jest.fn(), getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }), + updateAll: jest.fn(), deleteAll: jest.fn(), save: jest.fn(), findLivePhotoMatch: jest.fn(), diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 8ade1d5fde..03d260a6b1 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -344,6 +344,31 @@ export interface AllJobStatusResponseDto { */ 'videoConversion': JobStatusDto; } +/** + * + * @export + * @interface AssetBulkUpdateDto + */ +export interface AssetBulkUpdateDto { + /** + * + * @type {Array} + * @memberof AssetBulkUpdateDto + */ + 'ids': Array; + /** + * + * @type {boolean} + * @memberof AssetBulkUpdateDto + */ + 'isArchived'?: boolean; + /** + * + * @type {boolean} + * @memberof AssetBulkUpdateDto + */ + 'isFavorite'?: boolean; +} /** * * @export @@ -5871,6 +5896,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {AssetBulkUpdateDto} assetBulkUpdateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateAssets: async (assetBulkUpdateDto: AssetBulkUpdateDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'assetBulkUpdateDto' is not null or undefined + assertParamExists('updateAssets', 'assetBulkUpdateDto', assetBulkUpdateDto) + const localVarPath = `/asset`; + // 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(assetBulkUpdateDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {File} assetData @@ -6259,6 +6328,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.updateAsset(id, updateAssetDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {AssetBulkUpdateDto} assetBulkUpdateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updateAssets(assetBulkUpdateDto: AssetBulkUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {File} assetData @@ -6495,6 +6574,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath updateAsset(requestParameters: AssetApiUpdateAssetRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetApiUpdateAssetsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {AssetApiUploadFileRequest} requestParameters Request parameters. @@ -7011,6 +7099,20 @@ export interface AssetApiUpdateAssetRequest { readonly updateAssetDto: UpdateAssetDto } +/** + * Request parameters for updateAssets operation in AssetApi. + * @export + * @interface AssetApiUpdateAssetsRequest + */ +export interface AssetApiUpdateAssetsRequest { + /** + * + * @type {AssetBulkUpdateDto} + * @memberof AssetApiUpdateAssets + */ + readonly assetBulkUpdateDto: AssetBulkUpdateDto +} + /** * Request parameters for uploadFile operation in AssetApi. * @export @@ -7366,6 +7468,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiUpdateAssetsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiUploadFileRequest} requestParameters Request parameters. diff --git a/web/src/lib/components/photos-page/actions/archive-action.svelte b/web/src/lib/components/photos-page/actions/archive-action.svelte index a575c125a3..e106469674 100644 --- a/web/src/lib/components/photos-page/actions/archive-action.svelte +++ b/web/src/lib/components/photos-page/actions/archive-action.svelte @@ -4,15 +4,15 @@ NotificationType, notificationController, } from '$lib/components/shared-components/notification/notification'; + import { handleError } from '$lib/utils/handle-error'; import { api } from '@api'; import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte'; import ArchiveArrowUpOutline from 'svelte-material-icons/ArchiveArrowUpOutline.svelte'; + import TimerSand from 'svelte-material-icons/TimerSand.svelte'; import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; - import { OnAssetArchive, getAssetControlContext } from '../asset-select-control-bar.svelte'; + import { OnArchive, getAssetControlContext } from '../asset-select-control-bar.svelte'; - export let onAssetArchive: OnAssetArchive = (asset, isArchived) => { - asset.isArchived = isArchived; - }; + export let onArchive: OnArchive | undefined = undefined; export let menuItem = false; export let unarchive = false; @@ -20,32 +20,50 @@ $: text = unarchive ? 'Unarchive' : 'Archive'; $: logo = unarchive ? ArchiveArrowUpOutline : ArchiveArrowDownOutline; + let loading = false; + const { getAssets, clearSelect } = getAssetControlContext(); const handleArchive = async () => { const isArchived = !unarchive; - let cnt = 0; + loading = true; - for (const asset of getAssets()) { - if (asset.isArchived !== isArchived) { - api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { isArchived } }); + try { + const assets = Array.from(getAssets()).filter((asset) => asset.isArchived !== isArchived); + const ids = assets.map(({ id }) => id); - onAssetArchive(asset, isArchived); - cnt = cnt + 1; + if (ids.length > 0) { + await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, isArchived } }); } + + for (const asset of assets) { + asset.isArchived = isArchived; + } + + onArchive?.(ids, isArchived); + + notificationController.show({ + message: `${isArchived ? 'Archived' : 'Unarchived'} ${ids.length}`, + type: NotificationType.Info, + }); + + clearSelect(); + } catch (error) { + handleError(error, `Unable to ${isArchived ? 'archive' : 'unarchive'}`); + } finally { + loading = false; } - - notificationController.show({ - message: `${isArchived ? 'Archived' : 'Unarchived'} ${cnt}`, - type: NotificationType.Info, - }); - - clearSelect(); }; {#if menuItem} -{:else} - +{/if} + +{#if !menuItem} + {#if loading} + + {:else} + + {/if} {/if} diff --git a/web/src/lib/components/photos-page/actions/delete-assets.svelte b/web/src/lib/components/photos-page/actions/delete-assets.svelte index 7aa83b78f9..4ce62a9281 100644 --- a/web/src/lib/components/photos-page/actions/delete-assets.svelte +++ b/web/src/lib/components/photos-page/actions/delete-assets.svelte @@ -1,23 +1,27 @@ {#if menuItem} (isShowConfirmation = true)} /> -{:else} - (isShowConfirmation = true)} /> +{/if} + +{#if !menuItem} + {#if loading} + + {:else} + (isShowConfirmation = true)} /> + {/if} {/if} {#if isShowConfirmation} diff --git a/web/src/lib/components/photos-page/actions/favorite-action.svelte b/web/src/lib/components/photos-page/actions/favorite-action.svelte index c427d39345..86cb8cc9a0 100644 --- a/web/src/lib/components/photos-page/actions/favorite-action.svelte +++ b/web/src/lib/components/photos-page/actions/favorite-action.svelte @@ -5,14 +5,14 @@ NotificationType, notificationController, } from '$lib/components/shared-components/notification/notification'; + import { handleError } from '$lib/utils/handle-error'; import { api } from '@api'; import HeartMinusOutline from 'svelte-material-icons/HeartMinusOutline.svelte'; import HeartOutline from 'svelte-material-icons/HeartOutline.svelte'; - import { OnAssetFavorite, getAssetControlContext } from '../asset-select-control-bar.svelte'; + import TimerSand from 'svelte-material-icons/TimerSand.svelte'; + import { OnFavorite, getAssetControlContext } from '../asset-select-control-bar.svelte'; - export let onAssetFavorite: OnAssetFavorite = (asset, isFavorite) => { - asset.isFavorite = isFavorite; - }; + export let onFavorite: OnFavorite | undefined = undefined; export let menuItem = false; export let removeFavorite: boolean; @@ -20,31 +20,50 @@ $: text = removeFavorite ? 'Remove from Favorites' : 'Favorite'; $: logo = removeFavorite ? HeartMinusOutline : HeartOutline; + let loading = false; + const { getAssets, clearSelect } = getAssetControlContext(); - const handleFavorite = () => { + const handleFavorite = async () => { const isFavorite = !removeFavorite; + loading = true; - let cnt = 0; - for (const asset of getAssets()) { - if (asset.isFavorite !== isFavorite) { - api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { isFavorite } }); - onAssetFavorite(asset, isFavorite); - cnt = cnt + 1; + try { + const assets = Array.from(getAssets()).filter((asset) => asset.isFavorite !== isFavorite); + const ids = assets.map(({ id }) => id); + + if (ids.length > 0) { + await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, isFavorite } }); } + + for (const asset of assets) { + asset.isFavorite = isFavorite; + } + + onFavorite?.(ids, isFavorite); + + notificationController.show({ + message: isFavorite ? `Added ${ids.length} to favorites` : `Removed ${ids.length} from favorites`, + type: NotificationType.Info, + }); + + clearSelect(); + } catch (error) { + handleError(error, `Unable to ${isFavorite ? 'add to' : 'remove from'} favorites`); + } finally { + loading = false; } - - notificationController.show({ - message: isFavorite ? `Added ${cnt} to favorites` : `Removed ${cnt} from favorites`, - type: NotificationType.Info, - }); - - clearSelect(); }; {#if menuItem} -{:else} - +{/if} + +{#if !menuItem} + {#if loading} + + {:else} + + {/if} {/if} diff --git a/web/src/lib/components/photos-page/asset-select-control-bar.svelte b/web/src/lib/components/photos-page/asset-select-control-bar.svelte index 98c5229389..4e6d35ef5c 100644 --- a/web/src/lib/components/photos-page/asset-select-control-bar.svelte +++ b/web/src/lib/components/photos-page/asset-select-control-bar.svelte @@ -2,8 +2,8 @@ import { createContext } from '$lib/utils/context'; export type OnAssetDelete = (assetId: string) => void; - export type OnAssetArchive = (asset: AssetResponseDto, archived: boolean) => void; - export type OnAssetFavorite = (asset: AssetResponseDto, favorite: boolean) => void; + export type OnArchive = (ids: string[], isArchived: boolean) => void; + export type OnFavorite = (ids: string[], favorite: boolean) => void; export interface AssetControlContext { // Wrap assets in a function, because context isn't reactive. diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 852dce65ee..f517629721 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -180,12 +180,19 @@ export class AssetStore { this.emit(false); } - removeAsset(assetId: string) { + removeAssets(ids: string[]) { + // TODO: this could probably be more efficient + for (const id of ids) { + this.removeAsset(id); + } + } + + removeAsset(id: string) { for (let i = 0; i < this.buckets.length; i++) { const bucket = this.buckets[i]; for (let j = 0; j < bucket.assets.length; j++) { const asset = bucket.assets[j]; - if (asset.id !== assetId) { + if (asset.id !== id) { continue; } diff --git a/web/src/routes/(user)/archive/+page.svelte b/web/src/routes/(user)/archive/+page.svelte index fb5e4d8d61..fbabbe485f 100644 --- a/web/src/routes/(user)/archive/+page.svelte +++ b/web/src/routes/(user)/archive/+page.svelte @@ -37,7 +37,7 @@ {#if $isMultiSelectState} assetInteractionStore.clearMultiselect()}> - assetStore.removeAsset(asset.id)} /> + assetStore.removeAssets(ids)} /> diff --git a/web/src/routes/(user)/favorites/+page.svelte b/web/src/routes/(user)/favorites/+page.svelte index 2759d9d8e9..67d7ff082d 100644 --- a/web/src/routes/(user)/favorites/+page.svelte +++ b/web/src/routes/(user)/favorites/+page.svelte @@ -38,7 +38,7 @@ {#if $isMultiSelectState} assetInteractionStore.clearMultiselect()}> - assetStore.removeAsset(asset.id)} /> + assetStore.removeAssets(ids)} /> diff --git a/web/src/routes/(user)/people/[personId]/+page.svelte b/web/src/routes/(user)/people/[personId]/+page.svelte index 1ee8af573a..ca0af87070 100644 --- a/web/src/routes/(user)/people/[personId]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/+page.svelte @@ -202,11 +202,7 @@ - $assetStore.removeAsset(asset.id)} - /> + $assetStore.removeAssets(ids)} /> {:else} diff --git a/web/src/routes/(user)/photos/+page.svelte b/web/src/routes/(user)/photos/+page.svelte index 27a3b424e4..66dbc1d923 100644 --- a/web/src/routes/(user)/photos/+page.svelte +++ b/web/src/routes/(user)/photos/+page.svelte @@ -51,7 +51,7 @@ - assetStore.removeAsset(asset.id)} /> + assetStore.removeAssets(ids)} /> {/if}