Pārlūkot izejas kodu

feat(server) Tagging system (#1046)

Alex 2 gadi atpakaļ
vecāks
revīzija
5de8ea162d
74 mainītis faili ar 4804 papildinājumiem un 177 dzēšanām
  1. 13 0
      .gitattributes
  2. 3 0
      Makefile
  3. 0 9
      NOTES.md
  4. 20 0
      mobile/openapi/.openapi-generator/FILES
  5. 15 1
      mobile/openapi/README.md
  6. 5 5
      mobile/openapi/doc/AssetApi.md
  7. 34 0
      mobile/openapi/doc/AssetEntity.md
  8. 1 0
      mobile/openapi/doc/AssetResponseDto.md
  9. 16 0
      mobile/openapi/doc/CreateTagDto.md
  10. 39 0
      mobile/openapi/doc/ExifEntity.md
  11. 19 0
      mobile/openapi/doc/SmartInfoEntity.md
  12. 221 0
      mobile/openapi/doc/TagApi.md
  13. 21 0
      mobile/openapi/doc/TagEntity.md
  14. 17 0
      mobile/openapi/doc/TagResponseDto.md
  15. 14 0
      mobile/openapi/doc/TagTypeEnum.md
  16. 2 1
      mobile/openapi/doc/UpdateAssetDto.md
  17. 16 0
      mobile/openapi/doc/UpdateTagDto.md
  18. 27 0
      mobile/openapi/doc/UserEntity.md
  19. 10 0
      mobile/openapi/lib/api.dart
  20. 4 4
      mobile/openapi/lib/api/asset_api.dart
  21. 257 0
      mobile/openapi/lib/api/tag_api.dart
  22. 18 0
      mobile/openapi/lib/api_client.dart
  23. 3 0
      mobile/openapi/lib/api_helper.dart
  24. 41 53
      mobile/openapi/lib/model/album_response_dto.dart
  25. 384 0
      mobile/openapi/lib/model/asset_entity.dart
  26. 11 3
      mobile/openapi/lib/model/asset_response_dto.dart
  27. 119 0
      mobile/openapi/lib/model/create_tag_dto.dart
  28. 414 0
      mobile/openapi/lib/model/exif_entity.dart
  29. 164 0
      mobile/openapi/lib/model/smart_info_entity.dart
  30. 236 0
      mobile/openapi/lib/model/tag_entity.dart
  31. 127 0
      mobile/openapi/lib/model/tag_response_dto.dart
  32. 88 0
      mobile/openapi/lib/model/tag_type_enum.dart
  33. 24 6
      mobile/openapi/lib/model/update_asset_dto.dart
  34. 137 0
      mobile/openapi/lib/model/update_tag_dto.dart
  35. 234 0
      mobile/openapi/lib/model/user_entity.dart
  36. 122 0
      mobile/openapi/test/asset_entity_test.dart
  37. 32 0
      mobile/openapi/test/create_tag_dto_test.dart
  38. 150 0
      mobile/openapi/test/exif_entity_test.dart
  39. 47 0
      mobile/openapi/test/smart_info_entity_test.dart
  40. 46 0
      mobile/openapi/test/tag_api_test.dart
  41. 52 0
      mobile/openapi/test/tag_entity_test.dart
  42. 37 0
      mobile/openapi/test/tag_response_dto_test.dart
  43. 21 0
      mobile/openapi/test/tag_type_enum_test.dart
  44. 32 0
      mobile/openapi/test/update_tag_dto_test.dart
  45. 82 0
      mobile/openapi/test/user_entity_test.dart
  46. 13 16
      server/apps/immich/src/api-v1/album/album.module.ts
  47. 17 9
      server/apps/immich/src/api-v1/asset/asset-repository.ts
  48. 4 4
      server/apps/immich/src/api-v1/asset/asset.controller.ts
  49. 16 20
      server/apps/immich/src/api-v1/asset/asset.module.ts
  50. 2 2
      server/apps/immich/src/api-v1/asset/asset.service.ts
  51. 20 2
      server/apps/immich/src/api-v1/asset/dto/update-asset.dto.ts
  52. 3 0
      server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts
  53. 8 12
      server/apps/immich/src/api-v1/job/job.module.ts
  54. 14 0
      server/apps/immich/src/api-v1/tag/dto/create-tag.dto.ts
  55. 11 0
      server/apps/immich/src/api-v1/tag/dto/update-tag.dto.ts
  56. 20 0
      server/apps/immich/src/api-v1/tag/response-dto/tag-response.dto.ts
  57. 44 0
      server/apps/immich/src/api-v1/tag/tag.controller.ts
  58. 18 0
      server/apps/immich/src/api-v1/tag/tag.module.ts
  59. 61 0
      server/apps/immich/src/api-v1/tag/tag.repository.ts
  60. 91 0
      server/apps/immich/src/api-v1/tag/tag.service.spec.ts
  61. 48 0
      server/apps/immich/src/api-v1/tag/tag.service.ts
  62. 3 0
      server/apps/immich/src/api-v1/user/user.service.spec.ts
  63. 3 0
      server/apps/immich/src/app.module.ts
  64. 1 1
      server/apps/immich/src/main.ts
  65. 1 0
      server/apps/immich/src/modules/immich-jwt/immich-jwt.service.spec.ts
  66. 0 0
      server/immich-openapi-specs.json
  67. 7 1
      server/libs/database/src/entities/asset.entity.ts
  68. 45 0
      server/libs/database/src/entities/tag.entity.ts
  69. 5 1
      server/libs/database/src/entities/user.entity.ts
  70. 26 0
      server/libs/database/src/migrations/1670257571385-CreateTagsTable.ts
  71. 943 22
      web/src/api/open-api/api.ts
  72. 1 1
      web/src/lib/components/asset-viewer/asset-viewer.svelte
  73. 3 1
      web/src/lib/components/shared-components/status-box.svelte
  74. 1 3
      web/src/lib/components/shared-components/upload-panel.svelte

+ 13 - 0
.gitattributes

@@ -0,0 +1,13 @@
+mobile/openapi/**/*.md -diff -merge
+mobile/openapi/**/*.md linguist-generated=true
+mobile/openapi/**/*.dart -diff -merge
+mobile/openapi/**/*.dart linguist-generated=true
+
+web/src/api/open-api/**/*.md -diff -merge
+web/src/api/open-api/**/*.md linguist-generated=true
+
+web/src/api/open-api/**/*.ts -diff -merge
+web/src/api/open-api/**/*.ts linguist-generated=true
+
+mobile/openapi/.openapi-generator/FILES -diff -merge
+mobile/openapi/.openapi-generator/FILES linguist-generated=true

+ 3 - 0
Makefile

@@ -27,3 +27,6 @@ prod-scale:
 
 api:
 	cd ./server && npm run api:generate
+
+attach-server:
+	docker exec -it docker_immich-server_1 sh

+ 0 - 9
NOTES.md

@@ -1,9 +0,0 @@
-# TODO
-
-Server scenario with web
-
-[ ] 1 user exist without admin right -> make admin on first check
-
-[ ] 2 users exist without admin right -> ask user to choose which account will be the admin
-
-[ X ] No users exist -> prompt signup form for Admin

+ 20 - 0
mobile/openapi/.openapi-generator/FILES

@@ -14,6 +14,7 @@ doc/AssetApi.md
 doc/AssetCountByTimeBucket.md
 doc/AssetCountByTimeBucketResponseDto.md
 doc/AssetCountByUserIdResponseDto.md
+doc/AssetEntity.md
 doc/AssetFileUploadResponseDto.md
 doc/AssetResponseDto.md
 doc/AssetTypeEnum.md
@@ -25,6 +26,7 @@ doc/CheckExistingAssetsResponseDto.md
 doc/CreateAlbumDto.md
 doc/CreateDeviceInfoDto.md
 doc/CreateProfileImageResponseDto.md
+doc/CreateTagDto.md
 doc/CreateUserDto.md
 doc/CuratedLocationsResponseDto.md
 doc/CuratedObjectsResponseDto.md
@@ -34,6 +36,7 @@ doc/DeleteAssetStatus.md
 doc/DeviceInfoApi.md
 doc/DeviceInfoResponseDto.md
 doc/DeviceTypeEnum.md
+doc/ExifEntity.md
 doc/ExifResponseDto.md
 doc/GetAssetByTimeBucketDto.md
 doc/GetAssetCountByTimeBucketDto.md
@@ -58,20 +61,27 @@ doc/ServerPingResponse.md
 doc/ServerStatsResponseDto.md
 doc/ServerVersionReponseDto.md
 doc/SignUpDto.md
+doc/SmartInfoEntity.md
 doc/SmartInfoResponseDto.md
 doc/SystemConfigApi.md
 doc/SystemConfigKey.md
 doc/SystemConfigResponseDto.md
 doc/SystemConfigResponseItem.md
+doc/TagApi.md
+doc/TagEntity.md
+doc/TagResponseDto.md
+doc/TagTypeEnum.md
 doc/ThumbnailFormat.md
 doc/TimeGroupEnum.md
 doc/UpdateAlbumDto.md
 doc/UpdateAssetDto.md
 doc/UpdateDeviceInfoDto.md
+doc/UpdateTagDto.md
 doc/UpdateUserDto.md
 doc/UsageByUserDto.md
 doc/UserApi.md
 doc/UserCountResponseDto.md
+doc/UserEntity.md
 doc/UserResponseDto.md
 doc/ValidateAccessTokenResponseDto.md
 git_push.sh
@@ -84,6 +94,7 @@ lib/api/job_api.dart
 lib/api/o_auth_api.dart
 lib/api/server_info_api.dart
 lib/api/system_config_api.dart
+lib/api/tag_api.dart
 lib/api/user_api.dart
 lib/api_client.dart
 lib/api_exception.dart
@@ -103,6 +114,7 @@ lib/model/all_job_status_response_dto.dart
 lib/model/asset_count_by_time_bucket.dart
 lib/model/asset_count_by_time_bucket_response_dto.dart
 lib/model/asset_count_by_user_id_response_dto.dart
+lib/model/asset_entity.dart
 lib/model/asset_file_upload_response_dto.dart
 lib/model/asset_response_dto.dart
 lib/model/asset_type_enum.dart
@@ -113,6 +125,7 @@ lib/model/check_existing_assets_response_dto.dart
 lib/model/create_album_dto.dart
 lib/model/create_device_info_dto.dart
 lib/model/create_profile_image_response_dto.dart
+lib/model/create_tag_dto.dart
 lib/model/create_user_dto.dart
 lib/model/curated_locations_response_dto.dart
 lib/model/curated_objects_response_dto.dart
@@ -121,6 +134,7 @@ lib/model/delete_asset_response_dto.dart
 lib/model/delete_asset_status.dart
 lib/model/device_info_response_dto.dart
 lib/model/device_type_enum.dart
+lib/model/exif_entity.dart
 lib/model/exif_response_dto.dart
 lib/model/get_asset_by_time_bucket_dto.dart
 lib/model/get_asset_count_by_time_bucket_dto.dart
@@ -142,18 +156,24 @@ lib/model/server_ping_response.dart
 lib/model/server_stats_response_dto.dart
 lib/model/server_version_reponse_dto.dart
 lib/model/sign_up_dto.dart
+lib/model/smart_info_entity.dart
 lib/model/smart_info_response_dto.dart
 lib/model/system_config_key.dart
 lib/model/system_config_response_dto.dart
 lib/model/system_config_response_item.dart
+lib/model/tag_entity.dart
+lib/model/tag_response_dto.dart
+lib/model/tag_type_enum.dart
 lib/model/thumbnail_format.dart
 lib/model/time_group_enum.dart
 lib/model/update_album_dto.dart
 lib/model/update_asset_dto.dart
 lib/model/update_device_info_dto.dart
+lib/model/update_tag_dto.dart
 lib/model/update_user_dto.dart
 lib/model/usage_by_user_dto.dart
 lib/model/user_count_response_dto.dart
+lib/model/user_entity.dart
 lib/model/user_response_dto.dart
 lib/model/validate_access_token_response_dto.dart
 pubspec.yaml

+ 15 - 1
mobile/openapi/README.md

@@ -93,7 +93,7 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 *AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search | 
 *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{assetId} | 
-*AssetApi* | [**updateAssetById**](doc//AssetApi.md#updateassetbyid) | **PUT** /asset/assetById/{assetId} | 
+*AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{assetId} | 
 *AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload | 
 *AuthenticationApi* | [**adminSignUp**](doc//AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up | 
 *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | 
@@ -112,6 +112,11 @@ Class | Method | HTTP request | Description
 *ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping | 
 *SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | 
 *SystemConfigApi* | [**updateConfig**](doc//SystemConfigApi.md#updateconfig) | **PUT** /system-config | 
+*TagApi* | [**create**](doc//TagApi.md#create) | **POST** /tag | 
+*TagApi* | [**delete**](doc//TagApi.md#delete) | **DELETE** /tag/{id} | 
+*TagApi* | [**findAll**](doc//TagApi.md#findall) | **GET** /tag | 
+*TagApi* | [**findOne**](doc//TagApi.md#findone) | **GET** /tag/{id} | 
+*TagApi* | [**update**](doc//TagApi.md#update) | **PATCH** /tag/{id} | 
 *UserApi* | [**createProfileImage**](doc//UserApi.md#createprofileimage) | **POST** /user/profile-image | 
 *UserApi* | [**createUser**](doc//UserApi.md#createuser) | **POST** /user | 
 *UserApi* | [**deleteUser**](doc//UserApi.md#deleteuser) | **DELETE** /user/{userId} | 
@@ -136,6 +141,7 @@ Class | Method | HTTP request | Description
  - [AssetCountByTimeBucket](doc//AssetCountByTimeBucket.md)
  - [AssetCountByTimeBucketResponseDto](doc//AssetCountByTimeBucketResponseDto.md)
  - [AssetCountByUserIdResponseDto](doc//AssetCountByUserIdResponseDto.md)
+ - [AssetEntity](doc//AssetEntity.md)
  - [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
  - [AssetResponseDto](doc//AssetResponseDto.md)
  - [AssetTypeEnum](doc//AssetTypeEnum.md)
@@ -146,6 +152,7 @@ Class | Method | HTTP request | Description
  - [CreateAlbumDto](doc//CreateAlbumDto.md)
  - [CreateDeviceInfoDto](doc//CreateDeviceInfoDto.md)
  - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
+ - [CreateTagDto](doc//CreateTagDto.md)
  - [CreateUserDto](doc//CreateUserDto.md)
  - [CuratedLocationsResponseDto](doc//CuratedLocationsResponseDto.md)
  - [CuratedObjectsResponseDto](doc//CuratedObjectsResponseDto.md)
@@ -154,6 +161,7 @@ Class | Method | HTTP request | Description
  - [DeleteAssetStatus](doc//DeleteAssetStatus.md)
  - [DeviceInfoResponseDto](doc//DeviceInfoResponseDto.md)
  - [DeviceTypeEnum](doc//DeviceTypeEnum.md)
+ - [ExifEntity](doc//ExifEntity.md)
  - [ExifResponseDto](doc//ExifResponseDto.md)
  - [GetAssetByTimeBucketDto](doc//GetAssetByTimeBucketDto.md)
  - [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md)
@@ -175,18 +183,24 @@ Class | Method | HTTP request | Description
  - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md)
  - [ServerVersionReponseDto](doc//ServerVersionReponseDto.md)
  - [SignUpDto](doc//SignUpDto.md)
+ - [SmartInfoEntity](doc//SmartInfoEntity.md)
  - [SmartInfoResponseDto](doc//SmartInfoResponseDto.md)
  - [SystemConfigKey](doc//SystemConfigKey.md)
  - [SystemConfigResponseDto](doc//SystemConfigResponseDto.md)
  - [SystemConfigResponseItem](doc//SystemConfigResponseItem.md)
+ - [TagEntity](doc//TagEntity.md)
+ - [TagResponseDto](doc//TagResponseDto.md)
+ - [TagTypeEnum](doc//TagTypeEnum.md)
  - [ThumbnailFormat](doc//ThumbnailFormat.md)
  - [TimeGroupEnum](doc//TimeGroupEnum.md)
  - [UpdateAlbumDto](doc//UpdateAlbumDto.md)
  - [UpdateAssetDto](doc//UpdateAssetDto.md)
  - [UpdateDeviceInfoDto](doc//UpdateDeviceInfoDto.md)
+ - [UpdateTagDto](doc//UpdateTagDto.md)
  - [UpdateUserDto](doc//UpdateUserDto.md)
  - [UsageByUserDto](doc//UsageByUserDto.md)
  - [UserCountResponseDto](doc//UserCountResponseDto.md)
+ - [UserEntity](doc//UserEntity.md)
  - [UserResponseDto](doc//UserResponseDto.md)
  - [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md)
 

+ 5 - 5
mobile/openapi/doc/AssetApi.md

@@ -26,7 +26,7 @@ Method | HTTP request | Description
 [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 [**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search | 
 [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{assetId} | 
-[**updateAssetById**](AssetApi.md#updateassetbyid) | **PUT** /asset/assetById/{assetId} | 
+[**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{assetId} | 
 [**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload | 
 
 
@@ -833,8 +833,8 @@ Name | Type | Description  | Notes
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
-# **updateAssetById**
-> AssetResponseDto updateAssetById(assetId, updateAssetDto)
+# **updateAsset**
+> AssetResponseDto updateAsset(assetId, updateAssetDto)
 
 
 
@@ -855,10 +855,10 @@ final assetId = assetId_example; // String |
 final updateAssetDto = UpdateAssetDto(); // UpdateAssetDto | 
 
 try {
-    final result = api_instance.updateAssetById(assetId, updateAssetDto);
+    final result = api_instance.updateAsset(assetId, updateAssetDto);
     print(result);
 } catch (e) {
-    print('Exception when calling AssetApi->updateAssetById: $e\n');
+    print('Exception when calling AssetApi->updateAsset: $e\n');
 }
 ```
 

+ 34 - 0
mobile/openapi/doc/AssetEntity.md

@@ -0,0 +1,34 @@
+# openapi.model.AssetEntity
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**id** | **String** |  | 
+**deviceAssetId** | **String** |  | 
+**userId** | **String** |  | 
+**deviceId** | **String** |  | 
+**type** | **String** |  | 
+**originalPath** | **String** |  | 
+**resizePath** | **String** |  | 
+**webpPath** | **String** |  | 
+**encodedVideoPath** | **String** |  | 
+**createdAt** | **String** |  | 
+**modifiedAt** | **String** |  | 
+**isFavorite** | **bool** |  | 
+**mimeType** | **String** |  | 
+**checksum** | [**Object**](.md) |  | [optional] 
+**duration** | **String** |  | 
+**isVisible** | **bool** |  | 
+**livePhotoVideoId** | **String** |  | 
+**exifInfo** | [**ExifEntity**](ExifEntity.md) |  | [optional] 
+**smartInfo** | [**SmartInfoEntity**](SmartInfoEntity.md) |  | [optional] 
+**tags** | [**List<TagEntity>**](TagEntity.md) |  | [default to const []]
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 1 - 0
mobile/openapi/doc/AssetResponseDto.md

@@ -25,6 +25,7 @@ Name | Type | Description | Notes
 **exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) |  | [optional] 
 **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) |  | [optional] 
 **livePhotoVideoId** | **String** |  | [optional] 
+**tags** | [**List<TagResponseDto>**](TagResponseDto.md) |  | [default to const []]
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 

+ 16 - 0
mobile/openapi/doc/CreateTagDto.md

@@ -0,0 +1,16 @@
+# openapi.model.CreateTagDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**type** | [**TagTypeEnum**](TagTypeEnum.md) |  | 
+**name** | **String** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 39 - 0
mobile/openapi/doc/ExifEntity.md

@@ -0,0 +1,39 @@
+# openapi.model.ExifEntity
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**id** | **String** |  | 
+**assetId** | **String** |  | 
+**description** | **String** | General info | 
+**exifImageWidth** | **num** |  | 
+**exifImageHeight** | **num** |  | 
+**fileSizeInByte** | **num** |  | 
+**orientation** | **String** |  | 
+**dateTimeOriginal** | [**DateTime**](DateTime.md) |  | 
+**modifyDate** | [**DateTime**](DateTime.md) |  | 
+**latitude** | **num** |  | 
+**longitude** | **num** |  | 
+**city** | **String** |  | 
+**state** | **String** |  | 
+**country** | **String** |  | 
+**make** | **String** | Image info | 
+**model** | **String** |  | 
+**imageName** | **String** |  | 
+**lensModel** | **String** |  | 
+**fNumber** | **num** |  | 
+**focalLength** | **num** |  | 
+**iso** | **num** |  | 
+**exposureTime** | **num** |  | 
+**fps** | **num** | Video info | [optional] 
+**asset** | [**AssetEntity**](AssetEntity.md) |  | [optional] 
+**exifTextSearchableColumn** | **String** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 19 - 0
mobile/openapi/doc/SmartInfoEntity.md

@@ -0,0 +1,19 @@
+# openapi.model.SmartInfoEntity
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**id** | **String** |  | 
+**assetId** | **String** |  | 
+**tags** | **List<String>** |  | [default to const []]
+**objects** | **List<String>** |  | [default to const []]
+**asset** | [**AssetEntity**](AssetEntity.md) |  | [optional] 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 221 - 0
mobile/openapi/doc/TagApi.md

@@ -0,0 +1,221 @@
+# openapi.api.TagApi
+
+## Load the API package
+```dart
+import 'package:openapi/api.dart';
+```
+
+All URIs are relative to */api*
+
+Method | HTTP request | Description
+------------- | ------------- | -------------
+[**create**](TagApi.md#create) | **POST** /tag | 
+[**delete**](TagApi.md#delete) | **DELETE** /tag/{id} | 
+[**findAll**](TagApi.md#findall) | **GET** /tag | 
+[**findOne**](TagApi.md#findone) | **GET** /tag/{id} | 
+[**update**](TagApi.md#update) | **PATCH** /tag/{id} | 
+
+
+# **create**
+> TagEntity create(createTagDto)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+
+final api_instance = TagApi();
+final createTagDto = CreateTagDto(); // CreateTagDto | 
+
+try {
+    final result = api_instance.create(createTagDto);
+    print(result);
+} catch (e) {
+    print('Exception when calling TagApi->create: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **createTagDto** | [**CreateTagDto**](CreateTagDto.md)|  | 
+
+### Return type
+
+[**TagEntity**](TagEntity.md)
+
+### Authorization
+
+No authorization required
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
+# **delete**
+> TagEntity delete(id)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+
+final api_instance = TagApi();
+final id = id_example; // String | 
+
+try {
+    final result = api_instance.delete(id);
+    print(result);
+} catch (e) {
+    print('Exception when calling TagApi->delete: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **id** | **String**|  | 
+
+### Return type
+
+[**TagEntity**](TagEntity.md)
+
+### Authorization
+
+No authorization required
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
+# **findAll**
+> List<TagEntity> findAll()
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+
+final api_instance = TagApi();
+
+try {
+    final result = api_instance.findAll();
+    print(result);
+} catch (e) {
+    print('Exception when calling TagApi->findAll: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+[**List<TagEntity>**](TagEntity.md)
+
+### Authorization
+
+No authorization required
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
+# **findOne**
+> TagEntity findOne(id)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+
+final api_instance = TagApi();
+final id = id_example; // String | 
+
+try {
+    final result = api_instance.findOne(id);
+    print(result);
+} catch (e) {
+    print('Exception when calling TagApi->findOne: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **id** | **String**|  | 
+
+### Return type
+
+[**TagEntity**](TagEntity.md)
+
+### Authorization
+
+No authorization required
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
+# **update**
+> Object update(id, updateTagDto)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+
+final api_instance = TagApi();
+final id = id_example; // String | 
+final updateTagDto = UpdateTagDto(); // UpdateTagDto | 
+
+try {
+    final result = api_instance.update(id, updateTagDto);
+    print(result);
+} catch (e) {
+    print('Exception when calling TagApi->update: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **id** | **String**|  | 
+ **updateTagDto** | [**UpdateTagDto**](UpdateTagDto.md)|  | 
+
+### Return type
+
+[**Object**](Object.md)
+
+### Authorization
+
+No authorization required
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+

+ 21 - 0
mobile/openapi/doc/TagEntity.md

@@ -0,0 +1,21 @@
+# openapi.model.TagEntity
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**id** | **String** |  | 
+**type** | **String** |  | 
+**name** | **String** |  | 
+**userId** | **String** |  | 
+**renameTagId** | **String** |  | 
+**assets** | [**List<AssetEntity>**](AssetEntity.md) |  | [default to const []]
+**user** | [**UserEntity**](UserEntity.md) |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 17 - 0
mobile/openapi/doc/TagResponseDto.md

@@ -0,0 +1,17 @@
+# openapi.model.TagResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**id** | **String** |  | 
+**type** | [**TagTypeEnum**](TagTypeEnum.md) |  | 
+**name** | **String** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 14 - 0
mobile/openapi/doc/TagTypeEnum.md

@@ -0,0 +1,14 @@
+# openapi.model.TagTypeEnum
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 2 - 1
mobile/openapi/doc/UpdateAssetDto.md

@@ -8,7 +8,8 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
-**isFavorite** | **bool** |  | 
+**tagIds** | **List<String>** |  | [optional] [default to const []]
+**isFavorite** | **bool** |  | [optional] 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 

+ 16 - 0
mobile/openapi/doc/UpdateTagDto.md

@@ -0,0 +1,16 @@
+# openapi.model.UpdateTagDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**name** | **String** |  | [optional] 
+**renameTagId** | **String** |  | [optional] 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 27 - 0
mobile/openapi/doc/UserEntity.md

@@ -0,0 +1,27 @@
+# openapi.model.UserEntity
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**id** | **String** |  | 
+**firstName** | **String** |  | 
+**lastName** | **String** |  | 
+**isAdmin** | **bool** |  | 
+**email** | **String** |  | 
+**password** | **String** |  | [optional] 
+**salt** | **String** |  | [optional] 
+**oauthId** | **String** |  | 
+**profileImagePath** | **String** |  | 
+**shouldChangePassword** | **bool** |  | 
+**createdAt** | **String** |  | 
+**deletedAt** | [**DateTime**](DateTime.md) |  | [optional] 
+**tags** | [**List<TagEntity>**](TagEntity.md) |  | [default to const []]
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 10 - 0
mobile/openapi/lib/api.dart

@@ -35,6 +35,7 @@ part 'api/job_api.dart';
 part 'api/o_auth_api.dart';
 part 'api/server_info_api.dart';
 part 'api/system_config_api.dart';
+part 'api/tag_api.dart';
 part 'api/user_api.dart';
 
 part 'model/add_assets_dto.dart';
@@ -47,6 +48,7 @@ part 'model/all_job_status_response_dto.dart';
 part 'model/asset_count_by_time_bucket.dart';
 part 'model/asset_count_by_time_bucket_response_dto.dart';
 part 'model/asset_count_by_user_id_response_dto.dart';
+part 'model/asset_entity.dart';
 part 'model/asset_file_upload_response_dto.dart';
 part 'model/asset_response_dto.dart';
 part 'model/asset_type_enum.dart';
@@ -57,6 +59,7 @@ part 'model/check_existing_assets_response_dto.dart';
 part 'model/create_album_dto.dart';
 part 'model/create_device_info_dto.dart';
 part 'model/create_profile_image_response_dto.dart';
+part 'model/create_tag_dto.dart';
 part 'model/create_user_dto.dart';
 part 'model/curated_locations_response_dto.dart';
 part 'model/curated_objects_response_dto.dart';
@@ -65,6 +68,7 @@ part 'model/delete_asset_response_dto.dart';
 part 'model/delete_asset_status.dart';
 part 'model/device_info_response_dto.dart';
 part 'model/device_type_enum.dart';
+part 'model/exif_entity.dart';
 part 'model/exif_response_dto.dart';
 part 'model/get_asset_by_time_bucket_dto.dart';
 part 'model/get_asset_count_by_time_bucket_dto.dart';
@@ -86,18 +90,24 @@ part 'model/server_ping_response.dart';
 part 'model/server_stats_response_dto.dart';
 part 'model/server_version_reponse_dto.dart';
 part 'model/sign_up_dto.dart';
+part 'model/smart_info_entity.dart';
 part 'model/smart_info_response_dto.dart';
 part 'model/system_config_key.dart';
 part 'model/system_config_response_dto.dart';
 part 'model/system_config_response_item.dart';
+part 'model/tag_entity.dart';
+part 'model/tag_response_dto.dart';
+part 'model/tag_type_enum.dart';
 part 'model/thumbnail_format.dart';
 part 'model/time_group_enum.dart';
 part 'model/update_album_dto.dart';
 part 'model/update_asset_dto.dart';
 part 'model/update_device_info_dto.dart';
+part 'model/update_tag_dto.dart';
 part 'model/update_user_dto.dart';
 part 'model/usage_by_user_dto.dart';
 part 'model/user_count_response_dto.dart';
+part 'model/user_entity.dart';
 part 'model/user_response_dto.dart';
 part 'model/validate_access_token_response_dto.dart';
 

+ 4 - 4
mobile/openapi/lib/api/asset_api.dart

@@ -924,9 +924,9 @@ class AssetApi {
   /// * [String] assetId (required):
   ///
   /// * [UpdateAssetDto] updateAssetDto (required):
-  Future<Response> updateAssetByIdWithHttpInfo(String assetId, UpdateAssetDto updateAssetDto,) async {
+  Future<Response> updateAssetWithHttpInfo(String assetId, UpdateAssetDto updateAssetDto,) async {
     // ignore: prefer_const_declarations
-    final path = r'/asset/assetById/{assetId}'
+    final path = r'/asset/{assetId}'
       .replaceAll('{assetId}', assetId);
 
     // ignore: prefer_final_locals
@@ -959,8 +959,8 @@ class AssetApi {
   /// * [String] assetId (required):
   ///
   /// * [UpdateAssetDto] updateAssetDto (required):
-  Future<AssetResponseDto?> updateAssetById(String assetId, UpdateAssetDto updateAssetDto,) async {
-    final response = await updateAssetByIdWithHttpInfo(assetId, updateAssetDto,);
+  Future<AssetResponseDto?> updateAsset(String assetId, UpdateAssetDto updateAssetDto,) async {
+    final response = await updateAssetWithHttpInfo(assetId, updateAssetDto,);
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }

+ 257 - 0
mobile/openapi/lib/api/tag_api.dart

@@ -0,0 +1,257 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+
+class TagApi {
+  TagApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
+
+  final ApiClient apiClient;
+
+  /// Performs an HTTP 'POST /tag' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [CreateTagDto] createTagDto (required):
+  Future<Response> createWithHttpInfo(CreateTagDto createTagDto,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/tag';
+
+    // ignore: prefer_final_locals
+    Object? postBody = createTagDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'POST',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [CreateTagDto] createTagDto (required):
+  Future<TagEntity?> create(CreateTagDto createTagDto,) async {
+    final response = await createWithHttpInfo(createTagDto,);
+    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), 'TagEntity',) as TagEntity;
+    
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'DELETE /tag/{id}' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<Response> deleteWithHttpInfo(String id,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/tag/{id}'
+      .replaceAll('{id}', id);
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'DELETE',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<TagEntity?> delete(String id,) async {
+    final response = await deleteWithHttpInfo(id,);
+    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), 'TagEntity',) as TagEntity;
+    
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'GET /tag' operation and returns the [Response].
+  Future<Response> findAllWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/tag';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<List<TagEntity>?> findAll() async {
+    final response = await findAllWithHttpInfo();
+    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<TagEntity>') as List)
+        .cast<TagEntity>()
+        .toList();
+
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'GET /tag/{id}' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<Response> findOneWithHttpInfo(String id,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/tag/{id}'
+      .replaceAll('{id}', id);
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<TagEntity?> findOne(String id,) async {
+    final response = await findOneWithHttpInfo(id,);
+    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), 'TagEntity',) as TagEntity;
+    
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'PATCH /tag/{id}' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  ///
+  /// * [UpdateTagDto] updateTagDto (required):
+  Future<Response> updateWithHttpInfo(String id, UpdateTagDto updateTagDto,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/tag/{id}'
+      .replaceAll('{id}', id);
+
+    // ignore: prefer_final_locals
+    Object? postBody = updateTagDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'PATCH',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  ///
+  /// * [UpdateTagDto] updateTagDto (required):
+  Future<Object?> update(String id, UpdateTagDto updateTagDto,) async {
+    final response = await updateWithHttpInfo(id, updateTagDto,);
+    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), 'Object',) as Object;
+    
+    }
+    return null;
+  }
+}

+ 18 - 0
mobile/openapi/lib/api_client.dart

@@ -212,6 +212,8 @@ class ApiClient {
           return AssetCountByTimeBucketResponseDto.fromJson(value);
         case 'AssetCountByUserIdResponseDto':
           return AssetCountByUserIdResponseDto.fromJson(value);
+        case 'AssetEntity':
+          return AssetEntity.fromJson(value);
         case 'AssetFileUploadResponseDto':
           return AssetFileUploadResponseDto.fromJson(value);
         case 'AssetResponseDto':
@@ -232,6 +234,8 @@ class ApiClient {
           return CreateDeviceInfoDto.fromJson(value);
         case 'CreateProfileImageResponseDto':
           return CreateProfileImageResponseDto.fromJson(value);
+        case 'CreateTagDto':
+          return CreateTagDto.fromJson(value);
         case 'CreateUserDto':
           return CreateUserDto.fromJson(value);
         case 'CuratedLocationsResponseDto':
@@ -248,6 +252,8 @@ class ApiClient {
           return DeviceInfoResponseDto.fromJson(value);
         case 'DeviceTypeEnum':
           return DeviceTypeEnumTypeTransformer().decode(value);
+        case 'ExifEntity':
+          return ExifEntity.fromJson(value);
         case 'ExifResponseDto':
           return ExifResponseDto.fromJson(value);
         case 'GetAssetByTimeBucketDto':
@@ -290,6 +296,8 @@ class ApiClient {
           return ServerVersionReponseDto.fromJson(value);
         case 'SignUpDto':
           return SignUpDto.fromJson(value);
+        case 'SmartInfoEntity':
+          return SmartInfoEntity.fromJson(value);
         case 'SmartInfoResponseDto':
           return SmartInfoResponseDto.fromJson(value);
         case 'SystemConfigKey':
@@ -298,6 +306,12 @@ class ApiClient {
           return SystemConfigResponseDto.fromJson(value);
         case 'SystemConfigResponseItem':
           return SystemConfigResponseItem.fromJson(value);
+        case 'TagEntity':
+          return TagEntity.fromJson(value);
+        case 'TagResponseDto':
+          return TagResponseDto.fromJson(value);
+        case 'TagTypeEnum':
+          return TagTypeEnumTypeTransformer().decode(value);
         case 'ThumbnailFormat':
           return ThumbnailFormatTypeTransformer().decode(value);
         case 'TimeGroupEnum':
@@ -308,12 +322,16 @@ class ApiClient {
           return UpdateAssetDto.fromJson(value);
         case 'UpdateDeviceInfoDto':
           return UpdateDeviceInfoDto.fromJson(value);
+        case 'UpdateTagDto':
+          return UpdateTagDto.fromJson(value);
         case 'UpdateUserDto':
           return UpdateUserDto.fromJson(value);
         case 'UsageByUserDto':
           return UsageByUserDto.fromJson(value);
         case 'UserCountResponseDto':
           return UserCountResponseDto.fromJson(value);
+        case 'UserEntity':
+          return UserEntity.fromJson(value);
         case 'UserResponseDto':
           return UserResponseDto.fromJson(value);
         case 'ValidateAccessTokenResponseDto':

+ 3 - 0
mobile/openapi/lib/api_helper.dart

@@ -73,6 +73,9 @@ String parameterToString(dynamic value) {
   if (value is SystemConfigKey) {
     return SystemConfigKeyTypeTransformer().encode(value).toString();
   }
+  if (value is TagTypeEnum) {
+    return TagTypeEnumTypeTransformer().encode(value).toString();
+  }
   if (value is ThumbnailFormat) {
     return ThumbnailFormatTypeTransformer().encode(value).toString();
   }

+ 41 - 53
mobile/openapi/lib/model/album_response_dto.dart

@@ -43,51 +43,48 @@ class AlbumResponseDto {
   List<AssetResponseDto> assets;
 
   @override
-  bool operator ==(Object other) =>
-      identical(this, other) ||
-      other is AlbumResponseDto &&
-          other.assetCount == assetCount &&
-          other.id == id &&
-          other.ownerId == ownerId &&
-          other.albumName == albumName &&
-          other.createdAt == createdAt &&
-          other.albumThumbnailAssetId == albumThumbnailAssetId &&
-          other.shared == shared &&
-          other.sharedUsers == sharedUsers &&
-          other.assets == assets;
+  bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
+     other.assetCount == assetCount &&
+     other.id == id &&
+     other.ownerId == ownerId &&
+     other.albumName == albumName &&
+     other.createdAt == createdAt &&
+     other.albumThumbnailAssetId == albumThumbnailAssetId &&
+     other.shared == shared &&
+     other.sharedUsers == sharedUsers &&
+     other.assets == assets;
 
   @override
   int get hashCode =>
-      // ignore: unnecessary_parenthesis
-      (assetCount.hashCode) +
-      (id.hashCode) +
-      (ownerId.hashCode) +
-      (albumName.hashCode) +
-      (createdAt.hashCode) +
-      (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
-      (shared.hashCode) +
-      (sharedUsers.hashCode) +
-      (assets.hashCode);
+    // ignore: unnecessary_parenthesis
+    (assetCount.hashCode) +
+    (id.hashCode) +
+    (ownerId.hashCode) +
+    (albumName.hashCode) +
+    (createdAt.hashCode) +
+    (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) +
+    (shared.hashCode) +
+    (sharedUsers.hashCode) +
+    (assets.hashCode);
 
   @override
-  String toString() =>
-      'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
+  String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]';
 
   Map<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
-    _json[r'assetCount'] = assetCount;
-    _json[r'id'] = id;
-    _json[r'ownerId'] = ownerId;
-    _json[r'albumName'] = albumName;
-    _json[r'createdAt'] = createdAt;
+      _json[r'assetCount'] = assetCount;
+      _json[r'id'] = id;
+      _json[r'ownerId'] = ownerId;
+      _json[r'albumName'] = albumName;
+      _json[r'createdAt'] = createdAt;
     if (albumThumbnailAssetId != null) {
       _json[r'albumThumbnailAssetId'] = albumThumbnailAssetId;
     } else {
       _json[r'albumThumbnailAssetId'] = null;
     }
-    _json[r'shared'] = shared;
-    _json[r'sharedUsers'] = sharedUsers;
-    _json[r'assets'] = assets;
+      _json[r'shared'] = shared;
+      _json[r'sharedUsers'] = sharedUsers;
+      _json[r'assets'] = assets;
     return _json;
   }
 
@@ -101,13 +98,13 @@ class AlbumResponseDto {
       // Ensure that the map contains the required keys.
       // Note 1: the values aren't checked for validity beyond being non-null.
       // Note 2: this code is stripped in release mode!
-      // assert(() {
-      //   requiredKeys.forEach((key) {
-      //     assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
-      //     assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
-      //   });
-      //   return true;
-      // }());
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
 
       return AlbumResponseDto(
         assetCount: mapValueOfType<int>(json, r'assetCount')!,
@@ -115,8 +112,7 @@ class AlbumResponseDto {
         ownerId: mapValueOfType<String>(json, r'ownerId')!,
         albumName: mapValueOfType<String>(json, r'albumName')!,
         createdAt: mapValueOfType<String>(json, r'createdAt')!,
-        albumThumbnailAssetId:
-            mapValueOfType<String>(json, r'albumThumbnailAssetId'),
+        albumThumbnailAssetId: mapValueOfType<String>(json, r'albumThumbnailAssetId'),
         shared: mapValueOfType<bool>(json, r'shared')!,
         sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers'])!,
         assets: AssetResponseDto.listFromJson(json[r'assets'])!,
@@ -125,10 +121,7 @@ class AlbumResponseDto {
     return null;
   }
 
-  static List<AlbumResponseDto>? listFromJson(
-    dynamic json, {
-    bool growable = false,
-  }) {
+  static List<AlbumResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
     final result = <AlbumResponseDto>[];
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
@@ -156,18 +149,12 @@ class AlbumResponseDto {
   }
 
   // maps a json object with a list of AlbumResponseDto-objects as value to a dart map
-  static Map<String, List<AlbumResponseDto>> mapListFromJson(
-    dynamic json, {
-    bool growable = false,
-  }) {
+  static Map<String, List<AlbumResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
     final map = <String, List<AlbumResponseDto>>{};
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
-        final value = AlbumResponseDto.listFromJson(
-          entry.value,
-          growable: growable,
-        );
+        final value = AlbumResponseDto.listFromJson(entry.value, growable: growable,);
         if (value != null) {
           map[entry.key] = value;
         }
@@ -189,3 +176,4 @@ class AlbumResponseDto {
     'assets',
   };
 }
+

+ 384 - 0
mobile/openapi/lib/model/asset_entity.dart

@@ -0,0 +1,384 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class AssetEntity {
+  /// Returns a new [AssetEntity] instance.
+  AssetEntity({
+    required this.id,
+    required this.deviceAssetId,
+    required this.userId,
+    required this.deviceId,
+    required this.type,
+    required this.originalPath,
+    required this.resizePath,
+    required this.webpPath,
+    required this.encodedVideoPath,
+    required this.createdAt,
+    required this.modifiedAt,
+    required this.isFavorite,
+    required this.mimeType,
+    this.checksum,
+    required this.duration,
+    required this.isVisible,
+    required this.livePhotoVideoId,
+    this.exifInfo,
+    this.smartInfo,
+    this.tags = const [],
+  });
+
+  String id;
+
+  String deviceAssetId;
+
+  String userId;
+
+  String deviceId;
+
+  AssetEntityTypeEnum type;
+
+  String originalPath;
+
+  String? resizePath;
+
+  String? webpPath;
+
+  String encodedVideoPath;
+
+  String createdAt;
+
+  String modifiedAt;
+
+  bool isFavorite;
+
+  String? mimeType;
+
+  Object? checksum;
+
+  String? duration;
+
+  bool isVisible;
+
+  String? livePhotoVideoId;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  ExifEntity? exifInfo;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  SmartInfoEntity? smartInfo;
+
+  List<TagEntity> tags;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is AssetEntity &&
+     other.id == id &&
+     other.deviceAssetId == deviceAssetId &&
+     other.userId == userId &&
+     other.deviceId == deviceId &&
+     other.type == type &&
+     other.originalPath == originalPath &&
+     other.resizePath == resizePath &&
+     other.webpPath == webpPath &&
+     other.encodedVideoPath == encodedVideoPath &&
+     other.createdAt == createdAt &&
+     other.modifiedAt == modifiedAt &&
+     other.isFavorite == isFavorite &&
+     other.mimeType == mimeType &&
+     other.checksum == checksum &&
+     other.duration == duration &&
+     other.isVisible == isVisible &&
+     other.livePhotoVideoId == livePhotoVideoId &&
+     other.exifInfo == exifInfo &&
+     other.smartInfo == smartInfo &&
+     other.tags == tags;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (id.hashCode) +
+    (deviceAssetId.hashCode) +
+    (userId.hashCode) +
+    (deviceId.hashCode) +
+    (type.hashCode) +
+    (originalPath.hashCode) +
+    (resizePath == null ? 0 : resizePath!.hashCode) +
+    (webpPath == null ? 0 : webpPath!.hashCode) +
+    (encodedVideoPath.hashCode) +
+    (createdAt.hashCode) +
+    (modifiedAt.hashCode) +
+    (isFavorite.hashCode) +
+    (mimeType == null ? 0 : mimeType!.hashCode) +
+    (checksum == null ? 0 : checksum!.hashCode) +
+    (duration == null ? 0 : duration!.hashCode) +
+    (isVisible.hashCode) +
+    (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
+    (exifInfo == null ? 0 : exifInfo!.hashCode) +
+    (smartInfo == null ? 0 : smartInfo!.hashCode) +
+    (tags.hashCode);
+
+  @override
+  String toString() => 'AssetEntity[id=$id, deviceAssetId=$deviceAssetId, userId=$userId, deviceId=$deviceId, type=$type, originalPath=$originalPath, resizePath=$resizePath, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, checksum=$checksum, duration=$duration, isVisible=$isVisible, livePhotoVideoId=$livePhotoVideoId, exifInfo=$exifInfo, smartInfo=$smartInfo, tags=$tags]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'id'] = id;
+      _json[r'deviceAssetId'] = deviceAssetId;
+      _json[r'userId'] = userId;
+      _json[r'deviceId'] = deviceId;
+      _json[r'type'] = type;
+      _json[r'originalPath'] = originalPath;
+    if (resizePath != null) {
+      _json[r'resizePath'] = resizePath;
+    } else {
+      _json[r'resizePath'] = null;
+    }
+    if (webpPath != null) {
+      _json[r'webpPath'] = webpPath;
+    } else {
+      _json[r'webpPath'] = null;
+    }
+      _json[r'encodedVideoPath'] = encodedVideoPath;
+      _json[r'createdAt'] = createdAt;
+      _json[r'modifiedAt'] = modifiedAt;
+      _json[r'isFavorite'] = isFavorite;
+    if (mimeType != null) {
+      _json[r'mimeType'] = mimeType;
+    } else {
+      _json[r'mimeType'] = null;
+    }
+    if (checksum != null) {
+      _json[r'checksum'] = checksum;
+    } else {
+      _json[r'checksum'] = null;
+    }
+    if (duration != null) {
+      _json[r'duration'] = duration;
+    } else {
+      _json[r'duration'] = null;
+    }
+      _json[r'isVisible'] = isVisible;
+    if (livePhotoVideoId != null) {
+      _json[r'livePhotoVideoId'] = livePhotoVideoId;
+    } else {
+      _json[r'livePhotoVideoId'] = null;
+    }
+    if (exifInfo != null) {
+      _json[r'exifInfo'] = exifInfo;
+    } else {
+      _json[r'exifInfo'] = null;
+    }
+    if (smartInfo != null) {
+      _json[r'smartInfo'] = smartInfo;
+    } else {
+      _json[r'smartInfo'] = null;
+    }
+      _json[r'tags'] = tags;
+    return _json;
+  }
+
+  /// Returns a new [AssetEntity] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static AssetEntity? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "AssetEntity[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "AssetEntity[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return AssetEntity(
+        id: mapValueOfType<String>(json, r'id')!,
+        deviceAssetId: mapValueOfType<String>(json, r'deviceAssetId')!,
+        userId: mapValueOfType<String>(json, r'userId')!,
+        deviceId: mapValueOfType<String>(json, r'deviceId')!,
+        type: AssetEntityTypeEnum.fromJson(json[r'type'])!,
+        originalPath: mapValueOfType<String>(json, r'originalPath')!,
+        resizePath: mapValueOfType<String>(json, r'resizePath'),
+        webpPath: mapValueOfType<String>(json, r'webpPath'),
+        encodedVideoPath: mapValueOfType<String>(json, r'encodedVideoPath')!,
+        createdAt: mapValueOfType<String>(json, r'createdAt')!,
+        modifiedAt: mapValueOfType<String>(json, r'modifiedAt')!,
+        isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
+        mimeType: mapValueOfType<String>(json, r'mimeType'),
+        checksum: mapValueOfType<Object>(json, r'checksum'),
+        duration: mapValueOfType<String>(json, r'duration'),
+        isVisible: mapValueOfType<bool>(json, r'isVisible')!,
+        livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
+        exifInfo: ExifEntity.fromJson(json[r'exifInfo']),
+        smartInfo: SmartInfoEntity.fromJson(json[r'smartInfo']),
+        tags: TagEntity.listFromJson(json[r'tags'])!,
+      );
+    }
+    return null;
+  }
+
+  static List<AssetEntity>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <AssetEntity>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = AssetEntity.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, AssetEntity> mapFromJson(dynamic json) {
+    final map = <String, AssetEntity>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AssetEntity.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of AssetEntity-objects as value to a dart map
+  static Map<String, List<AssetEntity>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<AssetEntity>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AssetEntity.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'id',
+    'deviceAssetId',
+    'userId',
+    'deviceId',
+    'type',
+    'originalPath',
+    'resizePath',
+    'webpPath',
+    'encodedVideoPath',
+    'createdAt',
+    'modifiedAt',
+    'isFavorite',
+    'mimeType',
+    'duration',
+    'isVisible',
+    'livePhotoVideoId',
+    'tags',
+  };
+}
+
+
+class AssetEntityTypeEnum {
+  /// Instantiate a new enum with the provided [value].
+  const AssetEntityTypeEnum._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const IMAGE = AssetEntityTypeEnum._(r'IMAGE');
+  static const VIDEO = AssetEntityTypeEnum._(r'VIDEO');
+  static const AUDIO = AssetEntityTypeEnum._(r'AUDIO');
+  static const OTHER = AssetEntityTypeEnum._(r'OTHER');
+
+  /// List of all possible values in this [enum][AssetEntityTypeEnum].
+  static const values = <AssetEntityTypeEnum>[
+    IMAGE,
+    VIDEO,
+    AUDIO,
+    OTHER,
+  ];
+
+  static AssetEntityTypeEnum? fromJson(dynamic value) => AssetEntityTypeEnumTypeTransformer().decode(value);
+
+  static List<AssetEntityTypeEnum>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <AssetEntityTypeEnum>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = AssetEntityTypeEnum.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [AssetEntityTypeEnum] to String,
+/// and [decode] dynamic data back to [AssetEntityTypeEnum].
+class AssetEntityTypeEnumTypeTransformer {
+  factory AssetEntityTypeEnumTypeTransformer() => _instance ??= const AssetEntityTypeEnumTypeTransformer._();
+
+  const AssetEntityTypeEnumTypeTransformer._();
+
+  String encode(AssetEntityTypeEnum data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a AssetEntityTypeEnum.
+  ///
+  /// 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.
+  AssetEntityTypeEnum? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data.toString()) {
+        case r'IMAGE': return AssetEntityTypeEnum.IMAGE;
+        case r'VIDEO': return AssetEntityTypeEnum.VIDEO;
+        case r'AUDIO': return AssetEntityTypeEnum.AUDIO;
+        case r'OTHER': return AssetEntityTypeEnum.OTHER;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [AssetEntityTypeEnumTypeTransformer] instance.
+  static AssetEntityTypeEnumTypeTransformer? _instance;
+}
+
+

+ 11 - 3
mobile/openapi/lib/model/asset_response_dto.dart

@@ -30,6 +30,7 @@ class AssetResponseDto {
     this.exifInfo,
     this.smartInfo,
     this.livePhotoVideoId,
+    this.tags = const [],
   });
 
   AssetTypeEnum type;
@@ -78,6 +79,8 @@ class AssetResponseDto {
 
   String? livePhotoVideoId;
 
+  List<TagResponseDto> tags;
+
   @override
   bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
      other.type == type &&
@@ -96,7 +99,8 @@ class AssetResponseDto {
      other.encodedVideoPath == encodedVideoPath &&
      other.exifInfo == exifInfo &&
      other.smartInfo == smartInfo &&
-     other.livePhotoVideoId == livePhotoVideoId;
+     other.livePhotoVideoId == livePhotoVideoId &&
+     other.tags == tags;
 
   @override
   int get hashCode =>
@@ -117,10 +121,11 @@ class AssetResponseDto {
     (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
     (exifInfo == null ? 0 : exifInfo!.hashCode) +
     (smartInfo == null ? 0 : smartInfo!.hashCode) +
-    (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode);
+    (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
+    (tags.hashCode);
 
   @override
-  String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId]';
+  String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags]';
 
   Map<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
@@ -169,6 +174,7 @@ class AssetResponseDto {
     } else {
       _json[r'livePhotoVideoId'] = null;
     }
+      _json[r'tags'] = tags;
     return _json;
   }
 
@@ -208,6 +214,7 @@ class AssetResponseDto {
         exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']),
         smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
         livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
+        tags: TagResponseDto.listFromJson(json[r'tags'])!,
       );
     }
     return null;
@@ -270,6 +277,7 @@ class AssetResponseDto {
     'mimeType',
     'duration',
     'webpPath',
+    'tags',
   };
 }
 

+ 119 - 0
mobile/openapi/lib/model/create_tag_dto.dart

@@ -0,0 +1,119 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class CreateTagDto {
+  /// Returns a new [CreateTagDto] instance.
+  CreateTagDto({
+    required this.type,
+    required this.name,
+  });
+
+  TagTypeEnum type;
+
+  String name;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is CreateTagDto &&
+     other.type == type &&
+     other.name == name;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (type.hashCode) +
+    (name.hashCode);
+
+  @override
+  String toString() => 'CreateTagDto[type=$type, name=$name]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'type'] = type;
+      _json[r'name'] = name;
+    return _json;
+  }
+
+  /// Returns a new [CreateTagDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static CreateTagDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "CreateTagDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "CreateTagDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return CreateTagDto(
+        type: TagTypeEnum.fromJson(json[r'type'])!,
+        name: mapValueOfType<String>(json, r'name')!,
+      );
+    }
+    return null;
+  }
+
+  static List<CreateTagDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <CreateTagDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = CreateTagDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, CreateTagDto> mapFromJson(dynamic json) {
+    final map = <String, CreateTagDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = CreateTagDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of CreateTagDto-objects as value to a dart map
+  static Map<String, List<CreateTagDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<CreateTagDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = CreateTagDto.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'type',
+    'name',
+  };
+}
+

+ 414 - 0
mobile/openapi/lib/model/exif_entity.dart

@@ -0,0 +1,414 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class ExifEntity {
+  /// Returns a new [ExifEntity] instance.
+  ExifEntity({
+    required this.id,
+    required this.assetId,
+    required this.description,
+    required this.exifImageWidth,
+    required this.exifImageHeight,
+    required this.fileSizeInByte,
+    required this.orientation,
+    required this.dateTimeOriginal,
+    required this.modifyDate,
+    required this.latitude,
+    required this.longitude,
+    required this.city,
+    required this.state,
+    required this.country,
+    required this.make,
+    required this.model,
+    required this.imageName,
+    required this.lensModel,
+    required this.fNumber,
+    required this.focalLength,
+    required this.iso,
+    required this.exposureTime,
+    this.fps,
+    this.asset,
+    required this.exifTextSearchableColumn,
+  });
+
+  String id;
+
+  String assetId;
+
+  /// General info
+  String description;
+
+  num? exifImageWidth;
+
+  num? exifImageHeight;
+
+  num? fileSizeInByte;
+
+  String? orientation;
+
+  DateTime? dateTimeOriginal;
+
+  DateTime? modifyDate;
+
+  num? latitude;
+
+  num? longitude;
+
+  String? city;
+
+  String? state;
+
+  String? country;
+
+  /// Image info
+  String? make;
+
+  String? model;
+
+  String? imageName;
+
+  String? lensModel;
+
+  num? fNumber;
+
+  num? focalLength;
+
+  num? iso;
+
+  num? exposureTime;
+
+  /// Video info
+  num? fps;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  AssetEntity? asset;
+
+  String exifTextSearchableColumn;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is ExifEntity &&
+     other.id == id &&
+     other.assetId == assetId &&
+     other.description == description &&
+     other.exifImageWidth == exifImageWidth &&
+     other.exifImageHeight == exifImageHeight &&
+     other.fileSizeInByte == fileSizeInByte &&
+     other.orientation == orientation &&
+     other.dateTimeOriginal == dateTimeOriginal &&
+     other.modifyDate == modifyDate &&
+     other.latitude == latitude &&
+     other.longitude == longitude &&
+     other.city == city &&
+     other.state == state &&
+     other.country == country &&
+     other.make == make &&
+     other.model == model &&
+     other.imageName == imageName &&
+     other.lensModel == lensModel &&
+     other.fNumber == fNumber &&
+     other.focalLength == focalLength &&
+     other.iso == iso &&
+     other.exposureTime == exposureTime &&
+     other.fps == fps &&
+     other.asset == asset &&
+     other.exifTextSearchableColumn == exifTextSearchableColumn;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (id.hashCode) +
+    (assetId.hashCode) +
+    (description.hashCode) +
+    (exifImageWidth == null ? 0 : exifImageWidth!.hashCode) +
+    (exifImageHeight == null ? 0 : exifImageHeight!.hashCode) +
+    (fileSizeInByte == null ? 0 : fileSizeInByte!.hashCode) +
+    (orientation == null ? 0 : orientation!.hashCode) +
+    (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) +
+    (modifyDate == null ? 0 : modifyDate!.hashCode) +
+    (latitude == null ? 0 : latitude!.hashCode) +
+    (longitude == null ? 0 : longitude!.hashCode) +
+    (city == null ? 0 : city!.hashCode) +
+    (state == null ? 0 : state!.hashCode) +
+    (country == null ? 0 : country!.hashCode) +
+    (make == null ? 0 : make!.hashCode) +
+    (model == null ? 0 : model!.hashCode) +
+    (imageName == null ? 0 : imageName!.hashCode) +
+    (lensModel == null ? 0 : lensModel!.hashCode) +
+    (fNumber == null ? 0 : fNumber!.hashCode) +
+    (focalLength == null ? 0 : focalLength!.hashCode) +
+    (iso == null ? 0 : iso!.hashCode) +
+    (exposureTime == null ? 0 : exposureTime!.hashCode) +
+    (fps == null ? 0 : fps!.hashCode) +
+    (asset == null ? 0 : asset!.hashCode) +
+    (exifTextSearchableColumn.hashCode);
+
+  @override
+  String toString() => 'ExifEntity[id=$id, assetId=$assetId, description=$description, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, fileSizeInByte=$fileSizeInByte, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country, make=$make, model=$model, imageName=$imageName, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, fps=$fps, asset=$asset, exifTextSearchableColumn=$exifTextSearchableColumn]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'id'] = id;
+      _json[r'assetId'] = assetId;
+      _json[r'description'] = description;
+    if (exifImageWidth != null) {
+      _json[r'exifImageWidth'] = exifImageWidth;
+    } else {
+      _json[r'exifImageWidth'] = null;
+    }
+    if (exifImageHeight != null) {
+      _json[r'exifImageHeight'] = exifImageHeight;
+    } else {
+      _json[r'exifImageHeight'] = null;
+    }
+    if (fileSizeInByte != null) {
+      _json[r'fileSizeInByte'] = fileSizeInByte;
+    } else {
+      _json[r'fileSizeInByte'] = null;
+    }
+    if (orientation != null) {
+      _json[r'orientation'] = orientation;
+    } else {
+      _json[r'orientation'] = null;
+    }
+    if (dateTimeOriginal != null) {
+      _json[r'dateTimeOriginal'] = dateTimeOriginal!.toUtc().toIso8601String();
+    } else {
+      _json[r'dateTimeOriginal'] = null;
+    }
+    if (modifyDate != null) {
+      _json[r'modifyDate'] = modifyDate!.toUtc().toIso8601String();
+    } else {
+      _json[r'modifyDate'] = null;
+    }
+    if (latitude != null) {
+      _json[r'latitude'] = latitude;
+    } else {
+      _json[r'latitude'] = null;
+    }
+    if (longitude != null) {
+      _json[r'longitude'] = longitude;
+    } else {
+      _json[r'longitude'] = null;
+    }
+    if (city != null) {
+      _json[r'city'] = city;
+    } else {
+      _json[r'city'] = null;
+    }
+    if (state != null) {
+      _json[r'state'] = state;
+    } else {
+      _json[r'state'] = null;
+    }
+    if (country != null) {
+      _json[r'country'] = country;
+    } else {
+      _json[r'country'] = null;
+    }
+    if (make != null) {
+      _json[r'make'] = make;
+    } else {
+      _json[r'make'] = null;
+    }
+    if (model != null) {
+      _json[r'model'] = model;
+    } else {
+      _json[r'model'] = null;
+    }
+    if (imageName != null) {
+      _json[r'imageName'] = imageName;
+    } else {
+      _json[r'imageName'] = null;
+    }
+    if (lensModel != null) {
+      _json[r'lensModel'] = lensModel;
+    } else {
+      _json[r'lensModel'] = null;
+    }
+    if (fNumber != null) {
+      _json[r'fNumber'] = fNumber;
+    } else {
+      _json[r'fNumber'] = null;
+    }
+    if (focalLength != null) {
+      _json[r'focalLength'] = focalLength;
+    } else {
+      _json[r'focalLength'] = null;
+    }
+    if (iso != null) {
+      _json[r'iso'] = iso;
+    } else {
+      _json[r'iso'] = null;
+    }
+    if (exposureTime != null) {
+      _json[r'exposureTime'] = exposureTime;
+    } else {
+      _json[r'exposureTime'] = null;
+    }
+    if (fps != null) {
+      _json[r'fps'] = fps;
+    } else {
+      _json[r'fps'] = null;
+    }
+    if (asset != null) {
+      _json[r'asset'] = asset;
+    } else {
+      _json[r'asset'] = null;
+    }
+      _json[r'exifTextSearchableColumn'] = exifTextSearchableColumn;
+    return _json;
+  }
+
+  /// Returns a new [ExifEntity] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static ExifEntity? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "ExifEntity[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "ExifEntity[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return ExifEntity(
+        id: mapValueOfType<String>(json, r'id')!,
+        assetId: mapValueOfType<String>(json, r'assetId')!,
+        description: mapValueOfType<String>(json, r'description')!,
+        exifImageWidth: json[r'exifImageWidth'] == null
+            ? null
+            : num.parse(json[r'exifImageWidth'].toString()),
+        exifImageHeight: json[r'exifImageHeight'] == null
+            ? null
+            : num.parse(json[r'exifImageHeight'].toString()),
+        fileSizeInByte: json[r'fileSizeInByte'] == null
+            ? null
+            : num.parse(json[r'fileSizeInByte'].toString()),
+        orientation: mapValueOfType<String>(json, r'orientation'),
+        dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', ''),
+        modifyDate: mapDateTime(json, r'modifyDate', ''),
+        latitude: json[r'latitude'] == null
+            ? null
+            : num.parse(json[r'latitude'].toString()),
+        longitude: json[r'longitude'] == null
+            ? null
+            : num.parse(json[r'longitude'].toString()),
+        city: mapValueOfType<String>(json, r'city'),
+        state: mapValueOfType<String>(json, r'state'),
+        country: mapValueOfType<String>(json, r'country'),
+        make: mapValueOfType<String>(json, r'make'),
+        model: mapValueOfType<String>(json, r'model'),
+        imageName: mapValueOfType<String>(json, r'imageName'),
+        lensModel: mapValueOfType<String>(json, r'lensModel'),
+        fNumber: json[r'fNumber'] == null
+            ? null
+            : num.parse(json[r'fNumber'].toString()),
+        focalLength: json[r'focalLength'] == null
+            ? null
+            : num.parse(json[r'focalLength'].toString()),
+        iso: json[r'iso'] == null
+            ? null
+            : num.parse(json[r'iso'].toString()),
+        exposureTime: json[r'exposureTime'] == null
+            ? null
+            : num.parse(json[r'exposureTime'].toString()),
+        fps: json[r'fps'] == null
+            ? null
+            : num.parse(json[r'fps'].toString()),
+        asset: AssetEntity.fromJson(json[r'asset']),
+        exifTextSearchableColumn: mapValueOfType<String>(json, r'exifTextSearchableColumn')!,
+      );
+    }
+    return null;
+  }
+
+  static List<ExifEntity>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <ExifEntity>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = ExifEntity.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, ExifEntity> mapFromJson(dynamic json) {
+    final map = <String, ExifEntity>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = ExifEntity.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of ExifEntity-objects as value to a dart map
+  static Map<String, List<ExifEntity>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<ExifEntity>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = ExifEntity.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'id',
+    'assetId',
+    'description',
+    'exifImageWidth',
+    'exifImageHeight',
+    'fileSizeInByte',
+    'orientation',
+    'dateTimeOriginal',
+    'modifyDate',
+    'latitude',
+    'longitude',
+    'city',
+    'state',
+    'country',
+    'make',
+    'model',
+    'imageName',
+    'lensModel',
+    'fNumber',
+    'focalLength',
+    'iso',
+    'exposureTime',
+    'exifTextSearchableColumn',
+  };
+}
+

+ 164 - 0
mobile/openapi/lib/model/smart_info_entity.dart

@@ -0,0 +1,164 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class SmartInfoEntity {
+  /// Returns a new [SmartInfoEntity] instance.
+  SmartInfoEntity({
+    required this.id,
+    required this.assetId,
+    this.tags = const [],
+    this.objects = const [],
+    this.asset,
+  });
+
+  String id;
+
+  String assetId;
+
+  List<String>? tags;
+
+  List<String>? objects;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  AssetEntity? asset;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SmartInfoEntity &&
+     other.id == id &&
+     other.assetId == assetId &&
+     other.tags == tags &&
+     other.objects == objects &&
+     other.asset == asset;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (id.hashCode) +
+    (assetId.hashCode) +
+    (tags == null ? 0 : tags!.hashCode) +
+    (objects == null ? 0 : objects!.hashCode) +
+    (asset == null ? 0 : asset!.hashCode);
+
+  @override
+  String toString() => 'SmartInfoEntity[id=$id, assetId=$assetId, tags=$tags, objects=$objects, asset=$asset]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'id'] = id;
+      _json[r'assetId'] = assetId;
+    if (tags != null) {
+      _json[r'tags'] = tags;
+    } else {
+      _json[r'tags'] = null;
+    }
+    if (objects != null) {
+      _json[r'objects'] = objects;
+    } else {
+      _json[r'objects'] = null;
+    }
+    if (asset != null) {
+      _json[r'asset'] = asset;
+    } else {
+      _json[r'asset'] = null;
+    }
+    return _json;
+  }
+
+  /// Returns a new [SmartInfoEntity] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static SmartInfoEntity? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "SmartInfoEntity[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "SmartInfoEntity[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return SmartInfoEntity(
+        id: mapValueOfType<String>(json, r'id')!,
+        assetId: mapValueOfType<String>(json, r'assetId')!,
+        tags: json[r'tags'] is List
+            ? (json[r'tags'] as List).cast<String>()
+            : const [],
+        objects: json[r'objects'] is List
+            ? (json[r'objects'] as List).cast<String>()
+            : const [],
+        asset: AssetEntity.fromJson(json[r'asset']),
+      );
+    }
+    return null;
+  }
+
+  static List<SmartInfoEntity>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SmartInfoEntity>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SmartInfoEntity.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, SmartInfoEntity> mapFromJson(dynamic json) {
+    final map = <String, SmartInfoEntity>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SmartInfoEntity.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of SmartInfoEntity-objects as value to a dart map
+  static Map<String, List<SmartInfoEntity>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SmartInfoEntity>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SmartInfoEntity.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'id',
+    'assetId',
+    'tags',
+    'objects',
+  };
+}
+

+ 236 - 0
mobile/openapi/lib/model/tag_entity.dart

@@ -0,0 +1,236 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class TagEntity {
+  /// Returns a new [TagEntity] instance.
+  TagEntity({
+    required this.id,
+    required this.type,
+    required this.name,
+    required this.userId,
+    required this.renameTagId,
+    this.assets = const [],
+    required this.user,
+  });
+
+  String id;
+
+  TagEntityTypeEnum type;
+
+  String name;
+
+  String userId;
+
+  String renameTagId;
+
+  List<AssetEntity> assets;
+
+  UserEntity user;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is TagEntity &&
+     other.id == id &&
+     other.type == type &&
+     other.name == name &&
+     other.userId == userId &&
+     other.renameTagId == renameTagId &&
+     other.assets == assets &&
+     other.user == user;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (id.hashCode) +
+    (type.hashCode) +
+    (name.hashCode) +
+    (userId.hashCode) +
+    (renameTagId.hashCode) +
+    (assets.hashCode) +
+    (user.hashCode);
+
+  @override
+  String toString() => 'TagEntity[id=$id, type=$type, name=$name, userId=$userId, renameTagId=$renameTagId, assets=$assets, user=$user]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'id'] = id;
+      _json[r'type'] = type;
+      _json[r'name'] = name;
+      _json[r'userId'] = userId;
+      _json[r'renameTagId'] = renameTagId;
+      _json[r'assets'] = assets;
+      _json[r'user'] = user;
+    return _json;
+  }
+
+  /// Returns a new [TagEntity] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static TagEntity? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "TagEntity[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "TagEntity[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return TagEntity(
+        id: mapValueOfType<String>(json, r'id')!,
+        type: TagEntityTypeEnum.fromJson(json[r'type'])!,
+        name: mapValueOfType<String>(json, r'name')!,
+        userId: mapValueOfType<String>(json, r'userId')!,
+        renameTagId: mapValueOfType<String>(json, r'renameTagId')!,
+        assets: AssetEntity.listFromJson(json[r'assets'])!,
+        user: UserEntity.fromJson(json[r'user'])!,
+      );
+    }
+    return null;
+  }
+
+  static List<TagEntity>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <TagEntity>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = TagEntity.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, TagEntity> mapFromJson(dynamic json) {
+    final map = <String, TagEntity>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = TagEntity.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of TagEntity-objects as value to a dart map
+  static Map<String, List<TagEntity>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<TagEntity>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = TagEntity.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'id',
+    'type',
+    'name',
+    'userId',
+    'renameTagId',
+    'assets',
+    'user',
+  };
+}
+
+
+class TagEntityTypeEnum {
+  /// Instantiate a new enum with the provided [value].
+  const TagEntityTypeEnum._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const OBJECT = TagEntityTypeEnum._(r'OBJECT');
+  static const FACE = TagEntityTypeEnum._(r'FACE');
+  static const CUSTOM = TagEntityTypeEnum._(r'CUSTOM');
+
+  /// List of all possible values in this [enum][TagEntityTypeEnum].
+  static const values = <TagEntityTypeEnum>[
+    OBJECT,
+    FACE,
+    CUSTOM,
+  ];
+
+  static TagEntityTypeEnum? fromJson(dynamic value) => TagEntityTypeEnumTypeTransformer().decode(value);
+
+  static List<TagEntityTypeEnum>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <TagEntityTypeEnum>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = TagEntityTypeEnum.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [TagEntityTypeEnum] to String,
+/// and [decode] dynamic data back to [TagEntityTypeEnum].
+class TagEntityTypeEnumTypeTransformer {
+  factory TagEntityTypeEnumTypeTransformer() => _instance ??= const TagEntityTypeEnumTypeTransformer._();
+
+  const TagEntityTypeEnumTypeTransformer._();
+
+  String encode(TagEntityTypeEnum data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a TagEntityTypeEnum.
+  ///
+  /// 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.
+  TagEntityTypeEnum? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data.toString()) {
+        case r'OBJECT': return TagEntityTypeEnum.OBJECT;
+        case r'FACE': return TagEntityTypeEnum.FACE;
+        case r'CUSTOM': return TagEntityTypeEnum.CUSTOM;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [TagEntityTypeEnumTypeTransformer] instance.
+  static TagEntityTypeEnumTypeTransformer? _instance;
+}
+
+

+ 127 - 0
mobile/openapi/lib/model/tag_response_dto.dart

@@ -0,0 +1,127 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class TagResponseDto {
+  /// Returns a new [TagResponseDto] instance.
+  TagResponseDto({
+    required this.id,
+    required this.type,
+    required this.name,
+  });
+
+  String id;
+
+  TagTypeEnum type;
+
+  String name;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is TagResponseDto &&
+     other.id == id &&
+     other.type == type &&
+     other.name == name;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (id.hashCode) +
+    (type.hashCode) +
+    (name.hashCode);
+
+  @override
+  String toString() => 'TagResponseDto[id=$id, type=$type, name=$name]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'id'] = id;
+      _json[r'type'] = type;
+      _json[r'name'] = name;
+    return _json;
+  }
+
+  /// Returns a new [TagResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static TagResponseDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "TagResponseDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "TagResponseDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return TagResponseDto(
+        id: mapValueOfType<String>(json, r'id')!,
+        type: TagTypeEnum.fromJson(json[r'type'])!,
+        name: mapValueOfType<String>(json, r'name')!,
+      );
+    }
+    return null;
+  }
+
+  static List<TagResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <TagResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = TagResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, TagResponseDto> mapFromJson(dynamic json) {
+    final map = <String, TagResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = TagResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of TagResponseDto-objects as value to a dart map
+  static Map<String, List<TagResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<TagResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = TagResponseDto.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'id',
+    'type',
+    'name',
+  };
+}
+

+ 88 - 0
mobile/openapi/lib/model/tag_type_enum.dart

@@ -0,0 +1,88 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+
+class TagTypeEnum {
+  /// Instantiate a new enum with the provided [value].
+  const TagTypeEnum._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const OBJECT = TagTypeEnum._(r'OBJECT');
+  static const FACE = TagTypeEnum._(r'FACE');
+  static const CUSTOM = TagTypeEnum._(r'CUSTOM');
+
+  /// List of all possible values in this [enum][TagTypeEnum].
+  static const values = <TagTypeEnum>[
+    OBJECT,
+    FACE,
+    CUSTOM,
+  ];
+
+  static TagTypeEnum? fromJson(dynamic value) => TagTypeEnumTypeTransformer().decode(value);
+
+  static List<TagTypeEnum>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <TagTypeEnum>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = TagTypeEnum.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [TagTypeEnum] to String,
+/// and [decode] dynamic data back to [TagTypeEnum].
+class TagTypeEnumTypeTransformer {
+  factory TagTypeEnumTypeTransformer() => _instance ??= const TagTypeEnumTypeTransformer._();
+
+  const TagTypeEnumTypeTransformer._();
+
+  String encode(TagTypeEnum data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a TagTypeEnum.
+  ///
+  /// 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.
+  TagTypeEnum? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data.toString()) {
+        case r'OBJECT': return TagTypeEnum.OBJECT;
+        case r'FACE': return TagTypeEnum.FACE;
+        case r'CUSTOM': return TagTypeEnum.CUSTOM;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [TagTypeEnumTypeTransformer] instance.
+  static TagTypeEnumTypeTransformer? _instance;
+}
+

+ 24 - 6
mobile/openapi/lib/model/update_asset_dto.dart

@@ -13,26 +13,42 @@ part of openapi.api;
 class UpdateAssetDto {
   /// Returns a new [UpdateAssetDto] instance.
   UpdateAssetDto({
-    required this.isFavorite,
+    this.tagIds = const [],
+    this.isFavorite,
   });
 
-  bool isFavorite;
+  List<String> tagIds;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  bool? isFavorite;
 
   @override
   bool operator ==(Object other) => identical(this, other) || other is UpdateAssetDto &&
+     other.tagIds == tagIds &&
      other.isFavorite == isFavorite;
 
   @override
   int get hashCode =>
     // ignore: unnecessary_parenthesis
-    (isFavorite.hashCode);
+    (tagIds.hashCode) +
+    (isFavorite == null ? 0 : isFavorite!.hashCode);
 
   @override
-  String toString() => 'UpdateAssetDto[isFavorite=$isFavorite]';
+  String toString() => 'UpdateAssetDto[tagIds=$tagIds, isFavorite=$isFavorite]';
 
   Map<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
+      _json[r'tagIds'] = tagIds;
+    if (isFavorite != null) {
       _json[r'isFavorite'] = isFavorite;
+    } else {
+      _json[r'isFavorite'] = null;
+    }
     return _json;
   }
 
@@ -55,7 +71,10 @@ class UpdateAssetDto {
       }());
 
       return UpdateAssetDto(
-        isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
+        tagIds: json[r'tagIds'] is List
+            ? (json[r'tagIds'] as List).cast<String>()
+            : const [],
+        isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
       );
     }
     return null;
@@ -105,7 +124,6 @@ class UpdateAssetDto {
 
   /// The list of required keys that must be present in a JSON.
   static const requiredKeys = <String>{
-    'isFavorite',
   };
 }
 

+ 137 - 0
mobile/openapi/lib/model/update_tag_dto.dart

@@ -0,0 +1,137 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class UpdateTagDto {
+  /// Returns a new [UpdateTagDto] instance.
+  UpdateTagDto({
+    this.name,
+    this.renameTagId,
+  });
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? name;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? renameTagId;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is UpdateTagDto &&
+     other.name == name &&
+     other.renameTagId == renameTagId;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (name == null ? 0 : name!.hashCode) +
+    (renameTagId == null ? 0 : renameTagId!.hashCode);
+
+  @override
+  String toString() => 'UpdateTagDto[name=$name, renameTagId=$renameTagId]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+    if (name != null) {
+      _json[r'name'] = name;
+    } else {
+      _json[r'name'] = null;
+    }
+    if (renameTagId != null) {
+      _json[r'renameTagId'] = renameTagId;
+    } else {
+      _json[r'renameTagId'] = null;
+    }
+    return _json;
+  }
+
+  /// Returns a new [UpdateTagDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static UpdateTagDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "UpdateTagDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "UpdateTagDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return UpdateTagDto(
+        name: mapValueOfType<String>(json, r'name'),
+        renameTagId: mapValueOfType<String>(json, r'renameTagId'),
+      );
+    }
+    return null;
+  }
+
+  static List<UpdateTagDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <UpdateTagDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = UpdateTagDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, UpdateTagDto> mapFromJson(dynamic json) {
+    final map = <String, UpdateTagDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = UpdateTagDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of UpdateTagDto-objects as value to a dart map
+  static Map<String, List<UpdateTagDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<UpdateTagDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = UpdateTagDto.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+  };
+}
+

+ 234 - 0
mobile/openapi/lib/model/user_entity.dart

@@ -0,0 +1,234 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class UserEntity {
+  /// Returns a new [UserEntity] instance.
+  UserEntity({
+    required this.id,
+    required this.firstName,
+    required this.lastName,
+    required this.isAdmin,
+    required this.email,
+    this.password,
+    this.salt,
+    required this.oauthId,
+    required this.profileImagePath,
+    required this.shouldChangePassword,
+    required this.createdAt,
+    this.deletedAt,
+    this.tags = const [],
+  });
+
+  String id;
+
+  String firstName;
+
+  String lastName;
+
+  bool isAdmin;
+
+  String email;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? password;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? salt;
+
+  String oauthId;
+
+  String profileImagePath;
+
+  bool shouldChangePassword;
+
+  String createdAt;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  DateTime? deletedAt;
+
+  List<TagEntity> tags;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is UserEntity &&
+     other.id == id &&
+     other.firstName == firstName &&
+     other.lastName == lastName &&
+     other.isAdmin == isAdmin &&
+     other.email == email &&
+     other.password == password &&
+     other.salt == salt &&
+     other.oauthId == oauthId &&
+     other.profileImagePath == profileImagePath &&
+     other.shouldChangePassword == shouldChangePassword &&
+     other.createdAt == createdAt &&
+     other.deletedAt == deletedAt &&
+     other.tags == tags;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (id.hashCode) +
+    (firstName.hashCode) +
+    (lastName.hashCode) +
+    (isAdmin.hashCode) +
+    (email.hashCode) +
+    (password == null ? 0 : password!.hashCode) +
+    (salt == null ? 0 : salt!.hashCode) +
+    (oauthId.hashCode) +
+    (profileImagePath.hashCode) +
+    (shouldChangePassword.hashCode) +
+    (createdAt.hashCode) +
+    (deletedAt == null ? 0 : deletedAt!.hashCode) +
+    (tags.hashCode);
+
+  @override
+  String toString() => 'UserEntity[id=$id, firstName=$firstName, lastName=$lastName, isAdmin=$isAdmin, email=$email, password=$password, salt=$salt, oauthId=$oauthId, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, createdAt=$createdAt, deletedAt=$deletedAt, tags=$tags]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'id'] = id;
+      _json[r'firstName'] = firstName;
+      _json[r'lastName'] = lastName;
+      _json[r'isAdmin'] = isAdmin;
+      _json[r'email'] = email;
+    if (password != null) {
+      _json[r'password'] = password;
+    } else {
+      _json[r'password'] = null;
+    }
+    if (salt != null) {
+      _json[r'salt'] = salt;
+    } else {
+      _json[r'salt'] = null;
+    }
+      _json[r'oauthId'] = oauthId;
+      _json[r'profileImagePath'] = profileImagePath;
+      _json[r'shouldChangePassword'] = shouldChangePassword;
+      _json[r'createdAt'] = createdAt;
+    if (deletedAt != null) {
+      _json[r'deletedAt'] = deletedAt!.toUtc().toIso8601String();
+    } else {
+      _json[r'deletedAt'] = null;
+    }
+      _json[r'tags'] = tags;
+    return _json;
+  }
+
+  /// Returns a new [UserEntity] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static UserEntity? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "UserEntity[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "UserEntity[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return UserEntity(
+        id: mapValueOfType<String>(json, r'id')!,
+        firstName: mapValueOfType<String>(json, r'firstName')!,
+        lastName: mapValueOfType<String>(json, r'lastName')!,
+        isAdmin: mapValueOfType<bool>(json, r'isAdmin')!,
+        email: mapValueOfType<String>(json, r'email')!,
+        password: mapValueOfType<String>(json, r'password'),
+        salt: mapValueOfType<String>(json, r'salt'),
+        oauthId: mapValueOfType<String>(json, r'oauthId')!,
+        profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
+        shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
+        createdAt: mapValueOfType<String>(json, r'createdAt')!,
+        deletedAt: mapDateTime(json, r'deletedAt', ''),
+        tags: TagEntity.listFromJson(json[r'tags'])!,
+      );
+    }
+    return null;
+  }
+
+  static List<UserEntity>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <UserEntity>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = UserEntity.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, UserEntity> mapFromJson(dynamic json) {
+    final map = <String, UserEntity>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = UserEntity.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of UserEntity-objects as value to a dart map
+  static Map<String, List<UserEntity>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<UserEntity>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = UserEntity.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'id',
+    'firstName',
+    'lastName',
+    'isAdmin',
+    'email',
+    'oauthId',
+    'profileImagePath',
+    'shouldChangePassword',
+    'createdAt',
+    'tags',
+  };
+}
+

+ 122 - 0
mobile/openapi/test/asset_entity_test.dart

@@ -0,0 +1,122 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for AssetEntity
+void main() {
+  // final instance = AssetEntity();
+
+  group('test AssetEntity', () {
+    // String id
+    test('to test the property `id`', () async {
+      // TODO
+    });
+
+    // String deviceAssetId
+    test('to test the property `deviceAssetId`', () async {
+      // TODO
+    });
+
+    // String userId
+    test('to test the property `userId`', () async {
+      // TODO
+    });
+
+    // String deviceId
+    test('to test the property `deviceId`', () async {
+      // TODO
+    });
+
+    // String type
+    test('to test the property `type`', () async {
+      // TODO
+    });
+
+    // String originalPath
+    test('to test the property `originalPath`', () async {
+      // TODO
+    });
+
+    // String resizePath
+    test('to test the property `resizePath`', () async {
+      // TODO
+    });
+
+    // String webpPath
+    test('to test the property `webpPath`', () async {
+      // TODO
+    });
+
+    // String encodedVideoPath
+    test('to test the property `encodedVideoPath`', () async {
+      // TODO
+    });
+
+    // String createdAt
+    test('to test the property `createdAt`', () async {
+      // TODO
+    });
+
+    // String modifiedAt
+    test('to test the property `modifiedAt`', () async {
+      // TODO
+    });
+
+    // bool isFavorite
+    test('to test the property `isFavorite`', () async {
+      // TODO
+    });
+
+    // String mimeType
+    test('to test the property `mimeType`', () async {
+      // TODO
+    });
+
+    // Object checksum
+    test('to test the property `checksum`', () async {
+      // TODO
+    });
+
+    // String duration
+    test('to test the property `duration`', () async {
+      // TODO
+    });
+
+    // bool isVisible
+    test('to test the property `isVisible`', () async {
+      // TODO
+    });
+
+    // String livePhotoVideoId
+    test('to test the property `livePhotoVideoId`', () async {
+      // TODO
+    });
+
+    // ExifEntity exifInfo
+    test('to test the property `exifInfo`', () async {
+      // TODO
+    });
+
+    // SmartInfoEntity smartInfo
+    test('to test the property `smartInfo`', () async {
+      // TODO
+    });
+
+    // List<TagEntity> tags (default value: const [])
+    test('to test the property `tags`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 32 - 0
mobile/openapi/test/create_tag_dto_test.dart

@@ -0,0 +1,32 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for CreateTagDto
+void main() {
+  // final instance = CreateTagDto();
+
+  group('test CreateTagDto', () {
+    // TagTypeEnum type
+    test('to test the property `type`', () async {
+      // TODO
+    });
+
+    // String name
+    test('to test the property `name`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 150 - 0
mobile/openapi/test/exif_entity_test.dart

@@ -0,0 +1,150 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for ExifEntity
+void main() {
+  // final instance = ExifEntity();
+
+  group('test ExifEntity', () {
+    // String id
+    test('to test the property `id`', () async {
+      // TODO
+    });
+
+    // String assetId
+    test('to test the property `assetId`', () async {
+      // TODO
+    });
+
+    // General info
+    // String description
+    test('to test the property `description`', () async {
+      // TODO
+    });
+
+    // num exifImageWidth
+    test('to test the property `exifImageWidth`', () async {
+      // TODO
+    });
+
+    // num exifImageHeight
+    test('to test the property `exifImageHeight`', () async {
+      // TODO
+    });
+
+    // num fileSizeInByte
+    test('to test the property `fileSizeInByte`', () async {
+      // TODO
+    });
+
+    // String orientation
+    test('to test the property `orientation`', () async {
+      // TODO
+    });
+
+    // DateTime dateTimeOriginal
+    test('to test the property `dateTimeOriginal`', () async {
+      // TODO
+    });
+
+    // DateTime modifyDate
+    test('to test the property `modifyDate`', () async {
+      // TODO
+    });
+
+    // num latitude
+    test('to test the property `latitude`', () async {
+      // TODO
+    });
+
+    // num longitude
+    test('to test the property `longitude`', () async {
+      // TODO
+    });
+
+    // String city
+    test('to test the property `city`', () async {
+      // TODO
+    });
+
+    // String state
+    test('to test the property `state`', () async {
+      // TODO
+    });
+
+    // String country
+    test('to test the property `country`', () async {
+      // TODO
+    });
+
+    // Image info
+    // String make
+    test('to test the property `make`', () async {
+      // TODO
+    });
+
+    // String model
+    test('to test the property `model`', () async {
+      // TODO
+    });
+
+    // String imageName
+    test('to test the property `imageName`', () async {
+      // TODO
+    });
+
+    // String lensModel
+    test('to test the property `lensModel`', () async {
+      // TODO
+    });
+
+    // num fNumber
+    test('to test the property `fNumber`', () async {
+      // TODO
+    });
+
+    // num focalLength
+    test('to test the property `focalLength`', () async {
+      // TODO
+    });
+
+    // num iso
+    test('to test the property `iso`', () async {
+      // TODO
+    });
+
+    // num exposureTime
+    test('to test the property `exposureTime`', () async {
+      // TODO
+    });
+
+    // Video info
+    // num fps
+    test('to test the property `fps`', () async {
+      // TODO
+    });
+
+    // AssetEntity asset
+    test('to test the property `asset`', () async {
+      // TODO
+    });
+
+    // String exifTextSearchableColumn
+    test('to test the property `exifTextSearchableColumn`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 47 - 0
mobile/openapi/test/smart_info_entity_test.dart

@@ -0,0 +1,47 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for SmartInfoEntity
+void main() {
+  // final instance = SmartInfoEntity();
+
+  group('test SmartInfoEntity', () {
+    // String id
+    test('to test the property `id`', () async {
+      // TODO
+    });
+
+    // String assetId
+    test('to test the property `assetId`', () async {
+      // TODO
+    });
+
+    // List<String> tags (default value: const [])
+    test('to test the property `tags`', () async {
+      // TODO
+    });
+
+    // List<String> objects (default value: const [])
+    test('to test the property `objects`', () async {
+      // TODO
+    });
+
+    // AssetEntity asset
+    test('to test the property `asset`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 46 - 0
mobile/openapi/test/tag_api_test.dart

@@ -0,0 +1,46 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+
+/// tests for TagApi
+void main() {
+  // final instance = TagApi();
+
+  group('tests for TagApi', () {
+    //Future<TagEntity> create(CreateTagDto createTagDto) async
+    test('test create', () async {
+      // TODO
+    });
+
+    //Future<List<TagEntity>> findAll() async
+    test('test findAll', () async {
+      // TODO
+    });
+
+    //Future<Object> findOne(String id) async
+    test('test findOne', () async {
+      // TODO
+    });
+
+    //Future<String> remove(String id) async
+    test('test remove', () async {
+      // TODO
+    });
+
+    //Future<String> update(String id, UpdateTagDto updateTagDto) async
+    test('test update', () async {
+      // TODO
+    });
+
+  });
+}

+ 52 - 0
mobile/openapi/test/tag_entity_test.dart

@@ -0,0 +1,52 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for TagEntity
+void main() {
+  // final instance = TagEntity();
+
+  group('test TagEntity', () {
+    // String id
+    test('to test the property `id`', () async {
+      // TODO
+    });
+
+    // String type
+    test('to test the property `type`', () async {
+      // TODO
+    });
+
+    // String name
+    test('to test the property `name`', () async {
+      // TODO
+    });
+
+    // String renameTagId
+    test('to test the property `renameTagId`', () async {
+      // TODO
+    });
+
+    // List<AssetEntity> assets (default value: const [])
+    test('to test the property `assets`', () async {
+      // TODO
+    });
+
+    // UserEntity user
+    test('to test the property `user`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 37 - 0
mobile/openapi/test/tag_response_dto_test.dart

@@ -0,0 +1,37 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for TagResponseDto
+void main() {
+  // final instance = TagResponseDto();
+
+  group('test TagResponseDto', () {
+    // String id
+    test('to test the property `id`', () async {
+      // TODO
+    });
+
+    // TagTypeEnum type
+    test('to test the property `type`', () async {
+      // TODO
+    });
+
+    // String name
+    test('to test the property `name`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 21 - 0
mobile/openapi/test/tag_type_enum_test.dart

@@ -0,0 +1,21 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for TagTypeEnum
+void main() {
+
+  group('test TagTypeEnum', () {
+
+  });
+
+}

+ 32 - 0
mobile/openapi/test/update_tag_dto_test.dart

@@ -0,0 +1,32 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for UpdateTagDto
+void main() {
+  // final instance = UpdateTagDto();
+
+  group('test UpdateTagDto', () {
+    // String name
+    test('to test the property `name`', () async {
+      // TODO
+    });
+
+    // String renameTagId
+    test('to test the property `renameTagId`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 82 - 0
mobile/openapi/test/user_entity_test.dart

@@ -0,0 +1,82 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for UserEntity
+void main() {
+  // final instance = UserEntity();
+
+  group('test UserEntity', () {
+    // String id
+    test('to test the property `id`', () async {
+      // TODO
+    });
+
+    // String firstName
+    test('to test the property `firstName`', () async {
+      // TODO
+    });
+
+    // String lastName
+    test('to test the property `lastName`', () async {
+      // TODO
+    });
+
+    // bool isAdmin
+    test('to test the property `isAdmin`', () async {
+      // TODO
+    });
+
+    // String email
+    test('to test the property `email`', () async {
+      // TODO
+    });
+
+    // String password
+    test('to test the property `password`', () async {
+      // TODO
+    });
+
+    // String salt
+    test('to test the property `salt`', () async {
+      // TODO
+    });
+
+    // String profileImagePath
+    test('to test the property `profileImagePath`', () async {
+      // TODO
+    });
+
+    // bool shouldChangePassword
+    test('to test the property `shouldChangePassword`', () async {
+      // TODO
+    });
+
+    // String createdAt
+    test('to test the property `createdAt`', () async {
+      // TODO
+    });
+
+    // DateTime deletedAt
+    test('to test the property `deletedAt`', () async {
+      // TODO
+    });
+
+    // List<TagEntity> tags (default value: const [])
+    test('to test the property `tags`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 13 - 16
server/apps/immich/src/api-v1/album/album.module.ts

@@ -1,32 +1,29 @@
-import { Module } from '@nestjs/common';
+import { forwardRef, Module } from '@nestjs/common';
 import { AlbumService } from './album.service';
 import { AlbumController } from './album.controller';
 import { TypeOrmModule } from '@nestjs/typeorm';
-import { AssetEntity } from '@app/database/entities/asset.entity';
-import { UserEntity } from '@app/database/entities/user.entity';
 import { AlbumEntity } from '../../../../../libs/database/src/entities/album.entity';
 import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
 import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
 import { AlbumRepository, ALBUM_REPOSITORY } from './album-repository';
-import { AssetRepository, ASSET_REPOSITORY } from '../asset/asset-repository';
 import { DownloadModule } from '../../modules/download/download.module';
+import { AssetModule } from '../asset/asset.module';
+import { UserModule } from '../user/user.module';
+
+const ALBUM_REPOSITORY_PROVIDER = {
+  provide: ALBUM_REPOSITORY,
+  useClass: AlbumRepository,
+};
 
 @Module({
   imports: [
-    TypeOrmModule.forFeature([AssetEntity, UserEntity, AlbumEntity, AssetAlbumEntity, UserAlbumEntity]),
+    TypeOrmModule.forFeature([AlbumEntity, AssetAlbumEntity, UserAlbumEntity]),
     DownloadModule,
+    UserModule,
+    forwardRef(() => AssetModule),
   ],
   controllers: [AlbumController],
-  providers: [
-    AlbumService,
-    {
-      provide: ALBUM_REPOSITORY,
-      useClass: AlbumRepository,
-    },
-    {
-      provide: ASSET_REPOSITORY,
-      useClass: AssetRepository,
-    },
-  ],
+  providers: [AlbumService, ALBUM_REPOSITORY_PROVIDER],
+  exports: [ALBUM_REPOSITORY_PROVIDER],
 })
 export class AlbumModule {}

+ 17 - 9
server/apps/immich/src/api-v1/asset/asset-repository.ts

@@ -1,7 +1,7 @@
 import { SearchPropertiesDto } from './dto/search-properties.dto';
 import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
 import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
-import { BadRequestException, Injectable } from '@nestjs/common';
+import { BadRequestException, Inject, Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Repository } from 'typeorm/repository/Repository';
 import { CreateAssetDto } from './dto/create-asset.dto';
@@ -14,6 +14,7 @@ import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
 import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-assets-response.dto';
 import { In } from 'typeorm/find-options/operator/In';
 import { UpdateAssetDto } from './dto/update-asset.dto';
+import { ITagRepository, TAG_REPOSITORY } from '../tag/tag.repository';
 
 export interface IAssetRepository {
   create(
@@ -25,7 +26,7 @@ export interface IAssetRepository {
     checksum?: Buffer,
     livePhotoAssetEntity?: AssetEntity,
   ): Promise<AssetEntity>;
-  update(asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
+  update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity>;
   getAllByUserId(userId: string, skip?: number): Promise<AssetEntity[]>;
   getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
   getById(assetId: string): Promise<AssetEntity>;
@@ -53,6 +54,8 @@ export class AssetRepository implements IAssetRepository {
   constructor(
     @InjectRepository(AssetEntity)
     private assetRepository: Repository<AssetEntity>,
+
+    @Inject(TAG_REPOSITORY) private _tagRepository: ITagRepository,
   ) {}
 
   async getAssetWithNoSmartInfo(): Promise<AssetEntity[]> {
@@ -222,7 +225,7 @@ export class AssetRepository implements IAssetRepository {
       where: {
         id: assetId,
       },
-      relations: ['exifInfo'],
+      relations: ['exifInfo', 'tags'],
     });
   }
 
@@ -237,9 +240,9 @@ export class AssetRepository implements IAssetRepository {
       .andWhere('asset.resizePath is not NULL')
       .andWhere('asset.isVisible = true')
       .leftJoinAndSelect('asset.exifInfo', 'exifInfo')
+      .leftJoinAndSelect('asset.tags', 'tags')
       .skip(skip || 0)
       .orderBy('asset.createdAt', 'DESC');
-
     return await query.getMany();
   }
 
@@ -286,9 +289,14 @@ export class AssetRepository implements IAssetRepository {
   /**
    * Update asset
    */
-  async update(asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity> {
+  async update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise<AssetEntity> {
     asset.isFavorite = dto.isFavorite ?? asset.isFavorite;
 
+    if (dto.tagIds) {
+      const tags = await this._tagRepository.getByIds(userId, dto.tagIds);
+      asset.tags = tags;
+    }
+
     return await this.assetRepository.save(asset);
   }
 
@@ -347,10 +355,10 @@ export class AssetRepository implements IAssetRepository {
 
   async countByIdAndUser(assetId: string, userId: string): Promise<number> {
     return await this.assetRepository.count({
-        where: {
-          id: assetId,
-          userId
-      }
+      where: {
+        id: assetId,
+        userId,
+      },
     });
   }
 }

+ 4 - 4
server/apps/immich/src/api-v1/asset/asset.controller.ts

@@ -216,14 +216,14 @@ export class AssetController {
   /**
    * Update an asset
    */
-  @Put('/assetById/:assetId')
-  async updateAssetById(
+  @Put('/:assetId')
+  async updateAsset(
     @GetAuthUser() authUser: AuthUserDto,
     @Param('assetId') assetId: string,
-    @Body() dto: UpdateAssetDto,
+    @Body(ValidationPipe) dto: UpdateAssetDto,
   ): Promise<AssetResponseDto> {
     await this.assetService.checkAssetsAccess(authUser, [assetId], true);
-    return await this.assetService.updateAssetById(assetId, dto);
+    return await this.assetService.updateAsset(authUser, assetId, dto);
   }
 
   @Delete('/')

+ 16 - 20
server/apps/immich/src/api-v1/asset/asset.module.ts

@@ -1,4 +1,4 @@
-import { Module } from '@nestjs/common';
+import { forwardRef, Module } from '@nestjs/common';
 import { AssetService } from './asset.service';
 import { AssetController } from './asset.controller';
 import { TypeOrmModule } from '@nestjs/typeorm';
@@ -10,18 +10,25 @@ import { CommunicationModule } from '../communication/communication.module';
 import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
 import { AssetRepository, ASSET_REPOSITORY } from './asset-repository';
 import { DownloadModule } from '../../modules/download/download.module';
-import { ALBUM_REPOSITORY, AlbumRepository } from '../album/album-repository';
-import { AlbumEntity } from '@app/database/entities/album.entity';
-import { UserAlbumEntity } from '@app/database/entities/user-album.entity';
-import { UserEntity } from '@app/database/entities/user.entity';
-import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
+import { TagModule } from '../tag/tag.module';
+import { AlbumModule } from '../album/album.module';
+import { UserModule } from '../user/user.module';
+
+const ASSET_REPOSITORY_PROVIDER = {
+  provide: ASSET_REPOSITORY,
+  useClass: AssetRepository,
+};
 
 @Module({
   imports: [
+    TypeOrmModule.forFeature([AssetEntity]),
     CommunicationModule,
     BackgroundTaskModule,
     DownloadModule,
-    TypeOrmModule.forFeature([AssetEntity, AlbumEntity, UserAlbumEntity, UserEntity, AssetAlbumEntity]),
+    UserModule,
+    AlbumModule,
+    TagModule,
+    forwardRef(() => AlbumModule),
     BullModule.registerQueue({
       name: QueueNameEnum.ASSET_UPLOADED,
       defaultJobOptions: {
@@ -40,18 +47,7 @@ import { AssetAlbumEntity } from '@app/database/entities/asset-album.entity';
     }),
   ],
   controllers: [AssetController],
-  providers: [
-    AssetService,
-    BackgroundTaskService,
-    {
-      provide: ASSET_REPOSITORY,
-      useClass: AssetRepository,
-    },
-    {
-      provide: ALBUM_REPOSITORY,
-      useClass: AlbumRepository,
-    },
-  ],
-  exports: [AssetService],
+  providers: [AssetService, BackgroundTaskService, ASSET_REPOSITORY_PROVIDER],
+  exports: [ASSET_REPOSITORY_PROVIDER],
 })
 export class AssetModule {}

+ 2 - 2
server/apps/immich/src/api-v1/asset/asset.service.ts

@@ -231,13 +231,13 @@ export class AssetService {
     return mapAsset(asset);
   }
 
-  public async updateAssetById(assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
+  public async updateAsset(authUser: AuthUserDto, assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
     const asset = await this._assetRepository.getById(assetId);
     if (!asset) {
       throw new BadRequestException('Asset not found');
     }
 
-    const updatedAsset = await this._assetRepository.update(asset, dto);
+    const updatedAsset = await this._assetRepository.update(authUser.id, asset, dto);
 
     return mapAsset(updatedAsset);
   }

+ 20 - 2
server/apps/immich/src/api-v1/asset/dto/update-asset.dto.ts

@@ -1,6 +1,24 @@
-import { IsBoolean } from 'class-validator';
+import { ApiProperty } from '@nestjs/swagger';
+import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
 
 export class UpdateAssetDto {
+  @IsOptional()
   @IsBoolean()
-  isFavorite!: boolean;
+  isFavorite?: boolean;
+
+  @IsOptional()
+  @IsArray()
+  @IsString({ each: true })
+  @IsNotEmpty({ each: true })
+  @ApiProperty({
+    isArray: true,
+    type: String,
+    title: 'Array of tag IDs to add to the asset',
+    example: [
+      'bf973405-3f2a-48d2-a687-2ed4167164be',
+      'dd41870b-5d00-46d2-924e-1d8489a0aa0f',
+      'fad77c3f-deef-4e7e-9608-14c1aa4e559a',
+    ],
+  })
+  tagIds?: string[];
 }

+ 3 - 0
server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts

@@ -1,5 +1,6 @@
 import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
 import { ApiProperty } from '@nestjs/swagger';
+import { mapTag, TagResponseDto } from '../../tag/response-dto/tag-response.dto';
 import { ExifResponseDto, mapExif } from './exif-response.dto';
 import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
 
@@ -23,6 +24,7 @@ export class AssetResponseDto {
   exifInfo?: ExifResponseDto;
   smartInfo?: SmartInfoResponseDto;
   livePhotoVideoId?: string | null;
+  tags!: TagResponseDto[];
 }
 
 export function mapAsset(entity: AssetEntity): AssetResponseDto {
@@ -44,5 +46,6 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
     exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
     smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
     livePhotoVideoId: entity.livePhotoVideoId,
+    tags: entity.tags?.map(mapTag),
   };
 }

+ 8 - 12
server/apps/immich/src/api-v1/job/job.module.ts

@@ -5,18 +5,21 @@ import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
 import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
 import { JwtModule } from '@nestjs/jwt';
 import { jwtConfig } from '../../config/jwt.config';
-import { UserEntity } from '@app/database/entities/user.entity';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { BullModule } from '@nestjs/bull';
 import { QueueNameEnum } from '@app/job';
-import { AssetEntity } from '@app/database/entities/asset.entity';
 import { ExifEntity } from '@app/database/entities/exif.entity';
-import { AssetRepository, ASSET_REPOSITORY } from '../asset/asset-repository';
+import { TagModule } from '../tag/tag.module';
+import { AssetModule } from '../asset/asset.module';
+import { UserModule } from '../user/user.module';
 
 @Module({
   imports: [
-    TypeOrmModule.forFeature([UserEntity, AssetEntity, ExifEntity]),
+    TypeOrmModule.forFeature([ExifEntity]),
     ImmichJwtModule,
+    TagModule,
+    AssetModule,
+    UserModule,
     JwtModule.register(jwtConfig),
     BullModule.registerQueue(
       {
@@ -70,13 +73,6 @@ import { AssetRepository, ASSET_REPOSITORY } from '../asset/asset-repository';
     ),
   ],
   controllers: [JobController],
-  providers: [
-    JobService,
-    ImmichJwtService,
-    {
-      provide: ASSET_REPOSITORY,
-      useClass: AssetRepository,
-    },
-  ],
+  providers: [JobService, ImmichJwtService],
 })
 export class JobModule {}

+ 14 - 0
server/apps/immich/src/api-v1/tag/dto/create-tag.dto.ts

@@ -0,0 +1,14 @@
+import { TagType } from '@app/database/entities/tag.entity';
+import { ApiProperty } from '@nestjs/swagger';
+import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
+
+export class CreateTagDto {
+  @IsString()
+  @IsNotEmpty()
+  name!: string;
+
+  @IsEnum(TagType)
+  @IsNotEmpty()
+  @ApiProperty({ enumName: 'TagTypeEnum', enum: TagType })
+  type!: TagType;
+}

+ 11 - 0
server/apps/immich/src/api-v1/tag/dto/update-tag.dto.ts

@@ -0,0 +1,11 @@
+import { IsOptional, IsString } from 'class-validator';
+
+export class UpdateTagDto {
+  @IsString()
+  @IsOptional()
+  name?: string;
+
+  @IsString()
+  @IsOptional()
+  renameTagId?: string;
+}

+ 20 - 0
server/apps/immich/src/api-v1/tag/response-dto/tag-response.dto.ts

@@ -0,0 +1,20 @@
+import { TagEntity, TagType } from '@app/database/entities/tag.entity';
+import { ApiProperty } from '@nestjs/swagger';
+
+export class TagResponseDto {
+  @ApiProperty()
+  id!: string;
+
+  @ApiProperty({ enumName: 'TagTypeEnum', enum: TagType })
+  type!: string;
+
+  name!: string;
+}
+
+export function mapTag(entity: TagEntity): TagResponseDto {
+  return {
+    id: entity.id,
+    type: entity.type,
+    name: entity.name,
+  };
+}

+ 44 - 0
server/apps/immich/src/api-v1/tag/tag.controller.ts

@@ -0,0 +1,44 @@
+import { Controller, Get, Post, Body, Patch, Param, Delete, ValidationPipe } from '@nestjs/common';
+import { TagService } from './tag.service';
+import { CreateTagDto } from './dto/create-tag.dto';
+import { UpdateTagDto } from './dto/update-tag.dto';
+import { Authenticated } from '../../decorators/authenticated.decorator';
+import { ApiTags } from '@nestjs/swagger';
+import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
+import { TagEntity } from '@app/database/entities/tag.entity';
+
+@Authenticated()
+@ApiTags('Tag')
+@Controller('tag')
+export class TagController {
+  constructor(private readonly tagService: TagService) {}
+
+  @Post()
+  create(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createTagDto: CreateTagDto): Promise<TagEntity> {
+    return this.tagService.create(authUser, createTagDto);
+  }
+
+  @Get()
+  findAll(@GetAuthUser() authUser: AuthUserDto) {
+    return this.tagService.findAll(authUser);
+  }
+
+  @Get(':id')
+  findOne(@GetAuthUser() authUser: AuthUserDto, @Param('id') id: string) {
+    return this.tagService.findOne(authUser, id);
+  }
+
+  @Patch(':id')
+  update(
+    @GetAuthUser() authUser: AuthUserDto,
+    @Param('id') id: string,
+    @Body(ValidationPipe) updateTagDto: UpdateTagDto,
+  ) {
+    return this.tagService.update(authUser, id, updateTagDto);
+  }
+
+  @Delete(':id')
+  delete(@GetAuthUser() authUser: AuthUserDto, @Param('id') id: string) {
+    return this.tagService.remove(authUser, id);
+  }
+}

+ 18 - 0
server/apps/immich/src/api-v1/tag/tag.module.ts

@@ -0,0 +1,18 @@
+import { Module } from '@nestjs/common';
+import { TagService } from './tag.service';
+import { TagController } from './tag.controller';
+import { TagEntity } from '@app/database/entities/tag.entity';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { TagRepository, TAG_REPOSITORY } from './tag.repository';
+
+const TAG_REPOSITORY_PROVIDER = {
+  provide: TAG_REPOSITORY,
+  useClass: TagRepository,
+};
+@Module({
+  imports: [TypeOrmModule.forFeature([TagEntity])],
+  controllers: [TagController],
+  providers: [TagService, TAG_REPOSITORY_PROVIDER],
+  exports: [TAG_REPOSITORY_PROVIDER],
+})
+export class TagModule {}

+ 61 - 0
server/apps/immich/src/api-v1/tag/tag.repository.ts

@@ -0,0 +1,61 @@
+import { TagEntity, TagType } from '@app/database/entities/tag.entity';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { In, Repository } from 'typeorm';
+import { UpdateTagDto } from './dto/update-tag.dto';
+
+export interface ITagRepository {
+  create(userId: string, tagType: TagType, tagName: string): Promise<TagEntity>;
+  getByIds(userId: string, tagIds: string[]): Promise<TagEntity[]>;
+  getById(tagId: string, userId: string): Promise<TagEntity | null>;
+  getByUserId(userId: string): Promise<TagEntity[]>;
+  update(tag: TagEntity, updateTagDto: UpdateTagDto): Promise<TagEntity | null>;
+  remove(tag: TagEntity): Promise<TagEntity>;
+}
+
+export const TAG_REPOSITORY = 'TAG_REPOSITORY';
+
+@Injectable()
+export class TagRepository implements ITagRepository {
+  constructor(
+    @InjectRepository(TagEntity)
+    private tagRepository: Repository<TagEntity>,
+  ) {}
+
+  async create(userId: string, tagType: TagType, tagName: string): Promise<TagEntity> {
+    const tag = new TagEntity();
+    tag.name = tagName;
+    tag.type = tagType;
+    tag.userId = userId;
+
+    return this.tagRepository.save(tag);
+  }
+
+  async getById(tagId: string, userId: string): Promise<TagEntity | null> {
+    return await this.tagRepository.findOne({ where: { id: tagId, userId }, relations: ['user'] });
+  }
+
+  async getByIds(userId: string, tagIds: string[]): Promise<TagEntity[]> {
+    return await this.tagRepository.find({
+      where: { id: In(tagIds), userId },
+      relations: {
+        user: true,
+      },
+    });
+  }
+
+  async getByUserId(userId: string): Promise<TagEntity[]> {
+    return await this.tagRepository.find({ where: { userId } });
+  }
+
+  async update(tag: TagEntity, updateTagDto: UpdateTagDto): Promise<TagEntity> {
+    tag.name = updateTagDto.name ?? tag.name;
+    tag.renameTagId = updateTagDto.renameTagId ?? tag.renameTagId;
+
+    return this.tagRepository.save(tag);
+  }
+
+  async remove(tag: TagEntity): Promise<TagEntity> {
+    return await this.tagRepository.remove(tag);
+  }
+}

+ 91 - 0
server/apps/immich/src/api-v1/tag/tag.service.spec.ts

@@ -0,0 +1,91 @@
+import { TagEntity, TagType } from '@app/database/entities/tag.entity';
+import { UserEntity } from '@app/database/entities/user.entity';
+import { AuthUserDto } from '../../decorators/auth-user.decorator';
+import { ITagRepository } from './tag.repository';
+import { TagService } from './tag.service';
+
+describe('TagService', () => {
+  let sut: TagService;
+  let tagRepositoryMock: jest.Mocked<ITagRepository>;
+
+  const user1AuthUser: AuthUserDto = Object.freeze({
+    id: '1111',
+    email: 'testuser@email.com',
+  });
+
+  const user1: UserEntity = Object.freeze({
+    id: '1111',
+    firstName: 'Alex',
+    lastName: 'Tran',
+    isAdmin: true,
+    email: 'testuser@email.com',
+    profileImagePath: '',
+    shouldChangePassword: true,
+    createdAt: '2022-12-02T19:29:23.603Z',
+    deletedAt: undefined,
+    tags: [],
+    oauthId: 'oauth-id-1',
+  });
+
+  // const user2: UserEntity = Object.freeze({
+  //   id: '2222',
+  //   firstName: 'Alex',
+  //   lastName: 'Tran',
+  //   isAdmin: true,
+  //   email: 'testuser2@email.com',
+  //   profileImagePath: '',
+  //   shouldChangePassword: true,
+  //   createdAt: '2022-12-02T19:29:23.603Z',
+  //   deletedAt: undefined,
+  //   tags: [],
+  //   oauthId: 'oauth-id-2',
+  // });
+
+  const user1Tag1: TagEntity = Object.freeze({
+    name: 'user 1 tag 1',
+    type: TagType.CUSTOM,
+    userId: user1.id,
+    user: user1,
+    renameTagId: '',
+    id: 'user1-tag-1-id',
+    assets: [],
+  });
+
+  // const user1Tag2: TagEntity = Object.freeze({
+  //   name: 'user 1 tag 2',
+  //   type: TagType.CUSTOM,
+  //   userId: user1.id,
+  //   user: user1,
+  //   renameTagId: '',
+  //   id: 'user1-tag-2-id',
+  //   assets: [],
+  // });
+
+  beforeAll(() => {
+    tagRepositoryMock = {
+      create: jest.fn(),
+      getByIds: jest.fn(),
+      getById: jest.fn(),
+      getByUserId: jest.fn(),
+      remove: jest.fn(),
+      update: jest.fn(),
+    };
+
+    sut = new TagService(tagRepositoryMock);
+  });
+
+  it('creates tag', async () => {
+    const createTagDto = {
+      name: 'user 1 tag 1',
+      type: TagType.CUSTOM,
+    };
+
+    tagRepositoryMock.create.mockResolvedValue(user1Tag1);
+
+    const result = await sut.create(user1AuthUser, createTagDto);
+
+    expect(result.userId).toEqual(user1AuthUser.id);
+    expect(result.name).toEqual(createTagDto.name);
+    expect(result.type).toEqual(createTagDto.type);
+  });
+});

+ 48 - 0
server/apps/immich/src/api-v1/tag/tag.service.ts

@@ -0,0 +1,48 @@
+import { TagEntity } from '@app/database/entities/tag.entity';
+import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
+import { AuthUserDto } from '../../decorators/auth-user.decorator';
+import { CreateTagDto } from './dto/create-tag.dto';
+import { UpdateTagDto } from './dto/update-tag.dto';
+import { ITagRepository, TAG_REPOSITORY } from './tag.repository';
+
+@Injectable()
+export class TagService {
+  readonly logger = new Logger(TagService.name);
+
+  constructor(@Inject(TAG_REPOSITORY) private _tagRepository: ITagRepository) {}
+
+  async create(authUser: AuthUserDto, createTagDto: CreateTagDto) {
+    try {
+      return await this._tagRepository.create(authUser.id, createTagDto.type, createTagDto.name);
+    } catch (e: any) {
+      this.logger.error(e, e.stack);
+      throw new BadRequestException(`Failed to create tag: ${e.detail}`);
+    }
+  }
+
+  async findAll(authUser: AuthUserDto) {
+    return await this._tagRepository.getByUserId(authUser.id);
+  }
+
+  async findOne(authUser: AuthUserDto, id: string): Promise<TagEntity> {
+    const tag = await this._tagRepository.getById(id, authUser.id);
+
+    if (!tag) {
+      throw new BadRequestException('Tag not found');
+    }
+
+    return tag;
+  }
+
+  async update(authUser: AuthUserDto, id: string, updateTagDto: UpdateTagDto) {
+    const tag = await this.findOne(authUser, id);
+
+    return this._tagRepository.update(tag, updateTagDto);
+  }
+
+  async remove(authUser: AuthUserDto, id: string) {
+    const tag = await this.findOne(authUser, id);
+
+    return this._tagRepository.remove(tag);
+  }
+}

+ 3 - 0
server/apps/immich/src/api-v1/user/user.service.spec.ts

@@ -31,6 +31,7 @@ describe('UserService', () => {
     shouldChangePassword: false,
     profileImagePath: '',
     createdAt: '2021-01-01',
+    tags: [],
   });
 
   const immichUser: UserEntity = Object.freeze({
@@ -45,6 +46,7 @@ describe('UserService', () => {
     shouldChangePassword: false,
     profileImagePath: '',
     createdAt: '2021-01-01',
+    tags: [],
   });
 
   const updatedImmichUser: UserEntity = Object.freeze({
@@ -59,6 +61,7 @@ describe('UserService', () => {
     shouldChangePassword: true,
     profileImagePath: '',
     createdAt: '2021-01-01',
+    tags: [],
   });
 
   beforeAll(() => {

+ 3 - 0
server/apps/immich/src/app.module.ts

@@ -18,6 +18,7 @@ import { DatabaseModule } from '@app/database';
 import { JobModule } from './api-v1/job/job.module';
 import { SystemConfigModule } from './api-v1/system-config/system-config.module';
 import { OAuthModule } from './api-v1/oauth/oauth.module';
+import { TagModule } from './api-v1/tag/tag.module';
 
 @Module({
   imports: [
@@ -63,6 +64,8 @@ import { OAuthModule } from './api-v1/oauth/oauth.module';
     JobModule,
 
     SystemConfigModule,
+
+    TagModule,
   ],
   controllers: [AppController],
   providers: [],

+ 1 - 1
server/apps/immich/src/main.ts

@@ -55,7 +55,7 @@ async function bootstrap() {
     if (process.env.NODE_ENV == 'development') {
       // Generate API Documentation only in development mode
       const outputPath = path.resolve(process.cwd(), 'immich-openapi-specs.json');
-      writeFileSync(outputPath, JSON.stringify(apiDocument), { encoding: 'utf8' });
+      writeFileSync(outputPath, JSON.stringify(apiDocument, null, 2), { encoding: 'utf8' });
       Logger.log(
         `Running Immich Server in DEVELOPMENT environment - version ${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`,
         'ImmichServer',

+ 1 - 0
server/apps/immich/src/modules/immich-jwt/immich-jwt.service.spec.ts

@@ -56,6 +56,7 @@ describe('ImmichJwtService', () => {
         profileImagePath: '',
         shouldChangePassword: false,
         createdAt: 'today',
+        tags: [],
       };
 
       const dto: LoginResponseDto = {

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
server/immich-openapi-specs.json


+ 7 - 1
server/libs/database/src/entities/asset.entity.ts

@@ -1,6 +1,7 @@
-import { Column, Entity, Index, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
+import { Column, Entity, Index, JoinTable, ManyToMany, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
 import { ExifEntity } from './exif.entity';
 import { SmartInfoEntity } from './smart-info.entity';
+import { TagEntity } from './tag.entity';
 
 @Entity('assets')
 @Unique('UQ_userid_checksum', ['userId', 'checksum'])
@@ -62,6 +63,11 @@ export class AssetEntity {
 
   @OneToOne(() => SmartInfoEntity, (smartInfoEntity) => smartInfoEntity.asset)
   smartInfo?: SmartInfoEntity;
+
+  // https://github.com/typeorm/typeorm/blob/master/docs/many-to-many-relations.md
+  @ManyToMany(() => TagEntity, (tag) => tag.assets, { cascade: true })
+  @JoinTable({ name: 'tag_asset' })
+  tags!: TagEntity[];
 }
 
 export enum AssetType {

+ 45 - 0
server/libs/database/src/entities/tag.entity.ts

@@ -0,0 +1,45 @@
+import { Column, Entity, ManyToMany, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
+import { AssetEntity } from './asset.entity';
+import { UserEntity } from './user.entity';
+
+@Entity('tags')
+@Unique('UQ_tag_name_userId', ['name', 'userId'])
+export class TagEntity {
+  @PrimaryGeneratedColumn('uuid')
+  id!: string;
+
+  @Column()
+  type!: TagType;
+
+  @Column()
+  name!: string;
+
+  @Column()
+  userId!: string;
+
+  @Column({ type: 'uuid', comment: 'The new renamed tagId', nullable: true })
+  renameTagId!: string;
+
+  @ManyToMany(() => AssetEntity, (asset) => asset.tags)
+  assets!: AssetEntity[];
+
+  @ManyToOne(() => UserEntity, (user) => user.tags)
+  user!: UserEntity;
+}
+
+export enum TagType {
+  /**
+   * Tag that is detected by the ML model for object detection will use this type
+   */
+  OBJECT = 'OBJECT',
+
+  /**
+   * Face that is detected by the ML model for facial detection (TBD/NOT YET IMPLEMENTED) will use this type
+   */
+  FACE = 'FACE',
+
+  /**
+   * Tag that is created by the user will use this type
+   */
+  CUSTOM = 'CUSTOM',
+}

+ 5 - 1
server/libs/database/src/entities/user.entity.ts

@@ -1,4 +1,5 @@
-import { Column, CreateDateColumn, DeleteDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
+import { Column, CreateDateColumn, DeleteDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
+import { TagEntity } from './tag.entity';
 
 @Entity('users')
 export class UserEntity {
@@ -37,4 +38,7 @@ export class UserEntity {
 
   @DeleteDateColumn()
   deletedAt?: Date;
+
+  @OneToMany(() => TagEntity, (tag) => tag.user)
+  tags!: TagEntity[];
 }

+ 26 - 0
server/libs/database/src/migrations/1670257571385-CreateTagsTable.ts

@@ -0,0 +1,26 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class CreateTagsTable1670257571385 implements MigrationInterface {
+    name = 'CreateTagsTable1670257571385'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`CREATE TABLE "tags" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "type" character varying NOT NULL, "name" character varying NOT NULL, "userId" uuid NOT NULL, "renameTagId" uuid, CONSTRAINT "UQ_tag_name_userId" UNIQUE ("name", "userId"), CONSTRAINT "PK_e7dc17249a1148a1970748eda99" PRIMARY KEY ("id")); COMMENT ON COLUMN "tags"."renameTagId" IS 'The new renamed tagId'`);
+        await queryRunner.query(`CREATE TABLE "tag_asset" ("assetsId" uuid NOT NULL, "tagsId" uuid NOT NULL, CONSTRAINT "PK_ef5346fe522b5fb3bc96454747e" PRIMARY KEY ("assetsId", "tagsId"))`);
+        await queryRunner.query(`CREATE INDEX "IDX_f8e8a9e893cb5c54907f1b798e" ON "tag_asset" ("assetsId") `);
+        await queryRunner.query(`CREATE INDEX "IDX_e99f31ea4cdf3a2c35c7287eb4" ON "tag_asset" ("tagsId") `);
+        await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
+        await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42" FOREIGN KEY ("tagsId") REFERENCES "tags"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42"`);
+        await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9"`);
+        await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_e99f31ea4cdf3a2c35c7287eb4"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_f8e8a9e893cb5c54907f1b798e"`);
+        await queryRunner.query(`DROP TABLE "tag_asset"`);
+        await queryRunner.query(`DROP TABLE "tags"`);
+    }
+
+}

+ 943 - 22
web/src/api/open-api/api.ts

@@ -325,6 +325,143 @@ export interface AssetCountByUserIdResponseDto {
      */
     'total': number;
 }
+/**
+ * 
+ * @export
+ * @interface AssetEntity
+ */
+export interface AssetEntity {
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetEntity
+     */
+    'id': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetEntity
+     */
+    'deviceAssetId': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetEntity
+     */
+    'userId': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetEntity
+     */
+    'deviceId': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetEntity
+     */
+    'type': AssetEntityTypeEnum;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetEntity
+     */
+    'originalPath': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetEntity
+     */
+    'resizePath': string | null;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetEntity
+     */
+    'webpPath': string | null;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetEntity
+     */
+    'encodedVideoPath': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetEntity
+     */
+    'createdAt': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetEntity
+     */
+    'modifiedAt': string;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetEntity
+     */
+    'isFavorite': boolean;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetEntity
+     */
+    'mimeType': string | null;
+    /**
+     * 
+     * @type {object}
+     * @memberof AssetEntity
+     */
+    'checksum'?: object | null;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetEntity
+     */
+    'duration': string | null;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetEntity
+     */
+    'isVisible': boolean;
+    /**
+     * 
+     * @type {string}
+     * @memberof AssetEntity
+     */
+    'livePhotoVideoId': string | null;
+    /**
+     * 
+     * @type {ExifEntity}
+     * @memberof AssetEntity
+     */
+    'exifInfo'?: ExifEntity;
+    /**
+     * 
+     * @type {SmartInfoEntity}
+     * @memberof AssetEntity
+     */
+    'smartInfo'?: SmartInfoEntity;
+    /**
+     * 
+     * @type {Array<TagEntity>}
+     * @memberof AssetEntity
+     */
+    'tags': Array<TagEntity>;
+}
+
+export const AssetEntityTypeEnum = {
+    Image: 'IMAGE',
+    Video: 'VIDEO',
+    Audio: 'AUDIO',
+    Other: 'OTHER'
+} as const;
+
+export type AssetEntityTypeEnum = typeof AssetEntityTypeEnum[keyof typeof AssetEntityTypeEnum];
+
 /**
  * 
  * @export
@@ -446,6 +583,12 @@ export interface AssetResponseDto {
      * @memberof AssetResponseDto
      */
     'livePhotoVideoId'?: string | null;
+    /**
+     * 
+     * @type {Array<TagResponseDto>}
+     * @memberof AssetResponseDto
+     */
+    'tags': Array<TagResponseDto>;
 }
 /**
  * 
@@ -602,6 +745,25 @@ export interface CreateProfileImageResponseDto {
      */
     'profileImagePath': string;
 }
+/**
+ * 
+ * @export
+ * @interface CreateTagDto
+ */
+export interface CreateTagDto {
+    /**
+     * 
+     * @type {TagTypeEnum}
+     * @memberof CreateTagDto
+     */
+    'type': TagTypeEnum;
+    /**
+     * 
+     * @type {string}
+     * @memberof CreateTagDto
+     */
+    'name': string;
+}
 /**
  * 
  * @export
@@ -811,6 +973,163 @@ export const DeviceTypeEnum = {
 export type DeviceTypeEnum = typeof DeviceTypeEnum[keyof typeof DeviceTypeEnum];
 
 
+/**
+ * 
+ * @export
+ * @interface ExifEntity
+ */
+export interface ExifEntity {
+    /**
+     * 
+     * @type {string}
+     * @memberof ExifEntity
+     */
+    'id': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof ExifEntity
+     */
+    'assetId': string;
+    /**
+     * General info
+     * @type {string}
+     * @memberof ExifEntity
+     */
+    'description': string;
+    /**
+     * 
+     * @type {number}
+     * @memberof ExifEntity
+     */
+    'exifImageWidth': number | null;
+    /**
+     * 
+     * @type {number}
+     * @memberof ExifEntity
+     */
+    'exifImageHeight': number | null;
+    /**
+     * 
+     * @type {number}
+     * @memberof ExifEntity
+     */
+    'fileSizeInByte': number | null;
+    /**
+     * 
+     * @type {string}
+     * @memberof ExifEntity
+     */
+    'orientation': string | null;
+    /**
+     * 
+     * @type {string}
+     * @memberof ExifEntity
+     */
+    'dateTimeOriginal': string | null;
+    /**
+     * 
+     * @type {string}
+     * @memberof ExifEntity
+     */
+    'modifyDate': string | null;
+    /**
+     * 
+     * @type {number}
+     * @memberof ExifEntity
+     */
+    'latitude': number | null;
+    /**
+     * 
+     * @type {number}
+     * @memberof ExifEntity
+     */
+    'longitude': number | null;
+    /**
+     * 
+     * @type {string}
+     * @memberof ExifEntity
+     */
+    'city': string | null;
+    /**
+     * 
+     * @type {string}
+     * @memberof ExifEntity
+     */
+    'state': string | null;
+    /**
+     * 
+     * @type {string}
+     * @memberof ExifEntity
+     */
+    'country': string | null;
+    /**
+     * Image info
+     * @type {string}
+     * @memberof ExifEntity
+     */
+    'make': string | null;
+    /**
+     * 
+     * @type {string}
+     * @memberof ExifEntity
+     */
+    'model': string | null;
+    /**
+     * 
+     * @type {string}
+     * @memberof ExifEntity
+     */
+    'imageName': string | null;
+    /**
+     * 
+     * @type {string}
+     * @memberof ExifEntity
+     */
+    'lensModel': string | null;
+    /**
+     * 
+     * @type {number}
+     * @memberof ExifEntity
+     */
+    'fNumber': number | null;
+    /**
+     * 
+     * @type {number}
+     * @memberof ExifEntity
+     */
+    'focalLength': number | null;
+    /**
+     * 
+     * @type {number}
+     * @memberof ExifEntity
+     */
+    'iso': number | null;
+    /**
+     * 
+     * @type {number}
+     * @memberof ExifEntity
+     */
+    'exposureTime': number | null;
+    /**
+     * Video info
+     * @type {number}
+     * @memberof ExifEntity
+     */
+    'fps'?: number | null;
+    /**
+     * 
+     * @type {AssetEntity}
+     * @memberof ExifEntity
+     */
+    'asset'?: AssetEntity;
+    /**
+     * 
+     * @type {string}
+     * @memberof ExifEntity
+     */
+    'exifTextSearchableColumn': string;
+}
 /**
  * 
  * @export
@@ -1400,6 +1719,43 @@ export interface SignUpDto {
      */
     'lastName': string;
 }
+/**
+ * 
+ * @export
+ * @interface SmartInfoEntity
+ */
+export interface SmartInfoEntity {
+    /**
+     * 
+     * @type {string}
+     * @memberof SmartInfoEntity
+     */
+    'id': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof SmartInfoEntity
+     */
+    'assetId': string;
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof SmartInfoEntity
+     */
+    'tags': Array<string> | null;
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof SmartInfoEntity
+     */
+    'objects': Array<string> | null;
+    /**
+     * 
+     * @type {AssetEntity}
+     * @memberof SmartInfoEntity
+     */
+    'asset'?: AssetEntity;
+}
 /**
  * 
  * @export
@@ -1489,18 +1845,116 @@ export interface SystemConfigResponseItem {
 /**
  * 
  * @export
- * @enum {string}
+ * @interface TagEntity
  */
-
-export const ThumbnailFormat = {
-    Jpeg: 'JPEG',
-    Webp: 'WEBP'
-} as const;
-
-export type ThumbnailFormat = typeof ThumbnailFormat[keyof typeof ThumbnailFormat];
-
-
-/**
+export interface TagEntity {
+    /**
+     * 
+     * @type {string}
+     * @memberof TagEntity
+     */
+    'id': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof TagEntity
+     */
+    'type': TagEntityTypeEnum;
+    /**
+     * 
+     * @type {string}
+     * @memberof TagEntity
+     */
+    'name': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof TagEntity
+     */
+    'userId': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof TagEntity
+     */
+    'renameTagId': string;
+    /**
+     * 
+     * @type {Array<AssetEntity>}
+     * @memberof TagEntity
+     */
+    'assets': Array<AssetEntity>;
+    /**
+     * 
+     * @type {UserEntity}
+     * @memberof TagEntity
+     */
+    'user': UserEntity;
+}
+
+export const TagEntityTypeEnum = {
+    Object: 'OBJECT',
+    Face: 'FACE',
+    Custom: 'CUSTOM'
+} as const;
+
+export type TagEntityTypeEnum = typeof TagEntityTypeEnum[keyof typeof TagEntityTypeEnum];
+
+/**
+ * 
+ * @export
+ * @interface TagResponseDto
+ */
+export interface TagResponseDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof TagResponseDto
+     */
+    'id': string;
+    /**
+     * 
+     * @type {TagTypeEnum}
+     * @memberof TagResponseDto
+     */
+    'type': TagTypeEnum;
+    /**
+     * 
+     * @type {string}
+     * @memberof TagResponseDto
+     */
+    'name': string;
+}
+/**
+ * 
+ * @export
+ * @enum {string}
+ */
+
+export const TagTypeEnum = {
+    Object: 'OBJECT',
+    Face: 'FACE',
+    Custom: 'CUSTOM'
+} as const;
+
+export type TagTypeEnum = typeof TagTypeEnum[keyof typeof TagTypeEnum];
+
+
+/**
+ * 
+ * @export
+ * @enum {string}
+ */
+
+export const ThumbnailFormat = {
+    Jpeg: 'JPEG',
+    Webp: 'WEBP'
+} as const;
+
+export type ThumbnailFormat = typeof ThumbnailFormat[keyof typeof ThumbnailFormat];
+
+
+/**
  * 
  * @export
  * @enum {string}
@@ -1539,12 +1993,18 @@ export interface UpdateAlbumDto {
  * @interface UpdateAssetDto
  */
 export interface UpdateAssetDto {
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof UpdateAssetDto
+     */
+    'tagIds'?: Array<string>;
     /**
      * 
      * @type {boolean}
      * @memberof UpdateAssetDto
      */
-    'isFavorite': boolean;
+    'isFavorite'?: boolean;
 }
 /**
  * 
@@ -1571,6 +2031,25 @@ export interface UpdateDeviceInfoDto {
      */
     'isAutoBackup'?: boolean;
 }
+/**
+ * 
+ * @export
+ * @interface UpdateTagDto
+ */
+export interface UpdateTagDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof UpdateTagDto
+     */
+    'name'?: string;
+    /**
+     * 
+     * @type {string}
+     * @memberof UpdateTagDto
+     */
+    'renameTagId'?: string;
+}
 /**
  * 
  * @export
@@ -1670,6 +2149,91 @@ export interface UserCountResponseDto {
      */
     'userCount': number;
 }
+/**
+ * 
+ * @export
+ * @interface UserEntity
+ */
+export interface UserEntity {
+    /**
+     * 
+     * @type {string}
+     * @memberof UserEntity
+     */
+    'id': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof UserEntity
+     */
+    'firstName': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof UserEntity
+     */
+    'lastName': string;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof UserEntity
+     */
+    'isAdmin': boolean;
+    /**
+     * 
+     * @type {string}
+     * @memberof UserEntity
+     */
+    'email': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof UserEntity
+     */
+    'password'?: string;
+    /**
+     * 
+     * @type {string}
+     * @memberof UserEntity
+     */
+    'salt'?: string;
+    /**
+     * 
+     * @type {string}
+     * @memberof UserEntity
+     */
+    'oauthId': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof UserEntity
+     */
+    'profileImagePath': string;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof UserEntity
+     */
+    'shouldChangePassword': boolean;
+    /**
+     * 
+     * @type {string}
+     * @memberof UserEntity
+     */
+    'createdAt': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof UserEntity
+     */
+    'deletedAt'?: string;
+    /**
+     * 
+     * @type {Array<TagEntity>}
+     * @memberof UserEntity
+     */
+    'tags': Array<TagEntity>;
+}
 /**
  * 
  * @export
@@ -3246,12 +3810,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        updateAssetById: async (assetId: string, updateAssetDto: UpdateAssetDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        updateAsset: async (assetId: string, updateAssetDto: UpdateAssetDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             // verify required parameter 'assetId' is not null or undefined
-            assertParamExists('updateAssetById', 'assetId', assetId)
+            assertParamExists('updateAsset', 'assetId', assetId)
             // verify required parameter 'updateAssetDto' is not null or undefined
-            assertParamExists('updateAssetById', 'updateAssetDto', updateAssetDto)
-            const localVarPath = `/asset/assetById/{assetId}`
+            assertParamExists('updateAsset', 'updateAssetDto', updateAssetDto)
+            const localVarPath = `/asset/{assetId}`
                 .replace(`{${"assetId"}}`, encodeURIComponent(String(assetId)));
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -3520,8 +4084,8 @@ export const AssetApiFp = function(configuration?: Configuration) {
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        async updateAssetById(assetId: string, updateAssetDto: UpdateAssetDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetResponseDto>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssetById(assetId, updateAssetDto, options);
+        async updateAsset(assetId: string, updateAssetDto: UpdateAssetDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.updateAsset(assetId, updateAssetDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         /**
@@ -3711,8 +4275,8 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        updateAssetById(assetId: string, updateAssetDto: UpdateAssetDto, options?: any): AxiosPromise<AssetResponseDto> {
-            return localVarFp.updateAssetById(assetId, updateAssetDto, options).then((request) => request(axios, basePath));
+        updateAsset(assetId: string, updateAssetDto: UpdateAssetDto, options?: any): AxiosPromise<AssetResponseDto> {
+            return localVarFp.updateAsset(assetId, updateAssetDto, options).then((request) => request(axios, basePath));
         },
         /**
          * 
@@ -3935,8 +4499,8 @@ export class AssetApi extends BaseAPI {
      * @throws {RequiredError}
      * @memberof AssetApi
      */
-    public updateAssetById(assetId: string, updateAssetDto: UpdateAssetDto, options?: AxiosRequestConfig) {
-        return AssetApiFp(this.configuration).updateAssetById(assetId, updateAssetDto, options).then((request) => request(this.axios, this.basePath));
+    public updateAsset(assetId: string, updateAssetDto: UpdateAssetDto, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).updateAsset(assetId, updateAssetDto, options).then((request) => request(this.axios, this.basePath));
     }
 
     /**
@@ -5250,6 +5814,363 @@ export class SystemConfigApi extends BaseAPI {
 }
 
 
+/**
+ * TagApi - axios parameter creator
+ * @export
+ */
+export const TagApiAxiosParamCreator = function (configuration?: Configuration) {
+    return {
+        /**
+         * 
+         * @param {string} id 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        _delete: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'id' is not null or undefined
+            assertParamExists('_delete', 'id', id)
+            const localVarPath = `/tag/{id}`
+                .replace(`{${"id"}}`, encodeURIComponent(String(id)));
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+
+    
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {CreateTagDto} createTagDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        create: async (createTagDto: CreateTagDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'createTagDto' is not null or undefined
+            assertParamExists('create', 'createTagDto', createTagDto)
+            const localVarPath = `/tag`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(createTagDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        findAll: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/tag`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+
+    
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {string} id 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        findOne: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'id' is not null or undefined
+            assertParamExists('findOne', 'id', id)
+            const localVarPath = `/tag/{id}`
+                .replace(`{${"id"}}`, encodeURIComponent(String(id)));
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+
+    
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {string} id 
+         * @param {UpdateTagDto} updateTagDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        update: async (id: string, updateTagDto: UpdateTagDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'id' is not null or undefined
+            assertParamExists('update', 'id', id)
+            // verify required parameter 'updateTagDto' is not null or undefined
+            assertParamExists('update', 'updateTagDto', updateTagDto)
+            const localVarPath = `/tag/{id}`
+                .replace(`{${"id"}}`, encodeURIComponent(String(id)));
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(updateTagDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+    }
+};
+
+/**
+ * TagApi - functional programming interface
+ * @export
+ */
+export const TagApiFp = function(configuration?: Configuration) {
+    const localVarAxiosParamCreator = TagApiAxiosParamCreator(configuration)
+    return {
+        /**
+         * 
+         * @param {string} id 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async _delete(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<TagEntity>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator._delete(id, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+        /**
+         * 
+         * @param {CreateTagDto} createTagDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async create(createTagDto: CreateTagDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<TagEntity>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.create(createTagDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async findAll(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<TagEntity>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.findAll(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+        /**
+         * 
+         * @param {string} id 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async findOne(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<TagEntity>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.findOne(id, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+        /**
+         * 
+         * @param {string} id 
+         * @param {UpdateTagDto} updateTagDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async update(id: string, updateTagDto: UpdateTagDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.update(id, updateTagDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+    }
+};
+
+/**
+ * TagApi - factory interface
+ * @export
+ */
+export const TagApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
+    const localVarFp = TagApiFp(configuration)
+    return {
+        /**
+         * 
+         * @param {string} id 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        _delete(id: string, options?: any): AxiosPromise<TagEntity> {
+            return localVarFp._delete(id, options).then((request) => request(axios, basePath));
+        },
+        /**
+         * 
+         * @param {CreateTagDto} createTagDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        create(createTagDto: CreateTagDto, options?: any): AxiosPromise<TagEntity> {
+            return localVarFp.create(createTagDto, options).then((request) => request(axios, basePath));
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        findAll(options?: any): AxiosPromise<Array<TagEntity>> {
+            return localVarFp.findAll(options).then((request) => request(axios, basePath));
+        },
+        /**
+         * 
+         * @param {string} id 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        findOne(id: string, options?: any): AxiosPromise<TagEntity> {
+            return localVarFp.findOne(id, options).then((request) => request(axios, basePath));
+        },
+        /**
+         * 
+         * @param {string} id 
+         * @param {UpdateTagDto} updateTagDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        update(id: string, updateTagDto: UpdateTagDto, options?: any): AxiosPromise<object> {
+            return localVarFp.update(id, updateTagDto, options).then((request) => request(axios, basePath));
+        },
+    };
+};
+
+/**
+ * TagApi - object-oriented interface
+ * @export
+ * @class TagApi
+ * @extends {BaseAPI}
+ */
+export class TagApi extends BaseAPI {
+    /**
+     * 
+     * @param {string} id 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof TagApi
+     */
+    public _delete(id: string, options?: AxiosRequestConfig) {
+        return TagApiFp(this.configuration)._delete(id, options).then((request) => request(this.axios, this.basePath));
+    }
+
+    /**
+     * 
+     * @param {CreateTagDto} createTagDto 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof TagApi
+     */
+    public create(createTagDto: CreateTagDto, options?: AxiosRequestConfig) {
+        return TagApiFp(this.configuration).create(createTagDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof TagApi
+     */
+    public findAll(options?: AxiosRequestConfig) {
+        return TagApiFp(this.configuration).findAll(options).then((request) => request(this.axios, this.basePath));
+    }
+
+    /**
+     * 
+     * @param {string} id 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof TagApi
+     */
+    public findOne(id: string, options?: AxiosRequestConfig) {
+        return TagApiFp(this.configuration).findOne(id, options).then((request) => request(this.axios, this.basePath));
+    }
+
+    /**
+     * 
+     * @param {string} id 
+     * @param {UpdateTagDto} updateTagDto 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof TagApi
+     */
+    public update(id: string, updateTagDto: UpdateTagDto, options?: AxiosRequestConfig) {
+        return TagApiFp(this.configuration).update(id, updateTagDto, options).then((request) => request(this.axios, this.basePath));
+    }
+}
+
+
 /**
  * UserApi - axios parameter creator
  * @export

+ 1 - 1
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -183,7 +183,7 @@
 	};
 
 	const toggleFavorite = async () => {
-		const { data } = await api.assetApi.updateAssetById(asset.id, {
+		const { data } = await api.assetApi.updateAsset(asset.id, {
 			isFavorite: !asset.isFavorite
 		});
 

+ 3 - 1
web/src/lib/components/shared-components/status-box.svelte

@@ -62,7 +62,9 @@
 						style={`width: ${getStorageUsagePercentage()}%`}
 					/>
 				</div>
-				<p class="text-xs">{asByteUnitString(serverInfo?.diskUseRaw)} of {asByteUnitString(serverInfo?.diskSizeRaw)} used</p>
+				<p class="text-xs">
+					{asByteUnitString(serverInfo?.diskUseRaw)} of {asByteUnitString(serverInfo?.diskSizeRaw)} used
+				</p>
 			{:else}
 				<div class="mt-2">
 					<LoadingSpinner />

+ 1 - 3
web/src/lib/components/shared-components/upload-panel.svelte

@@ -115,9 +115,7 @@
 									<input
 										disabled
 										class="bg-gray-100 border w-full p-1 rounded-md text-[10px] px-2"
-										value={`[${getBytesWithUnit(uploadAsset.file.size)}] ${
-											uploadAsset.file.name
-										}`}
+										value={`[${getBytesWithUnit(uploadAsset.file.size)}] ${uploadAsset.file.name}`}
 									/>
 
 									<div class="w-full bg-gray-300 h-[15px] rounded-md mt-[5px] text-white relative">

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels