mirror of
https://github.com/immich-app/immich.git
synced 2025-08-30 23:02:39 -04:00
feat(web): Add to Multiple Albums (#20072)
* Multi add to album picker: - update modal for multi select - Update add-to-album and add-to-album-action to work with new array return from AlbumPickerModal - Add asset-utils.addAssetsToAlbums (incomplete) * initial addToAlbums endpoint * - fix endpoint - add test * - update return type - make open-api * - simplify return dto - handle notification * - fix returns - clean up * - update i18n - format & check * - checks * - correct successId count - fix assets_cannot_be_added language call * tests * foromat * refactor * - update successful add message to included total attempted * - fix web test - format i18n * - fix open-api * - fix imports to resolve checks * - PR suggestions * open-api * refactor addAssetsToAlbums * refactor it again * - fix error returns and tests * - swap icon for IconButton - don't nest the buttons * open-api * - Cleanup multi-select button to match Thumbnail * merge and openapi * - remove onclick from icon element * - fix double onClose call with keyboard shortcuts * - spelling and formatting - apply new api permission * - open-api * chore: styling * translation --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
e00556a34a
commit
9ff664ed36
@ -28,6 +28,9 @@
|
||||
"add_to_album": "Add to album",
|
||||
"add_to_album_bottom_sheet_added": "Added to {album}",
|
||||
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
|
||||
"add_to_album_toggle": "Toggle selection for {album}",
|
||||
"add_to_albums": "Add to albums",
|
||||
"add_to_albums_count": "Add to albums ({count})",
|
||||
"add_to_shared_album": "Add to shared album",
|
||||
"add_url": "Add URL",
|
||||
"added_to_archive": "Added to archive",
|
||||
@ -497,7 +500,9 @@
|
||||
"assets": "Assets",
|
||||
"assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
|
||||
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
|
||||
"assets_added_to_albums_count": "Added {assetTotal} assets to {albumTotal} albums",
|
||||
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
|
||||
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
|
||||
"assets_count": "{count, plural, one {# asset} other {# assets}}",
|
||||
"assets_deleted_permanently": "{count} asset(s) deleted permanently",
|
||||
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server",
|
||||
@ -514,6 +519,7 @@
|
||||
"assets_trashed_count": "Trashed {count, plural, one {# asset} other {# assets}}",
|
||||
"assets_trashed_from_server": "{count} asset(s) trashed from the Immich server",
|
||||
"assets_were_part_of_album_count": "{count, plural, one {Asset was} other {Assets were}} already part of the album",
|
||||
"assets_were_part_of_albums_count": "{count, plural, one {Asset was} other {Assets were}} already part of the albums",
|
||||
"authorized_devices": "Authorized Devices",
|
||||
"automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere",
|
||||
"automatic_endpoint_switching_title": "Automatic URL switching",
|
||||
|
4
mobile/openapi/README.md
generated
4
mobile/openapi/README.md
generated
@ -84,6 +84,7 @@ Class | Method | HTTP request | Description
|
||||
*ActivitiesApi* | [**getActivities**](doc//ActivitiesApi.md#getactivities) | **GET** /activities |
|
||||
*ActivitiesApi* | [**getActivityStatistics**](doc//ActivitiesApi.md#getactivitystatistics) | **GET** /activities/statistics |
|
||||
*AlbumsApi* | [**addAssetsToAlbum**](doc//AlbumsApi.md#addassetstoalbum) | **PUT** /albums/{id}/assets |
|
||||
*AlbumsApi* | [**addAssetsToAlbums**](doc//AlbumsApi.md#addassetstoalbums) | **PUT** /albums/assets |
|
||||
*AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users |
|
||||
*AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums |
|
||||
*AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} |
|
||||
@ -300,6 +301,8 @@ Class | Method | HTTP request | Description
|
||||
- [AlbumUserCreateDto](doc//AlbumUserCreateDto.md)
|
||||
- [AlbumUserResponseDto](doc//AlbumUserResponseDto.md)
|
||||
- [AlbumUserRole](doc//AlbumUserRole.md)
|
||||
- [AlbumsAddAssetsDto](doc//AlbumsAddAssetsDto.md)
|
||||
- [AlbumsAddAssetsResponseDto](doc//AlbumsAddAssetsResponseDto.md)
|
||||
- [AlbumsResponse](doc//AlbumsResponse.md)
|
||||
- [AlbumsUpdate](doc//AlbumsUpdate.md)
|
||||
- [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
|
||||
@ -334,6 +337,7 @@ Class | Method | HTTP request | Description
|
||||
- [AudioCodec](doc//AudioCodec.md)
|
||||
- [AuthStatusResponseDto](doc//AuthStatusResponseDto.md)
|
||||
- [AvatarUpdate](doc//AvatarUpdate.md)
|
||||
- [BulkIdErrorReason](doc//BulkIdErrorReason.md)
|
||||
- [BulkIdResponseDto](doc//BulkIdResponseDto.md)
|
||||
- [BulkIdsDto](doc//BulkIdsDto.md)
|
||||
- [CLIPConfig](doc//CLIPConfig.md)
|
||||
|
3
mobile/openapi/lib/api.dart
generated
3
mobile/openapi/lib/api.dart
generated
@ -79,6 +79,8 @@ part 'model/album_user_add_dto.dart';
|
||||
part 'model/album_user_create_dto.dart';
|
||||
part 'model/album_user_response_dto.dart';
|
||||
part 'model/album_user_role.dart';
|
||||
part 'model/albums_add_assets_dto.dart';
|
||||
part 'model/albums_add_assets_response_dto.dart';
|
||||
part 'model/albums_response.dart';
|
||||
part 'model/albums_update.dart';
|
||||
part 'model/all_job_status_response_dto.dart';
|
||||
@ -113,6 +115,7 @@ part 'model/asset_visibility.dart';
|
||||
part 'model/audio_codec.dart';
|
||||
part 'model/auth_status_response_dto.dart';
|
||||
part 'model/avatar_update.dart';
|
||||
part 'model/bulk_id_error_reason.dart';
|
||||
part 'model/bulk_id_response_dto.dart';
|
||||
part 'model/bulk_ids_dto.dart';
|
||||
part 'model/clip_config.dart';
|
||||
|
67
mobile/openapi/lib/api/albums_api.dart
generated
67
mobile/openapi/lib/api/albums_api.dart
generated
@ -91,6 +91,73 @@ class AlbumsApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// This endpoint requires the `albumAsset.create` permission.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [AlbumsAddAssetsDto] albumsAddAssetsDto (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> addAssetsToAlbumsWithHttpInfo(AlbumsAddAssetsDto albumsAddAssetsDto, { String? key, String? slug, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/albums/assets';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = albumsAddAssetsDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (slug != null) {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'PUT',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// This endpoint requires the `albumAsset.create` permission.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [AlbumsAddAssetsDto] albumsAddAssetsDto (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<AlbumsAddAssetsResponseDto?> addAssetsToAlbums(AlbumsAddAssetsDto albumsAddAssetsDto, { String? key, String? slug, }) async {
|
||||
final response = await addAssetsToAlbumsWithHttpInfo(albumsAddAssetsDto, key: key, slug: slug, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumsAddAssetsResponseDto',) as AlbumsAddAssetsResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// This endpoint requires the `albumUser.create` permission.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
|
6
mobile/openapi/lib/api_client.dart
generated
6
mobile/openapi/lib/api_client.dart
generated
@ -212,6 +212,10 @@ class ApiClient {
|
||||
return AlbumUserResponseDto.fromJson(value);
|
||||
case 'AlbumUserRole':
|
||||
return AlbumUserRoleTypeTransformer().decode(value);
|
||||
case 'AlbumsAddAssetsDto':
|
||||
return AlbumsAddAssetsDto.fromJson(value);
|
||||
case 'AlbumsAddAssetsResponseDto':
|
||||
return AlbumsAddAssetsResponseDto.fromJson(value);
|
||||
case 'AlbumsResponse':
|
||||
return AlbumsResponse.fromJson(value);
|
||||
case 'AlbumsUpdate':
|
||||
@ -280,6 +284,8 @@ class ApiClient {
|
||||
return AuthStatusResponseDto.fromJson(value);
|
||||
case 'AvatarUpdate':
|
||||
return AvatarUpdate.fromJson(value);
|
||||
case 'BulkIdErrorReason':
|
||||
return BulkIdErrorReasonTypeTransformer().decode(value);
|
||||
case 'BulkIdResponseDto':
|
||||
return BulkIdResponseDto.fromJson(value);
|
||||
case 'BulkIdsDto':
|
||||
|
3
mobile/openapi/lib/api_helper.dart
generated
3
mobile/openapi/lib/api_helper.dart
generated
@ -79,6 +79,9 @@ String parameterToString(dynamic value) {
|
||||
if (value is AudioCodec) {
|
||||
return AudioCodecTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is BulkIdErrorReason) {
|
||||
return BulkIdErrorReasonTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is CQMode) {
|
||||
return CQModeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
|
111
mobile/openapi/lib/model/albums_add_assets_dto.dart
generated
Normal file
111
mobile/openapi/lib/model/albums_add_assets_dto.dart
generated
Normal file
@ -0,0 +1,111 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// 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 AlbumsAddAssetsDto {
|
||||
/// Returns a new [AlbumsAddAssetsDto] instance.
|
||||
AlbumsAddAssetsDto({
|
||||
this.albumIds = const [],
|
||||
this.assetIds = const [],
|
||||
});
|
||||
|
||||
List<String> albumIds;
|
||||
|
||||
List<String> assetIds;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsDto &&
|
||||
_deepEquality.equals(other.albumIds, albumIds) &&
|
||||
_deepEquality.equals(other.assetIds, assetIds);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(albumIds.hashCode) +
|
||||
(assetIds.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AlbumsAddAssetsDto[albumIds=$albumIds, assetIds=$assetIds]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'albumIds'] = this.albumIds;
|
||||
json[r'assetIds'] = this.assetIds;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AlbumsAddAssetsDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AlbumsAddAssetsDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "AlbumsAddAssetsDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AlbumsAddAssetsDto(
|
||||
albumIds: json[r'albumIds'] is Iterable
|
||||
? (json[r'albumIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
assetIds: json[r'assetIds'] is Iterable
|
||||
? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AlbumsAddAssetsDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AlbumsAddAssetsDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AlbumsAddAssetsDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AlbumsAddAssetsDto> mapFromJson(dynamic json) {
|
||||
final map = <String, AlbumsAddAssetsDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AlbumsAddAssetsDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AlbumsAddAssetsDto-objects as value to a dart map
|
||||
static Map<String, List<AlbumsAddAssetsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AlbumsAddAssetsDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = AlbumsAddAssetsDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'albumIds',
|
||||
'assetIds',
|
||||
};
|
||||
}
|
||||
|
132
mobile/openapi/lib/model/albums_add_assets_response_dto.dart
generated
Normal file
132
mobile/openapi/lib/model/albums_add_assets_response_dto.dart
generated
Normal file
@ -0,0 +1,132 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// 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 AlbumsAddAssetsResponseDto {
|
||||
/// Returns a new [AlbumsAddAssetsResponseDto] instance.
|
||||
AlbumsAddAssetsResponseDto({
|
||||
required this.albumSuccessCount,
|
||||
required this.assetSuccessCount,
|
||||
this.error,
|
||||
required this.success,
|
||||
});
|
||||
|
||||
int albumSuccessCount;
|
||||
|
||||
int assetSuccessCount;
|
||||
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
BulkIdErrorReason? error;
|
||||
|
||||
bool success;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AlbumsAddAssetsResponseDto &&
|
||||
other.albumSuccessCount == albumSuccessCount &&
|
||||
other.assetSuccessCount == assetSuccessCount &&
|
||||
other.error == error &&
|
||||
other.success == success;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(albumSuccessCount.hashCode) +
|
||||
(assetSuccessCount.hashCode) +
|
||||
(error == null ? 0 : error!.hashCode) +
|
||||
(success.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AlbumsAddAssetsResponseDto[albumSuccessCount=$albumSuccessCount, assetSuccessCount=$assetSuccessCount, error=$error, success=$success]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'albumSuccessCount'] = this.albumSuccessCount;
|
||||
json[r'assetSuccessCount'] = this.assetSuccessCount;
|
||||
if (this.error != null) {
|
||||
json[r'error'] = this.error;
|
||||
} else {
|
||||
// json[r'error'] = null;
|
||||
}
|
||||
json[r'success'] = this.success;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [AlbumsAddAssetsResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static AlbumsAddAssetsResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "AlbumsAddAssetsResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AlbumsAddAssetsResponseDto(
|
||||
albumSuccessCount: mapValueOfType<int>(json, r'albumSuccessCount')!,
|
||||
assetSuccessCount: mapValueOfType<int>(json, r'assetSuccessCount')!,
|
||||
error: BulkIdErrorReason.fromJson(json[r'error']),
|
||||
success: mapValueOfType<bool>(json, r'success')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<AlbumsAddAssetsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <AlbumsAddAssetsResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = AlbumsAddAssetsResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, AlbumsAddAssetsResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, AlbumsAddAssetsResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = AlbumsAddAssetsResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of AlbumsAddAssetsResponseDto-objects as value to a dart map
|
||||
static Map<String, List<AlbumsAddAssetsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<AlbumsAddAssetsResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = AlbumsAddAssetsResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'albumSuccessCount',
|
||||
'assetSuccessCount',
|
||||
'success',
|
||||
};
|
||||
}
|
||||
|
91
mobile/openapi/lib/model/bulk_id_error_reason.dart
generated
Normal file
91
mobile/openapi/lib/model/bulk_id_error_reason.dart
generated
Normal file
@ -0,0 +1,91 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// 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 BulkIdErrorReason {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const BulkIdErrorReason._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const duplicate = BulkIdErrorReason._(r'duplicate');
|
||||
static const noPermission = BulkIdErrorReason._(r'no_permission');
|
||||
static const notFound = BulkIdErrorReason._(r'not_found');
|
||||
static const unknown = BulkIdErrorReason._(r'unknown');
|
||||
|
||||
/// List of all possible values in this [enum][BulkIdErrorReason].
|
||||
static const values = <BulkIdErrorReason>[
|
||||
duplicate,
|
||||
noPermission,
|
||||
notFound,
|
||||
unknown,
|
||||
];
|
||||
|
||||
static BulkIdErrorReason? fromJson(dynamic value) => BulkIdErrorReasonTypeTransformer().decode(value);
|
||||
|
||||
static List<BulkIdErrorReason> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <BulkIdErrorReason>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = BulkIdErrorReason.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [BulkIdErrorReason] to String,
|
||||
/// and [decode] dynamic data back to [BulkIdErrorReason].
|
||||
class BulkIdErrorReasonTypeTransformer {
|
||||
factory BulkIdErrorReasonTypeTransformer() => _instance ??= const BulkIdErrorReasonTypeTransformer._();
|
||||
|
||||
const BulkIdErrorReasonTypeTransformer._();
|
||||
|
||||
String encode(BulkIdErrorReason data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a BulkIdErrorReason.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
BulkIdErrorReason? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'duplicate': return BulkIdErrorReason.duplicate;
|
||||
case r'no_permission': return BulkIdErrorReason.noPermission;
|
||||
case r'not_found': return BulkIdErrorReason.notFound;
|
||||
case r'unknown': return BulkIdErrorReason.unknown;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [BulkIdErrorReasonTypeTransformer] instance.
|
||||
static BulkIdErrorReasonTypeTransformer? _instance;
|
||||
}
|
||||
|
@ -940,6 +940,67 @@
|
||||
"description": "This endpoint requires the `album.create` permission."
|
||||
}
|
||||
},
|
||||
"/albums/assets": {
|
||||
"put": {
|
||||
"operationId": "addAssetsToAlbums",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AlbumsAddAssetsDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AlbumsAddAssetsResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Albums"
|
||||
],
|
||||
"x-immich-permission": "albumAsset.create",
|
||||
"description": "This endpoint requires the `albumAsset.create` permission."
|
||||
}
|
||||
},
|
||||
"/albums/statistics": {
|
||||
"get": {
|
||||
"operationId": "getAlbumStatistics",
|
||||
@ -9921,6 +9982,55 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AlbumsAddAssetsDto": {
|
||||
"properties": {
|
||||
"albumIds": {
|
||||
"items": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"assetIds": {
|
||||
"items": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"albumIds",
|
||||
"assetIds"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AlbumsAddAssetsResponseDto": {
|
||||
"properties": {
|
||||
"albumSuccessCount": {
|
||||
"type": "integer"
|
||||
},
|
||||
"assetSuccessCount": {
|
||||
"type": "integer"
|
||||
},
|
||||
"error": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/BulkIdErrorReason"
|
||||
}
|
||||
]
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"albumSuccessCount",
|
||||
"assetSuccessCount",
|
||||
"success"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AlbumsResponse": {
|
||||
"properties": {
|
||||
"defaultAssetOrder": {
|
||||
@ -10877,6 +10987,15 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"BulkIdErrorReason": {
|
||||
"enum": [
|
||||
"duplicate",
|
||||
"no_permission",
|
||||
"not_found",
|
||||
"unknown"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"BulkIdResponseDto": {
|
||||
"properties": {
|
||||
"error": {
|
||||
|
@ -384,6 +384,16 @@ export type CreateAlbumDto = {
|
||||
assetIds?: string[];
|
||||
description?: string;
|
||||
};
|
||||
export type AlbumsAddAssetsDto = {
|
||||
albumIds: string[];
|
||||
assetIds: string[];
|
||||
};
|
||||
export type AlbumsAddAssetsResponseDto = {
|
||||
albumSuccessCount: number;
|
||||
assetSuccessCount: number;
|
||||
error?: BulkIdErrorReason;
|
||||
success: boolean;
|
||||
};
|
||||
export type AlbumStatisticsResponseDto = {
|
||||
notShared: number;
|
||||
owned: number;
|
||||
@ -1864,6 +1874,26 @@ export function createAlbum({ createAlbumDto }: {
|
||||
body: createAlbumDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* This endpoint requires the `albumAsset.create` permission.
|
||||
*/
|
||||
export function addAssetsToAlbums({ key, slug, albumsAddAssetsDto }: {
|
||||
key?: string;
|
||||
slug?: string;
|
||||
albumsAddAssetsDto: AlbumsAddAssetsDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: AlbumsAddAssetsResponseDto;
|
||||
}>(`/albums/assets${QS.query(QS.explode({
|
||||
key,
|
||||
slug
|
||||
}))}`, oazapfts.json({
|
||||
...opts,
|
||||
method: "PUT",
|
||||
body: albumsAddAssetsDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* This endpoint requires the `album.statistics` permission.
|
||||
*/
|
||||
@ -4553,6 +4583,12 @@ export enum AssetTypeEnum {
|
||||
Audio = "AUDIO",
|
||||
Other = "OTHER"
|
||||
}
|
||||
export enum BulkIdErrorReason {
|
||||
Duplicate = "duplicate",
|
||||
NoPermission = "no_permission",
|
||||
NotFound = "not_found",
|
||||
Unknown = "unknown"
|
||||
}
|
||||
export enum Error {
|
||||
Duplicate = "duplicate",
|
||||
NoPermission = "no_permission",
|
||||
|
@ -65,6 +65,13 @@ describe(AlbumController.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /albums/assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/albums/assets`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /albums/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).patch(`/albums/${factory.uuid()}`).send({ albumName: 'New album name' });
|
||||
|
@ -4,6 +4,8 @@ import {
|
||||
AddUsersDto,
|
||||
AlbumInfoDto,
|
||||
AlbumResponseDto,
|
||||
AlbumsAddAssetsDto,
|
||||
AlbumsAddAssetsResponseDto,
|
||||
AlbumStatisticsResponseDto,
|
||||
CreateAlbumDto,
|
||||
GetAlbumsDto,
|
||||
@ -77,6 +79,12 @@ export class AlbumController {
|
||||
return this.service.addAssets(auth, id, dto);
|
||||
}
|
||||
|
||||
@Put('assets')
|
||||
@Authenticated({ permission: Permission.AlbumAssetCreate, sharedLink: true })
|
||||
addAssetsToAlbums(@Auth() auth: AuthDto, @Body() dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
|
||||
return this.service.addAssetsToAlbums(auth, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/assets')
|
||||
@Authenticated({ permission: Permission.AlbumAssetDelete })
|
||||
removeAssetFromAlbum(
|
||||
|
@ -3,6 +3,7 @@ import { Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator';
|
||||
import _ from 'lodash';
|
||||
import { AlbumUser, AuthSharedLink, User } from 'src/database';
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
@ -54,6 +55,24 @@ export class CreateAlbumDto {
|
||||
assetIds?: string[];
|
||||
}
|
||||
|
||||
export class AlbumsAddAssetsDto {
|
||||
@ValidateUUID({ each: true })
|
||||
albumIds!: string[];
|
||||
|
||||
@ValidateUUID({ each: true })
|
||||
assetIds!: string[];
|
||||
}
|
||||
|
||||
export class AlbumsAddAssetsResponseDto {
|
||||
success!: boolean;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
albumSuccessCount!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
assetSuccessCount!: number;
|
||||
@ValidateEnum({ enum: BulkIdErrorReason, name: 'BulkIdErrorReason', optional: true })
|
||||
error?: BulkIdErrorReason;
|
||||
}
|
||||
|
||||
export class UpdateAlbumDto {
|
||||
@Optional()
|
||||
@IsString()
|
||||
|
@ -776,6 +776,338 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAssetsToAlbums', () => {
|
||||
it('should allow the owner to add assets', async () => {
|
||||
mocks.access.album.checkOwnerAccess
|
||||
.mockResolvedValueOnce(new Set(['album-123']))
|
||||
.mockResolvedValueOnce(new Set(['album-321']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
sut.addAssetsToAlbums(authStub.admin, {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', {
|
||||
id: 'album-321',
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
||||
});
|
||||
|
||||
it('should not set the thumbnail if the album has one already', async () => {
|
||||
mocks.access.album.checkOwnerAccess
|
||||
.mockResolvedValueOnce(new Set(['album-123']))
|
||||
.mockResolvedValueOnce(new Set(['album-321']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' }))
|
||||
.mockResolvedValueOnce(_.cloneDeep({ ...albumStub.oneAsset, albumThumbnailAssetId: 'asset-id' }));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
sut.addAssetsToAlbums(authStub.admin, {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-id',
|
||||
});
|
||||
expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', {
|
||||
id: 'album-321',
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-id',
|
||||
});
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
||||
});
|
||||
|
||||
it('should allow a shared user to add assets', async () => {
|
||||
mocks.access.album.checkSharedAlbumAccess
|
||||
.mockResolvedValueOnce(new Set(['album-123']))
|
||||
.mockResolvedValueOnce(new Set(['album-321']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
sut.addAssetsToAlbums(authStub.user1, {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', {
|
||||
id: 'album-321',
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
|
||||
id: 'album-123',
|
||||
recipientId: 'admin_id',
|
||||
});
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
|
||||
id: 'album-321',
|
||||
recipientId: 'admin_id',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not allow a shared user with viewer access to add assets', async () => {
|
||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithAdmin));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
sut.addAssetsToAlbums(authStub.user2, {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
albumSuccessCount: 0,
|
||||
assetSuccessCount: 0,
|
||||
error: BulkIdErrorReason.UNKNOWN,
|
||||
});
|
||||
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not allow a shared link user to add assets to multiple albums', async () => {
|
||||
mocks.access.album.checkSharedLinkAccess
|
||||
.mockResolvedValueOnce(new Set(['album-123']))
|
||||
.mockResolvedValueOnce(new Set());
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
sut.addAssetsToAlbums(authStub.adminSharedLink, {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 });
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
|
||||
id: 'album-123',
|
||||
recipientId: 'user-id',
|
||||
});
|
||||
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.sharedLink?.id,
|
||||
new Set(['album-123']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow adding assets shared via partner sharing', async () => {
|
||||
mocks.access.album.checkOwnerAccess
|
||||
.mockResolvedValueOnce(new Set(['album-123']))
|
||||
.mockResolvedValueOnce(new Set(['album-321']));
|
||||
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
sut.addAssetsToAlbums(authStub.admin, {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({ success: true, albumSuccessCount: 2, assetSuccessCount: 3 });
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-123', {
|
||||
id: 'album-123',
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.album.update).toHaveBeenNthCalledWith(2, 'album-321', {
|
||||
id: 'album-321',
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set(['asset-1', 'asset-2', 'asset-3']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip some duplicate assets', async () => {
|
||||
mocks.access.album.checkOwnerAccess
|
||||
.mockResolvedValueOnce(new Set(['album-123']))
|
||||
.mockResolvedValueOnce(new Set(['album-321']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
|
||||
mocks.album.getAssetIds
|
||||
.mockResolvedValueOnce(new Set(['asset-1', 'asset-2', 'asset-3']))
|
||||
.mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
sut.addAssetsToAlbums(authStub.admin, {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({ success: true, albumSuccessCount: 1, assetSuccessCount: 3 });
|
||||
|
||||
expect(mocks.album.update).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.album.update).toHaveBeenNthCalledWith(1, 'album-321', {
|
||||
id: 'album-321',
|
||||
updatedAt: expect.any(Date),
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-321', ['asset-1', 'asset-2', 'asset-3']);
|
||||
});
|
||||
|
||||
it('should skip all duplicate assets', async () => {
|
||||
mocks.access.album.checkOwnerAccess
|
||||
.mockResolvedValueOnce(new Set(['album-123']))
|
||||
.mockResolvedValueOnce(new Set(['album-321']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
|
||||
mocks.album.getAssetIds.mockResolvedValue(new Set(['asset-1', 'asset-2']));
|
||||
|
||||
await expect(
|
||||
sut.addAssetsToAlbums(authStub.admin, {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2'],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
albumSuccessCount: 0,
|
||||
assetSuccessCount: 0,
|
||||
error: BulkIdErrorReason.DUPLICATE,
|
||||
});
|
||||
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
expect(mocks.album.addAssetIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip assets not shared with user', async () => {
|
||||
mocks.access.album.checkSharedAlbumAccess
|
||||
.mockResolvedValueOnce(new Set(['album-123']))
|
||||
.mockResolvedValueOnce(new Set(['album-321']));
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
sut.addAssetsToAlbums(authStub.admin, {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
albumSuccessCount: 0,
|
||||
assetSuccessCount: 0,
|
||||
error: BulkIdErrorReason.UNKNOWN,
|
||||
});
|
||||
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
expect(mocks.album.addAssetIds).not.toHaveBeenCalled();
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set(['asset-1', 'asset-2', 'asset-3']),
|
||||
false,
|
||||
);
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set(['asset-1', 'asset-2', 'asset-3']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not allow unauthorized access to the albums', async () => {
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithUser))
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.sharedWithMultiple));
|
||||
|
||||
await expect(
|
||||
sut.addAssetsToAlbums(authStub.admin, {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
albumSuccessCount: 0,
|
||||
assetSuccessCount: 0,
|
||||
error: BulkIdErrorReason.UNKNOWN,
|
||||
});
|
||||
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
expect(mocks.album.addAssetIds).not.toHaveBeenCalled();
|
||||
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalled();
|
||||
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not allow unauthorized shared link access to the album', async () => {
|
||||
mocks.album.getById
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.empty))
|
||||
.mockResolvedValueOnce(_.cloneDeep(albumStub.oneAsset));
|
||||
|
||||
await expect(
|
||||
sut.addAssetsToAlbums(authStub.adminSharedLink, {
|
||||
albumIds: ['album-123', 'album-321'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
success: false,
|
||||
albumSuccessCount: 0,
|
||||
assetSuccessCount: 0,
|
||||
error: BulkIdErrorReason.UNKNOWN,
|
||||
});
|
||||
|
||||
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAssets', () => {
|
||||
it('should allow the owner to remove assets', async () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-123']));
|
||||
|
@ -3,6 +3,8 @@ import {
|
||||
AddUsersDto,
|
||||
AlbumInfoDto,
|
||||
AlbumResponseDto,
|
||||
AlbumsAddAssetsDto,
|
||||
AlbumsAddAssetsResponseDto,
|
||||
AlbumStatisticsResponseDto,
|
||||
CreateAlbumDto,
|
||||
GetAlbumsDto,
|
||||
@ -13,7 +15,7 @@ import {
|
||||
UpdateAlbumDto,
|
||||
UpdateAlbumUserDto,
|
||||
} from 'src/dtos/album.dto';
|
||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
|
||||
@ -186,6 +188,43 @@ export class AlbumService extends BaseService {
|
||||
return results;
|
||||
}
|
||||
|
||||
async addAssetsToAlbums(auth: AuthDto, dto: AlbumsAddAssetsDto): Promise<AlbumsAddAssetsResponseDto> {
|
||||
const results: AlbumsAddAssetsResponseDto = {
|
||||
success: false,
|
||||
albumSuccessCount: 0,
|
||||
assetSuccessCount: 0,
|
||||
error: BulkIdErrorReason.DUPLICATE,
|
||||
};
|
||||
const successfulAssetIds: Set<string> = new Set();
|
||||
for (const albumId of dto.albumIds) {
|
||||
try {
|
||||
const albumResults = await this.addAssets(auth, albumId, { ids: dto.assetIds });
|
||||
|
||||
let success = false;
|
||||
for (const res of albumResults) {
|
||||
if (res.success) {
|
||||
success = true;
|
||||
results.success = true;
|
||||
results.error = undefined;
|
||||
successfulAssetIds.add(res.id);
|
||||
} else if (results.error && res.error !== BulkIdErrorReason.DUPLICATE) {
|
||||
results.error = BulkIdErrorReason.UNKNOWN;
|
||||
}
|
||||
}
|
||||
if (success) {
|
||||
results.albumSuccessCount++;
|
||||
}
|
||||
} catch {
|
||||
if (results.error) {
|
||||
results.error = BulkIdErrorReason.UNKNOWN;
|
||||
}
|
||||
}
|
||||
}
|
||||
results.assetSuccessCount = successfulAssetIds.size;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.AlbumAssetDelete, ids: [id] });
|
||||
|
||||
|
@ -4,7 +4,7 @@
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
|
||||
import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
@ -20,14 +20,23 @@
|
||||
let { asset, onAction, shared = false }: Props = $props();
|
||||
|
||||
const onClick = async () => {
|
||||
const album = await modalManager.show(AlbumPickerModal, { shared });
|
||||
const albums = await modalManager.show(AlbumPickerModal, { shared });
|
||||
|
||||
if (!album) {
|
||||
if (!albums || albums.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await addAssetsToAlbum(album.id, [asset.id]);
|
||||
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
|
||||
if (albums.length === 1) {
|
||||
const album = albums[0];
|
||||
await addAssetsToAlbum(album.id, [asset.id]);
|
||||
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album });
|
||||
} else {
|
||||
await addAssetsToAlbums(
|
||||
albums.map((a) => a.id),
|
||||
[asset.id],
|
||||
);
|
||||
onAction({ type: AssetAction.ADD_TO_ALBUM, asset: toTimelineAsset(asset), album: albums[0] });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
|
@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils.js';
|
||||
import { type AlbumResponseDto } from '@immich/sdk';
|
||||
import { mdiCheckCircle } from '@mdi/js';
|
||||
import type { Action } from 'svelte/action';
|
||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||
|
||||
@ -10,10 +13,19 @@
|
||||
album: AlbumResponseDto;
|
||||
searchQuery?: string;
|
||||
selected: boolean;
|
||||
multiSelected?: boolean;
|
||||
onAlbumClick: () => void;
|
||||
onMultiSelect: () => void;
|
||||
}
|
||||
|
||||
let { album, searchQuery = '', selected = false, onAlbumClick }: Props = $props();
|
||||
let {
|
||||
album,
|
||||
searchQuery = '',
|
||||
selected = false,
|
||||
multiSelected = false,
|
||||
onAlbumClick,
|
||||
onMultiSelect,
|
||||
}: Props = $props();
|
||||
|
||||
const scrollIntoViewIfSelected: Action = (node) => {
|
||||
$effect(() => {
|
||||
@ -37,33 +49,127 @@
|
||||
albumName.slice(findIndex + findLength),
|
||||
];
|
||||
});
|
||||
|
||||
const handleMultiSelectClicked = (e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
e?.preventDefault();
|
||||
onMultiSelect();
|
||||
};
|
||||
|
||||
let usingMobileDevice = $derived(mobileDevice.pointerCoarse);
|
||||
let mouseOver = $state(false);
|
||||
const onMouseEnter = () => {
|
||||
if (usingMobileDevice) {
|
||||
return;
|
||||
}
|
||||
mouseOver = true;
|
||||
};
|
||||
|
||||
const onMouseLeave = () => {
|
||||
mouseOver = false;
|
||||
};
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const preventContextMenu = (evt: Event) => evt.preventDefault();
|
||||
const disposeables: (() => void)[] = [];
|
||||
const clearLongPressTimer = () => {
|
||||
if (!timer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
for (const dispose of disposeables) {
|
||||
dispose();
|
||||
}
|
||||
disposeables.length = 0;
|
||||
};
|
||||
function longPress(element: HTMLElement, { onLongPress }: { onLongPress: () => void }) {
|
||||
let didPress = false;
|
||||
const start = () => {
|
||||
didPress = false;
|
||||
// 350ms for longpress. For reference: iOS uses 500ms for default long press, or 200ms for fast long press.
|
||||
timer = setTimeout(() => {
|
||||
onLongPress();
|
||||
element.addEventListener('contextmenu', preventContextMenu, { once: true });
|
||||
disposeables.push(() => element.removeEventListener('contextmenu', preventContextMenu));
|
||||
didPress = true;
|
||||
}, 350);
|
||||
};
|
||||
const click = (e: MouseEvent) => {
|
||||
if (!didPress) {
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
};
|
||||
element.addEventListener('click', click);
|
||||
element.addEventListener('pointerdown', start, true);
|
||||
element.addEventListener('pointerup', clearLongPressTimer, { capture: true, passive: true });
|
||||
return {
|
||||
destroy: () => {
|
||||
element.removeEventListener('click', click);
|
||||
element.removeEventListener('pointerdown', start, true);
|
||||
element.removeEventListener('pointerup', clearLongPressTimer, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={onAlbumClick}
|
||||
use:scrollIntoViewIfSelected
|
||||
class="flex w-full gap-4 px-6 py-2 text-start transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
|
||||
class:bg-gray-200={selected}
|
||||
class:dark:bg-gray-700={selected}
|
||||
<div
|
||||
role="group"
|
||||
class={[
|
||||
'relative flex w-full text-start justify-between transition-colors hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl my-2 hover:cursor-pointer',
|
||||
{ 'bg-primary/10 hover:bg-primary/10': multiSelected },
|
||||
]}
|
||||
onmouseenter={onMouseEnter}
|
||||
onmouseleave={onMouseLeave}
|
||||
>
|
||||
<span class="h-12 w-12 shrink-0 rounded-xl bg-slate-300">
|
||||
{#if album.albumThumbnailAssetId}
|
||||
<img
|
||||
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
|
||||
alt={album.albumName}
|
||||
class="h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg"
|
||||
data-testid="album-image"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="flex h-12 flex-col items-start justify-center overflow-hidden">
|
||||
<span class="w-full shrink overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span
|
||||
>
|
||||
<span class="flex gap-1 text-sm">
|
||||
<AlbumListItemDetails {album} />
|
||||
<button
|
||||
type="button"
|
||||
onclick={onAlbumClick}
|
||||
use:scrollIntoViewIfSelected
|
||||
class="flex gap-4 px-2 py-2 text-start"
|
||||
class:bg-gray-200={selected}
|
||||
class:dark:bg-gray-700={selected}
|
||||
use:longPress={{ onLongPress: () => handleMultiSelectClicked() }}
|
||||
>
|
||||
<span class="h-16 w-16 shrink-0 rounded-xl bg-slate-300">
|
||||
{#if album.albumThumbnailAssetId}
|
||||
<img
|
||||
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
|
||||
alt={album.albumName}
|
||||
class={['h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg']}
|
||||
data-testid="album-image"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<span class="flex h-full flex-col items-start justify-center overflow-hidden">
|
||||
<span class="w-full shrink overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
>{albumNameArray[0]}<b>{albumNameArray[1]}</b>{albumNameArray[2]}</span
|
||||
>
|
||||
<span class="flex gap-1 text-sm">
|
||||
<AlbumListItemDetails {album} />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if mouseOver || multiSelected}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleMultiSelectClicked}
|
||||
class="p-3 focus:outline-none hover:cursor-pointer"
|
||||
role="checkbox"
|
||||
tabindex={-1}
|
||||
aria-checked={selected}
|
||||
>
|
||||
{#if multiSelected}
|
||||
<div class="rounded-full">
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-primary" />
|
||||
</div>
|
||||
{:else}
|
||||
<Icon path={mdiCheckCircle} size="24" class="text-gray-300 hover:text-primary/75" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
@ -2,7 +2,7 @@
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||
import type { OnAddToAlbum } from '$lib/utils/actions';
|
||||
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
|
||||
import { addAssetsToAlbum, addAssetsToAlbums } from '$lib/utils/asset-utils';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { mdiImageAlbum, mdiShareVariantOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
@ -18,15 +18,23 @@
|
||||
const { getAssets } = getAssetControlContext();
|
||||
|
||||
const onClick = async () => {
|
||||
const album = await modalManager.show(AlbumPickerModal, { shared });
|
||||
|
||||
if (!album) {
|
||||
const albums = await modalManager.show(AlbumPickerModal, { shared });
|
||||
if (!albums || albums.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetIds = [...getAssets()].map(({ id }) => id);
|
||||
await addAssetsToAlbum(album.id, assetIds);
|
||||
onAddToAlbum(assetIds, album.id);
|
||||
if (albums.length === 1) {
|
||||
const album = albums[0];
|
||||
await addAssetsToAlbum(album.id, assetIds);
|
||||
onAddToAlbum(assetIds, album.id);
|
||||
} else {
|
||||
await addAssetsToAlbums(
|
||||
albums.map(({ id }) => id),
|
||||
assetIds,
|
||||
);
|
||||
onAddToAlbum(assetIds, albums[0].id);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
|
@ -24,19 +24,26 @@ const createAlbumRow = (album: AlbumResponseDto, selected: boolean) => ({
|
||||
type: AlbumModalRowType.ALBUM_ITEM,
|
||||
album,
|
||||
selected,
|
||||
multiSelected: false,
|
||||
});
|
||||
|
||||
describe('Album Modal', () => {
|
||||
it('non-shared with no albums configured yet shows message and new', () => {
|
||||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||
const modalRows = converter.toModalRows('', [], [], -1);
|
||||
const modalRows = converter.toModalRows('', [], [], -1, []);
|
||||
|
||||
expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_yet')]);
|
||||
});
|
||||
|
||||
it('non-shared with no matching albums shows message and new', () => {
|
||||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||
const modalRows = converter.toModalRows('matches_nothing', [], [albumFactory.build({ albumName: 'Holidays' })], -1);
|
||||
const modalRows = converter.toModalRows(
|
||||
'matches_nothing',
|
||||
[],
|
||||
[albumFactory.build({ albumName: 'Holidays' })],
|
||||
-1,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(modalRows).toStrictEqual([createNewAlbumRow(false), createMessageRow('no_albums_with_name_yet')]);
|
||||
});
|
||||
@ -44,7 +51,7 @@ describe('Album Modal', () => {
|
||||
it('non-shared displays single albums', () => {
|
||||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
|
||||
const modalRows = converter.toModalRows('', [], [holidayAlbum], -1);
|
||||
const modalRows = converter.toModalRows('', [], [holidayAlbum], -1, []);
|
||||
|
||||
expect(modalRows).toStrictEqual([
|
||||
createNewAlbumRow(false),
|
||||
@ -64,6 +71,7 @@ describe('Album Modal', () => {
|
||||
[holidayAlbum, constructionAlbum],
|
||||
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
|
||||
-1,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(modalRows).toStrictEqual([
|
||||
@ -90,6 +98,7 @@ describe('Album Modal', () => {
|
||||
[holidayAlbum, constructionAlbum],
|
||||
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
|
||||
-1,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(modalRows).toStrictEqual([
|
||||
@ -112,6 +121,7 @@ describe('Album Modal', () => {
|
||||
[holidayAlbum, constructionAlbum],
|
||||
[holidayAlbum, constructionAlbum, birthdayAlbum, christmasAlbum],
|
||||
-1,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(modalRows).toStrictEqual([
|
||||
@ -125,7 +135,7 @@ describe('Album Modal', () => {
|
||||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
|
||||
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
|
||||
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0);
|
||||
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 0, []);
|
||||
|
||||
expect(modalRows).toStrictEqual([
|
||||
createNewAlbumRow(true),
|
||||
@ -141,7 +151,7 @@ describe('Album Modal', () => {
|
||||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
|
||||
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
|
||||
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1);
|
||||
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 1, []);
|
||||
|
||||
expect(modalRows).toStrictEqual([
|
||||
createNewAlbumRow(false),
|
||||
@ -157,7 +167,7 @@ describe('Album Modal', () => {
|
||||
const converter = new AlbumModalRowConverter(false, AlbumSortBy.MostRecentPhoto, SortOrder.Desc);
|
||||
const holidayAlbum = albumFactory.build({ albumName: 'Holidays' });
|
||||
const constructionAlbum = albumFactory.build({ albumName: 'Construction' });
|
||||
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3);
|
||||
const modalRows = converter.toModalRows('', [holidayAlbum], [holidayAlbum, constructionAlbum], 3, []);
|
||||
|
||||
expect(modalRows).toStrictEqual([
|
||||
createNewAlbumRow(false),
|
||||
|
@ -16,6 +16,7 @@ export enum AlbumModalRowType {
|
||||
export type AlbumModalRow = {
|
||||
type: AlbumModalRowType;
|
||||
selected?: boolean;
|
||||
multiSelected?: boolean;
|
||||
text?: string;
|
||||
album?: AlbumResponseDto;
|
||||
};
|
||||
@ -41,6 +42,7 @@ export class AlbumModalRowConverter {
|
||||
recentAlbums: AlbumResponseDto[],
|
||||
albums: AlbumResponseDto[],
|
||||
selectedRowIndex: number,
|
||||
multiSelectedAlbumIds: string[],
|
||||
): AlbumModalRow[] {
|
||||
// only show recent albums if no search was entered, or we're in the normal albums (non-shared) modal.
|
||||
const recentAlbumsToShow = !this.shared && search.length === 0 ? recentAlbums : [];
|
||||
@ -64,6 +66,7 @@ export class AlbumModalRowConverter {
|
||||
rows.push({
|
||||
type: AlbumModalRowType.ALBUM_ITEM,
|
||||
selected: selectedRowIndex === i + selectedOffsetDueToNewAlbumRow,
|
||||
multiSelected: multiSelectedAlbumIds.includes(album.id),
|
||||
album,
|
||||
});
|
||||
}
|
||||
@ -81,6 +84,7 @@ export class AlbumModalRowConverter {
|
||||
rows.push({
|
||||
type: AlbumModalRowType.ALBUM_ITEM,
|
||||
selected: selectedRowIndex === i + selectedOffsetDueToNewAndRecents,
|
||||
multiSelected: multiSelectedAlbumIds.includes(album.id),
|
||||
album,
|
||||
});
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { Action } from 'svelte/action';
|
||||
import { mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
|
||||
import { mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { Action } from 'svelte/action';
|
||||
|
||||
interface Props {
|
||||
searchQuery?: string;
|
||||
|
@ -6,8 +6,8 @@
|
||||
isSelectableRowType,
|
||||
} from '$lib/components/shared-components/album-selection/album-selection-utils';
|
||||
import { albumViewSettings } from '$lib/stores/preferences.store';
|
||||
import { type AlbumResponseDto, createAlbum, getAllAlbums } from '@immich/sdk';
|
||||
import { Modal, ModalBody } from '@immich/ui';
|
||||
import { createAlbum, getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
|
||||
import { Button, Modal, ModalBody } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import AlbumListItem from '../components/asset-viewer/album-list-item.svelte';
|
||||
@ -21,7 +21,7 @@
|
||||
|
||||
interface Props {
|
||||
shared: boolean;
|
||||
onClose: (album?: AlbumResponseDto) => void;
|
||||
onClose: (albums?: AlbumResponseDto[]) => void;
|
||||
}
|
||||
|
||||
let { shared, onClose }: Props = $props();
|
||||
@ -32,13 +32,54 @@
|
||||
loading = false;
|
||||
});
|
||||
|
||||
const multiSelectedAlbumIds: string[] = $state([]);
|
||||
const multiSelectActive = $derived(multiSelectedAlbumIds.length > 0);
|
||||
|
||||
const rowConverter = new AlbumModalRowConverter(shared, $albumViewSettings.sortBy, $albumViewSettings.sortOrder);
|
||||
const albumModalRows = $derived(rowConverter.toModalRows(search, recentAlbums, albums, selectedRowIndex));
|
||||
const albumModalRows = $derived(
|
||||
rowConverter.toModalRows(search, recentAlbums, albums, selectedRowIndex, multiSelectedAlbumIds),
|
||||
);
|
||||
const selectableRowCount = $derived(albumModalRows.filter((row) => isSelectableRowType(row.type)).length);
|
||||
|
||||
const onNewAlbum = async (name: string) => {
|
||||
const album = await createAlbum({ createAlbumDto: { albumName: name } });
|
||||
onClose(album);
|
||||
onClose([album]);
|
||||
};
|
||||
|
||||
const handleAlbumClick = (album?: AlbumResponseDto) => {
|
||||
if (multiSelectActive) {
|
||||
handleMultiSelect(album);
|
||||
return;
|
||||
}
|
||||
if (album) {
|
||||
onClose([album]);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleMultiSelect = (album?: AlbumResponseDto) => {
|
||||
const selectedAlbum = album ?? albumModalRows.find(({ selected }) => selected)?.album;
|
||||
|
||||
if (!selectedAlbum) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = multiSelectedAlbumIds.indexOf(selectedAlbum.id);
|
||||
if (index === -1) {
|
||||
multiSelectedAlbumIds.push(selectedAlbum.id);
|
||||
return;
|
||||
}
|
||||
multiSelectedAlbumIds.splice(index, 1);
|
||||
};
|
||||
|
||||
const handleMultiSubmit = () => {
|
||||
const albums = new Set(albumModalRows.filter((row) => row.multiSelected).map(({ album }) => album!));
|
||||
if (albums.size > 0) {
|
||||
onClose([...albums]);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const onEnter = async () => {
|
||||
@ -53,8 +94,12 @@
|
||||
break;
|
||||
}
|
||||
case AlbumModalRowType.ALBUM_ITEM: {
|
||||
if (multiSelectActive) {
|
||||
handleMultiSubmit();
|
||||
break;
|
||||
}
|
||||
if (item.album) {
|
||||
onClose(item.album);
|
||||
onClose([item.album]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -88,6 +133,11 @@
|
||||
await onEnter();
|
||||
break;
|
||||
}
|
||||
case 'm': {
|
||||
e.preventDefault();
|
||||
handleMultiSelect();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
selectedRowIndex = -1;
|
||||
}
|
||||
@ -133,13 +183,20 @@
|
||||
<AlbumListItem
|
||||
album={row.album}
|
||||
selected={row.selected || false}
|
||||
multiSelected={row.multiSelected}
|
||||
searchQuery={search}
|
||||
onAlbumClick={() => onClose(row.album)}
|
||||
onAlbumClick={() => handleAlbumClick(row.album)}
|
||||
onMultiSelect={() => handleMultiSelect(row.album)}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if multiSelectActive}
|
||||
<Button size="small" shape="round" fullWidth onclick={handleMultiSubmit}
|
||||
>{$t('add_to_albums_count', { values: { count: multiSelectedAlbumIds.length } })}</Button
|
||||
>
|
||||
{/if}
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
|
@ -16,7 +16,9 @@ import { navigate } from '$lib/utils/navigation';
|
||||
import { asQueryString } from '$lib/utils/shared-links';
|
||||
import {
|
||||
addAssetsToAlbum as addAssets,
|
||||
addAssetsToAlbums as addToAlbums,
|
||||
AssetVisibility,
|
||||
BulkIdErrorReason,
|
||||
bulkTagAssets,
|
||||
createStack,
|
||||
deleteAssets,
|
||||
@ -74,6 +76,52 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[], show
|
||||
}
|
||||
};
|
||||
|
||||
export const addAssetsToAlbums = async (albumIds: string[], assetIds: string[], showNotification = true) => {
|
||||
const result = await addToAlbums({
|
||||
...authManager.params,
|
||||
albumsAddAssetsDto: {
|
||||
albumIds,
|
||||
assetIds,
|
||||
},
|
||||
});
|
||||
|
||||
if (!showNotification) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (showNotification) {
|
||||
const $t = get(t);
|
||||
|
||||
if (result.error === BulkIdErrorReason.Duplicate) {
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
timeout: 5000,
|
||||
message: $t('assets_were_part_of_albums_count', { values: { count: assetIds.length } }),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
if (result.error) {
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
timeout: 5000,
|
||||
message: $t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
timeout: 5000,
|
||||
message: $t('assets_added_to_albums_count', {
|
||||
values: {
|
||||
albumTotal: albumIds.length,
|
||||
assetTotal: assetIds.length,
|
||||
},
|
||||
}),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
export const tagAssets = async ({
|
||||
assetIds,
|
||||
tagIds,
|
||||
|
Loading…
x
Reference in New Issue
Block a user