diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 7277a99ac8..657e62bf6d 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -100,6 +100,7 @@ Class | Method | HTTP request | Description *AssetsApi* | [**copyAsset**](doc//AssetsApi.md#copyasset) | **PUT** /assets/copy | Copy asset *AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} | Delete asset metadata by key *AssetsApi* | [**deleteAssets**](doc//AssetsApi.md#deleteassets) | **DELETE** /assets | Delete assets +*AssetsApi* | [**deleteBulkAssetMetadata**](doc//AssetsApi.md#deletebulkassetmetadata) | **DELETE** /assets/metadata | Delete asset metadata *AssetsApi* | [**downloadAsset**](doc//AssetsApi.md#downloadasset) | **GET** /assets/{id}/original | Download original asset *AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID *AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | Retrieve an asset @@ -114,6 +115,7 @@ Class | Method | HTTP request | Description *AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | Update an asset *AssetsApi* | [**updateAssetMetadata**](doc//AssetsApi.md#updateassetmetadata) | **PUT** /assets/{id}/metadata | Update asset metadata *AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets | Update assets +*AssetsApi* | [**updateBulkAssetMetadata**](doc//AssetsApi.md#updatebulkassetmetadata) | **PUT** /assets/metadata | Upsert asset metadata *AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | Upload asset *AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | View asset thumbnail *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | Change password @@ -358,7 +360,11 @@ Class | Method | HTTP request | Description - [AssetMediaResponseDto](doc//AssetMediaResponseDto.md) - [AssetMediaSize](doc//AssetMediaSize.md) - [AssetMediaStatus](doc//AssetMediaStatus.md) - - [AssetMetadataKey](doc//AssetMetadataKey.md) + - [AssetMetadataBulkDeleteDto](doc//AssetMetadataBulkDeleteDto.md) + - [AssetMetadataBulkDeleteItemDto](doc//AssetMetadataBulkDeleteItemDto.md) + - [AssetMetadataBulkResponseDto](doc//AssetMetadataBulkResponseDto.md) + - [AssetMetadataBulkUpsertDto](doc//AssetMetadataBulkUpsertDto.md) + - [AssetMetadataBulkUpsertItemDto](doc//AssetMetadataBulkUpsertItemDto.md) - [AssetMetadataResponseDto](doc//AssetMetadataResponseDto.md) - [AssetMetadataUpsertDto](doc//AssetMetadataUpsertDto.md) - [AssetMetadataUpsertItemDto](doc//AssetMetadataUpsertItemDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 05d1803979..59f31d1392 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -109,7 +109,11 @@ part 'model/asset_jobs_dto.dart'; part 'model/asset_media_response_dto.dart'; part 'model/asset_media_size.dart'; part 'model/asset_media_status.dart'; -part 'model/asset_metadata_key.dart'; +part 'model/asset_metadata_bulk_delete_dto.dart'; +part 'model/asset_metadata_bulk_delete_item_dto.dart'; +part 'model/asset_metadata_bulk_response_dto.dart'; +part 'model/asset_metadata_bulk_upsert_dto.dart'; +part 'model/asset_metadata_bulk_upsert_item_dto.dart'; part 'model/asset_metadata_response_dto.dart'; part 'model/asset_metadata_upsert_dto.dart'; part 'model/asset_metadata_upsert_item_dto.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index 5020afc4b2..18a0e35c4f 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -186,12 +186,12 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetMetadataKey] key (required): - Future deleteAssetMetadataWithHttpInfo(String id, AssetMetadataKey key,) async { + /// * [String] key (required): + Future deleteAssetMetadataWithHttpInfo(String id, String key,) async { // ignore: prefer_const_declarations final apiPath = r'/assets/{id}/metadata/{key}' .replaceAll('{id}', id) - .replaceAll('{key}', key.toString()); + .replaceAll('{key}', key); // ignore: prefer_final_locals Object? postBody; @@ -222,8 +222,8 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetMetadataKey] key (required): - Future deleteAssetMetadata(String id, AssetMetadataKey key,) async { + /// * [String] key (required): + Future deleteAssetMetadata(String id, String key,) async { final response = await deleteAssetMetadataWithHttpInfo(id, key,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -278,6 +278,54 @@ class AssetsApi { } } + /// Delete asset metadata + /// + /// Delete metadata key-value pairs for multiple assets. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [AssetMetadataBulkDeleteDto] assetMetadataBulkDeleteDto (required): + Future deleteBulkAssetMetadataWithHttpInfo(AssetMetadataBulkDeleteDto assetMetadataBulkDeleteDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/metadata'; + + // ignore: prefer_final_locals + Object? postBody = assetMetadataBulkDeleteDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Delete asset metadata + /// + /// Delete metadata key-value pairs for multiple assets. + /// + /// Parameters: + /// + /// * [AssetMetadataBulkDeleteDto] assetMetadataBulkDeleteDto (required): + Future deleteBulkAssetMetadata(AssetMetadataBulkDeleteDto assetMetadataBulkDeleteDto,) async { + final response = await deleteBulkAssetMetadataWithHttpInfo(assetMetadataBulkDeleteDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Download original asset /// /// Downloads the original file of the specified asset. @@ -552,12 +600,12 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetMetadataKey] key (required): - Future getAssetMetadataByKeyWithHttpInfo(String id, AssetMetadataKey key,) async { + /// * [String] key (required): + Future getAssetMetadataByKeyWithHttpInfo(String id, String key,) async { // ignore: prefer_const_declarations final apiPath = r'/assets/{id}/metadata/{key}' .replaceAll('{id}', id) - .replaceAll('{key}', key.toString()); + .replaceAll('{key}', key); // ignore: prefer_final_locals Object? postBody; @@ -588,8 +636,8 @@ class AssetsApi { /// /// * [String] id (required): /// - /// * [AssetMetadataKey] key (required): - Future getAssetMetadataByKey(String id, AssetMetadataKey key,) async { + /// * [String] key (required): + Future getAssetMetadataByKey(String id, String key,) async { final response = await getAssetMetadataByKeyWithHttpInfo(id, key,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -1228,6 +1276,65 @@ class AssetsApi { } } + /// Upsert asset metadata + /// + /// Upsert metadata key-value pairs for multiple assets. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [AssetMetadataBulkUpsertDto] assetMetadataBulkUpsertDto (required): + Future updateBulkAssetMetadataWithHttpInfo(AssetMetadataBulkUpsertDto assetMetadataBulkUpsertDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/metadata'; + + // ignore: prefer_final_locals + Object? postBody = assetMetadataBulkUpsertDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Upsert asset metadata + /// + /// Upsert metadata key-value pairs for multiple assets. + /// + /// Parameters: + /// + /// * [AssetMetadataBulkUpsertDto] assetMetadataBulkUpsertDto (required): + Future?> updateBulkAssetMetadata(AssetMetadataBulkUpsertDto assetMetadataBulkUpsertDto,) async { + final response = await updateBulkAssetMetadataWithHttpInfo(assetMetadataBulkUpsertDto,); + 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') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// Upload asset /// /// Uploads a new asset to the server. diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 39aea82c89..6f68e44cef 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -266,8 +266,16 @@ class ApiClient { return AssetMediaSizeTypeTransformer().decode(value); case 'AssetMediaStatus': return AssetMediaStatusTypeTransformer().decode(value); - case 'AssetMetadataKey': - return AssetMetadataKeyTypeTransformer().decode(value); + case 'AssetMetadataBulkDeleteDto': + return AssetMetadataBulkDeleteDto.fromJson(value); + case 'AssetMetadataBulkDeleteItemDto': + return AssetMetadataBulkDeleteItemDto.fromJson(value); + case 'AssetMetadataBulkResponseDto': + return AssetMetadataBulkResponseDto.fromJson(value); + case 'AssetMetadataBulkUpsertDto': + return AssetMetadataBulkUpsertDto.fromJson(value); + case 'AssetMetadataBulkUpsertItemDto': + return AssetMetadataBulkUpsertItemDto.fromJson(value); case 'AssetMetadataResponseDto': return AssetMetadataResponseDto.fromJson(value); case 'AssetMetadataUpsertDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 39caa4534f..1a5f703c78 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -67,9 +67,6 @@ String parameterToString(dynamic value) { if (value is AssetMediaStatus) { return AssetMediaStatusTypeTransformer().encode(value).toString(); } - if (value is AssetMetadataKey) { - return AssetMetadataKeyTypeTransformer().encode(value).toString(); - } if (value is AssetOrder) { return AssetOrderTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_delete_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_delete_dto.dart new file mode 100644 index 0000000000..23c34d7152 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_delete_dto.dart @@ -0,0 +1,99 @@ +// +// 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 AssetMetadataBulkDeleteDto { + /// Returns a new [AssetMetadataBulkDeleteDto] instance. + AssetMetadataBulkDeleteDto({ + this.items = const [], + }); + + List items; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkDeleteDto && + _deepEquality.equals(other.items, items); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (items.hashCode); + + @override + String toString() => 'AssetMetadataBulkDeleteDto[items=$items]'; + + Map toJson() { + final json = {}; + json[r'items'] = this.items; + return json; + } + + /// Returns a new [AssetMetadataBulkDeleteDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkDeleteDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkDeleteDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkDeleteDto( + items: AssetMetadataBulkDeleteItemDto.listFromJson(json[r'items']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataBulkDeleteDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataBulkDeleteDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkDeleteDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataBulkDeleteDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'items', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_delete_item_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_delete_item_dto.dart new file mode 100644 index 0000000000..a3a111f9f7 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_delete_item_dto.dart @@ -0,0 +1,107 @@ +// +// 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 AssetMetadataBulkDeleteItemDto { + /// Returns a new [AssetMetadataBulkDeleteItemDto] instance. + AssetMetadataBulkDeleteItemDto({ + required this.assetId, + required this.key, + }); + + String assetId; + + String key; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkDeleteItemDto && + other.assetId == assetId && + other.key == key; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (key.hashCode); + + @override + String toString() => 'AssetMetadataBulkDeleteItemDto[assetId=$assetId, key=$key]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'key'] = this.key; + return json; + } + + /// Returns a new [AssetMetadataBulkDeleteItemDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkDeleteItemDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkDeleteItemDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkDeleteItemDto( + assetId: mapValueOfType(json, r'assetId')!, + key: mapValueOfType(json, r'key')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataBulkDeleteItemDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataBulkDeleteItemDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkDeleteItemDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataBulkDeleteItemDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'key', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_response_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_response_dto.dart new file mode 100644 index 0000000000..15c130930b --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_response_dto.dart @@ -0,0 +1,123 @@ +// +// 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 AssetMetadataBulkResponseDto { + /// Returns a new [AssetMetadataBulkResponseDto] instance. + AssetMetadataBulkResponseDto({ + required this.assetId, + required this.key, + required this.updatedAt, + required this.value, + }); + + String assetId; + + String key; + + DateTime updatedAt; + + Object value; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkResponseDto && + other.assetId == assetId && + other.key == key && + other.updatedAt == updatedAt && + other.value == value; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (key.hashCode) + + (updatedAt.hashCode) + + (value.hashCode); + + @override + String toString() => 'AssetMetadataBulkResponseDto[assetId=$assetId, key=$key, updatedAt=$updatedAt, value=$value]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'key'] = this.key; + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + json[r'value'] = this.value; + return json; + } + + /// Returns a new [AssetMetadataBulkResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkResponseDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkResponseDto( + assetId: mapValueOfType(json, r'assetId')!, + key: mapValueOfType(json, r'key')!, + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + value: mapValueOfType(json, r'value')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataBulkResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataBulkResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataBulkResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'key', + 'updatedAt', + 'value', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_upsert_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_dto.dart new file mode 100644 index 0000000000..fe9d9ed251 --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_dto.dart @@ -0,0 +1,99 @@ +// +// 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 AssetMetadataBulkUpsertDto { + /// Returns a new [AssetMetadataBulkUpsertDto] instance. + AssetMetadataBulkUpsertDto({ + this.items = const [], + }); + + List items; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkUpsertDto && + _deepEquality.equals(other.items, items); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (items.hashCode); + + @override + String toString() => 'AssetMetadataBulkUpsertDto[items=$items]'; + + Map toJson() { + final json = {}; + json[r'items'] = this.items; + return json; + } + + /// Returns a new [AssetMetadataBulkUpsertDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkUpsertDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkUpsertDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkUpsertDto( + items: AssetMetadataBulkUpsertItemDto.listFromJson(json[r'items']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataBulkUpsertDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataBulkUpsertDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkUpsertDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataBulkUpsertDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'items', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_bulk_upsert_item_dto.dart b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_item_dto.dart new file mode 100644 index 0000000000..25a219537e --- /dev/null +++ b/mobile/openapi/lib/model/asset_metadata_bulk_upsert_item_dto.dart @@ -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 AssetMetadataBulkUpsertItemDto { + /// Returns a new [AssetMetadataBulkUpsertItemDto] instance. + AssetMetadataBulkUpsertItemDto({ + required this.assetId, + required this.key, + required this.value, + }); + + String assetId; + + String key; + + Object value; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetMetadataBulkUpsertItemDto && + other.assetId == assetId && + other.key == key && + other.value == value; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetId.hashCode) + + (key.hashCode) + + (value.hashCode); + + @override + String toString() => 'AssetMetadataBulkUpsertItemDto[assetId=$assetId, key=$key, value=$value]'; + + Map toJson() { + final json = {}; + json[r'assetId'] = this.assetId; + json[r'key'] = this.key; + json[r'value'] = this.value; + return json; + } + + /// Returns a new [AssetMetadataBulkUpsertItemDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetMetadataBulkUpsertItemDto? fromJson(dynamic value) { + upgradeDto(value, "AssetMetadataBulkUpsertItemDto"); + if (value is Map) { + final json = value.cast(); + + return AssetMetadataBulkUpsertItemDto( + assetId: mapValueOfType(json, r'assetId')!, + key: mapValueOfType(json, r'key')!, + value: mapValueOfType(json, r'value')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetMetadataBulkUpsertItemDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetMetadataBulkUpsertItemDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetMetadataBulkUpsertItemDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetMetadataBulkUpsertItemDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetId', + 'key', + 'value', + }; +} + diff --git a/mobile/openapi/lib/model/asset_metadata_key.dart b/mobile/openapi/lib/model/asset_metadata_key.dart deleted file mode 100644 index 70186cd41c..0000000000 --- a/mobile/openapi/lib/model/asset_metadata_key.dart +++ /dev/null @@ -1,82 +0,0 @@ -// -// 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 AssetMetadataKey { - /// Instantiate a new enum with the provided [value]. - const AssetMetadataKey._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const mobileApp = AssetMetadataKey._(r'mobile-app'); - - /// List of all possible values in this [enum][AssetMetadataKey]. - static const values = [ - mobileApp, - ]; - - static AssetMetadataKey? fromJson(dynamic value) => AssetMetadataKeyTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = AssetMetadataKey.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [AssetMetadataKey] to String, -/// and [decode] dynamic data back to [AssetMetadataKey]. -class AssetMetadataKeyTypeTransformer { - factory AssetMetadataKeyTypeTransformer() => _instance ??= const AssetMetadataKeyTypeTransformer._(); - - const AssetMetadataKeyTypeTransformer._(); - - String encode(AssetMetadataKey data) => data.value; - - /// Decodes a [dynamic value][data] to a AssetMetadataKey. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - AssetMetadataKey? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'mobile-app': return AssetMetadataKey.mobileApp; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [AssetMetadataKeyTypeTransformer] instance. - static AssetMetadataKeyTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/asset_metadata_response_dto.dart b/mobile/openapi/lib/model/asset_metadata_response_dto.dart index af5769b9bb..cccf42ae87 100644 --- a/mobile/openapi/lib/model/asset_metadata_response_dto.dart +++ b/mobile/openapi/lib/model/asset_metadata_response_dto.dart @@ -18,7 +18,7 @@ class AssetMetadataResponseDto { required this.value, }); - AssetMetadataKey key; + String key; DateTime updatedAt; @@ -57,7 +57,7 @@ class AssetMetadataResponseDto { final json = value.cast(); return AssetMetadataResponseDto( - key: AssetMetadataKey.fromJson(json[r'key'])!, + key: mapValueOfType(json, r'key')!, updatedAt: mapDateTime(json, r'updatedAt', r'')!, value: mapValueOfType(json, r'value')!, ); diff --git a/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart b/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart index 4b7e6579a1..3d247f8572 100644 --- a/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart +++ b/mobile/openapi/lib/model/asset_metadata_upsert_item_dto.dart @@ -17,7 +17,7 @@ class AssetMetadataUpsertItemDto { required this.value, }); - AssetMetadataKey key; + String key; Object value; @@ -51,7 +51,7 @@ class AssetMetadataUpsertItemDto { final json = value.cast(); return AssetMetadataUpsertItemDto( - key: AssetMetadataKey.fromJson(json[r'key'])!, + key: mapValueOfType(json, r'key')!, value: mapValueOfType(json, r'value')!, ); } diff --git a/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart b/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart index c9a7ef4670..cf67b68dd2 100644 --- a/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart +++ b/mobile/openapi/lib/model/sync_asset_metadata_delete_v1.dart @@ -19,7 +19,7 @@ class SyncAssetMetadataDeleteV1 { String assetId; - AssetMetadataKey key; + String key; @override bool operator ==(Object other) => identical(this, other) || other is SyncAssetMetadataDeleteV1 && @@ -52,7 +52,7 @@ class SyncAssetMetadataDeleteV1 { return SyncAssetMetadataDeleteV1( assetId: mapValueOfType(json, r'assetId')!, - key: AssetMetadataKey.fromJson(json[r'key'])!, + key: mapValueOfType(json, r'key')!, ); } return null; diff --git a/mobile/openapi/lib/model/sync_asset_metadata_v1.dart b/mobile/openapi/lib/model/sync_asset_metadata_v1.dart index 720fcef947..4fa6ed84ed 100644 --- a/mobile/openapi/lib/model/sync_asset_metadata_v1.dart +++ b/mobile/openapi/lib/model/sync_asset_metadata_v1.dart @@ -20,7 +20,7 @@ class SyncAssetMetadataV1 { String assetId; - AssetMetadataKey key; + String key; Object value; @@ -58,7 +58,7 @@ class SyncAssetMetadataV1 { return SyncAssetMetadataV1( assetId: mapValueOfType(json, r'assetId')!, - key: AssetMetadataKey.fromJson(json[r'key'])!, + key: mapValueOfType(json, r'key')!, value: mapValueOfType(json, r'value')!, ); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7e859ffee0..e46f29b50d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2906,6 +2906,112 @@ "x-immich-state": "Stable" } }, + "/assets/metadata": { + "delete": { + "description": "Delete metadata key-value pairs for multiple assets.", + "operationId": "deleteBulkAssetMetadata", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMetadataBulkDeleteDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Delete asset metadata", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-permission": "asset.update", + "x-immich-state": "Beta" + }, + "put": { + "description": "Upsert metadata key-value pairs for multiple assets.", + "operationId": "updateBulkAssetMetadata", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetMetadataBulkUpsertDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetMetadataBulkResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Upsert asset metadata", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v1", + "state": "Added" + }, + { + "version": "v2.5.0", + "state": "Beta" + } + ], + "x-immich-permission": "asset.update", + "x-immich-state": "Beta" + } + }, "/assets/random": { "get": { "deprecated": true, @@ -3340,7 +3446,7 @@ "required": true, "in": "path", "schema": { - "$ref": "#/components/schemas/AssetMetadataKey" + "type": "string" } } ], @@ -3399,7 +3505,7 @@ "required": true, "in": "path", "schema": { - "$ref": "#/components/schemas/AssetMetadataKey" + "type": "string" } } ], @@ -15575,20 +15681,98 @@ ], "type": "string" }, - "AssetMetadataKey": { - "enum": [ - "mobile-app" + "AssetMetadataBulkDeleteDto": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AssetMetadataBulkDeleteItemDto" + }, + "type": "array" + } + }, + "required": [ + "items" ], - "type": "string" + "type": "object" + }, + "AssetMetadataBulkDeleteItemDto": { + "properties": { + "assetId": { + "format": "uuid", + "type": "string" + }, + "key": { + "type": "string" + } + }, + "required": [ + "assetId", + "key" + ], + "type": "object" + }, + "AssetMetadataBulkResponseDto": { + "properties": { + "assetId": { + "type": "string" + }, + "key": { + "type": "string" + }, + "updatedAt": { + "format": "date-time", + "type": "string" + }, + "value": { + "type": "object" + } + }, + "required": [ + "assetId", + "key", + "updatedAt", + "value" + ], + "type": "object" + }, + "AssetMetadataBulkUpsertDto": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/AssetMetadataBulkUpsertItemDto" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "AssetMetadataBulkUpsertItemDto": { + "properties": { + "assetId": { + "format": "uuid", + "type": "string" + }, + "key": { + "type": "string" + }, + "value": { + "type": "object" + } + }, + "required": [ + "assetId", + "key", + "value" + ], + "type": "object" }, "AssetMetadataResponseDto": { "properties": { "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" }, "updatedAt": { "format": "date-time", @@ -15622,11 +15806,7 @@ "AssetMetadataUpsertItemDto": { "properties": { "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" }, "value": { "type": "object" @@ -20651,11 +20831,7 @@ "type": "string" }, "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" } }, "required": [ @@ -20670,11 +20846,7 @@ "type": "string" }, "key": { - "allOf": [ - { - "$ref": "#/components/schemas/AssetMetadataKey" - } - ] + "type": "string" }, "value": { "type": "object" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 5e024b560c..560f1077b2 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -471,7 +471,7 @@ export type AssetBulkDeleteDto = { ids: string[]; }; export type AssetMetadataUpsertItemDto = { - key: AssetMetadataKey; + key: string; value: object; }; export type AssetMediaCreateDto = { @@ -543,6 +543,27 @@ export type AssetJobsDto = { assetIds: string[]; name: AssetJobName; }; +export type AssetMetadataBulkDeleteItemDto = { + assetId: string; + key: string; +}; +export type AssetMetadataBulkDeleteDto = { + items: AssetMetadataBulkDeleteItemDto[]; +}; +export type AssetMetadataBulkUpsertItemDto = { + assetId: string; + key: string; + value: object; +}; +export type AssetMetadataBulkUpsertDto = { + items: AssetMetadataBulkUpsertItemDto[]; +}; +export type AssetMetadataBulkResponseDto = { + assetId: string; + key: string; + updatedAt: string; + value: object; +}; export type UpdateAssetDto = { dateTimeOriginal?: string; description?: string; @@ -554,7 +575,7 @@ export type UpdateAssetDto = { visibility?: AssetVisibility; }; export type AssetMetadataResponseDto = { - key: AssetMetadataKey; + key: string; updatedAt: string; value: object; }; @@ -2462,6 +2483,33 @@ export function runAssetJobs({ assetJobsDto }: { body: assetJobsDto }))); } +/** + * Delete asset metadata + */ +export function deleteBulkAssetMetadata({ assetMetadataBulkDeleteDto }: { + assetMetadataBulkDeleteDto: AssetMetadataBulkDeleteDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/assets/metadata", oazapfts.json({ + ...opts, + method: "DELETE", + body: assetMetadataBulkDeleteDto + }))); +} +/** + * Upsert asset metadata + */ +export function updateBulkAssetMetadata({ assetMetadataBulkUpsertDto }: { + assetMetadataBulkUpsertDto: AssetMetadataBulkUpsertDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetMetadataBulkResponseDto[]; + }>("/assets/metadata", oazapfts.json({ + ...opts, + method: "PUT", + body: assetMetadataBulkUpsertDto + }))); +} /** * Get random assets */ @@ -2564,7 +2612,7 @@ export function updateAssetMetadata({ id, assetMetadataUpsertDto }: { */ export function deleteAssetMetadata({ id, key }: { id: string; - key: AssetMetadataKey; + key: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText(`/assets/${encodeURIComponent(id)}/metadata/${encodeURIComponent(key)}`, { ...opts, @@ -2576,7 +2624,7 @@ export function deleteAssetMetadata({ id, key }: { */ export function getAssetMetadataByKey({ id, key }: { id: string; - key: AssetMetadataKey; + key: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -5363,9 +5411,6 @@ export enum Permission { AdminSessionRead = "adminSession.read", AdminAuthUnlinkAll = "adminAuth.unlinkAll" } -export enum AssetMetadataKey { - MobileApp = "mobile-app" -} export enum AssetMediaStatus { Created = "created", Replaced = "replaced", diff --git a/server/src/controllers/asset.controller.spec.ts b/server/src/controllers/asset.controller.spec.ts index 649c80e850..56c9d18049 100644 --- a/server/src/controllers/asset.controller.spec.ts +++ b/server/src/controllers/asset.controller.spec.ts @@ -79,6 +79,74 @@ describe(AssetController.name, () => { }); }); + describe('PUT /assets/metadata', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/assets/metadata`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid assetId', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put('/assets/metadata') + .send({ items: [{ assetId: '123', key: 'test', value: {} }] }); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID']))); + }); + + it('should require a key', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put('/assets/metadata') + .send({ items: [{ assetId: factory.uuid(), value: {} }] }); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest( + expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']), + ), + ); + }); + + it('should work', async () => { + const { status } = await request(ctx.getHttpServer()) + .put('/assets/metadata') + .send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } }] }); + expect(status).toBe(200); + }); + }); + + describe('DELETE /assets/metadata', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).delete(`/assets/metadata`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid assetId', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .delete('/assets/metadata') + .send({ items: [{ assetId: '123', key: 'test' }] }); + expect(status).toBe(400); + expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID']))); + }); + + it('should require a key', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .delete('/assets/metadata') + .send({ items: [{ assetId: factory.uuid() }] }); + expect(status).toBe(400); + expect(body).toEqual( + factory.responses.badRequest( + expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']), + ), + ); + }); + + it('should work', async () => { + const { status } = await request(ctx.getHttpServer()) + .delete('/assets/metadata') + .send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp }] }); + expect(status).toBe(204); + }); + }); + describe('PUT /assets/:id', () => { it('should be an authenticated route', async () => { await request(ctx.getHttpServer()).get(`/assets/123`); @@ -169,12 +237,10 @@ describe(AssetController.name, () => { it('should require each item to have a valid key', async () => { const { status, body } = await request(ctx.getHttpServer()) .put(`/assets/${factory.uuid()}/metadata`) - .send({ items: [{ key: 'someKey' }] }); + .send({ items: [{ value: { some: 'value' } }] }); expect(status).toBe(400); expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining([expect.stringContaining('items.0.key must be one of the following values')]), - ), + factory.responses.badRequest(['items.0.key must be a string', 'items.0.key should not be empty']), ); }); @@ -224,16 +290,6 @@ describe(AssetController.name, () => { expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID']))); }); - - it('should require a valid key', async () => { - const { status, body } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/invalid`); - expect(status).toBe(400); - expect(body).toEqual( - factory.responses.badRequest( - expect.arrayContaining([expect.stringContaining('key must be one of the following value')]), - ), - ); - }); }); describe('DELETE /assets/:id/metadata/:key', () => { @@ -247,13 +303,5 @@ describe(AssetController.name, () => { expect(status).toBe(400); expect(body).toEqual(factory.responses.badRequest(['id must be a UUID'])); }); - - it('should require a valid key', async () => { - const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/invalid`); - expect(status).toBe(400); - expect(body).toEqual( - factory.responses.badRequest([expect.stringContaining('key must be one of the following values')]), - ); - }); }); }); diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index bcc13fbc06..ba9ec865f9 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -7,6 +7,9 @@ import { AssetBulkUpdateDto, AssetCopyDto, AssetJobsDto, + AssetMetadataBulkDeleteDto, + AssetMetadataBulkResponseDto, + AssetMetadataBulkUpsertDto, AssetMetadataResponseDto, AssetMetadataRouteParams, AssetMetadataUpsertDto, @@ -120,6 +123,32 @@ export class AssetController { return this.service.copy(auth, dto); } + @Put('metadata') + @Authenticated({ permission: Permission.AssetUpdate }) + @Endpoint({ + summary: 'Upsert asset metadata', + description: 'Upsert metadata key-value pairs for multiple assets.', + history: new HistoryBuilder().added('v1').beta('v2.5.0'), + }) + updateBulkAssetMetadata( + @Auth() auth: AuthDto, + @Body() dto: AssetMetadataBulkUpsertDto, + ): Promise { + return this.service.upsertBulkMetadata(auth, dto); + } + + @Delete('metadata') + @Authenticated({ permission: Permission.AssetUpdate }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete asset metadata', + description: 'Delete metadata key-value pairs for multiple assets.', + history: new HistoryBuilder().added('v1').beta('v2.5.0'), + }) + deleteBulkAssetMetadata(@Auth() auth: AuthDto, @Body() dto: AssetMetadataBulkDeleteDto): Promise { + return this.service.deleteBulkMetadata(auth, dto); + } + @Put(':id') @Authenticated({ permission: Permission.AssetUpdate }) @Endpoint({ diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 03d1e31fb9..854c244ba9 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -17,9 +17,9 @@ import { ValidateNested, } from 'class-validator'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum'; +import { AssetType, AssetVisibility } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; -import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; +import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation'; export class DeviceIdDto { @IsNotEmpty() @@ -142,8 +142,8 @@ export class AssetMetadataRouteParams { @ValidateUUID() id!: string; - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + @ValidateString() + key!: string; } export class AssetMetadataUpsertDto { @@ -154,26 +154,57 @@ export class AssetMetadataUpsertDto { } export class AssetMetadataUpsertItemDto { - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + @ValidateString() + key!: string; @IsObject() value!: object; } -export class AssetMetadataMobileAppDto { - @IsString() - @Optional() - iCloudId?: string; +export class AssetMetadataBulkUpsertDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetMetadataBulkUpsertItemDto) + items!: AssetMetadataBulkUpsertItemDto[]; +} + +export class AssetMetadataBulkUpsertItemDto { + @ValidateUUID() + assetId!: string; + + @ValidateString() + key!: string; + + @IsObject() + value!: object; +} + +export class AssetMetadataBulkDeleteDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetMetadataBulkDeleteItemDto) + items!: AssetMetadataBulkDeleteItemDto[]; +} + +export class AssetMetadataBulkDeleteItemDto { + @ValidateUUID() + assetId!: string; + + @ValidateString() + key!: string; } export class AssetMetadataResponseDto { - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + @ValidateString() + key!: string; value!: object; updatedAt!: Date; } +export class AssetMetadataBulkResponseDto extends AssetMetadataResponseDto { + assetId!: string; +} + export class AssetCopyDto { @ValidateUUID() sourceId!: string; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index d6a557e2c5..7f811af371 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -4,7 +4,6 @@ import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AlbumUserRole, - AssetMetadataKey, AssetOrder, AssetType, AssetVisibility, @@ -167,16 +166,14 @@ export class SyncAssetExifV1 { @ExtraModel() export class SyncAssetMetadataV1 { assetId!: string; - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + key!: string; value!: object; } @ExtraModel() export class SyncAssetMetadataDeleteV1 { assetId!: string; - @ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' }) - key!: AssetMetadataKey; + key!: string; } @ExtraModel() diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index f25a0798d2..27e40139e1 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -76,6 +76,14 @@ where "assetId" = $1 and "key" = $2 +-- AssetRepository.deleteBulkMetadata +begin +delete from "asset_metadata" +where + "assetId" = $1 + and "key" = $2 +commit + -- AssetRepository.getByDayOfYear with "res" as ( diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 7db3a76f12..07e227ea11 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -5,11 +5,12 @@ import { InjectKysely } from 'nestjs-kysely'; import { LockableProperty, Stack } from 'src/database'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; +import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { anyUuid, @@ -256,7 +257,7 @@ export class AssetRepository { .execute(); } - upsertMetadata(id: string, items: Array<{ key: AssetMetadataKey; value: object }>) { + upsertMetadata(id: string, items: Array<{ key: string; value: object }>) { return this.db .insertInto('asset_metadata') .values(items.map((item) => ({ assetId: id, ...item }))) @@ -269,8 +270,21 @@ export class AssetRepository { .execute(); } + upsertBulkMetadata(items: Insertable[]) { + return this.db + .insertInto('asset_metadata') + .values(items) + .onConflict((oc) => + oc + .columns(['assetId', 'key']) + .doUpdateSet((eb) => ({ key: eb.ref('excluded.key'), value: eb.ref('excluded.value') })), + ) + .returning(['assetId', 'key', 'value', 'updatedAt']) + .execute(); + } + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - getMetadataByKey(assetId: string, key: AssetMetadataKey) { + getMetadataByKey(assetId: string, key: string) { return this.db .selectFrom('asset_metadata') .select(['key', 'value', 'updatedAt']) @@ -280,10 +294,23 @@ export class AssetRepository { } @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async deleteMetadataByKey(id: string, key: AssetMetadataKey) { + async deleteMetadataByKey(id: string, key: string) { await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute(); } + @GenerateSql({ params: [[{ assetId: DummyValue.UUID, key: DummyValue.STRING }]] }) + async deleteBulkMetadata(items: Array<{ assetId: string; key: string }>) { + if (items.length === 0) { + return; + } + + await this.db.transaction().execute(async (tx) => { + for (const { assetId, key } of items) { + await tx.deleteFrom('asset_metadata').where('assetId', '=', assetId).where('key', '=', key).execute(); + } + }); + } + create(asset: Insertable) { return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow(); } diff --git a/server/src/schema/tables/asset-metadata-audit.table.ts b/server/src/schema/tables/asset-metadata-audit.table.ts index 3b94ce6d1a..16272eacf7 100644 --- a/server/src/schema/tables/asset-metadata-audit.table.ts +++ b/server/src/schema/tables/asset-metadata-audit.table.ts @@ -1,5 +1,4 @@ import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; -import { AssetMetadataKey } from 'src/enum'; import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; @Table('asset_metadata_audit') @@ -11,7 +10,7 @@ export class AssetMetadataAuditTable { assetId!: string; @Column({ index: true }) - key!: AssetMetadataKey; + key!: string; @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) deletedAt!: Generated; diff --git a/server/src/schema/tables/asset-metadata.table.ts b/server/src/schema/tables/asset-metadata.table.ts index d529d6ad7b..8a7af1360f 100644 --- a/server/src/schema/tables/asset-metadata.table.ts +++ b/server/src/schema/tables/asset-metadata.table.ts @@ -32,7 +32,7 @@ export class AssetMetadataTable { assetId!: string; @PrimaryColumn({ type: 'character varying' }) - key!: AssetMetadataKey; + key!: AssetMetadataKey | string; @Column({ type: 'jsonb' }) value!: object; diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index c584cf134f..1e776bd256 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -11,6 +11,9 @@ import { AssetCopyDto, AssetJobName, AssetJobsDto, + AssetMetadataBulkDeleteDto, + AssetMetadataBulkResponseDto, + AssetMetadataBulkUpsertDto, AssetMetadataResponseDto, AssetMetadataUpsertDto, AssetStatsDto, @@ -19,16 +22,7 @@ import { } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; -import { - AssetFileType, - AssetMetadataKey, - AssetStatus, - AssetVisibility, - JobName, - JobStatus, - Permission, - QueueName, -} from 'src/enum'; +import { AssetFileType, AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; import { requireElevatedPermission } from 'src/utils/access'; @@ -381,12 +375,17 @@ export class AssetService extends BaseService { return this.ocrRepository.getByAssetId(id); } + async upsertBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkUpsertDto): Promise { + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) }); + return this.assetRepository.upsertBulkMetadata(dto.items); + } + async upsertMetadata(auth: AuthDto, id: string, dto: AssetMetadataUpsertDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); return this.assetRepository.upsertMetadata(id, dto.items); } - async getMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise { + async getMetadataByKey(auth: AuthDto, id: string, key: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); const item = await this.assetRepository.getMetadataByKey(id, key); @@ -396,11 +395,16 @@ export class AssetService extends BaseService { return item; } - async deleteMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise { + async deleteMetadataByKey(auth: AuthDto, id: string, key: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); return this.assetRepository.deleteMetadataByKey(id, key); } + async deleteBulkMetadata(auth: AuthDto, dto: AssetMetadataBulkDeleteDto) { + await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.items.map((item) => item.assetId) }); + await this.assetRepository.deleteBulkMetadata(dto.items); + } + async run(auth: AuthDto, dto: AssetJobsDto) { await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds }); diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 82ea2cd1fc..9cd91a5756 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -56,6 +56,7 @@ import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; +import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { MemoryTable } from 'src/schema/tables/memory.table'; @@ -179,6 +180,12 @@ export class MediumTestContext { return { asset, result }; } + async newMetadata(dto: Insertable) { + const { assetId, ...item } = dto; + const result = await this.get(AssetRepository).upsertMetadata(assetId, [item]); + return { metadata: dto, result }; + } + async newAssetFile(dto: Insertable) { const result = await this.get(AssetRepository).upsertFile(dto); return { result }; diff --git a/server/test/medium/specs/services/asset.service.spec.ts b/server/test/medium/specs/services/asset.service.spec.ts index 661c4f5cdb..d0949c153c 100644 --- a/server/test/medium/specs/services/asset.service.spec.ts +++ b/server/test/medium/specs/services/asset.service.spec.ts @@ -1,5 +1,5 @@ import { Kysely } from 'kysely'; -import { AssetFileType, JobName, SharedLinkType } from 'src/enum'; +import { AssetFileType, AssetMetadataKey, JobName, SharedLinkType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; @@ -430,4 +430,177 @@ describe(AssetService.name, () => { ); }); }); + + describe('upsertBulkMetadata', () => { + it('should work', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + const items = [{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }]; + + await sut.upsertBulkMetadata(auth, { items }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata.length).toEqual(1); + expect(metadata[0]).toEqual( + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }), + ); + }); + + it('should work on conflict', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'old-id' } }); + + // verify existing metadata + await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'old-id' } }), + ]); + + const items = [{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'new-id' } }]; + await sut.upsertBulkMetadata(auth, { items }); + + // verify updated metadata + await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'new-id' } }), + ]); + }); + + it('should work with multiple assets', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id }); + const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id }); + + const items = [ + { assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, + { assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }, + ]; + + await sut.upsertBulkMetadata(auth, { items }); + + const metadata1 = await ctx.get(AssetRepository).getMetadata(asset1.id); + expect(metadata1).toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }), + ]); + + const metadata2 = await ctx.get(AssetRepository).getMetadata(asset2.id); + expect(metadata2).toEqual([ + expect.objectContaining({ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }), + ]); + }); + + it('should work with multiple metadata for the same asset', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + const items = [ + { assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }, + { assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } }, + ]; + + await sut.upsertBulkMetadata(auth, { items }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: AssetMetadataKey.MobileApp, + value: { iCloudId: 'id1' }, + }), + expect.objectContaining({ + key: 'some-other-key', + value: { foo: 'bar' }, + }), + ]), + ); + }); + }); + + describe('deleteBulkMetadata', () => { + it('should work', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'foo' } }); + + await sut.deleteBulkMetadata(auth, { items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }] }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata.length).toEqual(0); + }); + + it('should work even if the item does not exist', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + await sut.deleteBulkMetadata(auth, { items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }] }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata.length).toEqual(0); + }); + + it('should work with multiple assets', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset1.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }); + const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset2.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id2' } }); + + await sut.deleteBulkMetadata(auth, { + items: [ + { assetId: asset1.id, key: AssetMetadataKey.MobileApp }, + { assetId: asset2.id, key: AssetMetadataKey.MobileApp }, + ], + }); + + await expect(ctx.get(AssetRepository).getMetadata(asset1.id)).resolves.toEqual([]); + await expect(ctx.get(AssetRepository).getMetadata(asset2.id)).resolves.toEqual([]); + }); + + it('should work with multiple metadata for the same asset', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }); + await ctx.newMetadata({ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } }); + + await sut.deleteBulkMetadata(auth, { + items: [ + { assetId: asset.id, key: AssetMetadataKey.MobileApp }, + { assetId: asset.id, key: 'some-other-key' }, + ], + }); + + await expect(ctx.get(AssetRepository).getMetadata(asset.id)).resolves.toEqual([]); + }); + + it('should not delete unspecified keys', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + const auth = factory.auth({ user }); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newMetadata({ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } }); + await ctx.newMetadata({ assetId: asset.id, key: 'some-other-key', value: { foo: 'bar' } }); + + await sut.deleteBulkMetadata(auth, { + items: [{ assetId: asset.id, key: AssetMetadataKey.MobileApp }], + }); + + const metadata = await ctx.get(AssetRepository).getMetadata(asset.id); + expect(metadata).toEqual([expect.objectContaining({ key: 'some-other-key', value: { foo: 'bar' } })]); + }); + }); }); diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 5ba77ddc2f..4847c84a35 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -44,8 +44,10 @@ export const newAssetRepositoryMock = (): Mocked