Merge 42c695191c into 963862b1b9
commit
980b1d1791
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
|
@ -10,9 +10,9 @@ You can enable this feature from the [`Account Settings > Features > Tags`](http
|
|||
|
||||
## Creating tags
|
||||
|
||||
Tags can be created and added to a photo or a video by clicking on the `+ Add` button at the bottom of the info panel.
|
||||
Tags can be created and added to a photo or a video by clicking on the `Add tag` button at the bottom of the info panel.
|
||||
|
||||
<img src={require('./img/tag-creation.webp').default} width="40%" title='Tag view enable' />
|
||||
<img src={require('./img/tag-creation.webp').default} width="40%" title='Tag creation' />
|
||||
|
||||
The tag prompt will appear, and you find and select a tag, or type in a new tag to create it.
|
||||
|
||||
|
|
@ -22,4 +22,14 @@ The tag prompt will appear, and you find and select a tag, or type in a new tag
|
|||
|
||||
You can navigate to the `Tags` view from the side navigation bar or by clicking on the tag in the info panel.
|
||||
|
||||
<img src={require('./img/tag-view.webp').default} width="80%" title='Tag form' />
|
||||
<img src={require('./img/tag-view.webp').default} width="80%" title='Tag view' />
|
||||
|
||||
## Editing tags
|
||||
|
||||
Tags can be edited for one or more photos by selecting the photos desired, and then clicking `Add/Edit tags` from the kebab (⋮) dropdown menu.
|
||||
|
||||
<img src={require('./img/tag-edit-menu.webp').default} width="80%" title='Tag edit menu' />
|
||||
|
||||
Tags that have already been added to the photos selected will appear in the prompt. Any tags that are present on some of the selected photos (but not all of them) will appear darker to differentiate them. They can be selected from the dropdown menu to add them to the remaining photos.
|
||||
|
||||
<img src={require('./img/tag-edit.webp').default} width="80%" title='Tag edit menu' />
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 6742055402de1aa48f93d12ded7d18f4057f9d1f
|
||||
Subproject commit 9b9f8bcc1ca88a98370914e079e2f126160c5d78
|
||||
|
|
@ -1604,6 +1604,7 @@
|
|||
"mobile_app_download_onboarding_note": "Download the companion mobile app using the following options",
|
||||
"model": "Model",
|
||||
"modify_date": "Modify Date",
|
||||
"modify_tags_confirmation": "Are you sure you want to modify tags for {count} selected assets?",
|
||||
"month": "Month",
|
||||
"more": "More",
|
||||
"motion": "Motion",
|
||||
|
|
@ -2300,6 +2301,7 @@
|
|||
"system_theme": "System theme",
|
||||
"system_theme_command_description": "Use the system theme ({value})",
|
||||
"tag": "Tag",
|
||||
"tag_add_edit": "Add/Edit tags",
|
||||
"tag_assets": "Tag assets",
|
||||
"tag_created": "Created tag: {tag}",
|
||||
"tag_face": "Tag face",
|
||||
|
|
|
|||
|
|
@ -276,9 +276,12 @@ Class | Method | HTTP request | Description
|
|||
*SystemMetadataApi* | [**getVersionCheckState**](doc//SystemMetadataApi.md#getversioncheckstate) | **GET** /system-metadata/version-check-state | Retrieve version check state
|
||||
*SystemMetadataApi* | [**updateAdminOnboarding**](doc//SystemMetadataApi.md#updateadminonboarding) | **POST** /system-metadata/admin-onboarding | Update admin onboarding
|
||||
*TagsApi* | [**bulkTagAssets**](doc//TagsApi.md#bulktagassets) | **PUT** /tags/assets | Tag assets
|
||||
*TagsApi* | [**bulkTagUntagAssets**](doc//TagsApi.md#bulktaguntagassets) | **POST** /tags/assets | Tag/Untag assets
|
||||
*TagsApi* | [**bulkUntagAssets**](doc//TagsApi.md#bulkuntagassets) | **DELETE** /tags/assets | Untag assets
|
||||
*TagsApi* | [**createTag**](doc//TagsApi.md#createtag) | **POST** /tags | Create a tag
|
||||
*TagsApi* | [**deleteTag**](doc//TagsApi.md#deletetag) | **DELETE** /tags/{id} | Delete a tag
|
||||
*TagsApi* | [**getAllTags**](doc//TagsApi.md#getalltags) | **GET** /tags | Retrieve tags
|
||||
*TagsApi* | [**getAllTagsForAssets**](doc//TagsApi.md#getalltagsforassets) | **GET** /tags/getAllTagsForAssets | Retrieve tags for assets
|
||||
*TagsApi* | [**getTagById**](doc//TagsApi.md#gettagbyid) | **GET** /tags/{id} | Retrieve a tag
|
||||
*TagsApi* | [**tagAssets**](doc//TagsApi.md#tagassets) | **PUT** /tags/{id}/assets | Tag assets
|
||||
*TagsApi* | [**untagAssets**](doc//TagsApi.md#untagassets) | **DELETE** /tags/{id}/assets | Untag assets
|
||||
|
|
@ -633,12 +636,15 @@ Class | Method | HTTP request | Description
|
|||
- [SystemConfigThemeDto](doc//SystemConfigThemeDto.md)
|
||||
- [SystemConfigTrashDto](doc//SystemConfigTrashDto.md)
|
||||
- [SystemConfigUserDto](doc//SystemConfigUserDto.md)
|
||||
- [TagBulkAddRemoveAssetsDto](doc//TagBulkAddRemoveAssetsDto.md)
|
||||
- [TagBulkAddRemoveAssetsResponseDto](doc//TagBulkAddRemoveAssetsResponseDto.md)
|
||||
- [TagBulkAssetsDto](doc//TagBulkAssetsDto.md)
|
||||
- [TagBulkAssetsResponseDto](doc//TagBulkAssetsResponseDto.md)
|
||||
- [TagCreateDto](doc//TagCreateDto.md)
|
||||
- [TagResponseDto](doc//TagResponseDto.md)
|
||||
- [TagUpdateDto](doc//TagUpdateDto.md)
|
||||
- [TagUpsertDto](doc//TagUpsertDto.md)
|
||||
- [TagsForAssetsResponseDto](doc//TagsForAssetsResponseDto.md)
|
||||
- [TagsResponse](doc//TagsResponse.md)
|
||||
- [TagsUpdate](doc//TagsUpdate.md)
|
||||
- [TemplateDto](doc//TemplateDto.md)
|
||||
|
|
|
|||
|
|
@ -375,12 +375,15 @@ part 'model/system_config_templates_dto.dart';
|
|||
part 'model/system_config_theme_dto.dart';
|
||||
part 'model/system_config_trash_dto.dart';
|
||||
part 'model/system_config_user_dto.dart';
|
||||
part 'model/tag_bulk_add_remove_assets_dto.dart';
|
||||
part 'model/tag_bulk_add_remove_assets_response_dto.dart';
|
||||
part 'model/tag_bulk_assets_dto.dart';
|
||||
part 'model/tag_bulk_assets_response_dto.dart';
|
||||
part 'model/tag_create_dto.dart';
|
||||
part 'model/tag_response_dto.dart';
|
||||
part 'model/tag_update_dto.dart';
|
||||
part 'model/tag_upsert_dto.dart';
|
||||
part 'model/tags_for_assets_response_dto.dart';
|
||||
part 'model/tags_response.dart';
|
||||
part 'model/tags_update.dart';
|
||||
part 'model/template_dto.dart';
|
||||
|
|
|
|||
|
|
@ -73,6 +73,118 @@ class TagsApi {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Tag/Untag assets
|
||||
///
|
||||
/// Add or remove multiple tags from multiple assets in a single request.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [TagBulkAddRemoveAssetsDto] tagBulkAddRemoveAssetsDto (required):
|
||||
Future<Response> bulkTagUntagAssetsWithHttpInfo(TagBulkAddRemoveAssetsDto tagBulkAddRemoveAssetsDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/tags/assets';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = tagBulkAddRemoveAssetsDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Tag/Untag assets
|
||||
///
|
||||
/// Add or remove multiple tags from multiple assets in a single request.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [TagBulkAddRemoveAssetsDto] tagBulkAddRemoveAssetsDto (required):
|
||||
Future<TagBulkAddRemoveAssetsResponseDto?> bulkTagUntagAssets(TagBulkAddRemoveAssetsDto tagBulkAddRemoveAssetsDto,) async {
|
||||
final response = await bulkTagUntagAssetsWithHttpInfo(tagBulkAddRemoveAssetsDto,);
|
||||
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), 'TagBulkAddRemoveAssetsResponseDto',) as TagBulkAddRemoveAssetsResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Untag assets
|
||||
///
|
||||
/// Remove multiple tags from multiple assets in a single request.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [TagBulkAssetsDto] tagBulkAssetsDto (required):
|
||||
Future<Response> bulkUntagAssetsWithHttpInfo(TagBulkAssetsDto tagBulkAssetsDto,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/tags/assets';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = tagBulkAssetsDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'DELETE',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Untag assets
|
||||
///
|
||||
/// Remove multiple tags from multiple assets in a single request.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [TagBulkAssetsDto] tagBulkAssetsDto (required):
|
||||
Future<TagBulkAssetsResponseDto?> bulkUntagAssets(TagBulkAssetsDto tagBulkAssetsDto,) async {
|
||||
final response = await bulkUntagAssetsWithHttpInfo(tagBulkAssetsDto,);
|
||||
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), 'TagBulkAssetsResponseDto',) as TagBulkAssetsResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Create a tag
|
||||
///
|
||||
/// Create a new tag by providing a name and optional color.
|
||||
|
|
@ -232,6 +344,69 @@ class TagsApi {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Retrieve tags for assets
|
||||
///
|
||||
/// Retrieve all tags associated with the specified assets.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [List<String>] assetIds (required):
|
||||
/// Asset IDs to retrieve tags for
|
||||
Future<Response> getAllTagsForAssetsWithHttpInfo(List<String> assetIds,) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/tags/getAllTagsForAssets';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
queryParams.addAll(_queryParams('multi', 'assetIds', assetIds));
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Retrieve tags for assets
|
||||
///
|
||||
/// Retrieve all tags associated with the specified assets.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [List<String>] assetIds (required):
|
||||
/// Asset IDs to retrieve tags for
|
||||
Future<List<TagsForAssetsResponseDto>?> getAllTagsForAssets(List<String> assetIds,) async {
|
||||
final response = await getAllTagsForAssetsWithHttpInfo(assetIds,);
|
||||
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) {
|
||||
final responseBody = await _decodeBodyBytes(response);
|
||||
return (await apiClient.deserializeAsync(responseBody, 'List<TagsForAssetsResponseDto>') as List)
|
||||
.cast<TagsForAssetsResponseDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Retrieve a tag
|
||||
///
|
||||
/// Retrieve a specific tag by its ID.
|
||||
|
|
|
|||
|
|
@ -795,6 +795,10 @@ class ApiClient {
|
|||
return SystemConfigTrashDto.fromJson(value);
|
||||
case 'SystemConfigUserDto':
|
||||
return SystemConfigUserDto.fromJson(value);
|
||||
case 'TagBulkAddRemoveAssetsDto':
|
||||
return TagBulkAddRemoveAssetsDto.fromJson(value);
|
||||
case 'TagBulkAddRemoveAssetsResponseDto':
|
||||
return TagBulkAddRemoveAssetsResponseDto.fromJson(value);
|
||||
case 'TagBulkAssetsDto':
|
||||
return TagBulkAssetsDto.fromJson(value);
|
||||
case 'TagBulkAssetsResponseDto':
|
||||
|
|
@ -807,6 +811,8 @@ class ApiClient {
|
|||
return TagUpdateDto.fromJson(value);
|
||||
case 'TagUpsertDto':
|
||||
return TagUpsertDto.fromJson(value);
|
||||
case 'TagsForAssetsResponseDto':
|
||||
return TagsForAssetsResponseDto.fromJson(value);
|
||||
case 'TagsResponse':
|
||||
return TagsResponse.fromJson(value);
|
||||
case 'TagsUpdate':
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
//
|
||||
// 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 TagBulkAddRemoveAssetsDto {
|
||||
/// Returns a new [TagBulkAddRemoveAssetsDto] instance.
|
||||
TagBulkAddRemoveAssetsDto({
|
||||
this.assetIds = const [],
|
||||
this.tagIdsToAdd = const [],
|
||||
this.tagIdsToRemove = const [],
|
||||
});
|
||||
|
||||
/// Asset IDs to tag/untag
|
||||
List<String> assetIds;
|
||||
|
||||
/// Tag IDs to add to assets
|
||||
List<String> tagIdsToAdd;
|
||||
|
||||
/// Tag IDs to remove from assets
|
||||
List<String> tagIdsToRemove;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is TagBulkAddRemoveAssetsDto &&
|
||||
_deepEquality.equals(other.assetIds, assetIds) &&
|
||||
_deepEquality.equals(other.tagIdsToAdd, tagIdsToAdd) &&
|
||||
_deepEquality.equals(other.tagIdsToRemove, tagIdsToRemove);
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assetIds.hashCode) +
|
||||
(tagIdsToAdd.hashCode) +
|
||||
(tagIdsToRemove.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'TagBulkAddRemoveAssetsDto[assetIds=$assetIds, tagIdsToAdd=$tagIdsToAdd, tagIdsToRemove=$tagIdsToRemove]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'assetIds'] = this.assetIds;
|
||||
json[r'tagIdsToAdd'] = this.tagIdsToAdd;
|
||||
json[r'tagIdsToRemove'] = this.tagIdsToRemove;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [TagBulkAddRemoveAssetsDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static TagBulkAddRemoveAssetsDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "TagBulkAddRemoveAssetsDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return TagBulkAddRemoveAssetsDto(
|
||||
assetIds: json[r'assetIds'] is Iterable
|
||||
? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
tagIdsToAdd: json[r'tagIdsToAdd'] is Iterable
|
||||
? (json[r'tagIdsToAdd'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
tagIdsToRemove: json[r'tagIdsToRemove'] is Iterable
|
||||
? (json[r'tagIdsToRemove'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<TagBulkAddRemoveAssetsDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <TagBulkAddRemoveAssetsDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = TagBulkAddRemoveAssetsDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, TagBulkAddRemoveAssetsDto> mapFromJson(dynamic json) {
|
||||
final map = <String, TagBulkAddRemoveAssetsDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = TagBulkAddRemoveAssetsDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of TagBulkAddRemoveAssetsDto-objects as value to a dart map
|
||||
static Map<String, List<TagBulkAddRemoveAssetsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<TagBulkAddRemoveAssetsDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = TagBulkAddRemoveAssetsDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'assetIds',
|
||||
'tagIdsToAdd',
|
||||
'tagIdsToRemove',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
//
|
||||
// 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 TagBulkAddRemoveAssetsResponseDto {
|
||||
/// Returns a new [TagBulkAddRemoveAssetsResponseDto] instance.
|
||||
TagBulkAddRemoveAssetsResponseDto({
|
||||
required this.addedCount,
|
||||
required this.removedCount,
|
||||
});
|
||||
|
||||
/// Number of assets tagged
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int addedCount;
|
||||
|
||||
/// Number of assets untagged
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int removedCount;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is TagBulkAddRemoveAssetsResponseDto &&
|
||||
other.addedCount == addedCount &&
|
||||
other.removedCount == removedCount;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(addedCount.hashCode) +
|
||||
(removedCount.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'TagBulkAddRemoveAssetsResponseDto[addedCount=$addedCount, removedCount=$removedCount]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'addedCount'] = this.addedCount;
|
||||
json[r'removedCount'] = this.removedCount;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [TagBulkAddRemoveAssetsResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static TagBulkAddRemoveAssetsResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "TagBulkAddRemoveAssetsResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return TagBulkAddRemoveAssetsResponseDto(
|
||||
addedCount: mapValueOfType<int>(json, r'addedCount')!,
|
||||
removedCount: mapValueOfType<int>(json, r'removedCount')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<TagBulkAddRemoveAssetsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <TagBulkAddRemoveAssetsResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = TagBulkAddRemoveAssetsResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, TagBulkAddRemoveAssetsResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, TagBulkAddRemoveAssetsResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = TagBulkAddRemoveAssetsResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of TagBulkAddRemoveAssetsResponseDto-objects as value to a dart map
|
||||
static Map<String, List<TagBulkAddRemoveAssetsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<TagBulkAddRemoveAssetsResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = TagBulkAddRemoveAssetsResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'addedCount',
|
||||
'removedCount',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -16,7 +16,7 @@ class TagBulkAssetsResponseDto {
|
|||
required this.count,
|
||||
});
|
||||
|
||||
/// Number of assets tagged
|
||||
/// Number of assets tagged/untagged
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
//
|
||||
// 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 TagsForAssetsResponseDto {
|
||||
/// Returns a new [TagsForAssetsResponseDto] instance.
|
||||
TagsForAssetsResponseDto({
|
||||
this.assetIds = const [],
|
||||
required this.tagId,
|
||||
});
|
||||
|
||||
/// Asset IDs associated with the tag
|
||||
List<String> assetIds;
|
||||
|
||||
/// Tag ID
|
||||
String tagId;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is TagsForAssetsResponseDto &&
|
||||
_deepEquality.equals(other.assetIds, assetIds) &&
|
||||
other.tagId == tagId;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(assetIds.hashCode) +
|
||||
(tagId.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'TagsForAssetsResponseDto[assetIds=$assetIds, tagId=$tagId]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'assetIds'] = this.assetIds;
|
||||
json[r'tagId'] = this.tagId;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [TagsForAssetsResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static TagsForAssetsResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "TagsForAssetsResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return TagsForAssetsResponseDto(
|
||||
assetIds: json[r'assetIds'] is Iterable
|
||||
? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
tagId: mapValueOfType<String>(json, r'tagId')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<TagsForAssetsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <TagsForAssetsResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = TagsForAssetsResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, TagsForAssetsResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, TagsForAssetsResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = TagsForAssetsResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of TagsForAssetsResponseDto-objects as value to a dart map
|
||||
static Map<String, List<TagsForAssetsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<TagsForAssetsResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = TagsForAssetsResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'tagId',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -13381,6 +13381,122 @@
|
|||
}
|
||||
},
|
||||
"/tags/assets": {
|
||||
"delete": {
|
||||
"description": "Remove multiple tags from multiple assets in a single request.",
|
||||
"operationId": "bulkUntagAssets",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TagBulkAssetsDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TagBulkAssetsResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Untag assets",
|
||||
"tags": [
|
||||
"Tags"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Beta"
|
||||
},
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "tag.asset",
|
||||
"x-immich-state": "Stable"
|
||||
},
|
||||
"post": {
|
||||
"description": "Add or remove multiple tags from multiple assets in a single request.",
|
||||
"operationId": "bulkTagUntagAssets",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TagBulkAddRemoveAssetsDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TagBulkAddRemoveAssetsResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Tag/Untag assets",
|
||||
"tags": [
|
||||
"Tags"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Beta"
|
||||
},
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "tag.asset",
|
||||
"x-immich-state": "Stable"
|
||||
},
|
||||
"put": {
|
||||
"description": "Add multiple tags to multiple assets in a single request.",
|
||||
"operationId": "bulkTagAssets",
|
||||
|
|
@ -13440,6 +13556,74 @@
|
|||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/tags/getAllTagsForAssets": {
|
||||
"get": {
|
||||
"description": "Retrieve all tags associated with the specified assets.",
|
||||
"operationId": "getAllTagsForAssets",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "assetIds",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "Asset IDs to retrieve tags for",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TagsForAssetsResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Retrieve tags for assets",
|
||||
"tags": [
|
||||
"Tags"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Beta"
|
||||
},
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "tag.read",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/tags/{id}": {
|
||||
"delete": {
|
||||
"description": "Delete a specific tag by its ID.",
|
||||
|
|
@ -25449,6 +25633,64 @@
|
|||
],
|
||||
"type": "object"
|
||||
},
|
||||
"TagBulkAddRemoveAssetsDto": {
|
||||
"properties": {
|
||||
"assetIds": {
|
||||
"description": "Asset IDs to tag/untag",
|
||||
"items": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"tagIdsToAdd": {
|
||||
"description": "Tag IDs to add to assets",
|
||||
"items": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"tagIdsToRemove": {
|
||||
"description": "Tag IDs to remove from assets",
|
||||
"items": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"assetIds",
|
||||
"tagIdsToAdd",
|
||||
"tagIdsToRemove"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"TagBulkAddRemoveAssetsResponseDto": {
|
||||
"properties": {
|
||||
"addedCount": {
|
||||
"description": "Number of assets tagged",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"removedCount": {
|
||||
"description": "Number of assets untagged",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"addedCount",
|
||||
"removedCount"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"TagBulkAssetsDto": {
|
||||
"properties": {
|
||||
"assetIds": {
|
||||
|
|
@ -25479,7 +25721,7 @@
|
|||
"TagBulkAssetsResponseDto": {
|
||||
"properties": {
|
||||
"count": {
|
||||
"description": "Number of assets tagged",
|
||||
"description": "Number of assets tagged/untagged",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
|
|
@ -25583,6 +25825,29 @@
|
|||
],
|
||||
"type": "object"
|
||||
},
|
||||
"TagsForAssetsResponseDto": {
|
||||
"properties": {
|
||||
"assetIds": {
|
||||
"description": "Asset IDs associated with the tag",
|
||||
"items": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"tagId": {
|
||||
"description": "Tag ID",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"tagId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"TagsResponse": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
|
|
|
|||
|
|
@ -2621,9 +2621,29 @@ export type TagBulkAssetsDto = {
|
|||
tagIds: string[];
|
||||
};
|
||||
export type TagBulkAssetsResponseDto = {
|
||||
/** Number of assets tagged */
|
||||
/** Number of assets tagged/untagged */
|
||||
count: number;
|
||||
};
|
||||
export type TagBulkAddRemoveAssetsDto = {
|
||||
/** Asset IDs to tag/untag */
|
||||
assetIds: string[];
|
||||
/** Tag IDs to add to assets */
|
||||
tagIdsToAdd: string[];
|
||||
/** Tag IDs to remove from assets */
|
||||
tagIdsToRemove: string[];
|
||||
};
|
||||
export type TagBulkAddRemoveAssetsResponseDto = {
|
||||
/** Number of assets tagged */
|
||||
addedCount: number;
|
||||
/** Number of assets untagged */
|
||||
removedCount: number;
|
||||
};
|
||||
export type TagsForAssetsResponseDto = {
|
||||
/** Asset IDs associated with the tag */
|
||||
assetIds?: string[];
|
||||
/** Tag ID */
|
||||
tagId: string;
|
||||
};
|
||||
export type TagUpdateDto = {
|
||||
/** Tag color (hex) */
|
||||
color?: string | null;
|
||||
|
|
@ -6329,6 +6349,36 @@ export function upsertTags({ tagUpsertDto }: {
|
|||
body: tagUpsertDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Untag assets
|
||||
*/
|
||||
export function bulkUntagAssets({ tagBulkAssetsDto }: {
|
||||
tagBulkAssetsDto: TagBulkAssetsDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: TagBulkAssetsResponseDto;
|
||||
}>("/tags/assets", oazapfts.json({
|
||||
...opts,
|
||||
method: "DELETE",
|
||||
body: tagBulkAssetsDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Tag/Untag assets
|
||||
*/
|
||||
export function bulkTagUntagAssets({ tagBulkAddRemoveAssetsDto }: {
|
||||
tagBulkAddRemoveAssetsDto: TagBulkAddRemoveAssetsDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 201;
|
||||
data: TagBulkAddRemoveAssetsResponseDto;
|
||||
}>("/tags/assets", oazapfts.json({
|
||||
...opts,
|
||||
method: "POST",
|
||||
body: tagBulkAddRemoveAssetsDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Tag assets
|
||||
*/
|
||||
|
|
@ -6344,6 +6394,21 @@ export function bulkTagAssets({ tagBulkAssetsDto }: {
|
|||
body: tagBulkAssetsDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Retrieve tags for assets
|
||||
*/
|
||||
export function getAllTagsForAssets({ assetIds }: {
|
||||
assetIds: string[];
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: TagsForAssetsResponseDto[];
|
||||
}>(`/tags/getAllTagsForAssets${QS.query(QS.explode({
|
||||
assetIds
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Delete a tag
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -45,6 +45,51 @@ describe(TagController.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('PUT /tags/assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put('/tags/assets');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /tags/assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete('/tags/assets');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /tags/assets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/tags/assets');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /tags/getAllTagsForAssets', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/tags/getAllTagsForAssets?assetIds=${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/tags/getAllTagsForAssets?assetIds=123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.validationError([{ path: ['assetIds', 0], message: 'Invalid UUID' }]));
|
||||
});
|
||||
|
||||
it('should allow passing an array of assetIds or a single assetId as a string', async () => {
|
||||
const uuid1 = factory.uuid();
|
||||
const uuid2 = factory.uuid();
|
||||
await request(ctx.getHttpServer()).get(`/tags/getAllTagsForAssets?assetIds=${uuid1}`);
|
||||
expect(service.getAllForAssets).toHaveBeenCalledWith(undefined, [uuid1]);
|
||||
|
||||
service.resetAllMocks();
|
||||
await request(ctx.getHttpServer()).get(`/tags/getAllTagsForAssets?assetIds=${uuid1}&assetIds=${uuid2}`);
|
||||
expect(service.getAllForAssets).toHaveBeenCalledWith(undefined, [uuid1, uuid2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /tags/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/tags/${factory.uuid()}`);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
TagBulkAddRemoveAssetsDto,
|
||||
TagBulkAddRemoveAssetsResponseDto,
|
||||
TagBulkAssetsDto,
|
||||
TagBulkAssetsResponseDto,
|
||||
TagCreateDto,
|
||||
TagResponseDto,
|
||||
TagsForAssetsQueryDto,
|
||||
TagsForAssetsResponseDto,
|
||||
TagUpdateDto,
|
||||
TagUpsertDto,
|
||||
} from 'src/dtos/tag.dto';
|
||||
|
|
@ -65,6 +69,45 @@ export class TagController {
|
|||
return this.service.bulkTagAssets(auth, dto);
|
||||
}
|
||||
|
||||
@Delete('assets')
|
||||
@Authenticated({ permission: Permission.TagAsset })
|
||||
@Endpoint({
|
||||
summary: 'Untag assets',
|
||||
description: 'Remove multiple tags from multiple assets in a single request.',
|
||||
history: new HistoryBuilder().added('v2').beta('v2').stable('v2'),
|
||||
})
|
||||
bulkUntagAssets(@Auth() auth: AuthDto, @Body() dto: TagBulkAssetsDto): Promise<TagBulkAssetsResponseDto> {
|
||||
return this.service.bulkUntagAssets(auth, dto);
|
||||
}
|
||||
|
||||
@Post('assets')
|
||||
@Authenticated({ permission: Permission.TagAsset })
|
||||
@Endpoint({
|
||||
summary: 'Tag/Untag assets',
|
||||
description: 'Add or remove multiple tags from multiple assets in a single request.',
|
||||
history: new HistoryBuilder().added('v2').beta('v2').stable('v2'),
|
||||
})
|
||||
bulkTagUntagAssets(
|
||||
@Auth() auth: AuthDto,
|
||||
@Body() dto: TagBulkAddRemoveAssetsDto,
|
||||
): Promise<TagBulkAddRemoveAssetsResponseDto> {
|
||||
return this.service.bulkTagUntagAssets(auth, dto);
|
||||
}
|
||||
|
||||
@Get('getAllTagsForAssets')
|
||||
@Authenticated({ permission: Permission.TagRead })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve tags for assets',
|
||||
description: 'Retrieve all tags associated with the specified assets.',
|
||||
history: new HistoryBuilder().added('v2').beta('v2').stable('v2'),
|
||||
})
|
||||
getAllTagsForAssets(
|
||||
@Auth() auth: AuthDto,
|
||||
@Query() { assetIds }: TagsForAssetsQueryDto,
|
||||
): Promise<TagsForAssetsResponseDto[]> {
|
||||
return this.service.getAllForAssets(auth, assetIds);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Authenticated({ permission: Permission.TagRead })
|
||||
@Endpoint({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import { TagBulkAddRemoveAssetsSchema, TagsForAssetsQuerySchema, TagsForAssetsResponseSchema } from 'src/dtos/tag.dto';
|
||||
|
||||
describe('Tag DTOs', () => {
|
||||
describe('TagsForAssetsQueryDto', () => {
|
||||
it('should validate a valid TagsForAssetsQueryDto', () => {
|
||||
const result = TagsForAssetsQuerySchema.safeParse({ assetIds: ['3fe388e4-2078-44d7-b36c-39d9dee3a657'] });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow a single asset id passed as string', () => {
|
||||
const result = TagsForAssetsQuerySchema.safeParse({ assetIds: '3fe388e4-2078-44d7-b36c-39d9dee3a657' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error for invalid assetId', () => {
|
||||
const result = TagsForAssetsQuerySchema.safeParse({ assetIds: ['invalid-uuid'] });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TagsForAssetsResponseDto', () => {
|
||||
it('should validate a valid TagsForAssetsResponseDto', () => {
|
||||
const data = {
|
||||
tagId: '3fe388e4-2078-44d7-b36c-39d9dee3a657',
|
||||
assetIds: ['3fe388e4-2078-44d7-b36c-39d9dee3a657', '4fe388e4-2078-44d7-b36c-39d9dee3a657'],
|
||||
};
|
||||
const result = TagsForAssetsResponseSchema.safeParse(data);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error for invalid tagId', () => {
|
||||
const result = TagsForAssetsResponseSchema.safeParse({
|
||||
tagId: 'invalid-uuid',
|
||||
assetIds: ['3fe388e4-2078-44d7-b36c-39d9dee3a657'],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error for invalid assetIds', () => {
|
||||
const result = TagsForAssetsResponseSchema.safeParse({
|
||||
tagId: '3fe388e4-2078-44d7-b36c-39d9dee3a657',
|
||||
assetIds: ['invalid-uuid'],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TagBulkAddRemoveAssetsDto', () => {
|
||||
it('should validate a valid TagBulkAddRemoveAssetsDto', () => {
|
||||
const result = TagBulkAddRemoveAssetsSchema.safeParse({
|
||||
tagIdsToAdd: ['2fe388e4-2078-44d7-b36c-39d9dee3a657'],
|
||||
tagIdsToRemove: ['4fe388e4-2078-44d7-b36c-39d9dee3a657'],
|
||||
assetIds: ['3fe388e4-2078-44d7-b36c-39d9dee3a657'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error for invalid assetIds', () => {
|
||||
const result = TagBulkAddRemoveAssetsSchema.safeParse({
|
||||
tagIdsToAdd: ['2fe388e4-2078-44d7-b36c-39d9dee3a657'],
|
||||
tagIdsToRemove: ['4fe388e4-2078-44d7-b36c-39d9dee3a657'],
|
||||
assetIds: ['invalid-uuid'],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error for invalid tagIdsToAdd', () => {
|
||||
const result = TagBulkAddRemoveAssetsSchema.safeParse({
|
||||
tagIdsToAdd: ['invalid-uuid'],
|
||||
tagIdsToRemove: ['4fe388e4-2078-44d7-b36c-39d9dee3a657'],
|
||||
assetIds: ['3fe388e4-2078-44d7-b36c-39d9dee3a657'],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error for invalid tagIdsToRemove', () => {
|
||||
const result = TagBulkAddRemoveAssetsSchema.safeParse({
|
||||
tagIdsToAdd: ['2fe388e4-2078-44d7-b36c-39d9dee3a657'],
|
||||
tagIdsToRemove: ['invalid-uuid'],
|
||||
assetIds: ['3fe388e4-2078-44d7-b36c-39d9dee3a657'],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -32,11 +32,41 @@ const TagBulkAssetsSchema = z
|
|||
})
|
||||
.meta({ id: 'TagBulkAssetsDto' });
|
||||
|
||||
export const TagBulkAddRemoveAssetsSchema = z
|
||||
.object({
|
||||
tagIdsToAdd: z.array(z.uuidv4()).describe('Tag IDs to add to assets'),
|
||||
tagIdsToRemove: z.array(z.uuidv4()).describe('Tag IDs to remove from assets'),
|
||||
assetIds: z.array(z.uuidv4()).describe('Asset IDs to tag/untag'),
|
||||
})
|
||||
.meta({ id: 'TagBulkAddRemoveAssetsDto' });
|
||||
|
||||
const TagBulkAssetsResponseSchema = z
|
||||
.object({
|
||||
count: z.int().describe('Number of assets tagged'),
|
||||
count: z.int().describe('Number of assets tagged/untagged'),
|
||||
})
|
||||
.meta({ id: 'TagBulkAssetsResponseDto' });
|
||||
const TagBulkAddRemoveAssetsResponseSchema = z
|
||||
.object({
|
||||
addedCount: z.int().describe('Number of assets tagged'),
|
||||
removedCount: z.int().describe('Number of assets untagged'),
|
||||
})
|
||||
.meta({ id: 'TagBulkAddRemoveAssetsResponseDto' });
|
||||
|
||||
export const TagsForAssetsQuerySchema = z
|
||||
.object({
|
||||
assetIds: z.preprocess(
|
||||
(val) => (typeof val === 'string' ? [val] : val),
|
||||
z.array(z.uuidv4()).describe('Asset IDs to retrieve tags for'),
|
||||
),
|
||||
})
|
||||
.meta({ id: 'TagsForAssetsQueryDto' });
|
||||
|
||||
export const TagsForAssetsResponseSchema = z
|
||||
.object({
|
||||
tagId: z.uuidv4().describe('Tag ID'),
|
||||
assetIds: z.array(z.uuidv4()).optional().describe('Asset IDs associated with the tag'),
|
||||
})
|
||||
.meta({ id: 'TagsForAssetsResponseDto' });
|
||||
|
||||
export const TagResponseSchema = z
|
||||
.object({
|
||||
|
|
@ -56,8 +86,12 @@ export class TagCreateDto extends createZodDto(TagCreateSchema) {}
|
|||
export class TagUpdateDto extends createZodDto(TagUpdateSchema) {}
|
||||
export class TagUpsertDto extends createZodDto(TagUpsertSchema) {}
|
||||
export class TagBulkAssetsDto extends createZodDto(TagBulkAssetsSchema) {}
|
||||
export class TagBulkAddRemoveAssetsDto extends createZodDto(TagBulkAddRemoveAssetsSchema) {}
|
||||
export class TagBulkAssetsResponseDto extends createZodDto(TagBulkAssetsResponseSchema) {}
|
||||
export class TagBulkAddRemoveAssetsResponseDto extends createZodDto(TagBulkAddRemoveAssetsResponseSchema) {}
|
||||
export class TagResponseDto extends createZodDto(TagResponseSchema) {}
|
||||
export class TagsForAssetsQueryDto extends createZodDto(TagsForAssetsQuerySchema) {}
|
||||
export class TagsForAssetsResponseDto extends createZodDto(TagsForAssetsResponseSchema) {}
|
||||
|
||||
export function mapTag(entity: MaybeDehydrated<Tag>): TagResponseDto {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -60,6 +60,23 @@ where
|
|||
order by
|
||||
"value"
|
||||
|
||||
-- TagRepository.getIdsForAssets
|
||||
select distinct
|
||||
"tagId" as "tagId",
|
||||
(
|
||||
select
|
||||
coalesce(array_agg(distinct ta."assetId"), '{}')
|
||||
from
|
||||
tag_asset as ta
|
||||
where
|
||||
ta."tagId" = tag_asset."tagId"
|
||||
and ta."assetId" in ($1)
|
||||
) as "assetIds"
|
||||
from
|
||||
"tag_asset"
|
||||
where
|
||||
"assetId" in ($2)
|
||||
|
||||
-- TagRepository.create
|
||||
insert into
|
||||
"tag" ("userId", "color", "value")
|
||||
|
|
@ -103,6 +120,13 @@ on conflict do nothing
|
|||
returning
|
||||
*
|
||||
|
||||
-- TagRepository.deleteAssetIds
|
||||
delete from "tag_asset"
|
||||
where
|
||||
("tagId", "assetId") IN (($1, $2))
|
||||
returning
|
||||
*
|
||||
|
||||
-- TagRepository.replaceAssetTags
|
||||
begin
|
||||
delete from "tag_asset"
|
||||
|
|
|
|||
|
|
@ -73,6 +73,23 @@ export class TagRepository {
|
|||
return this.db.selectFrom('tag').select(columns.tag).where('userId', '=', userId).orderBy('value').execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
getIdsForAssets(assetIds: string[]) {
|
||||
return this.db
|
||||
.selectFrom('tag_asset')
|
||||
.select([
|
||||
sql<string>`distinct "tagId"`.as('tagId'),
|
||||
sql<string[]>`(
|
||||
select coalesce(array_agg(distinct ta."assetId"), '{}')
|
||||
from tag_asset as ta
|
||||
where ta."tagId" = tag_asset."tagId"
|
||||
and ta."assetId" in (${sql.join(assetIds, sql`, `)})
|
||||
)`.as('assetIds'),
|
||||
])
|
||||
.where('assetId', 'in', assetIds)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ userId: DummyValue.UUID, color: DummyValue.STRING, value: DummyValue.STRING }] })
|
||||
create(tag: Insertable<TagTable>) {
|
||||
return this.db.insertInto('tag').values(tag).returningAll().executeTakeFirstOrThrow();
|
||||
|
|
@ -143,6 +160,25 @@ export class TagRepository {
|
|||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[{ tagId: DummyValue.UUID, assetId: DummyValue.UUID }]] })
|
||||
@Chunked()
|
||||
deleteAssetIds(items: Insertable<TagAssetTable>[]) {
|
||||
if (items.length === 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
return this.db
|
||||
.deleteFrom('tag_asset')
|
||||
.where(
|
||||
sql<boolean>`("tagId","assetId") IN (${sql.join(
|
||||
items.map(({ tagId, assetId }) => sql`(${tagId}, ${assetId})`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
)
|
||||
.returningAll()
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
|
||||
@Chunked({ paramIndex: 1 })
|
||||
replaceAssetTags(assetId: string, tagIds: string[]) {
|
||||
|
|
|
|||
|
|
@ -234,6 +234,173 @@ describe(TagService.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('bulkUntagAssets', () => {
|
||||
it('should handle invalid requests', async () => {
|
||||
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
mocks.tag.deleteAssetIds.mockResolvedValue([]);
|
||||
await expect(sut.bulkUntagAssets(authStub.admin, { tagIds: ['tag-1'], assetIds: ['asset-1'] })).resolves.toEqual({
|
||||
count: 0,
|
||||
});
|
||||
expect(mocks.tag.deleteAssetIds).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('should delete records', async () => {
|
||||
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.asset.getForUpdateTags.mockResolvedValue({ tags: [{ value: 'tag-1' }, { value: 'tag-2' }] });
|
||||
mocks.tag.deleteAssetIds.mockResolvedValue([
|
||||
{ tagId: 'tag-1', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-1', assetId: 'asset-2' },
|
||||
{ tagId: 'tag-1', assetId: 'asset-3' },
|
||||
{ tagId: 'tag-2', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-2', assetId: 'asset-2' },
|
||||
{ tagId: 'tag-2', assetId: 'asset-3' },
|
||||
]);
|
||||
await expect(
|
||||
sut.bulkUntagAssets(authStub.admin, {
|
||||
tagIds: ['tag-1', 'tag-2'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
count: 6,
|
||||
});
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
exif: { assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
|
||||
lockedPropertiesBehavior: 'append',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
exif: { assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
|
||||
lockedPropertiesBehavior: 'append',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
exif: { assetId: 'asset-3', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
|
||||
lockedPropertiesBehavior: 'append',
|
||||
}),
|
||||
);
|
||||
expect(mocks.tag.deleteAssetIds).toHaveBeenCalledWith([
|
||||
{ tagId: 'tag-1', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-1', assetId: 'asset-2' },
|
||||
{ tagId: 'tag-1', assetId: 'asset-3' },
|
||||
{ tagId: 'tag-2', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-2', assetId: 'asset-2' },
|
||||
{ tagId: 'tag-2', assetId: 'asset-3' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkTagUntagAssets', () => {
|
||||
it('should handle invalid requests', async () => {
|
||||
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
mocks.tag.upsertAssetIds.mockResolvedValue([]);
|
||||
mocks.tag.deleteAssetIds.mockResolvedValue([]);
|
||||
await expect(
|
||||
sut.bulkTagUntagAssets(authStub.admin, {
|
||||
tagIdsToAdd: ['tag-1'],
|
||||
tagIdsToRemove: ['tag-2'],
|
||||
assetIds: ['asset-1'],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
addedCount: 0,
|
||||
removedCount: 0,
|
||||
});
|
||||
expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([]);
|
||||
expect(mocks.tag.deleteAssetIds).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('should add and delete records', async () => {
|
||||
mocks.access.tag.checkOwnerAccess.mockResolvedValueOnce(new Set(['tag-1', 'tag-2']));
|
||||
mocks.access.tag.checkOwnerAccess.mockResolvedValueOnce(new Set(['tag-3', 'tag-4']));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
|
||||
mocks.asset.getForUpdateTags.mockResolvedValue({
|
||||
tags: [{ value: 'tag-1' }, { value: 'tag-2' }, { value: 'tag-3' }, { value: 'tag-4' }],
|
||||
});
|
||||
mocks.tag.upsertAssetIds.mockResolvedValue([
|
||||
{ tagId: 'tag-1', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-1', assetId: 'asset-2' },
|
||||
{ tagId: 'tag-1', assetId: 'asset-3' },
|
||||
{ tagId: 'tag-2', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-2', assetId: 'asset-2' },
|
||||
{ tagId: 'tag-2', assetId: 'asset-3' },
|
||||
]);
|
||||
mocks.tag.deleteAssetIds.mockResolvedValue([
|
||||
{ tagId: 'tag-3', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-3', assetId: 'asset-2' },
|
||||
{ tagId: 'tag-3', assetId: 'asset-3' },
|
||||
{ tagId: 'tag-4', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-4', assetId: 'asset-2' },
|
||||
{ tagId: 'tag-4', assetId: 'asset-3' },
|
||||
]);
|
||||
await expect(
|
||||
sut.bulkTagUntagAssets(authStub.admin, {
|
||||
tagIdsToAdd: ['tag-1', 'tag-2'],
|
||||
tagIdsToRemove: ['tag-3', 'tag-4'],
|
||||
assetIds: ['asset-1', 'asset-2', 'asset-3'],
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
addedCount: 6,
|
||||
removedCount: 6,
|
||||
});
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
exif: { assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2', 'tag-3', 'tag-4'] },
|
||||
lockedPropertiesBehavior: 'append',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
exif: { assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2', 'tag-3', 'tag-4'] },
|
||||
lockedPropertiesBehavior: 'append',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
exif: { assetId: 'asset-3', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2', 'tag-3', 'tag-4'] },
|
||||
lockedPropertiesBehavior: 'append',
|
||||
}),
|
||||
);
|
||||
expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([
|
||||
{ tagId: 'tag-1', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-1', assetId: 'asset-2' },
|
||||
{ tagId: 'tag-1', assetId: 'asset-3' },
|
||||
{ tagId: 'tag-2', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-2', assetId: 'asset-2' },
|
||||
{ tagId: 'tag-2', assetId: 'asset-3' },
|
||||
]);
|
||||
expect(mocks.tag.deleteAssetIds).toHaveBeenCalledWith([
|
||||
{ tagId: 'tag-3', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-3', assetId: 'asset-2' },
|
||||
{ tagId: 'tag-3', assetId: 'asset-3' },
|
||||
{ tagId: 'tag-4', assetId: 'asset-1' },
|
||||
{ tagId: 'tag-4', assetId: 'asset-2' },
|
||||
{ tagId: 'tag-4', assetId: 'asset-3' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllForAssets', () => {
|
||||
it('should throw an error for no asset read access', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
await expect(sut.getAllForAssets(authStub.admin, ['asset-1'])).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.tag.getIdsForAssets).not.toHaveBeenCalled();
|
||||
});
|
||||
it('should get all tags for the specified assets', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
mocks.tag.getIdsForAssets.mockResolvedValue([{ tagId: tagStub.tag.id, assetIds: ['asset-1'] }]);
|
||||
await expect(sut.getAllForAssets(authStub.admin, ['asset-1'])).resolves.toEqual([
|
||||
{
|
||||
tagId: tagStub.tag.id,
|
||||
assetIds: ['asset-1'],
|
||||
},
|
||||
]);
|
||||
expect(mocks.tag.getIdsForAssets).toHaveBeenCalledWith(['asset-1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAssets', () => {
|
||||
it('should handle invalid ids', async () => {
|
||||
mocks.tag.getAssetIds.mockResolvedValue(new Set());
|
||||
|
|
|
|||
|
|
@ -4,13 +4,16 @@ import { OnJob } from 'src/decorators';
|
|||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
mapTag,
|
||||
TagBulkAddRemoveAssetsDto,
|
||||
TagBulkAddRemoveAssetsResponseDto,
|
||||
TagBulkAssetsDto,
|
||||
TagBulkAssetsResponseDto,
|
||||
TagCreateDto,
|
||||
TagResponseDto,
|
||||
TagsForAssetsResponseDto,
|
||||
TagUpdateDto,
|
||||
TagUpsertDto,
|
||||
mapTag,
|
||||
} from 'src/dtos/tag.dto';
|
||||
import { JobName, JobStatus, Permission, QueueName } from 'src/enum';
|
||||
import { TagAssetTable } from 'src/schema/tables/tag-asset.table';
|
||||
|
|
@ -32,6 +35,11 @@ export class TagService extends BaseService {
|
|||
return mapTag(tag);
|
||||
}
|
||||
|
||||
async getAllForAssets(auth: AuthDto, assetIds: string[]): Promise<TagsForAssetsResponseDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: assetIds });
|
||||
return await this.tagRepository.getIdsForAssets(assetIds);
|
||||
}
|
||||
|
||||
async create(auth: AuthDto, dto: TagCreateDto) {
|
||||
let parent;
|
||||
if (dto.parentId) {
|
||||
|
|
@ -82,14 +90,7 @@ export class TagService extends BaseService {
|
|||
this.checkAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds }),
|
||||
]);
|
||||
|
||||
const items: Insertable<TagAssetTable>[] = [];
|
||||
for (const tagId of tagIds) {
|
||||
for (const assetId of assetIds) {
|
||||
items.push({ tagId, assetId });
|
||||
}
|
||||
}
|
||||
|
||||
const results = await this.tagRepository.upsertAssetIds(items);
|
||||
const results = await this.tagRepository.upsertAssetIds(this.createTagAssetInsertableList(tagIds, assetIds));
|
||||
for (const assetId of new Set(results.map((item) => item.assetId))) {
|
||||
await this.updateTags(assetId);
|
||||
await this.eventRepository.emit('AssetTag', { assetId });
|
||||
|
|
@ -98,6 +99,46 @@ export class TagService extends BaseService {
|
|||
return { count: results.length };
|
||||
}
|
||||
|
||||
async bulkUntagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise<TagBulkAssetsResponseDto> {
|
||||
const [tagIds, assetIds] = await Promise.all([
|
||||
this.checkAccess({ auth, permission: Permission.TagAsset, ids: dto.tagIds }),
|
||||
this.checkAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds }),
|
||||
]);
|
||||
|
||||
const results = await this.tagRepository.deleteAssetIds(this.createTagAssetInsertableList(tagIds, assetIds));
|
||||
for (const assetId of new Set(results.map((item) => item.assetId))) {
|
||||
await this.updateTags(assetId);
|
||||
await this.eventRepository.emit('AssetUntag', { assetId });
|
||||
}
|
||||
|
||||
return { count: results.length };
|
||||
}
|
||||
|
||||
// Add and remove tags from assets in bulk as part of once service, removing potential for race conditions.
|
||||
async bulkTagUntagAssets(auth: AuthDto, dto: TagBulkAddRemoveAssetsDto): Promise<TagBulkAddRemoveAssetsResponseDto> {
|
||||
const [tagIdsToAdd, tagIdsToRemove, assetIds] = await Promise.all([
|
||||
this.checkAccess({ auth, permission: Permission.TagAsset, ids: dto.tagIdsToAdd }),
|
||||
this.checkAccess({ auth, permission: Permission.TagAsset, ids: dto.tagIdsToRemove }),
|
||||
this.checkAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds }),
|
||||
]);
|
||||
|
||||
const addResults = await this.tagRepository.upsertAssetIds(
|
||||
this.createTagAssetInsertableList(tagIdsToAdd, assetIds),
|
||||
);
|
||||
const removeResults = await this.tagRepository.deleteAssetIds(
|
||||
this.createTagAssetInsertableList(tagIdsToRemove, assetIds),
|
||||
);
|
||||
|
||||
for (const assetId of new Set([...addResults, ...removeResults].map((item) => item.assetId))) {
|
||||
await this.updateTags(assetId);
|
||||
// AssetTag and AssetUntag events perform the same function, and we only want one event to be emitted for each asset
|
||||
// to avoid sidecar file clashes, so we can emit AssetTag for all changes.
|
||||
await this.eventRepository.emit('AssetTag', { assetId });
|
||||
}
|
||||
|
||||
return { addedCount: addResults.length, removedCount: removeResults.length };
|
||||
}
|
||||
|
||||
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.TagAsset, ids: [id] });
|
||||
|
||||
|
|
@ -157,4 +198,14 @@ export class TagService extends BaseService {
|
|||
lockedPropertiesBehavior: 'append',
|
||||
});
|
||||
}
|
||||
|
||||
private createTagAssetInsertableList(tagIds: Set<string>, assetIds: Set<string>): Insertable<TagAssetTable>[] {
|
||||
const items: Insertable<TagAssetTable>[] = [];
|
||||
for (const tagId of tagIds) {
|
||||
for (const assetId of assetIds) {
|
||||
items.push({ tagId, assetId });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -285,6 +285,12 @@ export class MediumTestContext<S extends BaseService = BaseService> {
|
|||
};
|
||||
}
|
||||
|
||||
async newTag(dto: Insertable<TagTable>) {
|
||||
const tag = mediumFactory.tagInsert(dto);
|
||||
const result = await this.get(TagRepository).create(tag);
|
||||
return { tag, result };
|
||||
}
|
||||
|
||||
async newTagAsset(tagBulkAssets: { tagIds: string[]; assetIds: string[] }) {
|
||||
const tagsAssets: Insertable<TagAssetTable>[] = [];
|
||||
for (const tagId of tagBulkAssets.tagIds) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
import { Kysely } from 'kysely';
|
||||
import { forEach } from 'lodash';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { TagRepository } from 'src/repositories/tag.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
const { ctx } = newMediumService(BaseService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
return { ctx, sut: ctx.get(TagRepository) };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(TagRepository.name, () => {
|
||||
afterEach(async () => {
|
||||
const { ctx } = setup();
|
||||
await ctx.database.deleteFrom('tag_closure').execute();
|
||||
await ctx.database.deleteFrom('tag_asset').execute();
|
||||
await ctx.database.deleteFrom('tag').execute();
|
||||
await ctx.database.deleteFrom('user').execute();
|
||||
await ctx.database.deleteFrom('asset').execute();
|
||||
});
|
||||
|
||||
describe('getIdsForAssets', () => {
|
||||
it('should return an array of tag IDs with the assets that have them', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const [{ asset: asset1 }, { asset: asset2 }, { asset: asset3 }, { asset: asset4 }, { asset: asset5 }] =
|
||||
await Promise.all([
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
const [{ tag: tag1 }, { tag: tag2 }, { tag: tag3 }] = await Promise.all([
|
||||
ctx.newTag({
|
||||
userId: user.id,
|
||||
value: 'tag1',
|
||||
color: '#000000',
|
||||
}),
|
||||
ctx.newTag({
|
||||
userId: user.id,
|
||||
value: 'tag2',
|
||||
color: '#000000',
|
||||
}),
|
||||
ctx.newTag({
|
||||
userId: user.id,
|
||||
value: 'tag3',
|
||||
color: '#000000',
|
||||
}),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
ctx.newTagAsset({
|
||||
tagIds: [tag1.id, tag3.id],
|
||||
assetIds: [asset1.id, asset2.id, asset5.id],
|
||||
}),
|
||||
ctx.newTagAsset({
|
||||
tagIds: [tag2.id, tag3.id],
|
||||
assetIds: [asset3.id, asset4.id],
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await sut.getIdsForAssets([asset1.id, asset2.id, asset3.id, asset4.id, asset5.id]);
|
||||
const expectedResponses = [
|
||||
{ tagId: tag1.id, assetIds: [asset1.id, asset2.id, asset5.id] },
|
||||
{ tagId: tag2.id, assetIds: [asset3.id, asset4.id] },
|
||||
{ tagId: tag3.id, assetIds: [asset1.id, asset2.id, asset3.id, asset4.id, asset5.id] },
|
||||
];
|
||||
|
||||
forEach(expectedResponses, (expectedResp) => {
|
||||
const tagResult = result.find((r) => r.tagId === expectedResp.tagId);
|
||||
expect(tagResult).toBeDefined();
|
||||
expect(tagResult?.assetIds).toHaveLength(expectedResp.assetIds.length);
|
||||
expect(tagResult?.assetIds).toEqual(expect.arrayContaining(expectedResp.assetIds));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertAssetIds', () => {
|
||||
it('should bulk insert asset id/tag id pairs', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const [{ asset: asset1 }, { asset: asset2 }, { asset: asset3 }, { asset: asset4 }, { asset: asset5 }] =
|
||||
await Promise.all([
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
const [{ tag: tag1 }, { tag: tag2 }, { tag: tag3 }] = await Promise.all([
|
||||
ctx.newTag({
|
||||
userId: user.id,
|
||||
value: 'tag1',
|
||||
color: '#000000',
|
||||
}),
|
||||
ctx.newTag({
|
||||
userId: user.id,
|
||||
value: 'tag2',
|
||||
color: '#000000',
|
||||
}),
|
||||
ctx.newTag({
|
||||
userId: user.id,
|
||||
value: 'tag3',
|
||||
color: '#000000',
|
||||
}),
|
||||
]);
|
||||
|
||||
const testPairs = [
|
||||
{ assetId: asset1.id, tagId: tag1.id },
|
||||
{ assetId: asset2.id, tagId: tag1.id },
|
||||
{ assetId: asset2.id, tagId: tag3.id },
|
||||
{ assetId: asset3.id, tagId: tag2.id },
|
||||
{ assetId: asset4.id, tagId: tag2.id },
|
||||
{ assetId: asset4.id, tagId: tag3.id },
|
||||
{ assetId: asset5.id, tagId: tag1.id },
|
||||
];
|
||||
const result = await sut.upsertAssetIds(testPairs);
|
||||
expect(result).toBeDefined();
|
||||
|
||||
await expect(ctx.database.selectFrom('tag_asset').selectAll().orderBy('assetId').execute()).resolves.toEqual(
|
||||
testPairs.sort((a, b) => (a.assetId < b.assetId ? -1 : 1)),
|
||||
);
|
||||
});
|
||||
|
||||
it('should ignore already existing tag id/asset id pairs', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
const { tag: tag1 } = await ctx.newTag({
|
||||
userId: user.id,
|
||||
value: 'tag1',
|
||||
color: '#000000',
|
||||
});
|
||||
|
||||
await ctx.newTagAsset({
|
||||
tagIds: [tag1.id],
|
||||
assetIds: [asset1.id],
|
||||
});
|
||||
|
||||
const testPairs = [
|
||||
{ assetId: asset1.id, tagId: tag1.id },
|
||||
{ assetId: asset2.id, tagId: tag1.id },
|
||||
];
|
||||
const result = await sut.upsertAssetIds(testPairs);
|
||||
expect(result).toBeDefined();
|
||||
await expect(ctx.database.selectFrom('tag_asset').selectAll().orderBy('assetId').execute()).resolves.toEqual(
|
||||
testPairs.sort((a, b) => (a.assetId < b.assetId ? -1 : 1)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAssetIds', () => {
|
||||
it('should bulk delete asset id/tag id pairs', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const [{ asset: asset1 }, { asset: asset2 }, { asset: asset3 }, { asset: asset4 }, { asset: asset5 }] =
|
||||
await Promise.all([
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
ctx.newAsset({
|
||||
ownerId: user.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
const [{ tag: tag1 }, { tag: tag2 }, { tag: tag3 }] = await Promise.all([
|
||||
ctx.newTag({
|
||||
userId: user.id,
|
||||
value: 'tag1',
|
||||
color: '#000000',
|
||||
}),
|
||||
ctx.newTag({
|
||||
userId: user.id,
|
||||
value: 'tag2',
|
||||
color: '#000000',
|
||||
}),
|
||||
ctx.newTag({
|
||||
userId: user.id,
|
||||
value: 'tag3',
|
||||
color: '#000000',
|
||||
}),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
ctx.newTagAsset({
|
||||
tagIds: [tag1.id, tag3.id],
|
||||
assetIds: [asset1.id, asset2.id, asset5.id],
|
||||
}),
|
||||
ctx.newTagAsset({
|
||||
tagIds: [tag2.id, tag3.id],
|
||||
assetIds: [asset3.id, asset4.id],
|
||||
}),
|
||||
]);
|
||||
|
||||
await sut.deleteAssetIds([
|
||||
{ tagId: tag1.id, assetId: asset1.id },
|
||||
{ tagId: tag2.id, assetId: asset3.id },
|
||||
{ tagId: tag3.id, assetId: asset4.id },
|
||||
]);
|
||||
|
||||
const testPairsRemainig = [
|
||||
{ tagId: tag1.id, assetId: asset2.id },
|
||||
{ tagId: tag1.id, assetId: asset5.id },
|
||||
{ tagId: tag2.id, assetId: asset4.id },
|
||||
{ tagId: tag3.id, assetId: asset1.id },
|
||||
{ tagId: tag3.id, assetId: asset2.id },
|
||||
{ tagId: tag3.id, assetId: asset3.id },
|
||||
{ tagId: tag3.id, assetId: asset5.id },
|
||||
];
|
||||
|
||||
await expect(ctx.database.selectFrom('tag_asset').selectAll().orderBy('assetId').execute()).resolves.toEqual(
|
||||
testPairsRemainig.sort((a, b) => (a.assetId < b.assetId ? -1 : 1)),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getAssetActions } from '$lib/services/asset.service';
|
||||
import { removeTag } from '$lib/utils/asset-utils';
|
||||
import { untagAssets } from '$lib/utils/asset-utils';
|
||||
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Badge, Link, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
let tags = $derived(asset.tags || []);
|
||||
|
||||
const handleRemove = async (tagId: string) => {
|
||||
const ids = await removeTag({ tagIds: [tagId], assetIds: [asset.id], showNotification: false });
|
||||
const ids = await untagAssets({ tagIds: [tagId], assetIds: [asset.id], showNotification: false });
|
||||
if (ids) {
|
||||
asset = await getAssetInfo({ id: asset.id });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,48 @@
|
|||
<script lang="ts">
|
||||
import { Icon } from '@immich/ui';
|
||||
import { Icon, Tooltip } from '@immich/ui';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
partial?: boolean;
|
||||
tooltipText?: string;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
let { label, onRemove }: Props = $props();
|
||||
let { label, partial, tooltipText, onRemove }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="group flex transition-all">
|
||||
<span
|
||||
class="inline-block h-min rounded-s-full bg-primary py-1 ps-3 pe-1 text-center align-baseline leading-none whitespace-nowrap text-gray-100 transition-all group-hover:ps-3 hover:bg-immich-primary/80 dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80"
|
||||
>
|
||||
<p class="text-sm">
|
||||
{label}
|
||||
</p>
|
||||
</span>
|
||||
<Tooltip text={tooltipText || null} delayDuration={300}>
|
||||
{#snippet child({ props })}
|
||||
<span
|
||||
{...props}
|
||||
class={`
|
||||
inline-block h-min rounded-s-full py-1 ps-3 pe-1 text-center align-baseline
|
||||
leading-none whitespace-nowrap text-gray-100 transition-all group-hover:ps-3
|
||||
hover:bg-immich-primary/80 dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80
|
||||
${partial ? 'bg-gray-500' : 'bg-primary'}
|
||||
`}
|
||||
data-testid="tag-pill-label"
|
||||
>
|
||||
<p class="text-sm">
|
||||
{label}
|
||||
</p>
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="place-content-center place-items-center rounded-e-full bg-immich-primary/95 py-1 ps-1 pe-2 text-gray-100 transition-all hover:bg-immich-primary/80 dark:bg-immich-dark-primary/95 dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80"
|
||||
title={$t('remove_tag')}
|
||||
onclick={onRemove}
|
||||
>
|
||||
<Icon icon={mdiClose} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`
|
||||
place-content-center place-items-center rounded-e-full py-1 ps-1 pe-2 text-gray-100 transition-all hover:bg-immich-primary/80 dark:text-immich-dark-gray dark:hover:bg-immich-dark-primary/80
|
||||
${partial ? 'bg-gray-500/95' : 'bg-immich-primary/95 dark:bg-immich-dark-primary/95'}
|
||||
`}
|
||||
title={$t('remove_tag')}
|
||||
onclick={onRemove}
|
||||
data-testid="tag-pill-remove-button"
|
||||
>
|
||||
<Icon icon={mdiClose} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
let { menuItem = false }: Props = $props();
|
||||
|
||||
const text = $t('tag');
|
||||
const text = $t('tag_add_edit');
|
||||
const icon = mdiTagMultipleOutline;
|
||||
|
||||
const handleTagAssets = async () => {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export type Events = {
|
|||
AssetsDelete: [string[]];
|
||||
AssetEditsApplied: [string];
|
||||
AssetsTag: [string[]];
|
||||
AssetsUntag: [string[]];
|
||||
|
||||
AlbumAddAssets: [{ assetIds: string[]; albumIds: string[] }];
|
||||
AlbumCreate: [AlbumResponseDto];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,457 @@
|
|||
import {
|
||||
getAllTags,
|
||||
getAllTagsForAssets,
|
||||
upsertTags,
|
||||
type TagResponseDto,
|
||||
type TagsForAssetsResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
|
||||
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
|
||||
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
|
||||
import { tagUntagAssets } from '$lib/utils/asset-utils';
|
||||
import AssetTagModal from './AssetTagModal.svelte';
|
||||
|
||||
vi.mock('@immich/sdk', () => {
|
||||
return {
|
||||
getAllTags: vi.fn(),
|
||||
getAllTagsForAssets: vi.fn(),
|
||||
upsertTags: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('$lib/utils/asset-utils', () => {
|
||||
return {
|
||||
tagUntagAssets: vi.fn(),
|
||||
};
|
||||
});
|
||||
const mockTagUntagAssets = vi.mocked(tagUntagAssets);
|
||||
const mockGetAllTags = vi.mocked(getAllTags);
|
||||
const mockGetAllTagsForAssets = vi.mocked(getAllTagsForAssets);
|
||||
const mockUpsertTags = vi.mocked(upsertTags);
|
||||
|
||||
describe('AssetTagModal component', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
const getTagPills = () => screen.queryAllByTestId('tag-pill-label');
|
||||
const getTagPillRemoveButtons = () => screen.queryAllByTestId('tag-pill-remove-button');
|
||||
const getTagsCombobox = () => screen.getByRole('combobox');
|
||||
const getTagComboboxOptions = () => screen.queryAllByRole('option');
|
||||
|
||||
const simpleTag: TagResponseDto = {
|
||||
id: 'tag-id',
|
||||
value: 'Tag',
|
||||
color: '#ff0000',
|
||||
parentId: undefined,
|
||||
name: 'Tag',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
|
||||
const parentTag: TagResponseDto = {
|
||||
id: 'tag-id-parent',
|
||||
value: 'TagParent',
|
||||
color: '#ff0000',
|
||||
parentId: undefined,
|
||||
name: 'TagParent',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
|
||||
const childTag: TagResponseDto = {
|
||||
id: 'tag-id-child',
|
||||
value: 'TagParent/TagChild',
|
||||
color: '#ff0000',
|
||||
parentId: 'tag-id-parent',
|
||||
name: 'TagChild',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
|
||||
const tagDtos = [simpleTag, parentTag, childTag] as TagResponseDto[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
|
||||
vi.stubGlobal('visualViewport', getVisualViewportMock());
|
||||
vi.resetAllMocks();
|
||||
Element.prototype.animate = getAnimateMock();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await waitFor(() => {
|
||||
expect(document.body.style.pointerEvents).not.toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
test('renders combobox with available tags', async () => {
|
||||
mockGetAllTags.mockResolvedValueOnce(tagDtos);
|
||||
mockGetAllTagsForAssets.mockResolvedValueOnce([] as TagsForAssetsResponseDto[]);
|
||||
|
||||
render(AssetTagModal, {
|
||||
props: {
|
||||
assetIds: ['asset-id'],
|
||||
onClose,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAllTagsForAssets).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await fireEvent.focus(getTagsCombobox());
|
||||
const options = getTagComboboxOptions();
|
||||
expect(options.length).toBe(tagDtos.length);
|
||||
expect(options[0]).toHaveTextContent(simpleTag.value);
|
||||
expect(options[1]).toHaveTextContent(parentTag.value);
|
||||
expect(options[2]).toHaveTextContent(childTag.value);
|
||||
});
|
||||
|
||||
test('does not render tag pills if no existing tags present', async () => {
|
||||
mockGetAllTags.mockResolvedValueOnce(tagDtos);
|
||||
mockGetAllTagsForAssets.mockResolvedValueOnce([] as TagsForAssetsResponseDto[]);
|
||||
|
||||
render(AssetTagModal, {
|
||||
props: {
|
||||
assetIds: ['asset-id'],
|
||||
onClose,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAllTagsForAssets).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const tagPills = getTagPills();
|
||||
expect(tagPills.length).toBe(0);
|
||||
});
|
||||
|
||||
test('renders tag pills if existing tags are present', async () => {
|
||||
mockGetAllTags.mockResolvedValueOnce(tagDtos);
|
||||
mockGetAllTagsForAssets.mockResolvedValueOnce([
|
||||
{ tagId: simpleTag.id, assetIds: ['asset-id'] },
|
||||
{ tagId: parentTag.id, assetIds: ['asset-id'] },
|
||||
] as TagsForAssetsResponseDto[]);
|
||||
|
||||
render(AssetTagModal, {
|
||||
props: {
|
||||
assetIds: ['asset-id'],
|
||||
onClose,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAllTagsForAssets).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const tagPills = getTagPills();
|
||||
expect(tagPills.length).toBe(2);
|
||||
expect(tagPills[0]).toHaveTextContent(simpleTag.value);
|
||||
expect(tagPills[1]).toHaveTextContent(parentTag.value);
|
||||
});
|
||||
|
||||
test('removes available tags from combobox as they are selected and displays as pills', async () => {
|
||||
mockGetAllTags.mockResolvedValueOnce(tagDtos);
|
||||
mockGetAllTagsForAssets.mockResolvedValueOnce([] as TagsForAssetsResponseDto[]);
|
||||
|
||||
render(AssetTagModal, {
|
||||
props: {
|
||||
assetIds: ['asset-id'],
|
||||
onClose,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAllTagsForAssets).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Select first option (simpleTag)
|
||||
await fireEvent.focus(getTagsCombobox());
|
||||
let options = getTagComboboxOptions();
|
||||
await fireEvent.click(options[0]);
|
||||
|
||||
// Check simpleTag is added as pill
|
||||
let tagPills = getTagPills();
|
||||
expect(tagPills.length).toBe(1);
|
||||
expect(tagPills[0]).toHaveTextContent(simpleTag.value);
|
||||
|
||||
// Check simpleTag is removed from options
|
||||
await fireEvent.focus(getTagsCombobox());
|
||||
options = getTagComboboxOptions();
|
||||
expect(options.length).toBe(tagDtos.length - 1);
|
||||
expect(options[0]).toHaveTextContent(parentTag.value);
|
||||
expect(options[1]).toHaveTextContent(childTag.value);
|
||||
|
||||
// Select second option (parentTag)
|
||||
await fireEvent.click(options[0]);
|
||||
|
||||
// Check parentTag is added as pill
|
||||
tagPills = getTagPills();
|
||||
expect(tagPills.length).toBe(2);
|
||||
expect(tagPills[1]).toHaveTextContent(parentTag.value);
|
||||
|
||||
// Check parentTag is removed from options
|
||||
await fireEvent.focus(getTagsCombobox());
|
||||
options = getTagComboboxOptions();
|
||||
expect(options.length).toBe(tagDtos.length - 2);
|
||||
expect(options[0]).toHaveTextContent(childTag.value);
|
||||
});
|
||||
|
||||
test('makes tags available in combobox if the remove button is clicked on pill', async () => {
|
||||
mockGetAllTags.mockResolvedValueOnce(tagDtos);
|
||||
mockGetAllTagsForAssets.mockResolvedValueOnce([
|
||||
{ tagId: simpleTag.id, assetIds: ['asset-id'] },
|
||||
{ tagId: parentTag.id, assetIds: ['asset-id'] },
|
||||
] as TagsForAssetsResponseDto[]);
|
||||
|
||||
render(AssetTagModal, {
|
||||
props: {
|
||||
assetIds: ['asset-id'],
|
||||
onClose,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAllTagsForAssets).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const tagPillRemoveButtons = getTagPillRemoveButtons();
|
||||
expect(tagPillRemoveButtons.length).toBe(2);
|
||||
|
||||
// Click remove button on first pill (simpleTag)
|
||||
await fireEvent.click(tagPillRemoveButtons[0]);
|
||||
|
||||
// Check simpleTag pill is removed
|
||||
const tagPills = getTagPills();
|
||||
expect(tagPills.length).toBe(1);
|
||||
expect(tagPills[0]).toHaveTextContent(parentTag.value);
|
||||
|
||||
// Check simpleTag is back in options
|
||||
await fireEvent.focus(getTagsCombobox());
|
||||
const options = getTagComboboxOptions();
|
||||
expect(options.length).toBe(tagDtos.length - 1);
|
||||
expect(options[0]).toHaveTextContent(simpleTag.value);
|
||||
expect(options[1]).toHaveTextContent(childTag.value);
|
||||
});
|
||||
|
||||
test('renders pill as partial if only some assets have that tag', async () => {
|
||||
mockGetAllTags.mockResolvedValueOnce(tagDtos);
|
||||
mockGetAllTagsForAssets.mockResolvedValueOnce([
|
||||
{ tagId: simpleTag.id, assetIds: ['asset-id', 'asset-id2'] },
|
||||
{ tagId: parentTag.id, assetIds: ['asset-id'] },
|
||||
] as TagsForAssetsResponseDto[]);
|
||||
|
||||
render(AssetTagModal, {
|
||||
props: {
|
||||
assetIds: ['asset-id', 'asset-id2'],
|
||||
onClose,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAllTagsForAssets).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// One tag should have bg-primary class, the other should have bg-grey-500 class indicating partial
|
||||
const tagPills = getTagPills();
|
||||
expect(tagPills.length).toBe(2);
|
||||
expect(tagPills[0]).toHaveClass('bg-primary');
|
||||
expect(tagPills[1]).toHaveClass('bg-gray-500');
|
||||
});
|
||||
|
||||
test('allows partial tag to be selected in dropdown, and turns into full tag when selected', async () => {
|
||||
mockGetAllTags.mockResolvedValueOnce(tagDtos);
|
||||
mockGetAllTagsForAssets.mockResolvedValueOnce([
|
||||
{ tagId: simpleTag.id, assetIds: ['asset-id', 'asset-id2'] },
|
||||
{ tagId: parentTag.id, assetIds: ['asset-id'] },
|
||||
] as TagsForAssetsResponseDto[]);
|
||||
|
||||
render(AssetTagModal, {
|
||||
props: {
|
||||
assetIds: ['asset-id', 'asset-id2'],
|
||||
onClose,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAllTagsForAssets).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Partial tag should be available in tag dropdown, full tag should not
|
||||
await fireEvent.focus(getTagsCombobox());
|
||||
let options = getTagComboboxOptions();
|
||||
expect(options.length).toBe(2);
|
||||
expect(options[0]).toHaveTextContent(parentTag.value);
|
||||
expect(options[1]).toHaveTextContent(childTag.value);
|
||||
|
||||
// Select partial tag option (parentTag)
|
||||
await fireEvent.click(options[0]);
|
||||
|
||||
// Both tag pills should now have bg primary class indicating they are full tags
|
||||
const tagPills = getTagPills();
|
||||
expect(tagPills.length).toBe(2);
|
||||
expect(tagPills[0]).toHaveClass('bg-primary');
|
||||
expect(tagPills[1]).toHaveClass('bg-primary');
|
||||
|
||||
// Partial tag (that is now full) should be removed from tag dropdown
|
||||
await fireEvent.focus(getTagsCombobox());
|
||||
options = getTagComboboxOptions();
|
||||
expect(options.length).toBe(1);
|
||||
expect(options[0]).toHaveTextContent(childTag.value);
|
||||
});
|
||||
|
||||
test('filters the list of available tags in the combobox when search string entered', async () => {
|
||||
mockGetAllTags.mockResolvedValueOnce(tagDtos);
|
||||
mockGetAllTagsForAssets.mockResolvedValueOnce([] as TagsForAssetsResponseDto[]);
|
||||
|
||||
render(AssetTagModal, {
|
||||
props: {
|
||||
assetIds: ['asset-id'],
|
||||
onClose,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAllTagsForAssets).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const combobox = getTagsCombobox();
|
||||
await fireEvent.focus(combobox);
|
||||
await fireEvent.input(combobox, { target: { value: 'TagParent' } });
|
||||
const options = getTagComboboxOptions();
|
||||
expect(options.length).toBe(2);
|
||||
expect(options[0]).toHaveTextContent(parentTag.value);
|
||||
expect(options[1]).toHaveTextContent(childTag.value);
|
||||
});
|
||||
|
||||
test('adds a new tag when text entered in the combobox does not match an existing tag', async () => {
|
||||
mockGetAllTags.mockResolvedValueOnce(tagDtos);
|
||||
mockGetAllTagsForAssets.mockResolvedValueOnce([] as TagsForAssetsResponseDto[]);
|
||||
mockUpsertTags.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'new-tag-id',
|
||||
value: 'NewTag',
|
||||
color: '#ff0000',
|
||||
parentId: undefined,
|
||||
name: 'NewTag',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
},
|
||||
] as TagResponseDto[]);
|
||||
|
||||
render(AssetTagModal, {
|
||||
props: {
|
||||
assetIds: ['asset-id'],
|
||||
onClose,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAllTagsForAssets).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const combobox = getTagsCombobox();
|
||||
await fireEvent.focus(combobox);
|
||||
await fireEvent.input(combobox, { target: { value: 'NewTag' } });
|
||||
await fireEvent.keyDown(combobox, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(mockUpsertTags).toHaveBeenCalledWith({ tagUpsertDto: { tags: ['NewTag'] } });
|
||||
|
||||
const tagPills = getTagPills();
|
||||
expect(tagPills.length).toBe(1);
|
||||
expect(tagPills[0]).toHaveClass('bg-primary');
|
||||
expect(tagPills[0]).toHaveTextContent('NewTag');
|
||||
});
|
||||
|
||||
test('displays confirmation dialog with correct asset count if modifying tags for over 40 assets', async () => {
|
||||
mockGetAllTags.mockResolvedValueOnce(tagDtos);
|
||||
mockGetAllTagsForAssets.mockResolvedValueOnce([] as TagsForAssetsResponseDto[]);
|
||||
|
||||
render(AssetTagModal, {
|
||||
props: {
|
||||
assetIds: Array.from({ length: 41 }).fill('asset-id') as string[],
|
||||
onClose,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAllTagsForAssets).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await fireEvent.focus(getTagsCombobox());
|
||||
const options = getTagComboboxOptions();
|
||||
await fireEvent.click(options[0]);
|
||||
|
||||
// Click save button
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save tags/i }));
|
||||
|
||||
expect(screen.getByText(/modify_tags_confirmation/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls tagUntagAssets correctly with the correct set of tag/asset ids', async () => {
|
||||
const addedTag1: TagResponseDto = {
|
||||
id: 'tag-id-added1',
|
||||
value: 'TagAdded1',
|
||||
color: '#ff0000',
|
||||
parentId: undefined,
|
||||
name: 'TagAdded1',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
const addedTag2: TagResponseDto = {
|
||||
id: 'tag-id-added2',
|
||||
value: 'TagAdded2',
|
||||
color: '#ff0000',
|
||||
parentId: undefined,
|
||||
name: 'TagAdded2',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
};
|
||||
const tagDtosLocal = [...tagDtos, addedTag1, addedTag2] as TagResponseDto[];
|
||||
|
||||
mockGetAllTags.mockResolvedValueOnce(tagDtosLocal);
|
||||
mockGetAllTagsForAssets.mockResolvedValueOnce([
|
||||
{ tagId: simpleTag.id, assetIds: ['asset-id', 'asset-id2', 'asset-id3'] },
|
||||
{ tagId: parentTag.id, assetIds: ['asset-id', 'asset-id2'] },
|
||||
{ tagId: childTag.id, assetIds: ['asset-id'] },
|
||||
] as TagsForAssetsResponseDto[]);
|
||||
|
||||
render(AssetTagModal, {
|
||||
props: {
|
||||
assetIds: ['asset-id', 'asset-id2', 'asset-id3'],
|
||||
onClose,
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetAllTagsForAssets).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Remove an existing full tag and partial tag first
|
||||
const tagPillRemoveButtons = getTagPillRemoveButtons();
|
||||
expect(tagPillRemoveButtons.length).toBe(3);
|
||||
await fireEvent.click(tagPillRemoveButtons[0]);
|
||||
await fireEvent.click(tagPillRemoveButtons[1]);
|
||||
|
||||
// Add two new tags using the combobox
|
||||
await fireEvent.focus(getTagsCombobox());
|
||||
let options = getTagComboboxOptions();
|
||||
expect(options.length).toBe(5);
|
||||
await fireEvent.click(options[4]);
|
||||
await fireEvent.focus(getTagsCombobox());
|
||||
options = getTagComboboxOptions();
|
||||
expect(options.length).toBe(4);
|
||||
await fireEvent.click(options[3]);
|
||||
|
||||
// Click save button
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save tags/i }));
|
||||
|
||||
// Check tagAssets is called with correct tag and asset ids
|
||||
// The partial tag ID should not be included in the tag ids to add.
|
||||
expect(mockTagUntagAssets).toHaveBeenCalledWith({
|
||||
assetIds: ['asset-id', 'asset-id2', 'asset-id3'],
|
||||
showNotification: false,
|
||||
tagIdsToAdd: [addedTag2.id, addedTag1.id],
|
||||
tagIdsToRemove: [simpleTag.id, parentTag.id],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
<script lang="ts">
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { tagAssets } from '$lib/utils/asset-utils';
|
||||
import { getAllTags, upsertTags, type TagResponseDto } from '@immich/sdk';
|
||||
import { FormModal } from '@immich/ui';
|
||||
import { tagUntagAssets } from '$lib/utils/asset-utils';
|
||||
import {
|
||||
getAllTags,
|
||||
getAllTagsForAssets,
|
||||
upsertTags,
|
||||
type TagResponseDto,
|
||||
type TagsForAssetsResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { FormModal, modalManager } from '@immich/ui';
|
||||
import { mdiTag } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
|
@ -18,23 +24,67 @@
|
|||
let { onClose, assetIds }: Props = $props();
|
||||
|
||||
let allTags: TagResponseDto[] = $state([]);
|
||||
let existingTagsForAssets: TagsForAssetsResponseDto[] = $state([]);
|
||||
let tagMap = $derived(Object.fromEntries(allTags.map((tag) => [tag.id, tag])));
|
||||
let selectedIds = new SvelteSet<string>();
|
||||
let disabled = $derived(selectedIds.size === 0);
|
||||
let selectedTags = new SvelteSet<{ id: string; count: number; partial: boolean }>();
|
||||
let allowCreate: boolean = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
allTags = await getAllTags();
|
||||
existingTagsForAssets = await getAllTagsForAssets({ assetIds });
|
||||
|
||||
for (const tagForAsset of existingTagsForAssets) {
|
||||
selectedTags.add({
|
||||
id: tagForAsset.tagId,
|
||||
count: tagForAsset.assetIds?.length || 0,
|
||||
partial: (tagForAsset.assetIds?.length || 0) < assetIds.length,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (selectedIds.size === 0) {
|
||||
return;
|
||||
const tagIsSelected = (tagId: string, excludePartials: boolean) => {
|
||||
for (const tag of selectedTags) {
|
||||
if (tag.id === tagId && (!excludePartials || !tag.partial)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const updatedIds = await tagAssets({ tagIds: [...selectedIds], assetIds, showNotification: false });
|
||||
eventManager.emit('AssetsTag', updatedIds);
|
||||
onClose(true);
|
||||
const onSubmit = async () => {
|
||||
// Only add tags from the selected tags list that are not partials and are not in the existing tags list. This ensures only newly added tags are sent server-side.
|
||||
const tagIdsToAdd = [...selectedTags]
|
||||
.filter(
|
||||
(tag) =>
|
||||
tag.partial === false &&
|
||||
!existingTagsForAssets.some((t) => t.tagId === tag.id && t.assetIds?.length === assetIds.length),
|
||||
)
|
||||
.map((tag) => tag.id);
|
||||
|
||||
// Only remove tags that were in the existing tags list, but are no longer in the selected tags list. This ensures only removed tags are sent server-side.
|
||||
const tagIdsToRemove: string[] = existingTagsForAssets
|
||||
.filter((tagForAsset) => !tagIsSelected(tagForAsset.tagId, false))
|
||||
.map((tagForAsset) => tagForAsset.tagId);
|
||||
|
||||
const isConfirmed =
|
||||
assetIds.length > 40
|
||||
? await modalManager.showDialog({
|
||||
prompt: $t('modify_tags_confirmation', { values: { count: assetIds.length } }),
|
||||
})
|
||||
: true;
|
||||
|
||||
if (isConfirmed && (tagIdsToAdd.length > 0 || tagIdsToRemove.length > 0)) {
|
||||
await tagUntagAssets({
|
||||
tagIdsToAdd,
|
||||
tagIdsToRemove,
|
||||
assetIds,
|
||||
showNotification: false,
|
||||
});
|
||||
eventManager.emit('AssetsTag', assetIds);
|
||||
onClose(true);
|
||||
} else {
|
||||
onClose(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = async (option?: ComboBoxOption) => {
|
||||
|
|
@ -43,28 +93,39 @@
|
|||
}
|
||||
|
||||
if (option.id) {
|
||||
selectedIds.add(option.value);
|
||||
for (const item of selectedTags) {
|
||||
if (item.id === option.id) {
|
||||
selectedTags.delete(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
selectedTags.add({ id: option.id, count: assetIds.length, partial: false });
|
||||
} else {
|
||||
const [newTag] = await upsertTags({ tagUpsertDto: { tags: [option.label] } });
|
||||
allTags.push(newTag);
|
||||
selectedIds.add(newTag.id);
|
||||
selectedTags.add({ id: newTag.id, count: assetIds.length, partial: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (tag: string) => {
|
||||
selectedIds.delete(tag);
|
||||
const handleRemove = (tag: { id: string; count: number; partial: boolean }) => {
|
||||
for (const item of selectedTags) {
|
||||
if (item.id === tag.id) {
|
||||
selectedTags.delete(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormModal
|
||||
size="small"
|
||||
title={$t('tag_assets')}
|
||||
title={$t('tag_add_edit')}
|
||||
icon={mdiTag}
|
||||
{onClose}
|
||||
{onSubmit}
|
||||
submitText={$t('tag_assets')}
|
||||
submitText={$t('save') + ' ' + $t('tags')}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
{disabled}
|
||||
disabled={selectedTags.size === 0 && existingTagsForAssets.length === 0}
|
||||
>
|
||||
<div class="my-4 flex flex-col gap-2">
|
||||
<Combobox
|
||||
|
|
@ -72,17 +133,26 @@
|
|||
label={$t('tag')}
|
||||
{allowCreate}
|
||||
defaultFirstOption
|
||||
options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
|
||||
options={allTags
|
||||
.filter((tag) => !tagIsSelected(tag.id, true))
|
||||
.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
|
||||
placeholder={$t('search_tags')}
|
||||
forceFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section class="flex flex-wrap gap-1 pt-2">
|
||||
{#each selectedIds as tagId (tagId)}
|
||||
{@const tag = tagMap[tagId]}
|
||||
{#each selectedTags as { id, count, partial } (id)}
|
||||
{@const tag = tagMap[id]}
|
||||
{#if tag}
|
||||
<TagPill label={tag.value} onRemove={() => handleRemove(tagId)} />
|
||||
<TagPill
|
||||
label={tag.value}
|
||||
{partial}
|
||||
tooltipText={partial
|
||||
? `${count} of the ${assetIds.length} selected assets have this tag`
|
||||
: assetIds.length > 1
|
||||
? `All ${assetIds.length} selected assets have this tag`
|
||||
: undefined}
|
||||
onRemove={() => handleRemove({ id, count, partial })}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import {
|
||||
AssetVisibility,
|
||||
bulkTagAssets,
|
||||
bulkUntagAssets,
|
||||
bulkTagUntagAssets,
|
||||
createStack,
|
||||
deleteAssets,
|
||||
deleteStacks,
|
||||
getBaseUrl,
|
||||
getDownloadInfo,
|
||||
getStack,
|
||||
untagAssets,
|
||||
updateAsset,
|
||||
updateAssets,
|
||||
type AssetResponseDto,
|
||||
|
|
@ -42,17 +43,17 @@ export const tagAssets = async ({
|
|||
tagIds: string[];
|
||||
showNotification?: boolean;
|
||||
}) => {
|
||||
await bulkTagAssets({ tagBulkAssetsDto: { tagIds, assetIds } });
|
||||
const assetCount = await bulkTagAssets({ tagBulkAssetsDto: { tagIds, assetIds } });
|
||||
|
||||
if (showNotification) {
|
||||
const $t = await getFormatter();
|
||||
toastManager.primary($t('tagged_assets', { values: { count: assetIds.length } }));
|
||||
toastManager.primary($t('tagged_assets', { values: { count: assetCount } }));
|
||||
}
|
||||
|
||||
return assetIds;
|
||||
};
|
||||
|
||||
export const removeTag = async ({
|
||||
export const untagAssets = async ({
|
||||
assetIds,
|
||||
tagIds,
|
||||
showNotification = true,
|
||||
|
|
@ -61,13 +62,35 @@ export const removeTag = async ({
|
|||
tagIds: string[];
|
||||
showNotification?: boolean;
|
||||
}) => {
|
||||
for (const tagId of tagIds) {
|
||||
await untagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } });
|
||||
}
|
||||
const assetCount = await bulkUntagAssets({ tagBulkAssetsDto: { tagIds, assetIds } });
|
||||
|
||||
if (showNotification) {
|
||||
const $t = await getFormatter();
|
||||
toastManager.primary($t('removed_tagged_assets', { values: { count: assetIds.length } }));
|
||||
toastManager.primary($t('removed_tagged_assets', { values: { count: assetCount } }));
|
||||
}
|
||||
|
||||
return assetIds;
|
||||
};
|
||||
|
||||
export const tagUntagAssets = async ({
|
||||
assetIds,
|
||||
tagIdsToAdd,
|
||||
tagIdsToRemove,
|
||||
showNotification = true,
|
||||
}: {
|
||||
assetIds: string[];
|
||||
tagIdsToAdd: string[];
|
||||
tagIdsToRemove: string[];
|
||||
showNotification?: boolean;
|
||||
}) => {
|
||||
const { addedCount, removedCount } = await bulkTagUntagAssets({
|
||||
tagBulkAddRemoveAssetsDto: { tagIdsToAdd, tagIdsToRemove, assetIds },
|
||||
});
|
||||
|
||||
if (showNotification) {
|
||||
const $t = await getFormatter();
|
||||
toastManager.primary($t('tagged_assets', { values: { count: addedCount } }));
|
||||
toastManager.primary($t('removed_tagged_assets', { values: { count: removedCount } }));
|
||||
}
|
||||
|
||||
return assetIds;
|
||||
|
|
|
|||
Loading…
Reference in New Issue