diff --git a/i18n/en.json b/i18n/en.json index 95fddca5d5d90..3988cfdca7dbc 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index f0673e70b9ca9..d07f13f7a3af6 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -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) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 8ecb9cd5f5503..f5f353c968ebb 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -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'; diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart index 10674b894f2da..a45083669c402 100644 --- a/mobile/openapi/lib/api/albums_api.dart +++ b/mobile/openapi/lib/api/albums_api.dart @@ -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 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 = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + if (slug != null) { + queryParams.addAll(_queryParams('', 'slug', slug)); + } + + const contentTypes = ['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 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]. diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index bd306cb216fe6..3f31d4ed90f6d 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -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': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 098d32f4f4504..4adb62768b889 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -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(); } diff --git a/mobile/openapi/lib/model/albums_add_assets_dto.dart b/mobile/openapi/lib/model/albums_add_assets_dto.dart new file mode 100644 index 0000000000000..bdbf68980ca27 --- /dev/null +++ b/mobile/openapi/lib/model/albums_add_assets_dto.dart @@ -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 albumIds; + + List 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 toJson() { + final json = {}; + 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(); + + return AlbumsAddAssetsDto( + albumIds: json[r'albumIds'] is Iterable + ? (json[r'albumIds'] as Iterable).cast().toList(growable: false) + : const [], + assetIds: json[r'assetIds'] is Iterable + ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AlbumsAddAssetsDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AlbumsAddAssetsDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'albumIds', + 'assetIds', + }; +} + diff --git a/mobile/openapi/lib/model/albums_add_assets_response_dto.dart b/mobile/openapi/lib/model/albums_add_assets_response_dto.dart new file mode 100644 index 0000000000000..168b3f2c45acc --- /dev/null +++ b/mobile/openapi/lib/model/albums_add_assets_response_dto.dart @@ -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 toJson() { + final json = {}; + 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(); + + return AlbumsAddAssetsResponseDto( + albumSuccessCount: mapValueOfType(json, r'albumSuccessCount')!, + assetSuccessCount: mapValueOfType(json, r'assetSuccessCount')!, + error: BulkIdErrorReason.fromJson(json[r'error']), + success: mapValueOfType(json, r'success')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AlbumsAddAssetsResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AlbumsAddAssetsResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'albumSuccessCount', + 'assetSuccessCount', + 'success', + }; +} + diff --git a/mobile/openapi/lib/model/bulk_id_error_reason.dart b/mobile/openapi/lib/model/bulk_id_error_reason.dart new file mode 100644 index 0000000000000..cdaf70217e92a --- /dev/null +++ b/mobile/openapi/lib/model/bulk_id_error_reason.dart @@ -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 = [ + duplicate, + noPermission, + notFound, + unknown, + ]; + + static BulkIdErrorReason? fromJson(dynamic value) => BulkIdErrorReasonTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 197d414921276..96c42f981e4ba 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index f0cdbef5086f1..6dd0a5c622e0d 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -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", diff --git a/server/src/controllers/album.controller.spec.ts b/server/src/controllers/album.controller.spec.ts index 9b8a19c129961..d13227555b2f1 100644 --- a/server/src/controllers/album.controller.spec.ts +++ b/server/src/controllers/album.controller.spec.ts @@ -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' }); diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index a331fc04f1b5e..47f8b5603aecb 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -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 { + return this.service.addAssetsToAlbums(auth, dto); + } + @Delete(':id/assets') @Authenticated({ permission: Permission.AlbumAssetDelete }) removeAssetFromAlbum( diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 3a88ba5be3793..73630b63cbc9b 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -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() diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 6f07a31dd9426..f3ba57d744482 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -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'])); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 90aefa6d72882..32832f0dd36e2 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -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 { + const results: AlbumsAddAssetsResponseDto = { + success: false, + albumSuccessCount: 0, + assetSuccessCount: 0, + error: BulkIdErrorReason.DUPLICATE, + }; + const successfulAssetIds: Set = 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 { await this.requireAccess({ auth, permission: Permission.AlbumAssetDelete, ids: [id] }); diff --git a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte index dca0b7918ec33..2c6ac54ef7cf8 100644 --- a/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/add-to-album-action.svelte @@ -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] }); + } }; diff --git a/web/src/lib/components/asset-viewer/album-list-item.svelte b/web/src/lib/components/asset-viewer/album-list-item.svelte index 7751bd09d822a..bf2e34b7c9fab 100644 --- a/web/src/lib/components/asset-viewer/album-list-item.svelte +++ b/web/src/lib/components/asset-viewer/album-list-item.svelte @@ -1,8 +1,11 @@ - + + {albumNameArray[0]}{albumNameArray[1]}{albumNameArray[2]} + + + + + + + {#if mouseOver || multiSelected} + + {/if} + diff --git a/web/src/lib/components/photos-page/actions/add-to-album.svelte b/web/src/lib/components/photos-page/actions/add-to-album.svelte index 5ec2e879c9982..13a26cd1378da 100644 --- a/web/src/lib/components/photos-page/actions/add-to-album.svelte +++ b/web/src/lib/components/photos-page/actions/add-to-album.svelte @@ -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); + } }; diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts index 242809d58f984..a078e55762e00 100644 --- a/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.spec.ts @@ -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), diff --git a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts index 73f289eb1d8cf..f016916f7f243 100644 --- a/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts +++ b/web/src/lib/components/shared-components/album-selection/album-selection-utils.ts @@ -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, }); } diff --git a/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte b/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte index d8be0e2a30d13..a1adc3ef4f9c0 100644 --- a/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte +++ b/web/src/lib/components/shared-components/album-selection/new-album-list-item.svelte @@ -1,9 +1,9 @@