1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

restore: bulk actions (#3730)

* feat: improve bulk isArchive and isFavorite updates

* chore: open api
This commit is contained in:
Jason Rasmussen 2023-08-16 16:04:55 -04:00 committed by GitHub
parent 8568ec838a
commit bab739efbd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 442 additions and 57 deletions

View File

@ -344,6 +344,31 @@ export interface AllJobStatusResponseDto {
*/ */
'videoConversion': JobStatusDto; 'videoConversion': JobStatusDto;
} }
/**
*
* @export
* @interface AssetBulkUpdateDto
*/
export interface AssetBulkUpdateDto {
/**
*
* @type {Array<string>}
* @memberof AssetBulkUpdateDto
*/
'ids': Array<string>;
/**
*
* @type {boolean}
* @memberof AssetBulkUpdateDto
*/
'isArchived'?: boolean;
/**
*
* @type {boolean}
* @memberof AssetBulkUpdateDto
*/
'isFavorite'?: boolean;
}
/** /**
* *
* @export * @export
@ -5871,6 +5896,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions, options: localVarRequestOptions,
}; };
}, },
/**
*
* @param {AssetBulkUpdateDto} assetBulkUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateAssets: async (assetBulkUpdateDto: AssetBulkUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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 * @param {File} assetData
@ -6259,6 +6328,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAsset(id, updateAssetDto, options); const localVarAxiosArgs = await localVarAxiosParamCreator.updateAsset(id, updateAssetDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); 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<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/** /**
* *
* @param {File} assetData * @param {File} assetData
@ -6495,6 +6574,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
updateAsset(requestParameters: AssetApiUpdateAssetRequest, options?: AxiosRequestConfig): AxiosPromise<AssetResponseDto> { updateAsset(requestParameters: AssetApiUpdateAssetRequest, options?: AxiosRequestConfig): AxiosPromise<AssetResponseDto> {
return localVarFp.updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(axios, basePath)); 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<void> {
return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
},
/** /**
* *
* @param {AssetApiUploadFileRequest} requestParameters Request parameters. * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@ -7011,6 +7099,20 @@ export interface AssetApiUpdateAssetRequest {
readonly updateAssetDto: UpdateAssetDto 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. * Request parameters for uploadFile operation in AssetApi.
* @export * @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)); 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. * @param {AssetApiUploadFileRequest} requestParameters Request parameters.

View File

@ -15,6 +15,7 @@ doc/AlbumCountResponseDto.md
doc/AlbumResponseDto.md doc/AlbumResponseDto.md
doc/AllJobStatusResponseDto.md doc/AllJobStatusResponseDto.md
doc/AssetApi.md doc/AssetApi.md
doc/AssetBulkUpdateDto.md
doc/AssetBulkUploadCheckDto.md doc/AssetBulkUploadCheckDto.md
doc/AssetBulkUploadCheckItem.md doc/AssetBulkUploadCheckItem.md
doc/AssetBulkUploadCheckResponseDto.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_create_response_dto.dart
lib/model/api_key_response_dto.dart lib/model/api_key_response_dto.dart
lib/model/api_key_update_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_dto.dart
lib/model/asset_bulk_upload_check_item.dart lib/model/asset_bulk_upload_check_item.dart
lib/model/asset_bulk_upload_check_response_dto.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_response_dto_test.dart
test/api_key_update_dto_test.dart test/api_key_update_dto_test.dart
test/asset_api_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_dto_test.dart
test/asset_bulk_upload_check_item_test.dart test/asset_bulk_upload_check_item_test.dart
test/asset_bulk_upload_check_response_dto_test.dart test/asset_bulk_upload_check_response_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/AssetBulkUpdateDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -808,6 +808,39 @@
"tags": [ "tags": [
"Asset" "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}": { "/asset/assetById/{id}": {
@ -4841,6 +4874,27 @@
], ],
"type": "object" "type": "object"
}, },
"AssetBulkUpdateDto": {
"properties": {
"ids": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"isArchived": {
"type": "boolean"
},
"isFavorite": {
"type": "boolean"
}
},
"required": [
"ids"
],
"type": "object"
},
"AssetBulkUploadCheckDto": { "AssetBulkUploadCheckDto": {
"properties": { "properties": {
"assets": { "assets": {

View File

@ -79,6 +79,7 @@ export interface IAssetRepository {
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>; getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
deleteAll(ownerId: string): Promise<void>; deleteAll(ownerId: string): Promise<void>;
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void>;
save(asset: Partial<AssetEntity>): Promise<AssetEntity>; save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>; findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>; getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;

View File

@ -514,4 +514,22 @@ describe(AssetService.name, () => {
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, {}); 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 });
});
});
}); });

View File

@ -11,6 +11,7 @@ import { HumanReadableSize, usePagination } from '../domain.util';
import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage'; import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { IAssetRepository } from './asset.repository'; import { IAssetRepository } from './asset.repository';
import { import {
AssetBulkUpdateDto,
AssetIdsDto, AssetIdsDto,
DownloadArchiveInfo, DownloadArchiveInfo,
DownloadInfoDto, DownloadInfoDto,
@ -268,4 +269,10 @@ export class AssetService {
const stats = await this.assetRepository.getStatistics(authUser.id, dto); const stats = await this.assetRepository.getStatistics(authUser.id, dto);
return mapStats(stats); 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);
}
} }

View File

@ -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;
}

View File

@ -1,5 +1,6 @@
export * from './asset-ids.dto'; export * from './asset-ids.dto';
export * from './asset-statistics.dto'; export * from './asset-statistics.dto';
export * from './asset.dto';
export * from './download.dto'; export * from './download.dto';
export * from './map-marker.dto'; export * from './map-marker.dto';
export * from './memory-lane.dto'; export * from './memory-lane.dto';

View File

@ -1,4 +1,5 @@
import { import {
AssetBulkUpdateDto,
AssetIdsDto, AssetIdsDto,
AssetResponseDto, AssetResponseDto,
AssetService, AssetService,
@ -15,7 +16,7 @@ import {
} from '@app/domain'; } from '@app/domain';
import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto'; import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto';
import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.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 { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Authenticated, AuthUser, SharedLinkRoute } from '../app.guard'; import { Authenticated, AuthUser, SharedLinkRoute } from '../app.guard';
import { asStreamableFile, UseValidation } from '../app.utils'; import { asStreamableFile, UseValidation } from '../app.utils';
@ -76,4 +77,10 @@ export class AssetController {
getByTimeBucket(@AuthUser() authUser: AuthUserDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> { getByTimeBucket(@AuthUser() authUser: AuthUserDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
return this.service.getByTimeBucket(authUser, dto); return this.service.getByTimeBucket(authUser, dto);
} }
@Put()
@HttpCode(HttpStatus.NO_CONTENT)
updateAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetBulkUpdateDto): Promise<void> {
return this.service.updateAll(authUser, dto);
}
} }

View File

@ -129,6 +129,10 @@ export class AssetRepository implements IAssetRepository {
}); });
} }
async updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void> {
await this.repository.update({ id: In(ids) }, options);
}
async save(asset: Partial<AssetEntity>): Promise<AssetEntity> { async save(asset: Partial<AssetEntity>): Promise<AssetEntity> {
const { id } = await this.repository.save(asset); const { id } = await this.repository.save(asset);
return this.repository.findOneOrFail({ return this.repository.findOneOrFail({

View File

@ -11,6 +11,7 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
getFirstAssetForAlbumId: jest.fn(), getFirstAssetForAlbumId: jest.fn(),
getLastUpdatedAssetForAlbumId: jest.fn(), getLastUpdatedAssetForAlbumId: jest.fn(),
getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }), getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
updateAll: jest.fn(),
deleteAll: jest.fn(), deleteAll: jest.fn(),
save: jest.fn(), save: jest.fn(),
findLivePhotoMatch: jest.fn(), findLivePhotoMatch: jest.fn(),

View File

@ -344,6 +344,31 @@ export interface AllJobStatusResponseDto {
*/ */
'videoConversion': JobStatusDto; 'videoConversion': JobStatusDto;
} }
/**
*
* @export
* @interface AssetBulkUpdateDto
*/
export interface AssetBulkUpdateDto {
/**
*
* @type {Array<string>}
* @memberof AssetBulkUpdateDto
*/
'ids': Array<string>;
/**
*
* @type {boolean}
* @memberof AssetBulkUpdateDto
*/
'isArchived'?: boolean;
/**
*
* @type {boolean}
* @memberof AssetBulkUpdateDto
*/
'isFavorite'?: boolean;
}
/** /**
* *
* @export * @export
@ -5871,6 +5896,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions, options: localVarRequestOptions,
}; };
}, },
/**
*
* @param {AssetBulkUpdateDto} assetBulkUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateAssets: async (assetBulkUpdateDto: AssetBulkUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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 * @param {File} assetData
@ -6259,6 +6328,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAsset(id, updateAssetDto, options); const localVarAxiosArgs = await localVarAxiosParamCreator.updateAsset(id, updateAssetDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); 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<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/** /**
* *
* @param {File} assetData * @param {File} assetData
@ -6495,6 +6574,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
updateAsset(requestParameters: AssetApiUpdateAssetRequest, options?: AxiosRequestConfig): AxiosPromise<AssetResponseDto> { updateAsset(requestParameters: AssetApiUpdateAssetRequest, options?: AxiosRequestConfig): AxiosPromise<AssetResponseDto> {
return localVarFp.updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(axios, basePath)); 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<void> {
return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
},
/** /**
* *
* @param {AssetApiUploadFileRequest} requestParameters Request parameters. * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@ -7011,6 +7099,20 @@ export interface AssetApiUpdateAssetRequest {
readonly updateAssetDto: UpdateAssetDto 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. * Request parameters for uploadFile operation in AssetApi.
* @export * @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)); 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. * @param {AssetApiUploadFileRequest} requestParameters Request parameters.

View File

@ -4,15 +4,15 @@
NotificationType, NotificationType,
notificationController, notificationController,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { api } from '@api'; import { api } from '@api';
import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte'; import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
import ArchiveArrowUpOutline from 'svelte-material-icons/ArchiveArrowUpOutline.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 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) => { export let onArchive: OnArchive | undefined = undefined;
asset.isArchived = isArchived;
};
export let menuItem = false; export let menuItem = false;
export let unarchive = false; export let unarchive = false;
@ -20,32 +20,50 @@
$: text = unarchive ? 'Unarchive' : 'Archive'; $: text = unarchive ? 'Unarchive' : 'Archive';
$: logo = unarchive ? ArchiveArrowUpOutline : ArchiveArrowDownOutline; $: logo = unarchive ? ArchiveArrowUpOutline : ArchiveArrowDownOutline;
let loading = false;
const { getAssets, clearSelect } = getAssetControlContext(); const { getAssets, clearSelect } = getAssetControlContext();
const handleArchive = async () => { const handleArchive = async () => {
const isArchived = !unarchive; const isArchived = !unarchive;
let cnt = 0; loading = true;
for (const asset of getAssets()) { try {
if (asset.isArchived !== isArchived) { const assets = Array.from(getAssets()).filter((asset) => asset.isArchived !== isArchived);
api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { isArchived } }); const ids = assets.map(({ id }) => id);
onAssetArchive(asset, isArchived); if (ids.length > 0) {
cnt = cnt + 1; 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();
}; };
</script> </script>
{#if menuItem} {#if menuItem}
<MenuOption {text} on:click={handleArchive} /> <MenuOption {text} on:click={handleArchive} />
{:else} {/if}
<CircleIconButton title={text} {logo} on:click={handleArchive} />
{#if !menuItem}
{#if loading}
<CircleIconButton title="Loading" logo={TimerSand} />
{:else}
<CircleIconButton title={text} {logo} on:click={handleArchive} />
{/if}
{/if} {/if}

View File

@ -1,23 +1,27 @@
<script lang="ts"> <script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { import {
NotificationType, NotificationType,
notificationController, notificationController,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { api } from '@api'; import { api } from '@api';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte'; import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import { OnAssetDelete, getAssetControlContext } from '../asset-select-control-bar.svelte'; import TimerSand from 'svelte-material-icons/TimerSand.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '../../../utils/handle-error';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { OnAssetDelete, getAssetControlContext } from '../asset-select-control-bar.svelte';
export let onAssetDelete: OnAssetDelete; export let onAssetDelete: OnAssetDelete;
export let menuItem = false; export let menuItem = false;
const { getAssets, clearSelect } = getAssetControlContext(); const { getAssets, clearSelect } = getAssetControlContext();
let isShowConfirmation = false; let isShowConfirmation = false;
let loading = false;
const handleDelete = async () => { const handleDelete = async () => {
loading = true;
try { try {
let count = 0; let count = 0;
@ -44,14 +48,21 @@
handleError(e, 'Error deleting assets'); handleError(e, 'Error deleting assets');
} finally { } finally {
isShowConfirmation = false; isShowConfirmation = false;
loading = false;
} }
}; };
</script> </script>
{#if menuItem} {#if menuItem}
<MenuOption text="Delete" on:click={() => (isShowConfirmation = true)} /> <MenuOption text="Delete" on:click={() => (isShowConfirmation = true)} />
{:else} {/if}
<CircleIconButton title="Delete" logo={DeleteOutline} on:click={() => (isShowConfirmation = true)} />
{#if !menuItem}
{#if loading}
<CircleIconButton title="Loading" logo={TimerSand} />
{:else}
<CircleIconButton title="Delete" logo={DeleteOutline} on:click={() => (isShowConfirmation = true)} />
{/if}
{/if} {/if}
{#if isShowConfirmation} {#if isShowConfirmation}

View File

@ -5,14 +5,14 @@
NotificationType, NotificationType,
notificationController, notificationController,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import { handleError } from '$lib/utils/handle-error';
import { api } from '@api'; import { api } from '@api';
import HeartMinusOutline from 'svelte-material-icons/HeartMinusOutline.svelte'; import HeartMinusOutline from 'svelte-material-icons/HeartMinusOutline.svelte';
import HeartOutline from 'svelte-material-icons/HeartOutline.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) => { export let onFavorite: OnFavorite | undefined = undefined;
asset.isFavorite = isFavorite;
};
export let menuItem = false; export let menuItem = false;
export let removeFavorite: boolean; export let removeFavorite: boolean;
@ -20,31 +20,50 @@
$: text = removeFavorite ? 'Remove from Favorites' : 'Favorite'; $: text = removeFavorite ? 'Remove from Favorites' : 'Favorite';
$: logo = removeFavorite ? HeartMinusOutline : HeartOutline; $: logo = removeFavorite ? HeartMinusOutline : HeartOutline;
let loading = false;
const { getAssets, clearSelect } = getAssetControlContext(); const { getAssets, clearSelect } = getAssetControlContext();
const handleFavorite = () => { const handleFavorite = async () => {
const isFavorite = !removeFavorite; const isFavorite = !removeFavorite;
loading = true;
let cnt = 0; try {
for (const asset of getAssets()) { const assets = Array.from(getAssets()).filter((asset) => asset.isFavorite !== isFavorite);
if (asset.isFavorite !== isFavorite) { const ids = assets.map(({ id }) => id);
api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { isFavorite } });
onAssetFavorite(asset, isFavorite); if (ids.length > 0) {
cnt = cnt + 1; 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();
}; };
</script> </script>
{#if menuItem} {#if menuItem}
<MenuOption {text} on:click={handleFavorite} /> <MenuOption {text} on:click={handleFavorite} />
{:else} {/if}
<CircleIconButton title={text} {logo} on:click={handleFavorite} />
{#if !menuItem}
{#if loading}
<CircleIconButton title="Loading" logo={TimerSand} />
{:else}
<CircleIconButton title={text} {logo} on:click={handleFavorite} />
{/if}
{/if} {/if}

View File

@ -2,8 +2,8 @@
import { createContext } from '$lib/utils/context'; import { createContext } from '$lib/utils/context';
export type OnAssetDelete = (assetId: string) => void; export type OnAssetDelete = (assetId: string) => void;
export type OnAssetArchive = (asset: AssetResponseDto, archived: boolean) => void; export type OnArchive = (ids: string[], isArchived: boolean) => void;
export type OnAssetFavorite = (asset: AssetResponseDto, favorite: boolean) => void; export type OnFavorite = (ids: string[], favorite: boolean) => void;
export interface AssetControlContext { export interface AssetControlContext {
// Wrap assets in a function, because context isn't reactive. // Wrap assets in a function, because context isn't reactive.

View File

@ -180,12 +180,19 @@ export class AssetStore {
this.emit(false); 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++) { for (let i = 0; i < this.buckets.length; i++) {
const bucket = this.buckets[i]; const bucket = this.buckets[i];
for (let j = 0; j < bucket.assets.length; j++) { for (let j = 0; j < bucket.assets.length; j++) {
const asset = bucket.assets[j]; const asset = bucket.assets[j];
if (asset.id !== assetId) { if (asset.id !== id) {
continue; continue;
} }

View File

@ -37,7 +37,7 @@
{#if $isMultiSelectState} {#if $isMultiSelectState}
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}> <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
<ArchiveAction unarchive onAssetArchive={(asset) => assetStore.removeAsset(asset.id)} /> <ArchiveAction unarchive onArchive={(ids) => assetStore.removeAssets(ids)} />
<CreateSharedLink /> <CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} /> <SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={Plus} title="Add"> <AssetSelectContextMenu icon={Plus} title="Add">

View File

@ -38,7 +38,7 @@
<!-- Multiselection mode app bar --> <!-- Multiselection mode app bar -->
{#if $isMultiSelectState} {#if $isMultiSelectState}
<AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}> <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
<FavoriteAction removeFavorite onAssetFavorite={(asset) => assetStore.removeAsset(asset.id)} /> <FavoriteAction removeFavorite onFavorite={(ids) => assetStore.removeAssets(ids)} />
<CreateSharedLink /> <CreateSharedLink />
<SelectAllAssets {assetStore} {assetInteractionStore} /> <SelectAllAssets {assetStore} {assetInteractionStore} />
<AssetSelectContextMenu icon={Plus} title="Add"> <AssetSelectContextMenu icon={Plus} title="Add">

View File

@ -202,11 +202,7 @@
<AssetSelectContextMenu icon={DotsVertical} title="Add"> <AssetSelectContextMenu icon={DotsVertical} title="Add">
<DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" /> <DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
<FavoriteAction menuItem removeFavorite={isAllFavorite} /> <FavoriteAction menuItem removeFavorite={isAllFavorite} />
<ArchiveAction <ArchiveAction menuItem unarchive={isAllArchive} onArchive={(ids) => $assetStore.removeAssets(ids)} />
menuItem
unarchive={isAllArchive}
onAssetArchive={(asset) => $assetStore.removeAsset(asset.id)}
/>
</AssetSelectContextMenu> </AssetSelectContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>
{:else} {:else}

View File

@ -51,7 +51,7 @@
<AssetSelectContextMenu icon={DotsVertical} title="Menu"> <AssetSelectContextMenu icon={DotsVertical} title="Menu">
<FavoriteAction menuItem removeFavorite={isAllFavorite} /> <FavoriteAction menuItem removeFavorite={isAllFavorite} />
<DownloadAction menuItem /> <DownloadAction menuItem />
<ArchiveAction menuItem onAssetArchive={(asset) => assetStore.removeAsset(asset.id)} /> <ArchiveAction menuItem onArchive={(ids) => assetStore.removeAssets(ids)} />
</AssetSelectContextMenu> </AssetSelectContextMenu>
</AssetSelectControlBar> </AssetSelectControlBar>
{/if} {/if}