mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(web,server): explore (#1926)
* feat: explore * chore: generate open api * styling explore page * styling no result page * style overlay * style: bluring text on thumbnail card for readability * explore page tweaks * fix(web): search urls * feat(web): use objects for things * feat(server): filter by motion, sort by createdAt * More styling * better navigation --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com> Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
This commit is contained in:
		
							
								
								
									
										6
									
								
								mobile/openapi/.openapi-generator/FILES
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								mobile/openapi/.openapi-generator/FILES
									
									
									
										generated
									
									
									
								
							| @@ -66,6 +66,8 @@ doc/SearchApi.md | ||||
| doc/SearchAssetDto.md | ||||
| doc/SearchAssetResponseDto.md | ||||
| doc/SearchConfigResponseDto.md | ||||
| doc/SearchExploreItem.md | ||||
| doc/SearchExploreResponseDto.md | ||||
| doc/SearchFacetCountResponseDto.md | ||||
| doc/SearchFacetResponseDto.md | ||||
| doc/SearchResponseDto.md | ||||
| @@ -179,6 +181,8 @@ lib/model/search_album_response_dto.dart | ||||
| lib/model/search_asset_dto.dart | ||||
| lib/model/search_asset_response_dto.dart | ||||
| lib/model/search_config_response_dto.dart | ||||
| lib/model/search_explore_item.dart | ||||
| lib/model/search_explore_response_dto.dart | ||||
| lib/model/search_facet_count_response_dto.dart | ||||
| lib/model/search_facet_response_dto.dart | ||||
| lib/model/search_response_dto.dart | ||||
| @@ -273,6 +277,8 @@ test/search_api_test.dart | ||||
| test/search_asset_dto_test.dart | ||||
| test/search_asset_response_dto_test.dart | ||||
| test/search_config_response_dto_test.dart | ||||
| test/search_explore_item_test.dart | ||||
| test/search_explore_response_dto_test.dart | ||||
| test/search_facet_count_response_dto_test.dart | ||||
| test/search_facet_response_dto_test.dart | ||||
| test/search_response_dto_test.dart | ||||
|   | ||||
							
								
								
									
										3
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							| @@ -121,6 +121,7 @@ Class | Method | HTTP request | Description | ||||
| *OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link |  | ||||
| *OAuthApi* | [**mobileRedirect**](doc//OAuthApi.md#mobileredirect) | **GET** /oauth/mobile-redirect |  | ||||
| *OAuthApi* | [**unlink**](doc//OAuthApi.md#unlink) | **POST** /oauth/unlink |  | ||||
| *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore |  | ||||
| *SearchApi* | [**getSearchConfig**](doc//SearchApi.md#getsearchconfig) | **GET** /search/config |  | ||||
| *SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search |  | ||||
| *ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info |  | ||||
| @@ -210,6 +211,8 @@ Class | Method | HTTP request | Description | ||||
|  - [SearchAssetDto](doc//SearchAssetDto.md) | ||||
|  - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md) | ||||
|  - [SearchConfigResponseDto](doc//SearchConfigResponseDto.md) | ||||
|  - [SearchExploreItem](doc//SearchExploreItem.md) | ||||
|  - [SearchExploreResponseDto](doc//SearchExploreResponseDto.md) | ||||
|  - [SearchFacetCountResponseDto](doc//SearchFacetCountResponseDto.md) | ||||
|  - [SearchFacetResponseDto](doc//SearchFacetResponseDto.md) | ||||
|  - [SearchResponseDto](doc//SearchResponseDto.md) | ||||
|   | ||||
							
								
								
									
										58
									
								
								mobile/openapi/doc/SearchApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										58
									
								
								mobile/openapi/doc/SearchApi.md
									
									
									
										generated
									
									
									
								
							| @@ -9,10 +9,60 @@ All URIs are relative to */api* | ||||
| 
 | ||||
| Method | HTTP request | Description | ||||
| ------------- | ------------- | ------------- | ||||
| [**getExploreData**](SearchApi.md#getexploredata) | **GET** /search/explore |  | ||||
| [**getSearchConfig**](SearchApi.md#getsearchconfig) | **GET** /search/config |  | ||||
| [**search**](SearchApi.md#search) | **GET** /search |  | ||||
| 
 | ||||
| 
 | ||||
| # **getExploreData** | ||||
| > List<SearchExploreResponseDto> getExploreData() | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| ### Example | ||||
| ```dart | ||||
| import 'package:openapi/api.dart'; | ||||
| // TODO Configure HTTP Bearer authorization: bearer | ||||
| // Case 1. Use String Token | ||||
| //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); | ||||
| // Case 2. Use Function which generate token. | ||||
| // String yourTokenGeneratorFunction() { ... } | ||||
| //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction); | ||||
| // TODO Configure API key authorization: cookie | ||||
| //defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY'; | ||||
| // uncomment below to setup prefix (e.g. Bearer) for API key, if needed | ||||
| //defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer'; | ||||
| 
 | ||||
| final api_instance = SearchApi(); | ||||
| 
 | ||||
| try { | ||||
|     final result = api_instance.getExploreData(); | ||||
|     print(result); | ||||
| } catch (e) { | ||||
|     print('Exception when calling SearchApi->getExploreData: $e\n'); | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| ### Parameters | ||||
| This endpoint does not need any parameter. | ||||
| 
 | ||||
| ### Return type | ||||
| 
 | ||||
| [**List<SearchExploreResponseDto>**](SearchExploreResponseDto.md) | ||||
| 
 | ||||
| ### Authorization | ||||
| 
 | ||||
| [bearer](../README.md#bearer), [cookie](../README.md#cookie) | ||||
| 
 | ||||
| ### 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) | ||||
| 
 | ||||
| # **getSearchConfig** | ||||
| > SearchConfigResponseDto getSearchConfig() | ||||
| 
 | ||||
| @@ -63,7 +113,7 @@ This endpoint does not need any parameter. | ||||
| [[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) | ||||
| 
 | ||||
| # **search** | ||||
| > SearchResponseDto search(query, type, isFavorite, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, smartInfoPeriodObjects, smartInfoPeriodTags) | ||||
| > SearchResponseDto search(query, type, isFavorite, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, smartInfoPeriodObjects, smartInfoPeriodTags, recent, motion) | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| @@ -94,9 +144,11 @@ final exifInfoPeriodMake = exifInfoPeriodMake_example; // String | | ||||
| final exifInfoPeriodModel = exifInfoPeriodModel_example; // String |  | ||||
| final smartInfoPeriodObjects = []; // List<String> |  | ||||
| final smartInfoPeriodTags = []; // List<String> |  | ||||
| final recent = true; // bool |  | ||||
| final motion = true; // bool |  | ||||
| 
 | ||||
| try { | ||||
|     final result = api_instance.search(query, type, isFavorite, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, smartInfoPeriodObjects, smartInfoPeriodTags); | ||||
|     final result = api_instance.search(query, type, isFavorite, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, smartInfoPeriodObjects, smartInfoPeriodTags, recent, motion); | ||||
|     print(result); | ||||
| } catch (e) { | ||||
|     print('Exception when calling SearchApi->search: $e\n'); | ||||
| @@ -117,6 +169,8 @@ Name | Type | Description  | Notes | ||||
|  **exifInfoPeriodModel** | **String**|  | [optional]  | ||||
|  **smartInfoPeriodObjects** | [**List<String>**](String.md)|  | [optional] [default to const []] | ||||
|  **smartInfoPeriodTags** | [**List<String>**](String.md)|  | [optional] [default to const []] | ||||
|  **recent** | **bool**|  | [optional]  | ||||
|  **motion** | **bool**|  | [optional]  | ||||
| 
 | ||||
| ### Return type | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										16
									
								
								mobile/openapi/doc/SearchExploreItem.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								mobile/openapi/doc/SearchExploreItem.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| # openapi.model.SearchExploreItem | ||||
| 
 | ||||
| ## Load the model package | ||||
| ```dart | ||||
| import 'package:openapi/api.dart'; | ||||
| ``` | ||||
| 
 | ||||
| ## Properties | ||||
| Name | Type | Description | Notes | ||||
| ------------ | ------------- | ------------- | ------------- | ||||
| **value** | **String** |  |  | ||||
| **data** | [**AssetResponseDto**](AssetResponseDto.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) | ||||
| 
 | ||||
| 
 | ||||
							
								
								
									
										16
									
								
								mobile/openapi/doc/SearchExploreResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								mobile/openapi/doc/SearchExploreResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| # openapi.model.SearchExploreResponseDto | ||||
| 
 | ||||
| ## Load the model package | ||||
| ```dart | ||||
| import 'package:openapi/api.dart'; | ||||
| ``` | ||||
| 
 | ||||
| ## Properties | ||||
| Name | Type | Description | Notes | ||||
| ------------ | ------------- | ------------- | ------------- | ||||
| **fieldName** | **String** |  |  | ||||
| **items** | [**List<SearchExploreItem>**](SearchExploreItem.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) | ||||
| 
 | ||||
| 
 | ||||
							
								
								
									
										2
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							| @@ -97,6 +97,8 @@ part 'model/search_album_response_dto.dart'; | ||||
| part 'model/search_asset_dto.dart'; | ||||
| part 'model/search_asset_response_dto.dart'; | ||||
| part 'model/search_config_response_dto.dart'; | ||||
| part 'model/search_explore_item.dart'; | ||||
| part 'model/search_explore_response_dto.dart'; | ||||
| part 'model/search_facet_count_response_dto.dart'; | ||||
| part 'model/search_facet_response_dto.dart'; | ||||
| part 'model/search_response_dto.dart'; | ||||
|   | ||||
							
								
								
									
										67
									
								
								mobile/openapi/lib/api/search_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										67
									
								
								mobile/openapi/lib/api/search_api.dart
									
									
									
										generated
									
									
									
								
							| @@ -16,6 +16,53 @@ class SearchApi { | ||||
| 
 | ||||
|   final ApiClient apiClient; | ||||
| 
 | ||||
|   ///  | ||||
|   /// | ||||
|   /// Note: This method returns the HTTP [Response]. | ||||
|   Future<Response> getExploreDataWithHttpInfo() async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final path = r'/search/explore'; | ||||
| 
 | ||||
|     // 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<SearchExploreResponseDto>?> getExploreData() async { | ||||
|     final response = await getExploreDataWithHttpInfo(); | ||||
|     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<SearchExploreResponseDto>') as List) | ||||
|         .cast<SearchExploreResponseDto>() | ||||
|         .toList(); | ||||
| 
 | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   ///  | ||||
|   /// | ||||
|   /// Note: This method returns the HTTP [Response]. | ||||
| @@ -85,7 +132,11 @@ class SearchApi { | ||||
|   /// * [List<String>] smartInfoPeriodObjects: | ||||
|   /// | ||||
|   /// * [List<String>] smartInfoPeriodTags: | ||||
|   Future<Response> searchWithHttpInfo({ String? query, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List<String>? smartInfoPeriodObjects, List<String>? smartInfoPeriodTags, }) async { | ||||
|   /// | ||||
|   /// * [bool] recent: | ||||
|   /// | ||||
|   /// * [bool] motion: | ||||
|   Future<Response> searchWithHttpInfo({ String? query, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List<String>? smartInfoPeriodObjects, List<String>? smartInfoPeriodTags, bool? recent, bool? motion, }) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final path = r'/search'; | ||||
| 
 | ||||
| @@ -126,6 +177,12 @@ class SearchApi { | ||||
|     if (smartInfoPeriodTags != null) { | ||||
|       queryParams.addAll(_queryParams('multi', 'smartInfo.tags', smartInfoPeriodTags)); | ||||
|     } | ||||
|     if (recent != null) { | ||||
|       queryParams.addAll(_queryParams('', 'recent', recent)); | ||||
|     } | ||||
|     if (motion != null) { | ||||
|       queryParams.addAll(_queryParams('', 'motion', motion)); | ||||
|     } | ||||
| 
 | ||||
|     const contentTypes = <String>[]; | ||||
| 
 | ||||
| @@ -164,8 +221,12 @@ class SearchApi { | ||||
|   /// * [List<String>] smartInfoPeriodObjects: | ||||
|   /// | ||||
|   /// * [List<String>] smartInfoPeriodTags: | ||||
|   Future<SearchResponseDto?> search({ String? query, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List<String>? smartInfoPeriodObjects, List<String>? smartInfoPeriodTags, }) async { | ||||
|     final response = await searchWithHttpInfo( query: query, type: type, isFavorite: isFavorite, exifInfoPeriodCity: exifInfoPeriodCity, exifInfoPeriodState: exifInfoPeriodState, exifInfoPeriodCountry: exifInfoPeriodCountry, exifInfoPeriodMake: exifInfoPeriodMake, exifInfoPeriodModel: exifInfoPeriodModel, smartInfoPeriodObjects: smartInfoPeriodObjects, smartInfoPeriodTags: smartInfoPeriodTags, ); | ||||
|   /// | ||||
|   /// * [bool] recent: | ||||
|   /// | ||||
|   /// * [bool] motion: | ||||
|   Future<SearchResponseDto?> search({ String? query, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List<String>? smartInfoPeriodObjects, List<String>? smartInfoPeriodTags, bool? recent, bool? motion, }) async { | ||||
|     final response = await searchWithHttpInfo( query: query, type: type, isFavorite: isFavorite, exifInfoPeriodCity: exifInfoPeriodCity, exifInfoPeriodState: exifInfoPeriodState, exifInfoPeriodCountry: exifInfoPeriodCountry, exifInfoPeriodMake: exifInfoPeriodMake, exifInfoPeriodModel: exifInfoPeriodModel, smartInfoPeriodObjects: smartInfoPeriodObjects, smartInfoPeriodTags: smartInfoPeriodTags, recent: recent, motion: motion, ); | ||||
|     if (response.statusCode >= HttpStatus.badRequest) { | ||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||
|     } | ||||
|   | ||||
							
								
								
									
										4
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							| @@ -302,6 +302,10 @@ class ApiClient { | ||||
|           return SearchAssetResponseDto.fromJson(value); | ||||
|         case 'SearchConfigResponseDto': | ||||
|           return SearchConfigResponseDto.fromJson(value); | ||||
|         case 'SearchExploreItem': | ||||
|           return SearchExploreItem.fromJson(value); | ||||
|         case 'SearchExploreResponseDto': | ||||
|           return SearchExploreResponseDto.fromJson(value); | ||||
|         case 'SearchFacetCountResponseDto': | ||||
|           return SearchFacetCountResponseDto.fromJson(value); | ||||
|         case 'SearchFacetResponseDto': | ||||
|   | ||||
							
								
								
									
										119
									
								
								mobile/openapi/lib/model/search_explore_item.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								mobile/openapi/lib/model/search_explore_item.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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 SearchExploreItem { | ||||
|   /// Returns a new [SearchExploreItem] instance. | ||||
|   SearchExploreItem({ | ||||
|     required this.value, | ||||
|     required this.data, | ||||
|   }); | ||||
| 
 | ||||
|   String value; | ||||
| 
 | ||||
|   AssetResponseDto data; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is SearchExploreItem && | ||||
|      other.value == value && | ||||
|      other.data == data; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (value.hashCode) + | ||||
|     (data.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'SearchExploreItem[value=$value, data=$data]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|       json[r'value'] = this.value; | ||||
|       json[r'data'] = this.data; | ||||
|     return json; | ||||
|   } | ||||
| 
 | ||||
|   /// Returns a new [SearchExploreItem] instance and imports its values from | ||||
|   /// [value] if it's a [Map], null otherwise. | ||||
|   // ignore: prefer_constructors_over_static_methods | ||||
|   static SearchExploreItem? 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 "SearchExploreItem[$key]" is missing from JSON.'); | ||||
|           assert(json[key] != null, 'Required key "SearchExploreItem[$key]" has a null value in JSON.'); | ||||
|         }); | ||||
|         return true; | ||||
|       }()); | ||||
| 
 | ||||
|       return SearchExploreItem( | ||||
|         value: mapValueOfType<String>(json, r'value')!, | ||||
|         data: AssetResponseDto.fromJson(json[r'data'])!, | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   static List<SearchExploreItem>? listFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final result = <SearchExploreItem>[]; | ||||
|     if (json is List && json.isNotEmpty) { | ||||
|       for (final row in json) { | ||||
|         final value = SearchExploreItem.fromJson(row); | ||||
|         if (value != null) { | ||||
|           result.add(value); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return result.toList(growable: growable); | ||||
|   } | ||||
| 
 | ||||
|   static Map<String, SearchExploreItem> mapFromJson(dynamic json) { | ||||
|     final map = <String, SearchExploreItem>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = SearchExploreItem.fromJson(entry.value); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   // maps a json object with a list of SearchExploreItem-objects as value to a dart map | ||||
|   static Map<String, List<SearchExploreItem>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final map = <String, List<SearchExploreItem>>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = SearchExploreItem.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>{ | ||||
|     'value', | ||||
|     'data', | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
							
								
								
									
										119
									
								
								mobile/openapi/lib/model/search_explore_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								mobile/openapi/lib/model/search_explore_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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 SearchExploreResponseDto { | ||||
|   /// Returns a new [SearchExploreResponseDto] instance. | ||||
|   SearchExploreResponseDto({ | ||||
|     required this.fieldName, | ||||
|     this.items = const [], | ||||
|   }); | ||||
| 
 | ||||
|   String fieldName; | ||||
| 
 | ||||
|   List<SearchExploreItem> items; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is SearchExploreResponseDto && | ||||
|      other.fieldName == fieldName && | ||||
|      other.items == items; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (fieldName.hashCode) + | ||||
|     (items.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'SearchExploreResponseDto[fieldName=$fieldName, items=$items]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|       json[r'fieldName'] = this.fieldName; | ||||
|       json[r'items'] = this.items; | ||||
|     return json; | ||||
|   } | ||||
| 
 | ||||
|   /// Returns a new [SearchExploreResponseDto] instance and imports its values from | ||||
|   /// [value] if it's a [Map], null otherwise. | ||||
|   // ignore: prefer_constructors_over_static_methods | ||||
|   static SearchExploreResponseDto? 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 "SearchExploreResponseDto[$key]" is missing from JSON.'); | ||||
|           assert(json[key] != null, 'Required key "SearchExploreResponseDto[$key]" has a null value in JSON.'); | ||||
|         }); | ||||
|         return true; | ||||
|       }()); | ||||
| 
 | ||||
|       return SearchExploreResponseDto( | ||||
|         fieldName: mapValueOfType<String>(json, r'fieldName')!, | ||||
|         items: SearchExploreItem.listFromJson(json[r'items'])!, | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   static List<SearchExploreResponseDto>? listFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final result = <SearchExploreResponseDto>[]; | ||||
|     if (json is List && json.isNotEmpty) { | ||||
|       for (final row in json) { | ||||
|         final value = SearchExploreResponseDto.fromJson(row); | ||||
|         if (value != null) { | ||||
|           result.add(value); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return result.toList(growable: growable); | ||||
|   } | ||||
| 
 | ||||
|   static Map<String, SearchExploreResponseDto> mapFromJson(dynamic json) { | ||||
|     final map = <String, SearchExploreResponseDto>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = SearchExploreResponseDto.fromJson(entry.value); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   // maps a json object with a list of SearchExploreResponseDto-objects as value to a dart map | ||||
|   static Map<String, List<SearchExploreResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final map = <String, List<SearchExploreResponseDto>>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = SearchExploreResponseDto.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>{ | ||||
|     'fieldName', | ||||
|     'items', | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
							
								
								
									
										9
									
								
								mobile/openapi/test/search_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										9
									
								
								mobile/openapi/test/search_api_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -17,6 +17,13 @@ void main() { | ||||
|   // final instance = SearchApi(); | ||||
| 
 | ||||
|   group('tests for SearchApi', () { | ||||
|     //  | ||||
|     // | ||||
|     //Future<List<SearchExploreResponseDto>> getExploreData() async | ||||
|     test('test getExploreData', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //  | ||||
|     // | ||||
|     //Future<SearchConfigResponseDto> getSearchConfig() async | ||||
| @@ -26,7 +33,7 @@ void main() { | ||||
| 
 | ||||
|     //  | ||||
|     // | ||||
|     //Future<SearchResponseDto> search({ String query, String type, bool isFavorite, String exifInfoPeriodCity, String exifInfoPeriodState, String exifInfoPeriodCountry, String exifInfoPeriodMake, String exifInfoPeriodModel, List<String> smartInfoPeriodObjects, List<String> smartInfoPeriodTags }) async | ||||
|     //Future<SearchResponseDto> search({ String query, String type, bool isFavorite, String exifInfoPeriodCity, String exifInfoPeriodState, String exifInfoPeriodCountry, String exifInfoPeriodMake, String exifInfoPeriodModel, List<String> smartInfoPeriodObjects, List<String> smartInfoPeriodTags, bool recent, bool motion }) async | ||||
|     test('test search', () async { | ||||
|       // TODO | ||||
|     }); | ||||
|   | ||||
							
								
								
									
										32
									
								
								mobile/openapi/test/search_explore_item_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								mobile/openapi/test/search_explore_item_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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 SearchExploreItem | ||||
| void main() { | ||||
|   // final instance = SearchExploreItem(); | ||||
| 
 | ||||
|   group('test SearchExploreItem', () { | ||||
|     // String value | ||||
|     test('to test the property `value`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // AssetResponseDto data | ||||
|     test('to test the property `data`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
| 
 | ||||
|   }); | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										32
									
								
								mobile/openapi/test/search_explore_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								mobile/openapi/test/search_explore_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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 SearchExploreResponseDto | ||||
| void main() { | ||||
|   // final instance = SearchExploreResponseDto(); | ||||
| 
 | ||||
|   group('test SearchExploreResponseDto', () { | ||||
|     // String fieldName | ||||
|     test('to test the property `fieldName`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // List<SearchExploreItem> items (default value: const []) | ||||
|     test('to test the property `items`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
| 
 | ||||
|   }); | ||||
| 
 | ||||
| } | ||||
| @@ -1,4 +1,11 @@ | ||||
| import { AuthUserDto, SearchConfigResponseDto, SearchDto, SearchResponseDto, SearchService } from '@app/domain'; | ||||
| import { | ||||
|   AuthUserDto, | ||||
|   SearchConfigResponseDto, | ||||
|   SearchDto, | ||||
|   SearchExploreResponseDto, | ||||
|   SearchResponseDto, | ||||
|   SearchService, | ||||
| } from '@app/domain'; | ||||
| import { Controller, Get, Query, ValidationPipe } from '@nestjs/common'; | ||||
| import { ApiTags } from '@nestjs/swagger'; | ||||
| import { GetAuthUser } from '../decorators/auth-user.decorator'; | ||||
| @@ -10,7 +17,6 @@ import { Authenticated } from '../decorators/authenticated.decorator'; | ||||
| export class SearchController { | ||||
|   constructor(private readonly searchService: SearchService) {} | ||||
|  | ||||
|   @Authenticated() | ||||
|   @Get() | ||||
|   async search( | ||||
|     @GetAuthUser() authUser: AuthUserDto, | ||||
| @@ -19,9 +25,13 @@ export class SearchController { | ||||
|     return this.searchService.search(authUser, dto); | ||||
|   } | ||||
|  | ||||
|   @Authenticated() | ||||
|   @Get('config') | ||||
|   getSearchConfig(): SearchConfigResponseDto { | ||||
|     return this.searchService.getConfig(); | ||||
|   } | ||||
|  | ||||
|   @Get('explore') | ||||
|   getExploreData(@GetAuthUser() authUser: AuthUserDto): Promise<SearchExploreResponseDto[]> { | ||||
|     return this.searchService.getExploreData(authUser) as Promise<SearchExploreResponseDto[]>; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -2,8 +2,8 @@ import { | ||||
|   AssetCore, | ||||
|   IAssetRepository, | ||||
|   IAssetUploadedJob, | ||||
|   IJobRepository, | ||||
|   IReverseGeocodingJob, | ||||
|   ISearchRepository, | ||||
|   JobName, | ||||
|   QueueName, | ||||
| } from '@app/domain'; | ||||
| @@ -86,14 +86,14 @@ export class MetadataExtractionProcessor { | ||||
|  | ||||
|   constructor( | ||||
|     @Inject(IAssetRepository) assetRepository: IAssetRepository, | ||||
|     @Inject(ISearchRepository) searchRepository: ISearchRepository, | ||||
|     @Inject(IJobRepository) jobRepository: IJobRepository, | ||||
|  | ||||
|     @InjectRepository(ExifEntity) | ||||
|     private exifRepository: Repository<ExifEntity>, | ||||
|  | ||||
|     configService: ConfigService, | ||||
|   ) { | ||||
|     this.assetCore = new AssetCore(assetRepository, searchRepository); | ||||
|     this.assetCore = new AssetCore(assetRepository, jobRepository); | ||||
|  | ||||
|     if (!configService.get('DISABLE_REVERSE_GEOCODING')) { | ||||
|       this.logger.log('Initializing Reverse Geocoding'); | ||||
|   | ||||
| @@ -640,6 +640,22 @@ | ||||
|                 "type": "string" | ||||
|               } | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             "name": "recent", | ||||
|             "required": false, | ||||
|             "in": "query", | ||||
|             "schema": { | ||||
|               "type": "boolean" | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             "name": "motion", | ||||
|             "required": false, | ||||
|             "in": "query", | ||||
|             "schema": { | ||||
|               "type": "boolean" | ||||
|             } | ||||
|           } | ||||
|         ], | ||||
|         "responses": { | ||||
| @@ -658,12 +674,6 @@ | ||||
|           "Search" | ||||
|         ], | ||||
|         "security": [ | ||||
|           { | ||||
|             "bearer": [] | ||||
|           }, | ||||
|           { | ||||
|             "cookie": [] | ||||
|           }, | ||||
|           { | ||||
|             "bearer": [] | ||||
|           }, | ||||
| @@ -699,7 +709,34 @@ | ||||
|           }, | ||||
|           { | ||||
|             "cookie": [] | ||||
|           } | ||||
|         ] | ||||
|       } | ||||
|     }, | ||||
|     "/search/explore": { | ||||
|       "get": { | ||||
|         "operationId": "getExploreData", | ||||
|         "description": "", | ||||
|         "parameters": [], | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "description": "", | ||||
|             "content": { | ||||
|               "application/json": { | ||||
|                 "schema": { | ||||
|                   "type": "array", | ||||
|                   "items": { | ||||
|                     "$ref": "#/components/schemas/SearchExploreResponseDto" | ||||
|                   } | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "tags": [ | ||||
|           "Search" | ||||
|         ], | ||||
|         "security": [ | ||||
|           { | ||||
|             "bearer": [] | ||||
|           }, | ||||
| @@ -4149,6 +4186,39 @@ | ||||
|           "enabled" | ||||
|         ] | ||||
|       }, | ||||
|       "SearchExploreItem": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|           "value": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "data": { | ||||
|             "$ref": "#/components/schemas/AssetResponseDto" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "value", | ||||
|           "data" | ||||
|         ] | ||||
|       }, | ||||
|       "SearchExploreResponseDto": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|           "fieldName": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "items": { | ||||
|             "type": "array", | ||||
|             "items": { | ||||
|               "$ref": "#/components/schemas/SearchExploreItem" | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "fieldName", | ||||
|           "items" | ||||
|         ] | ||||
|       }, | ||||
|       "SharedLinkType": { | ||||
|         "type": "string", | ||||
|         "enum": [ | ||||
|   | ||||
| @@ -1,21 +1,21 @@ | ||||
| import { AssetEntity, AssetType } from '@app/infra/db/entities'; | ||||
| import { ISearchRepository, SearchCollection } from '../search/search.repository'; | ||||
| import { IJobRepository, JobName } from '../job'; | ||||
| import { AssetSearchOptions, IAssetRepository } from './asset.repository'; | ||||
|  | ||||
| export class AssetCore { | ||||
|   constructor(private repository: IAssetRepository, private searchRepository: ISearchRepository) {} | ||||
|   constructor(private assetRepository: IAssetRepository, private jobRepository: IJobRepository) {} | ||||
|  | ||||
|   getAll(options: AssetSearchOptions) { | ||||
|     return this.repository.getAll(options); | ||||
|     return this.assetRepository.getAll(options); | ||||
|   } | ||||
|  | ||||
|   async save(asset: Partial<AssetEntity>) { | ||||
|     const _asset = await this.repository.save(asset); | ||||
|     await this.searchRepository.index(SearchCollection.ASSETS, _asset); | ||||
|     const _asset = await this.assetRepository.save(asset); | ||||
|     await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { asset: _asset } }); | ||||
|     return _asset; | ||||
|   } | ||||
|  | ||||
|   findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null> { | ||||
|     return this.repository.findLivePhotoMatch(livePhotoCID, otherAssetId, type); | ||||
|     return this.assetRepository.findLivePhotoMatch(livePhotoCID, otherAssetId, type); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,15 +1,12 @@ | ||||
| import { AssetEntity, AssetType } from '@app/infra/db/entities'; | ||||
| import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test'; | ||||
| import { newSearchRepositoryMock } from '../../test/search.repository.mock'; | ||||
| import { AssetService, IAssetRepository } from '../asset'; | ||||
| import { IJobRepository, JobName } from '../job'; | ||||
| import { ISearchRepository } from '../search'; | ||||
|  | ||||
| describe(AssetService.name, () => { | ||||
|   let sut: AssetService; | ||||
|   let assetMock: jest.Mocked<IAssetRepository>; | ||||
|   let jobMock: jest.Mocked<IJobRepository>; | ||||
|   let searchMock: jest.Mocked<ISearchRepository>; | ||||
|  | ||||
|   it('should work', () => { | ||||
|     expect(sut).toBeDefined(); | ||||
| @@ -18,8 +15,7 @@ describe(AssetService.name, () => { | ||||
|   beforeEach(async () => { | ||||
|     assetMock = newAssetRepositoryMock(); | ||||
|     jobMock = newJobRepositoryMock(); | ||||
|     searchMock = newSearchRepositoryMock(); | ||||
|     sut = new AssetService(assetMock, jobMock, searchMock); | ||||
|     sut = new AssetService(assetMock, jobMock); | ||||
|   }); | ||||
|  | ||||
|   describe(`handle asset upload`, () => { | ||||
| @@ -56,7 +52,10 @@ describe(AssetService.name, () => { | ||||
|       await sut.save(assetEntityStub.image); | ||||
|  | ||||
|       expect(assetMock.save).toHaveBeenCalledWith(assetEntityStub.image); | ||||
|       expect(searchMock.index).toHaveBeenCalledWith('assets', assetEntityStub.image); | ||||
|       expect(jobMock.queue).toHaveBeenCalledWith({ | ||||
|         name: JobName.SEARCH_INDEX_ASSET, | ||||
|         data: { asset: assetEntityStub.image }, | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import { AssetEntity, AssetType } from '@app/infra/db/entities'; | ||||
| import { Inject } from '@nestjs/common'; | ||||
| import { IAssetUploadedJob, IJobRepository, JobName } from '../job'; | ||||
| import { ISearchRepository } from '../search'; | ||||
| import { AssetCore } from './asset.core'; | ||||
| import { IAssetRepository } from './asset.repository'; | ||||
|  | ||||
| @@ -11,9 +10,8 @@ export class AssetService { | ||||
|   constructor( | ||||
|     @Inject(IAssetRepository) assetRepository: IAssetRepository, | ||||
|     @Inject(IJobRepository) private jobRepository: IJobRepository, | ||||
|     @Inject(ISearchRepository) searchRepository: ISearchRepository, | ||||
|   ) { | ||||
|     this.assetCore = new AssetCore(assetRepository, searchRepository); | ||||
|     this.assetCore = new AssetCore(assetRepository, jobRepository); | ||||
|   } | ||||
|  | ||||
|   async handleAssetUpload(data: IAssetUploadedJob) { | ||||
|   | ||||
| @@ -54,4 +54,14 @@ export class SearchDto { | ||||
|   @IsOptional() | ||||
|   @Transform(({ value }) => value.split(',')) | ||||
|   'smartInfo.tags'?: string[]; | ||||
|  | ||||
|   @IsBoolean() | ||||
|   @IsOptional() | ||||
|   @Transform(toBoolean) | ||||
|   recent?: boolean; | ||||
|  | ||||
|   @IsBoolean() | ||||
|   @IsOptional() | ||||
|   @Transform(toBoolean) | ||||
|   motion?: boolean; | ||||
| } | ||||
|   | ||||
| @@ -1,2 +1,3 @@ | ||||
| export * from './search-config-response.dto'; | ||||
| export * from './search-explore.response.dto'; | ||||
| export * from './search-response.dto'; | ||||
|   | ||||
| @@ -0,0 +1,11 @@ | ||||
| import { AssetResponseDto } from '../../asset'; | ||||
|  | ||||
| class SearchExploreItem { | ||||
|   value!: string; | ||||
|   data!: AssetResponseDto; | ||||
| } | ||||
|  | ||||
| export class SearchExploreResponseDto { | ||||
|   fieldName!: string; | ||||
|   items!: SearchExploreItem[]; | ||||
| } | ||||
| @@ -17,6 +17,8 @@ export interface SearchFilter { | ||||
|   model?: string; | ||||
|   objects?: string[]; | ||||
|   tags?: string[]; | ||||
|   recent?: boolean; | ||||
|   motion?: boolean; | ||||
| } | ||||
|  | ||||
| export interface SearchResult<T> { | ||||
| @@ -39,6 +41,14 @@ export interface SearchFacet { | ||||
|   }>; | ||||
| } | ||||
|  | ||||
| export interface SearchExploreItem<T> { | ||||
|   fieldName: string; | ||||
|   items: Array<{ | ||||
|     value: string; | ||||
|     data: T; | ||||
|   }>; | ||||
| } | ||||
|  | ||||
| export type SearchCollectionIndexStatus = Record<SearchCollection, boolean>; | ||||
|  | ||||
| export const ISearchRepository = 'ISearchRepository'; | ||||
| @@ -57,4 +67,6 @@ export interface ISearchRepository { | ||||
|  | ||||
|   search(collection: SearchCollection.ASSETS, query: string, filters: SearchFilter): Promise<SearchResult<AssetEntity>>; | ||||
|   search(collection: SearchCollection.ALBUMS, query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>>; | ||||
|  | ||||
|   explore(userId: string): Promise<SearchExploreItem<AssetEntity>[]>; | ||||
| } | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import { AssetEntity } from '@app/infra/db/entities'; | ||||
| import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; | ||||
| import { ConfigService } from '@nestjs/config'; | ||||
| import { IAlbumRepository } from '../album/album.repository'; | ||||
| @@ -6,7 +7,7 @@ import { AuthUserDto } from '../auth'; | ||||
| import { IAlbumJob, IAssetJob, IDeleteJob, IJobRepository, JobName } from '../job'; | ||||
| import { SearchDto } from './dto'; | ||||
| import { SearchConfigResponseDto, SearchResponseDto } from './response-dto'; | ||||
| import { ISearchRepository, SearchCollection } from './search.repository'; | ||||
| import { ISearchRepository, SearchCollection, SearchExploreItem } from './search.repository'; | ||||
|  | ||||
| @Injectable() | ||||
| export class SearchService { | ||||
| @@ -52,11 +53,14 @@ export class SearchService { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async search(authUser: AuthUserDto, dto: SearchDto): Promise<SearchResponseDto> { | ||||
|     if (!this.enabled) { | ||||
|       throw new BadRequestException('Search is disabled'); | ||||
|   async getExploreData(authUser: AuthUserDto): Promise<SearchExploreItem<AssetEntity>[]> { | ||||
|     this.assertEnabled(); | ||||
|     return this.searchRepository.explore(authUser.id); | ||||
|   } | ||||
|  | ||||
|   async search(authUser: AuthUserDto, dto: SearchDto): Promise<SearchResponseDto> { | ||||
|     this.assertEnabled(); | ||||
|  | ||||
|     const query = dto.query || '*'; | ||||
|  | ||||
|     return { | ||||
| @@ -83,6 +87,7 @@ export class SearchService { | ||||
|  | ||||
|       this.logger.log(`Indexing ${assets.length} assets`); | ||||
|       await this.searchRepository.import(SearchCollection.ASSETS, assets, true); | ||||
|       this.logger.debug('Finished re-indexing all assets'); | ||||
|     } catch (error: any) { | ||||
|       this.logger.error(`Unable to index all assets`, error?.stack); | ||||
|     } | ||||
| @@ -94,6 +99,9 @@ export class SearchService { | ||||
|     } | ||||
|  | ||||
|     const { asset } = data; | ||||
|     if (!asset.isVisible) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       await this.searchRepository.index(SearchCollection.ASSETS, asset); | ||||
| @@ -111,6 +119,7 @@ export class SearchService { | ||||
|       const albums = await this.albumRepository.getAll(); | ||||
|       this.logger.log(`Indexing ${albums.length} albums`); | ||||
|       await this.searchRepository.import(SearchCollection.ALBUMS, albums, true); | ||||
|       this.logger.debug('Finished re-indexing all albums'); | ||||
|     } catch (error: any) { | ||||
|       this.logger.error(`Unable to index all albums`, error?.stack); | ||||
|     } | ||||
| @@ -151,4 +160,10 @@ export class SearchService { | ||||
|       this.logger.error(`Unable to remove ${collection}: ${id}`, error?.stack); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private assertEnabled() { | ||||
|     if (!this.enabled) { | ||||
|       throw new BadRequestException('Search is disabled'); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -8,5 +8,6 @@ export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => { | ||||
|     import: jest.fn(), | ||||
|     search: jest.fn(), | ||||
|     delete: jest.fn(), | ||||
|     explore: jest.fn(), | ||||
|   }; | ||||
| }; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; | ||||
|  | ||||
| export const assetSchemaVersion = 1; | ||||
| export const assetSchemaVersion = 2; | ||||
| export const assetSchema: CollectionCreateSchema = { | ||||
|   name: `assets-v${assetSchemaVersion}`, | ||||
|   fields: [ | ||||
| @@ -22,7 +22,6 @@ export const assetSchema: CollectionCreateSchema = { | ||||
|     { name: 'exifInfo.state', type: 'string', facet: true, optional: true }, | ||||
|     { name: 'exifInfo.description', type: 'string', facet: false, optional: true }, | ||||
|     { name: 'exifInfo.imageName', type: 'string', facet: false, optional: true }, | ||||
|     { name: 'geo', type: 'geopoint', facet: false, optional: true }, | ||||
|     { name: 'exifInfo.make', type: 'string', facet: true, optional: true }, | ||||
|     { name: 'exifInfo.model', type: 'string', facet: true, optional: true }, | ||||
|     { name: 'exifInfo.orientation', type: 'string', optional: true }, | ||||
| @@ -30,6 +29,10 @@ export const assetSchema: CollectionCreateSchema = { | ||||
|     // smart info | ||||
|     { name: 'smartInfo.objects', type: 'string[]', facet: true, optional: true }, | ||||
|     { name: 'smartInfo.tags', type: 'string[]', facet: true, optional: true }, | ||||
|  | ||||
|     // computed | ||||
|     { name: 'geo', type: 'geopoint', facet: false, optional: true }, | ||||
|     { name: 'motion', type: 'bool', facet: true }, | ||||
|   ], | ||||
|   token_separators: ['.'], | ||||
|   enable_nested_fields: true, | ||||
|   | ||||
| @@ -2,11 +2,13 @@ import { | ||||
|   ISearchRepository, | ||||
|   SearchCollection, | ||||
|   SearchCollectionIndexStatus, | ||||
|   SearchExploreItem, | ||||
|   SearchFilter, | ||||
|   SearchResult, | ||||
| } from '@app/domain'; | ||||
| import { Injectable, Logger } from '@nestjs/common'; | ||||
| import _, { Dictionary } from 'lodash'; | ||||
| import { filter, firstValueFrom, from, map, mergeMap, toArray } from 'rxjs'; | ||||
| import { Client } from 'typesense'; | ||||
| import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; | ||||
| import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents'; | ||||
| @@ -14,8 +16,9 @@ import { AlbumEntity, AssetEntity } from '../db'; | ||||
| import { albumSchema } from './schemas/album.schema'; | ||||
| import { assetSchema } from './schemas/asset.schema'; | ||||
|  | ||||
| interface GeoAssetEntity extends AssetEntity { | ||||
| interface CustomAssetEntity extends AssetEntity { | ||||
|   geo?: [number, number]; | ||||
|   motion?: boolean; | ||||
| } | ||||
|  | ||||
| function removeNil<T extends Dictionary<any>>(item: T): Partial<T> { | ||||
| @@ -85,6 +88,12 @@ export class TypesenseRepository implements ISearchRepository { | ||||
|   } | ||||
|  | ||||
|   async setup(): Promise<void> { | ||||
|     const collections = await this.client.collections().retrieve(); | ||||
|     for (const collection of collections) { | ||||
|       this.logger.debug(`${collection.name} => ${collection.num_documents}`); | ||||
|       // await this.client.collections(collection.name).delete(); | ||||
|     } | ||||
|  | ||||
|     // upsert collections | ||||
|     for (const [collectionName, schema] of schemas) { | ||||
|       const collection = await this.client | ||||
| @@ -172,6 +181,59 @@ export class TypesenseRepository implements ISearchRepository { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async explore(userId: string): Promise<SearchExploreItem<AssetEntity>[]> { | ||||
|     const alias = await this.client.aliases(SearchCollection.ASSETS).retrieve(); | ||||
|  | ||||
|     const common = { | ||||
|       q: '*', | ||||
|       filter_by: `ownerId:${userId}`, | ||||
|       per_page: 100, | ||||
|     }; | ||||
|  | ||||
|     const asset$ = this.client.collections<AssetEntity>(alias.collection_name).documents(); | ||||
|  | ||||
|     const { facet_counts: facets } = await asset$.search({ | ||||
|       ...common, | ||||
|       query_by: 'exifInfo.imageName', | ||||
|       facet_by: this.getFacetFieldNames(SearchCollection.ASSETS), | ||||
|       max_facet_values: 50, | ||||
|     }); | ||||
|  | ||||
|     return firstValueFrom( | ||||
|       from(facets || []).pipe( | ||||
|         mergeMap( | ||||
|           (facet) => | ||||
|             from(facet.counts).pipe( | ||||
|               mergeMap( | ||||
|                 (count) => | ||||
|                   from( | ||||
|                     asset$.search({ | ||||
|                       ...common, | ||||
|                       query_by: 'exifInfo.imageName', | ||||
|                       filter_by: `${facet.field_name}:${count.value}`, | ||||
|                     }), | ||||
|                   ).pipe( | ||||
|                     map((result) => ({ | ||||
|                       value: count.value, | ||||
|                       data: result.hits?.[0]?.document as AssetEntity, | ||||
|                     })), | ||||
|                     filter((item) => !!item.data), | ||||
|                   ), | ||||
|                 5, | ||||
|               ), | ||||
|               toArray(), | ||||
|               map((items) => ({ | ||||
|                 fieldName: facet.field_name as string, | ||||
|                 items, | ||||
|               })), | ||||
|             ), | ||||
|           3, | ||||
|         ), | ||||
|         toArray(), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   search(collection: SearchCollection.ASSETS, query: string, filter: SearchFilter): Promise<SearchResult<AssetEntity>>; | ||||
|   search(collection: SearchCollection.ALBUMS, query: string, filter: SearchFilter): Promise<SearchResult<AlbumEntity>>; | ||||
|   async search(collection: SearchCollection, query: string, filters: SearchFilter) { | ||||
| @@ -213,10 +275,8 @@ export class TypesenseRepository implements ISearchRepository { | ||||
|           ].join(','), | ||||
|           filter_by: _filters.join(' && '), | ||||
|           per_page: 250, | ||||
|           facet_by: (assetSchema.fields || []) | ||||
|             .filter((field) => field.facet) | ||||
|             .map((field) => field.name) | ||||
|             .join(','), | ||||
|           sort_by: filters.recent ? 'createdAt:desc' : undefined, | ||||
|           facet_by: this.getFacetFieldNames(SearchCollection.ASSETS), | ||||
|         }); | ||||
|  | ||||
|       return this.asResponse(results); | ||||
| @@ -313,13 +373,24 @@ export class TypesenseRepository implements ISearchRepository { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   private patchAsset(asset: AssetEntity): GeoAssetEntity { | ||||
|   private patchAsset(asset: AssetEntity): CustomAssetEntity { | ||||
|     let custom = asset as CustomAssetEntity; | ||||
|  | ||||
|     const lat = asset.exifInfo?.latitude; | ||||
|     const lng = asset.exifInfo?.longitude; | ||||
|     if (lat && lng && lat !== 0 && lng !== 0) { | ||||
|       return { ...asset, geo: [lat, lng] }; | ||||
|       custom = { ...custom, geo: [lat, lng] }; | ||||
|     } | ||||
|  | ||||
|     return asset; | ||||
|     custom = { ...custom, motion: !!asset.livePhotoVideoId }; | ||||
|  | ||||
|     return custom; | ||||
|   } | ||||
|  | ||||
|   private getFacetFieldNames(collection: SearchCollection) { | ||||
|     return (schemaMap[collection].fields || []) | ||||
|       .filter((field) => field.facet) | ||||
|       .map((field) => field.name) | ||||
|       .join(','); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										130
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										130
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -1539,6 +1539,44 @@ export interface SearchConfigResponseDto { | ||||
|      */ | ||||
|     'enabled': boolean; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
|  * @export | ||||
|  * @interface SearchExploreItem | ||||
|  */ | ||||
| export interface SearchExploreItem { | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof SearchExploreItem | ||||
|      */ | ||||
|     'value': string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {AssetResponseDto} | ||||
|      * @memberof SearchExploreItem | ||||
|      */ | ||||
|     'data': AssetResponseDto; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
|  * @export | ||||
|  * @interface SearchExploreResponseDto | ||||
|  */ | ||||
| export interface SearchExploreResponseDto { | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof SearchExploreResponseDto | ||||
|      */ | ||||
|     'fieldName': string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {Array<SearchExploreItem>} | ||||
|      * @memberof SearchExploreResponseDto | ||||
|      */ | ||||
|     'items': Array<SearchExploreItem>; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
|  * @export | ||||
| @@ -6629,6 +6667,41 @@ export class OAuthApi extends BaseAPI { | ||||
|  */ | ||||
| export const SearchApiAxiosParamCreator = function (configuration?: Configuration) { | ||||
|     return { | ||||
|         /** | ||||
|          *  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getExploreData: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             const localVarPath = `/search/explore`; | ||||
|             // 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; | ||||
| 
 | ||||
|             // authentication bearer required
 | ||||
|             // http bearer authentication required
 | ||||
|             await setBearerAuthToObject(localVarHeaderParameter, configuration) | ||||
| 
 | ||||
|             // authentication cookie required
 | ||||
| 
 | ||||
| 
 | ||||
|      | ||||
|             setSearchParams(localVarUrlObj, localVarQueryParameter); | ||||
|             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; | ||||
|             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; | ||||
| 
 | ||||
|             return { | ||||
|                 url: toPathString(localVarUrlObj), | ||||
|                 options: localVarRequestOptions, | ||||
|             }; | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {*} [options] Override http request option. | ||||
| @@ -6676,10 +6749,12 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio | ||||
|          * @param {string} [exifInfoModel]  | ||||
|          * @param {Array<string>} [smartInfoObjects]  | ||||
|          * @param {Array<string>} [smartInfoTags]  | ||||
|          * @param {boolean} [recent]  | ||||
|          * @param {boolean} [motion]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         search: async (query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|         search: async (query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             const localVarPath = `/search`; | ||||
|             // use dummy base URL string because the URL constructor only accepts absolute URLs.
 | ||||
|             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); | ||||
| @@ -6738,6 +6813,14 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio | ||||
|                 localVarQueryParameter['smartInfo.tags'] = smartInfoTags; | ||||
|             } | ||||
| 
 | ||||
|             if (recent !== undefined) { | ||||
|                 localVarQueryParameter['recent'] = recent; | ||||
|             } | ||||
| 
 | ||||
|             if (motion !== undefined) { | ||||
|                 localVarQueryParameter['motion'] = motion; | ||||
|             } | ||||
| 
 | ||||
| 
 | ||||
|      | ||||
|             setSearchParams(localVarUrlObj, localVarQueryParameter); | ||||
| @@ -6759,6 +6842,15 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio | ||||
| export const SearchApiFp = function(configuration?: Configuration) { | ||||
|     const localVarAxiosParamCreator = SearchApiAxiosParamCreator(configuration) | ||||
|     return { | ||||
|         /** | ||||
|          *  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async getExploreData(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<SearchExploreResponseDto>>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getExploreData(options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {*} [options] Override http request option. | ||||
| @@ -6780,11 +6872,13 @@ export const SearchApiFp = function(configuration?: Configuration) { | ||||
|          * @param {string} [exifInfoModel]  | ||||
|          * @param {Array<string>} [smartInfoObjects]  | ||||
|          * @param {Array<string>} [smartInfoTags]  | ||||
|          * @param {boolean} [recent]  | ||||
|          * @param {boolean} [motion]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options); | ||||
|         async search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|     } | ||||
| @@ -6797,6 +6891,14 @@ export const SearchApiFp = function(configuration?: Configuration) { | ||||
| export const SearchApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { | ||||
|     const localVarFp = SearchApiFp(configuration) | ||||
|     return { | ||||
|         /** | ||||
|          *  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         getExploreData(options?: any): AxiosPromise<Array<SearchExploreResponseDto>> { | ||||
|             return localVarFp.getExploreData(options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {*} [options] Override http request option. | ||||
| @@ -6817,11 +6919,13 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat | ||||
|          * @param {string} [exifInfoModel]  | ||||
|          * @param {Array<string>} [smartInfoObjects]  | ||||
|          * @param {Array<string>} [smartInfoTags]  | ||||
|          * @param {boolean} [recent]  | ||||
|          * @param {boolean} [motion]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options?: any): AxiosPromise<SearchResponseDto> { | ||||
|             return localVarFp.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(axios, basePath)); | ||||
|         search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: any): AxiosPromise<SearchResponseDto> { | ||||
|             return localVarFp.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|     }; | ||||
| }; | ||||
| @@ -6833,6 +6937,16 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat | ||||
|  * @extends {BaseAPI} | ||||
|  */ | ||||
| export class SearchApi extends BaseAPI { | ||||
|     /** | ||||
|      *  | ||||
|      * @param {*} [options] Override http request option. | ||||
|      * @throws {RequiredError} | ||||
|      * @memberof SearchApi | ||||
|      */ | ||||
|     public getExploreData(options?: AxiosRequestConfig) { | ||||
|         return SearchApiFp(this.configuration).getExploreData(options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @param {*} [options] Override http request option. | ||||
| @@ -6855,12 +6969,14 @@ export class SearchApi extends BaseAPI { | ||||
|      * @param {string} [exifInfoModel]  | ||||
|      * @param {Array<string>} [smartInfoObjects]  | ||||
|      * @param {Array<string>} [smartInfoTags]  | ||||
|      * @param {boolean} [recent]  | ||||
|      * @param {boolean} [motion]  | ||||
|      * @param {*} [options] Override http request option. | ||||
|      * @throws {RequiredError} | ||||
|      * @memberof SearchApi | ||||
|      */ | ||||
|     public search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options?: AxiosRequestConfig) { | ||||
|         return SearchApiFp(this.configuration).search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(this.axios, this.basePath)); | ||||
|     public search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig) { | ||||
|         return SearchApiFp(this.configuration).search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|   | ||||
| @@ -19,6 +19,7 @@ | ||||
| 	export let format: ThumbnailFormat = ThumbnailFormat.Webp; | ||||
| 	export let selected = false; | ||||
| 	export let disabled = false; | ||||
| 	export let readonly = false; | ||||
| 	export let publicSharedKey = ''; | ||||
| 	export let isRoundedCorner = false; | ||||
|  | ||||
| @@ -56,6 +57,7 @@ | ||||
| 	}; | ||||
|  | ||||
| 	const parseVideoDuration = (duration: string) => { | ||||
| 		duration = duration || '0:00:00.00000'; | ||||
| 		const timePart = duration.split(':'); | ||||
| 		const hours = timePart[0]; | ||||
| 		const minutes = timePart[1]; | ||||
| @@ -118,7 +120,7 @@ | ||||
| 		} else if (disabled) { | ||||
| 			return 'border-[20px] border-gray-300'; | ||||
| 		} else if (isRoundedCorner) { | ||||
| 			return 'rounded-[20px]'; | ||||
| 			return 'rounded-lg'; | ||||
| 		} else { | ||||
| 			return ''; | ||||
| 		} | ||||
| @@ -157,7 +159,7 @@ | ||||
| 		on:click={thumbnailClickedHandler} | ||||
| 		on:keydown={thumbnailClickedHandler} | ||||
| 	> | ||||
| 		{#if mouseOver || selected || disabled} | ||||
| 		{#if (mouseOver || selected || disabled) && !readonly} | ||||
| 			<div | ||||
| 				in:fade={{ duration: 200 }} | ||||
| 				class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2 z-10`} | ||||
|   | ||||
| @@ -4,6 +4,7 @@ | ||||
| 	import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte'; | ||||
| 	import ImageAlbum from 'svelte-material-icons/ImageAlbum.svelte'; | ||||
| 	import ImageOutline from 'svelte-material-icons/ImageOutline.svelte'; | ||||
| 	import Magnify from 'svelte-material-icons/Magnify.svelte'; | ||||
| 	import StarOutline from 'svelte-material-icons/StarOutline.svelte'; | ||||
| 	import { AppRoute } from '../../../constants'; | ||||
| 	import LoadingSpinner from '../loading-spinner.svelte'; | ||||
| @@ -62,6 +63,18 @@ | ||||
| 			</svelte:fragment> | ||||
| 		</SideBarButton> | ||||
| 	</a> | ||||
| 	<a | ||||
| 		data-sveltekit-preload-data="hover" | ||||
| 		data-sveltekit-noscroll | ||||
| 		href={AppRoute.EXPLORE} | ||||
| 		draggable="false" | ||||
| 	> | ||||
| 		<SideBarButton | ||||
| 			title="Explore" | ||||
| 			logo={Magnify} | ||||
| 			isSelected={$page.route.id === '/(user)/explore'} | ||||
| 		/> | ||||
| 	</a> | ||||
| 	<a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} draggable="false"> | ||||
| 		<SideBarButton | ||||
| 			title="Sharing" | ||||
|   | ||||
| @@ -10,7 +10,7 @@ export enum AppRoute { | ||||
| 	ALBUMS = '/albums', | ||||
| 	FAVORITES = '/favorites', | ||||
| 	PHOTOS = '/photos', | ||||
| 	EXPLORE = '/explore', | ||||
| 	SHARING = '/sharing', | ||||
|  | ||||
| 	AUTH_LOGIN = '/auth/login' | ||||
| } | ||||
|   | ||||
							
								
								
									
										13
									
								
								web/src/routes/(user)/explore/+page.server.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								web/src/routes/(user)/explore/+page.server.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| import { redirect } from '@sveltejs/kit'; | ||||
| import type { PageServerLoad } from './$types'; | ||||
|  | ||||
| export const load = (async ({ locals, parent }) => { | ||||
| 	const { user } = await parent(); | ||||
| 	if (!user) { | ||||
| 		throw redirect(302, '/auth/login'); | ||||
| 	} | ||||
|  | ||||
| 	const { data: items } = await locals.api.searchApi.getExploreData(); | ||||
|  | ||||
| 	return { user, items }; | ||||
| }) satisfies PageServerLoad; | ||||
							
								
								
									
										173
									
								
								web/src/routes/(user)/explore/+page.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								web/src/routes/(user)/explore/+page.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,173 @@ | ||||
| <script lang="ts"> | ||||
| 	import ImmichThumbnail from '$lib/components/shared-components/immich-thumbnail.svelte'; | ||||
| 	import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte'; | ||||
| 	import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte'; | ||||
| 	import { AppRoute } from '$lib/constants'; | ||||
| 	import { AssetTypeEnum, SearchExploreItem } from '@api'; | ||||
| 	import ClockOutline from 'svelte-material-icons/ClockOutline.svelte'; | ||||
| 	import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte'; | ||||
| 	import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte'; | ||||
| 	import StarOutline from 'svelte-material-icons/StarOutline.svelte'; | ||||
| 	import type { PageData } from './$types'; | ||||
|  | ||||
| 	export let data: PageData; | ||||
|  | ||||
| 	enum Field { | ||||
| 		CITY = 'exifInfo.city', | ||||
| 		TAGS = 'smartInfo.tags', | ||||
| 		OBJECTS = 'smartInfo.objects' | ||||
| 	} | ||||
|  | ||||
| 	const MAX_ITEMS = 12; | ||||
|  | ||||
| 	let things: SearchExploreItem[] = []; | ||||
| 	let places: SearchExploreItem[] = []; | ||||
|  | ||||
| 	for (const item of data.items) { | ||||
| 		switch (item.fieldName) { | ||||
| 			case Field.OBJECTS: | ||||
| 				things = item.items; | ||||
| 				break; | ||||
|  | ||||
| 			case Field.CITY: | ||||
| 				places = item.items; | ||||
| 				break; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	things = things.slice(0, MAX_ITEMS); | ||||
| 	places = places.slice(0, MAX_ITEMS); | ||||
| </script> | ||||
|  | ||||
| <section> | ||||
| 	<NavigationBar user={data.user} shouldShowUploadButton={false} /> | ||||
| </section> | ||||
|  | ||||
| <section | ||||
| 	class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg dark:bg-immich-dark-bg" | ||||
| > | ||||
| 	<SideBar /> | ||||
|  | ||||
| 	<section class="overflow-y-auto relative immich-scrollbar"> | ||||
| 		<section | ||||
| 			id="album-content" | ||||
| 			class="relative pt-8 pl-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg" | ||||
| 		> | ||||
| 			<!-- Main Section --> | ||||
| 			<div class="px-4 flex justify-between place-items-center dark:text-immich-dark-fg"> | ||||
| 				<div> | ||||
| 					<p class="font-medium">Explore</p> | ||||
| 				</div> | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="my-4"> | ||||
| 				<hr class="dark:border-immich-dark-gray" /> | ||||
| 			</div> | ||||
|  | ||||
| 			<div class="mx-4 flex flex-col"> | ||||
| 				{#if places.length > 0} | ||||
| 					<div class="mb-6 mt-2"> | ||||
| 						<div> | ||||
| 							<p class="mb-4 dark:text-immich-dark-fg font-medium">Places</p> | ||||
| 						</div> | ||||
| 						<div class="flex flex-row flex-wrap gap-4"> | ||||
| 							{#each places as item} | ||||
| 								<a class="relative" href="/search?{Field.CITY}={item.value}" draggable="false"> | ||||
| 									<div class="filter brightness-75 rounded-xl overflow-hidden"> | ||||
| 										<ImmichThumbnail | ||||
| 											isRoundedCorner={true} | ||||
| 											thumbnailSize={156} | ||||
| 											asset={item.data} | ||||
| 											readonly={true} | ||||
| 										/> | ||||
| 									</div> | ||||
| 									<span | ||||
| 										class="capitalize absolute bottom-2 w-full text-center text-sm font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]" | ||||
| 									> | ||||
| 										{item.value} | ||||
| 									</span> | ||||
| 								</a> | ||||
| 							{/each} | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				{/if} | ||||
|  | ||||
| 				{#if things.length > 0} | ||||
| 					<div class="mb-6 mt-2"> | ||||
| 						<div> | ||||
| 							<p class="mb-4 dark:text-immich-dark-fg font-medium">Things</p> | ||||
| 						</div> | ||||
| 						<div class="flex flex-row flex-wrap gap-4"> | ||||
| 							{#each things as item} | ||||
| 								<a class="relative" href="/search?{Field.OBJECTS}={item.value}" draggable="false"> | ||||
| 									<div class="filter brightness-75 rounded-xl overflow-hidden"> | ||||
| 										<ImmichThumbnail | ||||
| 											isRoundedCorner={true} | ||||
| 											thumbnailSize={156} | ||||
| 											asset={item.data} | ||||
| 											readonly={true} | ||||
| 										/> | ||||
| 									</div> | ||||
| 									<span | ||||
| 										class="capitalize absolute bottom-2 w-full text-center text-sm font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]" | ||||
| 									> | ||||
| 										{item.value} | ||||
| 									</span> | ||||
| 								</a> | ||||
| 							{/each} | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				{/if} | ||||
|  | ||||
| 				<hr class="dark:border-immich-dark-gray mb-4" /> | ||||
|  | ||||
| 				<div | ||||
| 					class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-8" | ||||
| 				> | ||||
| 					<div class="flex flex-col gap-6 dark:text-immich-dark-fg"> | ||||
| 						<p class="text-sm">YOUR ACTIVITY</p> | ||||
| 						<div class="flex flex-col gap-4 dark:text-immich-dark-fg/80"> | ||||
| 							<a | ||||
| 								href={AppRoute.FAVORITES} | ||||
| 								class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary content-center gap-2" | ||||
| 								draggable="false" | ||||
| 							> | ||||
| 								<StarOutline size={24} /> | ||||
| 								<span>Favorites</span> | ||||
| 							</a> | ||||
| 							<a | ||||
| 								href="/search?recent=true" | ||||
| 								class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary content-center gap-2" | ||||
| 								draggable="false" | ||||
| 							> | ||||
| 								<ClockOutline size={24} /> | ||||
| 								<span>Recently added</span> | ||||
| 							</a> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 					<div class="flex flex-col gap-6 dark:text-immich-dark-fg"> | ||||
| 						<p class="text-sm">CATEGORIES</p> | ||||
| 						<div class="flex flex-col gap-4 dark:text-immich-dark-fg/80"> | ||||
| 							<a | ||||
| 								href="/search?type={AssetTypeEnum.Video}" | ||||
| 								class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary items-center gap-2" | ||||
| 							> | ||||
| 								<PlayCircleOutline size={24} /> | ||||
| 								<span>Videos</span> | ||||
| 							</a> | ||||
| 							<div> | ||||
| 								<a | ||||
| 									href="/search?motion=true" | ||||
| 									class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary items-center gap-2" | ||||
| 								> | ||||
| 									<MotionPlayOutline size={24} /> | ||||
| 									<span>Motion photos</span> | ||||
| 								</a> | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		</section> | ||||
| 	</section> | ||||
| </section> | ||||
| @@ -8,7 +8,6 @@ export const load = (async ({ locals, parent, url }) => { | ||||
| 	} | ||||
|  | ||||
| 	const term = url.searchParams.get('q') || undefined; | ||||
|  | ||||
| 	const { data: results } = await locals.api.searchApi.search( | ||||
| 		term, | ||||
| 		undefined, | ||||
| @@ -20,6 +19,8 @@ export const load = (async ({ locals, parent, url }) => { | ||||
| 		undefined, | ||||
| 		undefined, | ||||
| 		undefined, | ||||
| 		undefined, | ||||
| 		undefined, | ||||
| 		{ params: url.searchParams } | ||||
| 	); | ||||
| 	return { user, term, results }; | ||||
|   | ||||
| @@ -1,16 +1,34 @@ | ||||
| <script lang="ts"> | ||||
| 	import { page } from '$app/stores'; | ||||
| 	import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; | ||||
| 	import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; | ||||
| 	import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte'; | ||||
| 	import type { PageData } from './$types'; | ||||
| 	import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte'; | ||||
| 	import ImageOffOutline from 'svelte-material-icons/ImageOffOutline.svelte'; | ||||
| 	import { afterNavigate, goto } from '$app/navigation'; | ||||
|  | ||||
| 	export let data: PageData; | ||||
| 	const term = $page.url.searchParams.get('q') || data.term || ''; | ||||
|  | ||||
| 	const term = $page.url.searchParams.get('q') || ''; | ||||
| 	let goBackRoute = '/explore'; | ||||
| 	afterNavigate((r) => { | ||||
| 		if (r.from) { | ||||
| 			goBackRoute = r.from.url.href; | ||||
| 		} | ||||
| 	}); | ||||
| </script> | ||||
|  | ||||
| <section> | ||||
| 	<NavigationBar {term} user={data.user} shouldShowUploadButton={false} /> | ||||
| 	<ControlAppBar on:close-button-click={() => goto(goBackRoute)} backIcon={ArrowLeft}> | ||||
| 		<svelte:fragment slot="leading"> | ||||
| 			<p class="text-xl capitalize"> | ||||
| 				Search | ||||
| 				{#if term} | ||||
| 					- {term} | ||||
| 				{/if} | ||||
| 			</p> | ||||
| 		</svelte:fragment> | ||||
| 	</ControlAppBar> | ||||
| </section> | ||||
|  | ||||
| <section class="relative pt-[72px] h-screen bg-immich-bg  dark:bg-immich-dark-bg"> | ||||
| @@ -19,8 +37,16 @@ | ||||
| 			id="search-content" | ||||
| 			class="relative pt-8 pl-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg" | ||||
| 		> | ||||
| 			{#if data.results?.assets?.items} | ||||
| 			{#if data.results?.assets?.items.length != 0} | ||||
| 				<GalleryViewer assets={data.results.assets.items} /> | ||||
| 			{:else} | ||||
| 				<div class="w-full text-center dark:text-white "> | ||||
| 					<div class="mt-60 flex flex-col place-content-center place-items-center"> | ||||
| 						<ImageOffOutline size="56" /> | ||||
| 						<p class="font-medium text-3xl mt-5">No results</p> | ||||
| 						<p class="text-base font-normal">Try a synonym or more general keyword</p> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			{/if} | ||||
| 		</section> | ||||
| 	</section> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user