From bab739efbdc00c4c7182e73d899a081680a26a5b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 16 Aug 2023 16:04:55 -0400 Subject: [PATCH] restore: bulk actions (#3730) * feat: improve bulk isArchive and isFavorite updates * chore: open api --- cli/src/api/open-api/api.ts | 113 ++++++++++++++++++ mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | Bin 18192 -> 18327 bytes mobile/openapi/doc/AssetApi.md | Bin 54728 -> 56743 bytes mobile/openapi/doc/AssetBulkUpdateDto.md | Bin 0 -> 524 bytes mobile/openapi/lib/api.dart | Bin 5888 -> 5929 bytes mobile/openapi/lib/api/asset_api.dart | Bin 51365 -> 52530 bytes mobile/openapi/lib/api_client.dart | Bin 18555 -> 18643 bytes .../lib/model/asset_bulk_update_dto.dart | Bin 0 -> 4268 bytes mobile/openapi/test/asset_api_test.dart | Bin 5217 -> 5352 bytes .../test/asset_bulk_update_dto_test.dart | Bin 0 -> 806 bytes server/immich-openapi-specs.json | 54 +++++++++ server/src/domain/asset/asset.repository.ts | 1 + server/src/domain/asset/asset.service.spec.ts | 18 +++ server/src/domain/asset/asset.service.ts | 7 ++ server/src/domain/asset/dto/asset.dto.ts | 12 ++ server/src/domain/asset/dto/index.ts | 1 + .../immich/controllers/asset.controller.ts | 9 +- .../infra/repositories/asset.repository.ts | 4 + .../repositories/asset.repository.mock.ts | 1 + web/src/api/open-api/api.ts | 113 ++++++++++++++++++ .../photos-page/actions/archive-action.svelte | 56 ++++++--- .../photos-page/actions/delete-assets.svelte | 21 +++- .../actions/favorite-action.svelte | 59 +++++---- .../asset-select-control-bar.svelte | 4 +- web/src/lib/stores/assets.store.ts | 11 +- web/src/routes/(user)/archive/+page.svelte | 2 +- web/src/routes/(user)/favorites/+page.svelte | 2 +- .../(user)/people/[personId]/+page.svelte | 6 +- web/src/routes/(user)/photos/+page.svelte | 2 +- 30 files changed, 442 insertions(+), 57 deletions(-) create mode 100644 mobile/openapi/doc/AssetBulkUpdateDto.md create mode 100644 mobile/openapi/lib/model/asset_bulk_update_dto.dart create mode 100644 mobile/openapi/test/asset_bulk_update_dto_test.dart create mode 100644 server/src/domain/asset/dto/asset.dto.ts 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 9167613b255d463913bc2f4e4a1af0025c9aadaa..042d69591f174f50b0b5fa91b48a6f9532d95961 100644 GIT binary patch delta 70 zcmbQx$2h&8af7%xM@nKzs$+3+>g0`r;*;--aSIk}D%2=wX$6FaXlW_vCl(h^J}4x% XSya4EiW#VOvZ9d~h~8XnI8_t?EKU|? delta 19 acmbQ<&p4rvaf7({<}C3Vsm)SGwW0t<#0G!> diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 5c1c7bb4bfb2c1f80706cbea11443790ffa228ec..6981d9ec364f2d2a15c85813b6f50b3f6d7abda5 100644 GIT binary patch delta 347 zcmX@HntAzd<_(%0Tq%hqsgA|PsU^je-?2*z7i%iiC}?Q~gobEoDd;DHBsME>^c>Jb zsMFHovQsE6KnQ4nl{uB>WQT%9T}tvdUyN2}QNa{fuvGwChEP1&fmhTCWEM~t&}uHO zlA=n5YAyvJNG!;V&&(?>Nz6-5)gxfxWQSr~E`*xN4bY|650vq)2| ze3}L>O_$p9{R=y7_78&;)Jow__ALVwG-NzORoBtw3)dX<3DszRCu)C8H2hGuH4NyZ zJdx9kS5Q$^^UzsbwTeAM8$MLmH+epLUKHhOk#{Ol)bt%1ma&6uM=l%M_EY$E7n`Sl WdPDaa8f8fv{uA-N@M8H?2=N8LbfYBz literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index a38d8784e34111d5cc8a58a44c15517c2187811d..e83c7b868bf3b748646bfcf7b0932cfffa654151 100644 GIT binary patch delta 16 XcmZqBTdB9<59?$WR@u!TSOo+DH#Y^j delta 16 YcmZ3f*PyrI59{PTY}}g-*nSEC06Vw_i2wiq diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index ba9f1d5a1f598d6c69e82ec83dea422a23b8142d..2c73a4ba8ccf7503101030e19f9f17c7a5b8dd16 100644 GIT binary patch delta 263 zcmZ2Fk$KZB<_%}IaD;{^=qDBzr%rC1;WgPnnN`lIG$%W>ASJOR)ukjqRskeef)JaW zzfO;_cyce3gg8_QhKh~z117(cwBtwD8MxrfY`(tboG<_lfM-Pj delta 14 Wcmdlqi+Slp<_%}IY>wP{Qy2g?y9a6j diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index eb76c12c58dfc6c6bd2c4ce621db67f0255838a7..a46580899d5c50f488c6e540f9acd45a0ab70b54 100644 GIT binary patch delta 35 rcmex8f${Q0#tmO(CO?vr;V3RnEpaN%$)4<>r#X3ptjOknGPT+O8vqW* delta 18 acmcaSk@5Eg#tmO(Ca;&}+#Dubs|^5E5eMl2 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 0000000000000000000000000000000000000000..7eb0e31afce8a999e467e19213606ce938026212 GIT binary patch literal 4268 zcmeHK+in{-5PjEIjDsSU(2bntsZb(0ZfqAx6RSv^0)=1*^e$yFSuItPYe8`Q?>$2< zSF31!X&?Gj15qS$W;mR4=E8%6&cOj4eLf#Q`}O4Pr5Coe9`~uMTdW?t3n$&ooO?jsay`IEJ`c0sm$d<7A}du)nciQYt<0> zO04a4S-Po_f0fEWy$}n0E``DWP8tj2#9a$^gFJ=Dq^D+}o9y?7p9ijt- z;G=Yd5GWp}^JmWbS3*}lpw4awh$CKpL#!ycJ*uC8UawRkG7 zwNXy;;%yJ^61`W}z53*gDy~KZdwvm)VvF7VJ(t2tDzpPy=Qxv=1~)ctO?Ix!Jfpd| zmUJm)0iohqQRcb0%;g?^Qbj7^rX;(RDI%Q;r*uIKd}Tb-(z0pXIL*sUA}E=hi86O| zE%H)Qx3uuf8+lUg44%{;^b(rumAsZF@gi5UkOtw-D)UyC1}>x;mKUXUG!uDFmm>X2 z00e%sZQxM3Om4mOG3R@~0Z%{?Ln6z9Sc?Pj`;H6x1E7y>gp8xdV1}?>YMmdFsuB7> z;+|>$LBu2deZ*gD5XS%`0K<@QB7;5J&~VrtUw)A((i`+OJsNUmaJfiD-ep9Sxby8> z_y#yzQk9-Qg>n3OJ0V+#oMhi`)Lg*X4QA|!#R~+n_-p4ns&I5A9Vl++ZnM!MgS##mU<>t~V>&3e8upf1M^kcQSH!9ny0eH2mFb(~4fQ&s$n?qoK8P zlTetDP2o$|W3j(m6enoY^xc02K6_t(Ow8lJcnuvwf7A} z^;)oZOEO1&4juyEJ9Ihlm4f%O;YN(aJ#IvirWX$Qio6 zZ?2|HQ5fu$OU4xQ11iB3qsClkoH1dcCI1h-7oJ2Fse*SAX+|M5I+PE%6xRt&vcWYk z(oaR*s5cz#h9=H6ri$fmZgk;d_yZ*eH9faQN9sJN9tAh*2n*Y|^`BK_ zBKbVaD$M-!vm3Gz<%r5DD6|D0hb5sK-z~0;Uh$xV498euWpW$Q3)mTHd5%G)XfZ#z z2>vkv*eGJ~m818^QT1 zL_X9t4|hrtZ-=Y&F$o2>HZ((kW1zRfU1>SZG49524Jdx|R+?BeYj8`w!-dtyNr&NSrQ94# zoJssn+C}175QJV;wRvjB6M!F9u!r2Ovh+ivJKvSDBUa(fR*pP2Yhk0wo;wfwHynB- zJsv}~s`fT2!h=l0&9|T*K$GxLSRErPx4ztTD*N z>B-yEY$&<4E|6(lFb#Kf2EFYg)rLCSl9lk2p(y!q9DyNps3Yh~U=bt9W#2vC`5J`^aoNss4O?V8ox!gRK>3C( zcED+jir`{IHl3Yk3b(=aemWTfMxe=d;G7N=7opnl ej_QB=|3UvJW`#Q!Sm*^YNAzVeYM7>a%ANteRskmf literal 0 HcmV?d00001 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}