merge main

This commit is contained in:
Alex Tran 2023-08-16 15:10:59 -05:00
commit bd865d02b3
37 changed files with 762 additions and 69 deletions

View file

@ -356,6 +356,31 @@ export interface AllJobStatusResponseDto {
*/
'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
@ -5991,6 +6016,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<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
@ -6379,6 +6448,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<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {File} assetData
@ -6615,6 +6694,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
updateAsset(requestParameters: AssetApiUpdateAssetRequest, options?: AxiosRequestConfig): AxiosPromise<AssetResponseDto> {
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.
@ -7131,6 +7219,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
@ -7486,6 +7588,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.

View file

@ -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
@ -164,6 +165,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
@ -280,6 +282,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

View file

@ -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 |
@ -191,6 +192,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)

View file

@ -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<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AssetApi();
final 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)

17
mobile/openapi/doc/AssetBulkUpdateDto.md generated Normal file
View file

@ -0,0 +1,17 @@
# openapi.model.AssetBulkUpdateDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**ids** | **List<String>** | | [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)

View file

@ -53,6 +53,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';

View file

@ -1404,6 +1404,45 @@ class AssetApi {
return null;
}
/// Performs an HTTP 'PUT /asset' operation and returns the [Response].
/// Parameters:
///
/// * [AssetBulkUpdateDto] assetBulkUpdateDto (required):
Future<Response> updateAssetsWithHttpInfo(AssetBulkUpdateDto assetBulkUpdateDto,) async {
// ignore: prefer_const_declarations
final path = r'/asset';
// ignore: prefer_final_locals
Object? postBody = assetBulkUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [AssetBulkUpdateDto] assetBulkUpdateDto (required):
Future<void> 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:
///

View file

@ -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':

View file

@ -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<String> 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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return AssetBulkUpdateDto(
ids: json[r'ids'] is List
? (json[r'ids'] as List).cast<String>()
: const [],
isArchived: mapValueOfType<bool>(json, r'isArchived'),
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
);
}
return null;
}
static List<AssetBulkUpdateDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetBulkUpdateDto>[];
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<String, AssetBulkUpdateDto> mapFromJson(dynamic json) {
final map = <String, AssetBulkUpdateDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<AssetBulkUpdateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetBulkUpdateDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'ids',
};
}

View file

@ -146,6 +146,11 @@ void main() {
// TODO
});
//Future updateAssets(AssetBulkUpdateDto assetBulkUpdateDto) async
test('test updateAssets', () async {
// TODO
});
//Future<AssetFileUploadResponseDto> 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

View file

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

View file

@ -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}": {
@ -5019,6 +5052,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": {

View file

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

View file

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

View file

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

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-statistics.dto';
export * from './asset.dto';
export * from './download.dto';
export * from './map-marker.dto';
export * from './memory-lane.dto';

View file

@ -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<AssetResponseDto[]> {
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> {
const { id } = await this.repository.save(asset);
return this.repository.findOneOrFail({

View file

@ -11,6 +11,7 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
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(),

View file

@ -356,6 +356,31 @@ export interface AllJobStatusResponseDto {
*/
'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
@ -5991,6 +6016,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<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
@ -6379,6 +6448,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<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {File} assetData
@ -6615,6 +6694,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
updateAsset(requestParameters: AssetApiUpdateAssetRequest, options?: AxiosRequestConfig): AxiosPromise<AssetResponseDto> {
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.
@ -7131,6 +7219,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
@ -7486,6 +7588,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.

View file

@ -175,7 +175,7 @@
<form autocomplete="off" class="flex flex-col" on:submit|preventDefault>
<div class="my-2 flex flex-col">
<label class="text-xs" for="presets">PRESET</label>
<label class="text-xs" for="preset-select">PRESET</label>
<select
class="mt-2 rounded-lg bg-slate-200 p-2 text-sm hover:cursor-pointer dark:bg-gray-600"
name="presets"

View file

@ -79,8 +79,8 @@
<form autocomplete="off" on:submit|preventDefault>
<div class="ml-4 mt-4 flex flex-col gap-4">
<SettingSelect
label="WEBP RESOLUTION"
desc="Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
label="SMALL THUMBNAIL RESOLUTION"
desc="Used when viewing groups of photos (main timeline, album view, etc.). Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
number
bind:value={thumbnailConfig.webpSize}
options={[
@ -94,8 +94,8 @@
/>
<SettingSelect
label="JPEG RESOLUTION"
desc="Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
label="LARGE THUMBNAIL RESOLUTION"
desc="Used when viewing a single photo and for machine learning. Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
number
bind:value={thumbnailConfig.jpegSize}
options={[

View file

@ -29,7 +29,7 @@
<form on:submit|preventDefault={handleSave} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Description</label>
<label class="immich-form-label" for="name">Description</label>
<!-- svelte-ignore a11y-autofocus -->
<textarea
class="immich-form-input focus:outline-none"

View file

@ -30,7 +30,7 @@
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Name</label>
<label class="immich-form-label" for="name">Name</label>
<input class="immich-form-input" id="name" name="name" type="text" bind:value={apiKey.name} />
</div>

View file

@ -45,7 +45,7 @@
</div>
<div class="m-4 flex flex-col gap-2">
<!-- <label class="immich-form-label" for="email">API Key</label> -->
<!-- <label class="immich-form-label" for="secret">API Key</label> -->
<textarea class="immich-form-input" id="secret" name="secret" readonly={true} value={secret} />
</div>

View file

@ -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'} ${cnt}`,
message: `${isArchived ? 'Archived' : 'Unarchived'} ${ids.length}`,
type: NotificationType.Info,
});
clearSelect();
} catch (error) {
handleError(error, `Unable to ${isArchived ? 'archive' : 'unarchive'}`);
} finally {
loading = false;
}
};
</script>
{#if menuItem}
<MenuOption {text} on:click={handleArchive} />
{/if}
{#if !menuItem}
{#if loading}
<CircleIconButton title="Loading" logo={TimerSand} />
{:else}
<CircleIconButton title={text} {logo} on:click={handleArchive} />
{/if}
{/if}

View file

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

View file

@ -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 ${cnt} to favorites` : `Removed ${cnt} from favorites`,
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;
}
};
</script>
{#if menuItem}
<MenuOption {text} on:click={handleFavorite} />
{/if}
{#if !menuItem}
{#if loading}
<CircleIconButton title="Loading" logo={TimerSand} />
{:else}
<CircleIconButton title={text} {logo} on:click={handleFavorite} />
{/if}
{/if}

View file

@ -1,16 +1,18 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import {
NotificationType,
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { AlbumResponseDto, api } from '@api';
import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
export let album: AlbumResponseDto;
export let onRemove: ((assetIds: string[]) => void) | undefined = undefined;
export let menuItem = false;
const { getAssets, clearSelect } = getAssetControlContext();
@ -48,11 +50,15 @@
};
</script>
<CircleIconButton title="Remove from album" on:click={() => (isShowConfirmation = true)} logo={DeleteOutline} />
{#if menuItem}
<MenuOption text="Remove from album" on:click={() => (isShowConfirmation = true)} />
{:else}
<CircleIconButton title="Remove from album" logo={DeleteOutline} on:click={() => (isShowConfirmation = true)} />
{/if}
{#if isShowConfirmation}
<ConfirmDialogue
title="Remove Asset{getAssets().size > 1 ? 's' : ''}"
title="Remove from {album.albumName}"
confirmText="Remove"
on:confirm={removeFromAlbum}
on:cancel={() => (isShowConfirmation = false)}

View file

@ -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.

View file

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

View file

@ -7,6 +7,7 @@
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte';
@ -312,14 +313,17 @@
<AddToAlbum />
<AddToAlbum shared />
</AssetSelectContextMenu>
{#if isOwned || isAllUserOwned}
<RemoveFromAlbum bind:album onRemove={(assetIds) => handleRemoveAssets(assetIds)} />
{/if}
<AssetSelectContextMenu icon={DotsVertical} title="Menu">
{#if isAllUserOwned}
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
{/if}
<DownloadAction menuItem filename="{album.albumName}.zip" />
{#if isOwned || isAllUserOwned}
<RemoveFromAlbum menuItem bind:album onRemove={(assetIds) => handleRemoveAssets(assetIds)} />
{/if}
{#if isAllUserOwned}
<DeleteAssets menuItem onAssetDelete={(assetId) => assetStore.removeAsset(assetId)} />
{/if}
</AssetSelectContextMenu>
</AssetSelectControlBar>
{:else}

View file

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

View file

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

View file

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

View file

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