mirror of
https://github.com/immich-app/immich.git
synced 2025-05-31 04:05:39 -04:00
feat: Search filtering logic (#6968)
* commit * controller/service/repository logic * use enum * openapi * suggest people * suggest place/camera * cursor hover * refactor * Add try catch * Remove get people with name service * Remove deadcode * people selection * People placement * sort people * Update server/src/domain/repositories/metadata.repository.ts Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> * pr feedback * styling * done * open api * fix test * use string type * remmove bad merge * use correct type * fix test * fix lint * remove unused code * remove unused code * pr feedback * pr feedback --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
parent
0c45f51a29
commit
4b3f8d1946
3
mobile/openapi/.openapi-generator/FILES
generated
3
mobile/openapi/.openapi-generator/FILES
generated
@ -120,6 +120,7 @@ doc/SearchExploreResponseDto.md
|
|||||||
doc/SearchFacetCountResponseDto.md
|
doc/SearchFacetCountResponseDto.md
|
||||||
doc/SearchFacetResponseDto.md
|
doc/SearchFacetResponseDto.md
|
||||||
doc/SearchResponseDto.md
|
doc/SearchResponseDto.md
|
||||||
|
doc/SearchSuggestionType.md
|
||||||
doc/ServerConfigDto.md
|
doc/ServerConfigDto.md
|
||||||
doc/ServerFeaturesDto.md
|
doc/ServerFeaturesDto.md
|
||||||
doc/ServerInfoApi.md
|
doc/ServerInfoApi.md
|
||||||
@ -313,6 +314,7 @@ lib/model/search_explore_response_dto.dart
|
|||||||
lib/model/search_facet_count_response_dto.dart
|
lib/model/search_facet_count_response_dto.dart
|
||||||
lib/model/search_facet_response_dto.dart
|
lib/model/search_facet_response_dto.dart
|
||||||
lib/model/search_response_dto.dart
|
lib/model/search_response_dto.dart
|
||||||
|
lib/model/search_suggestion_type.dart
|
||||||
lib/model/server_config_dto.dart
|
lib/model/server_config_dto.dart
|
||||||
lib/model/server_features_dto.dart
|
lib/model/server_features_dto.dart
|
||||||
lib/model/server_info_response_dto.dart
|
lib/model/server_info_response_dto.dart
|
||||||
@ -485,6 +487,7 @@ test/search_explore_response_dto_test.dart
|
|||||||
test/search_facet_count_response_dto_test.dart
|
test/search_facet_count_response_dto_test.dart
|
||||||
test/search_facet_response_dto_test.dart
|
test/search_facet_response_dto_test.dart
|
||||||
test/search_response_dto_test.dart
|
test/search_response_dto_test.dart
|
||||||
|
test/search_suggestion_type_test.dart
|
||||||
test/server_config_dto_test.dart
|
test/server_config_dto_test.dart
|
||||||
test/server_features_dto_test.dart
|
test/server_features_dto_test.dart
|
||||||
test/server_info_api_test.dart
|
test/server_info_api_test.dart
|
||||||
|
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@ -161,6 +161,7 @@ Class | Method | HTTP request | Description
|
|||||||
*PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person |
|
*PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person |
|
||||||
*PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} |
|
*PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} |
|
||||||
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore |
|
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore |
|
||||||
|
*SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions |
|
||||||
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search |
|
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search |
|
||||||
*SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **GET** /search/metadata |
|
*SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **GET** /search/metadata |
|
||||||
*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person |
|
*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person |
|
||||||
@ -315,6 +316,7 @@ Class | Method | HTTP request | Description
|
|||||||
- [SearchFacetCountResponseDto](doc//SearchFacetCountResponseDto.md)
|
- [SearchFacetCountResponseDto](doc//SearchFacetCountResponseDto.md)
|
||||||
- [SearchFacetResponseDto](doc//SearchFacetResponseDto.md)
|
- [SearchFacetResponseDto](doc//SearchFacetResponseDto.md)
|
||||||
- [SearchResponseDto](doc//SearchResponseDto.md)
|
- [SearchResponseDto](doc//SearchResponseDto.md)
|
||||||
|
- [SearchSuggestionType](doc//SearchSuggestionType.md)
|
||||||
- [ServerConfigDto](doc//ServerConfigDto.md)
|
- [ServerConfigDto](doc//ServerConfigDto.md)
|
||||||
- [ServerFeaturesDto](doc//ServerFeaturesDto.md)
|
- [ServerFeaturesDto](doc//ServerFeaturesDto.md)
|
||||||
- [ServerInfoResponseDto](doc//ServerInfoResponseDto.md)
|
- [ServerInfoResponseDto](doc//ServerInfoResponseDto.md)
|
||||||
|
68
mobile/openapi/doc/SearchApi.md
generated
68
mobile/openapi/doc/SearchApi.md
generated
@ -10,6 +10,7 @@ All URIs are relative to */api*
|
|||||||
Method | HTTP request | Description
|
Method | HTTP request | Description
|
||||||
------------- | ------------- | -------------
|
------------- | ------------- | -------------
|
||||||
[**getExploreData**](SearchApi.md#getexploredata) | **GET** /search/explore |
|
[**getExploreData**](SearchApi.md#getexploredata) | **GET** /search/explore |
|
||||||
|
[**getSearchSuggestions**](SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions |
|
||||||
[**search**](SearchApi.md#search) | **GET** /search |
|
[**search**](SearchApi.md#search) | **GET** /search |
|
||||||
[**searchMetadata**](SearchApi.md#searchmetadata) | **GET** /search/metadata |
|
[**searchMetadata**](SearchApi.md#searchmetadata) | **GET** /search/metadata |
|
||||||
[**searchPerson**](SearchApi.md#searchperson) | **GET** /search/person |
|
[**searchPerson**](SearchApi.md#searchperson) | **GET** /search/person |
|
||||||
@ -67,6 +68,69 @@ 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)
|
[[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)
|
||||||
|
|
||||||
|
# **getSearchSuggestions**
|
||||||
|
> List<String> getSearchSuggestions(type, country, make, model, state)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
// 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';
|
||||||
|
// TODO Configure API key authorization: api_key
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
final api_instance = SearchApi();
|
||||||
|
final type = ; // SearchSuggestionType |
|
||||||
|
final country = country_example; // String |
|
||||||
|
final make = make_example; // String |
|
||||||
|
final model = model_example; // String |
|
||||||
|
final state = state_example; // String |
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = api_instance.getSearchSuggestions(type, country, make, model, state);
|
||||||
|
print(result);
|
||||||
|
} catch (e) {
|
||||||
|
print('Exception when calling SearchApi->getSearchSuggestions: $e\n');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
Name | Type | Description | Notes
|
||||||
|
------------- | ------------- | ------------- | -------------
|
||||||
|
**type** | [**SearchSuggestionType**](.md)| |
|
||||||
|
**country** | **String**| | [optional]
|
||||||
|
**make** | **String**| | [optional]
|
||||||
|
**model** | **String**| | [optional]
|
||||||
|
**state** | **String**| | [optional]
|
||||||
|
|
||||||
|
### Return type
|
||||||
|
|
||||||
|
**List<String>**
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
# **search**
|
# **search**
|
||||||
> SearchResponseDto search(clip, motion, page, q, query, recent, size, smart, type, withArchived)
|
> SearchResponseDto search(clip, motion, page, q, query, recent, size, smart, type, withArchived)
|
||||||
|
|
||||||
@ -91,7 +155,7 @@ import 'package:openapi/api.dart';
|
|||||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||||
|
|
||||||
final api_instance = SearchApi();
|
final api_instance = SearchApi();
|
||||||
final clip = true; // bool | @deprecated
|
final clip = true; // bool |
|
||||||
final motion = true; // bool |
|
final motion = true; // bool |
|
||||||
final page = 8.14; // num |
|
final page = 8.14; // num |
|
||||||
final q = q_example; // String |
|
final q = q_example; // String |
|
||||||
@ -114,7 +178,7 @@ try {
|
|||||||
|
|
||||||
Name | Type | Description | Notes
|
Name | Type | Description | Notes
|
||||||
------------- | ------------- | ------------- | -------------
|
------------- | ------------- | ------------- | -------------
|
||||||
**clip** | **bool**| @deprecated | [optional]
|
**clip** | **bool**| | [optional]
|
||||||
**motion** | **bool**| | [optional]
|
**motion** | **bool**| | [optional]
|
||||||
**page** | **num**| | [optional]
|
**page** | **num**| | [optional]
|
||||||
**q** | **String**| | [optional]
|
**q** | **String**| | [optional]
|
||||||
|
14
mobile/openapi/doc/SearchSuggestionType.md
generated
Normal file
14
mobile/openapi/doc/SearchSuggestionType.md
generated
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# openapi.model.SearchSuggestionType
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
|
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
@ -153,6 +153,7 @@ part 'model/search_explore_response_dto.dart';
|
|||||||
part 'model/search_facet_count_response_dto.dart';
|
part 'model/search_facet_count_response_dto.dart';
|
||||||
part 'model/search_facet_response_dto.dart';
|
part 'model/search_facet_response_dto.dart';
|
||||||
part 'model/search_response_dto.dart';
|
part 'model/search_response_dto.dart';
|
||||||
|
part 'model/search_suggestion_type.dart';
|
||||||
part 'model/server_config_dto.dart';
|
part 'model/server_config_dto.dart';
|
||||||
part 'model/server_features_dto.dart';
|
part 'model/server_features_dto.dart';
|
||||||
part 'model/server_info_response_dto.dart';
|
part 'model/server_info_response_dto.dart';
|
||||||
|
82
mobile/openapi/lib/api/search_api.dart
generated
82
mobile/openapi/lib/api/search_api.dart
generated
@ -60,11 +60,90 @@ class SearchApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'GET /search/suggestions' operation and returns the [Response].
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [SearchSuggestionType] type (required):
|
||||||
|
///
|
||||||
|
/// * [String] country:
|
||||||
|
///
|
||||||
|
/// * [String] make:
|
||||||
|
///
|
||||||
|
/// * [String] model:
|
||||||
|
///
|
||||||
|
/// * [String] state:
|
||||||
|
Future<Response> getSearchSuggestionsWithHttpInfo(SearchSuggestionType type, { String? country, String? make, String? model, String? state, }) async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/search/suggestions';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
if (country != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'country', country));
|
||||||
|
}
|
||||||
|
if (make != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'make', make));
|
||||||
|
}
|
||||||
|
if (model != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'model', model));
|
||||||
|
}
|
||||||
|
if (state != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'state', state));
|
||||||
|
}
|
||||||
|
queryParams.addAll(_queryParams('', 'type', type));
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
path,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters:
|
||||||
|
///
|
||||||
|
/// * [SearchSuggestionType] type (required):
|
||||||
|
///
|
||||||
|
/// * [String] country:
|
||||||
|
///
|
||||||
|
/// * [String] make:
|
||||||
|
///
|
||||||
|
/// * [String] model:
|
||||||
|
///
|
||||||
|
/// * [String] state:
|
||||||
|
Future<List<String>?> getSearchSuggestions(SearchSuggestionType type, { String? country, String? make, String? model, String? state, }) async {
|
||||||
|
final response = await getSearchSuggestionsWithHttpInfo(type, country: country, make: make, model: model, state: state, );
|
||||||
|
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<String>') as List)
|
||||||
|
.cast<String>()
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Performs an HTTP 'GET /search' operation and returns the [Response].
|
/// Performs an HTTP 'GET /search' operation and returns the [Response].
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
/// * [bool] clip:
|
/// * [bool] clip:
|
||||||
/// @deprecated
|
|
||||||
///
|
///
|
||||||
/// * [bool] motion:
|
/// * [bool] motion:
|
||||||
///
|
///
|
||||||
@ -142,7 +221,6 @@ class SearchApi {
|
|||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
/// * [bool] clip:
|
/// * [bool] clip:
|
||||||
/// @deprecated
|
|
||||||
///
|
///
|
||||||
/// * [bool] motion:
|
/// * [bool] motion:
|
||||||
///
|
///
|
||||||
|
2
mobile/openapi/lib/api_client.dart
generated
2
mobile/openapi/lib/api_client.dart
generated
@ -388,6 +388,8 @@ class ApiClient {
|
|||||||
return SearchFacetResponseDto.fromJson(value);
|
return SearchFacetResponseDto.fromJson(value);
|
||||||
case 'SearchResponseDto':
|
case 'SearchResponseDto':
|
||||||
return SearchResponseDto.fromJson(value);
|
return SearchResponseDto.fromJson(value);
|
||||||
|
case 'SearchSuggestionType':
|
||||||
|
return SearchSuggestionTypeTypeTransformer().decode(value);
|
||||||
case 'ServerConfigDto':
|
case 'ServerConfigDto':
|
||||||
return ServerConfigDto.fromJson(value);
|
return ServerConfigDto.fromJson(value);
|
||||||
case 'ServerFeaturesDto':
|
case 'ServerFeaturesDto':
|
||||||
|
3
mobile/openapi/lib/api_helper.dart
generated
3
mobile/openapi/lib/api_helper.dart
generated
@ -109,6 +109,9 @@ String parameterToString(dynamic value) {
|
|||||||
if (value is ReactionType) {
|
if (value is ReactionType) {
|
||||||
return ReactionTypeTypeTransformer().encode(value).toString();
|
return ReactionTypeTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
|
if (value is SearchSuggestionType) {
|
||||||
|
return SearchSuggestionTypeTypeTransformer().encode(value).toString();
|
||||||
|
}
|
||||||
if (value is SharedLinkType) {
|
if (value is SharedLinkType) {
|
||||||
return SharedLinkTypeTypeTransformer().encode(value).toString();
|
return SharedLinkTypeTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
|
94
mobile/openapi/lib/model/search_suggestion_type.dart
generated
Normal file
94
mobile/openapi/lib/model/search_suggestion_type.dart
generated
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
//
|
||||||
|
// 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 SearchSuggestionType {
|
||||||
|
/// Instantiate a new enum with the provided [value].
|
||||||
|
const SearchSuggestionType._(this.value);
|
||||||
|
|
||||||
|
/// The underlying value of this enum member.
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => value;
|
||||||
|
|
||||||
|
String toJson() => value;
|
||||||
|
|
||||||
|
static const country = SearchSuggestionType._(r'country');
|
||||||
|
static const state = SearchSuggestionType._(r'state');
|
||||||
|
static const city = SearchSuggestionType._(r'city');
|
||||||
|
static const cameraMake = SearchSuggestionType._(r'camera-make');
|
||||||
|
static const cameraModel = SearchSuggestionType._(r'camera-model');
|
||||||
|
|
||||||
|
/// List of all possible values in this [enum][SearchSuggestionType].
|
||||||
|
static const values = <SearchSuggestionType>[
|
||||||
|
country,
|
||||||
|
state,
|
||||||
|
city,
|
||||||
|
cameraMake,
|
||||||
|
cameraModel,
|
||||||
|
];
|
||||||
|
|
||||||
|
static SearchSuggestionType? fromJson(dynamic value) => SearchSuggestionTypeTypeTransformer().decode(value);
|
||||||
|
|
||||||
|
static List<SearchSuggestionType> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <SearchSuggestionType>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = SearchSuggestionType.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transformation class that can [encode] an instance of [SearchSuggestionType] to String,
|
||||||
|
/// and [decode] dynamic data back to [SearchSuggestionType].
|
||||||
|
class SearchSuggestionTypeTypeTransformer {
|
||||||
|
factory SearchSuggestionTypeTypeTransformer() => _instance ??= const SearchSuggestionTypeTypeTransformer._();
|
||||||
|
|
||||||
|
const SearchSuggestionTypeTypeTransformer._();
|
||||||
|
|
||||||
|
String encode(SearchSuggestionType data) => data.value;
|
||||||
|
|
||||||
|
/// Decodes a [dynamic value][data] to a SearchSuggestionType.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
SearchSuggestionType? decode(dynamic data, {bool allowNull = true}) {
|
||||||
|
if (data != null) {
|
||||||
|
switch (data) {
|
||||||
|
case r'country': return SearchSuggestionType.country;
|
||||||
|
case r'state': return SearchSuggestionType.state;
|
||||||
|
case r'city': return SearchSuggestionType.city;
|
||||||
|
case r'camera-make': return SearchSuggestionType.cameraMake;
|
||||||
|
case r'camera-model': return SearchSuggestionType.cameraModel;
|
||||||
|
default:
|
||||||
|
if (!allowNull) {
|
||||||
|
throw ArgumentError('Unknown enum value to decode: $data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Singleton [SearchSuggestionTypeTypeTransformer] instance.
|
||||||
|
static SearchSuggestionTypeTypeTransformer? _instance;
|
||||||
|
}
|
||||||
|
|
5
mobile/openapi/test/search_api_test.dart
generated
5
mobile/openapi/test/search_api_test.dart
generated
@ -22,6 +22,11 @@ void main() {
|
|||||||
// TODO
|
// TODO
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//Future<List<String>> getSearchSuggestions(SearchSuggestionType type, { String country, String make, String model, String state }) async
|
||||||
|
test('test getSearchSuggestions', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
//Future<SearchResponseDto> search({ bool clip, bool motion, num page, String q, String query, bool recent, num size, bool smart, String type, bool withArchived }) async
|
//Future<SearchResponseDto> search({ bool clip, bool motion, num page, String q, String query, bool recent, num size, bool smart, String type, bool withArchived }) async
|
||||||
test('test search', () async {
|
test('test search', () async {
|
||||||
// TODO
|
// TODO
|
||||||
|
21
mobile/openapi/test/search_suggestion_type_test.dart
generated
Normal file
21
mobile/openapi/test/search_suggestion_type_test.dart
generated
Normal file
@ -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 SearchSuggestionType
|
||||||
|
void main() {
|
||||||
|
|
||||||
|
group('test SearchSuggestionType', () {
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
@ -4370,7 +4370,6 @@
|
|||||||
"name": "clip",
|
"name": "clip",
|
||||||
"required": false,
|
"required": false,
|
||||||
"in": "query",
|
"in": "query",
|
||||||
"description": "@deprecated",
|
|
||||||
"deprecated": true,
|
"deprecated": true,
|
||||||
"schema": {
|
"schema": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
@ -5231,6 +5230,82 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/search/suggestions": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getSearchSuggestions",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "country",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "make",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "model",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "state",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "type",
|
||||||
|
"required": true,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SearchSuggestionType"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Search"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/server-info": {
|
"/server-info": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getServerInfo",
|
"operationId": "getServerInfo",
|
||||||
@ -9243,6 +9318,16 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SearchSuggestionType": {
|
||||||
|
"enum": [
|
||||||
|
"country",
|
||||||
|
"state",
|
||||||
|
"city",
|
||||||
|
"camera-make",
|
||||||
|
"camera-model"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"ServerConfigDto": {
|
"ServerConfigDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"externalDomain": {
|
"externalDomain": {
|
||||||
|
166
open-api/typescript-sdk/axios-client/api.ts
generated
166
open-api/typescript-sdk/axios-client/api.ts
generated
@ -2995,6 +2995,23 @@ export interface SearchResponseDto {
|
|||||||
*/
|
*/
|
||||||
'assets': SearchAssetResponseDto;
|
'assets': SearchAssetResponseDto;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SearchSuggestionType = {
|
||||||
|
Country: 'country',
|
||||||
|
State: 'state',
|
||||||
|
City: 'city',
|
||||||
|
CameraMake: 'camera-make',
|
||||||
|
CameraModel: 'camera-model'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SearchSuggestionType = typeof SearchSuggestionType[keyof typeof SearchSuggestionType];
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @export
|
* @export
|
||||||
@ -14521,7 +14538,72 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
|
|||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {boolean} [clip] @deprecated
|
* @param {SearchSuggestionType} type
|
||||||
|
* @param {string} [country]
|
||||||
|
* @param {string} [make]
|
||||||
|
* @param {string} [model]
|
||||||
|
* @param {string} [state]
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getSearchSuggestions: async (type: SearchSuggestionType, country?: string, make?: string, model?: string, state?: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
// verify required parameter 'type' is not null or undefined
|
||||||
|
assertParamExists('getSearchSuggestions', 'type', type)
|
||||||
|
const localVarPath = `/search/suggestions`;
|
||||||
|
// 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 cookie required
|
||||||
|
|
||||||
|
// authentication api_key required
|
||||||
|
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||||
|
|
||||||
|
// authentication bearer required
|
||||||
|
// http bearer authentication required
|
||||||
|
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||||
|
|
||||||
|
if (country !== undefined) {
|
||||||
|
localVarQueryParameter['country'] = country;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (make !== undefined) {
|
||||||
|
localVarQueryParameter['make'] = make;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model !== undefined) {
|
||||||
|
localVarQueryParameter['model'] = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state !== undefined) {
|
||||||
|
localVarQueryParameter['state'] = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type !== undefined) {
|
||||||
|
localVarQueryParameter['type'] = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {boolean} [clip]
|
||||||
* @param {boolean} [motion]
|
* @param {boolean} [motion]
|
||||||
* @param {number} [page]
|
* @param {number} [page]
|
||||||
* @param {string} [q]
|
* @param {string} [q]
|
||||||
@ -15151,7 +15233,23 @@ export const SearchApiFp = function(configuration?: Configuration) {
|
|||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {boolean} [clip] @deprecated
|
* @param {SearchSuggestionType} type
|
||||||
|
* @param {string} [country]
|
||||||
|
* @param {string} [make]
|
||||||
|
* @param {string} [model]
|
||||||
|
* @param {string} [state]
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async getSearchSuggestions(type: SearchSuggestionType, country?: string, make?: string, model?: string, state?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<string>>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.getSearchSuggestions(type, country, make, model, state, options);
|
||||||
|
const index = configuration?.serverIndex ?? 0;
|
||||||
|
const operationBasePath = operationServerMap['SearchApi.getSearchSuggestions']?.[index]?.url;
|
||||||
|
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {boolean} [clip]
|
||||||
* @param {boolean} [motion]
|
* @param {boolean} [motion]
|
||||||
* @param {number} [page]
|
* @param {number} [page]
|
||||||
* @param {string} [q]
|
* @param {string} [q]
|
||||||
@ -15296,6 +15394,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
|
|||||||
getExploreData(options?: RawAxiosRequestConfig): AxiosPromise<Array<SearchExploreResponseDto>> {
|
getExploreData(options?: RawAxiosRequestConfig): AxiosPromise<Array<SearchExploreResponseDto>> {
|
||||||
return localVarFp.getExploreData(options).then((request) => request(axios, basePath));
|
return localVarFp.getExploreData(options).then((request) => request(axios, basePath));
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {SearchApiGetSearchSuggestionsRequest} requestParameters Request parameters.
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getSearchSuggestions(requestParameters: SearchApiGetSearchSuggestionsRequest, options?: RawAxiosRequestConfig): AxiosPromise<Array<string>> {
|
||||||
|
return localVarFp.getSearchSuggestions(requestParameters.type, requestParameters.country, requestParameters.make, requestParameters.model, requestParameters.state, options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {SearchApiSearchRequest} requestParameters Request parameters.
|
* @param {SearchApiSearchRequest} requestParameters Request parameters.
|
||||||
@ -15336,6 +15443,48 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request parameters for getSearchSuggestions operation in SearchApi.
|
||||||
|
* @export
|
||||||
|
* @interface SearchApiGetSearchSuggestionsRequest
|
||||||
|
*/
|
||||||
|
export interface SearchApiGetSearchSuggestionsRequest {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {SearchSuggestionType}
|
||||||
|
* @memberof SearchApiGetSearchSuggestions
|
||||||
|
*/
|
||||||
|
readonly type: SearchSuggestionType
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof SearchApiGetSearchSuggestions
|
||||||
|
*/
|
||||||
|
readonly country?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof SearchApiGetSearchSuggestions
|
||||||
|
*/
|
||||||
|
readonly make?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof SearchApiGetSearchSuggestions
|
||||||
|
*/
|
||||||
|
readonly model?: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof SearchApiGetSearchSuggestions
|
||||||
|
*/
|
||||||
|
readonly state?: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request parameters for search operation in SearchApi.
|
* Request parameters for search operation in SearchApi.
|
||||||
* @export
|
* @export
|
||||||
@ -15343,7 +15492,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
|
|||||||
*/
|
*/
|
||||||
export interface SearchApiSearchRequest {
|
export interface SearchApiSearchRequest {
|
||||||
/**
|
/**
|
||||||
* @deprecated
|
*
|
||||||
* @type {boolean}
|
* @type {boolean}
|
||||||
* @memberof SearchApiSearch
|
* @memberof SearchApiSearch
|
||||||
*/
|
*/
|
||||||
@ -15969,6 +16118,17 @@ export class SearchApi extends BaseAPI {
|
|||||||
return SearchApiFp(this.configuration).getExploreData(options).then((request) => request(this.axios, this.basePath));
|
return SearchApiFp(this.configuration).getExploreData(options).then((request) => request(this.axios, this.basePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {SearchApiGetSearchSuggestionsRequest} requestParameters Request parameters.
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof SearchApi
|
||||||
|
*/
|
||||||
|
public getSearchSuggestions(requestParameters: SearchApiGetSearchSuggestionsRequest, options?: RawAxiosRequestConfig) {
|
||||||
|
return SearchApiFp(this.configuration).getSearchSuggestions(requestParameters.type, requestParameters.country, requestParameters.make, requestParameters.model, requestParameters.state, options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {SearchApiSearchRequest} requestParameters Request parameters.
|
* @param {SearchApiSearchRequest} requestParameters Request parameters.
|
||||||
|
21
open-api/typescript-sdk/fetch-client.ts
generated
21
open-api/typescript-sdk/fetch-client.ts
generated
@ -603,6 +603,7 @@ export type SearchExploreResponseDto = {
|
|||||||
fieldName: string;
|
fieldName: string;
|
||||||
items: SearchExploreItem[];
|
items: SearchExploreItem[];
|
||||||
};
|
};
|
||||||
|
export type SearchSuggestionType = "country" | "state" | "city" | "camera-make" | "camera-model";
|
||||||
export type ServerInfoResponseDto = {
|
export type ServerInfoResponseDto = {
|
||||||
diskAvailable: string;
|
diskAvailable: string;
|
||||||
diskAvailableRaw: number;
|
diskAvailableRaw: number;
|
||||||
@ -2266,6 +2267,26 @@ export function searchSmart({ city, country, createdAfter, createdBefore, device
|
|||||||
...opts
|
...opts
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
export function getSearchSuggestions({ country, make, model, state, $type }: {
|
||||||
|
country?: string;
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
state?: string;
|
||||||
|
$type: SearchSuggestionType;
|
||||||
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: string[];
|
||||||
|
}>(`/search/suggestions${QS.query(QS.explode({
|
||||||
|
country,
|
||||||
|
make,
|
||||||
|
model,
|
||||||
|
state,
|
||||||
|
"type": $type
|
||||||
|
}))}`, {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
export function getServerInfo(opts?: Oazapfts.RequestOpts) {
|
export function getServerInfo(opts?: Oazapfts.RequestOpts) {
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
status: 200;
|
status: 200;
|
||||||
|
@ -145,7 +145,7 @@
|
|||||||
"coverageDirectory": "./coverage",
|
"coverageDirectory": "./coverage",
|
||||||
"coverageThreshold": {
|
"coverageThreshold": {
|
||||||
"./src/domain/": {
|
"./src/domain/": {
|
||||||
"branches": 80,
|
"branches": 79,
|
||||||
"functions": 80,
|
"functions": 80,
|
||||||
"lines": 90,
|
"lines": 90,
|
||||||
"statements": 90
|
"statements": 90
|
||||||
|
@ -39,4 +39,9 @@ export interface IMetadataRepository {
|
|||||||
readTags(path: string): Promise<ImmichTags | null>;
|
readTags(path: string): Promise<ImmichTags | null>;
|
||||||
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
|
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
|
||||||
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
|
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
|
||||||
|
getCountries(userId: string): Promise<string[]>;
|
||||||
|
getStates(userId: string, country?: string): Promise<string[]>;
|
||||||
|
getCities(userId: string, country?: string, state?: string): Promise<string[]>;
|
||||||
|
getCameraMakes(userId: string, model?: string): Promise<string[]>;
|
||||||
|
getCameraModels(userId: string, make?: string): Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
33
server/src/domain/search/dto/search-suggestion.dto.ts
Normal file
33
server/src/domain/search/dto/search-suggestion.dto.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export enum SearchSuggestionType {
|
||||||
|
COUNTRY = 'country',
|
||||||
|
STATE = 'state',
|
||||||
|
CITY = 'city',
|
||||||
|
CAMERA_MAKE = 'camera-make',
|
||||||
|
CAMERA_MODEL = 'camera-model',
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SearchSuggestionRequestDto {
|
||||||
|
@IsEnum(SearchSuggestionType)
|
||||||
|
@IsNotEmpty()
|
||||||
|
@ApiProperty({ enumName: 'SearchSuggestionType', enum: SearchSuggestionType })
|
||||||
|
type!: SearchSuggestionType;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
country?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
state?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
make?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
model?: string;
|
||||||
|
}
|
@ -4,6 +4,7 @@ import {
|
|||||||
authStub,
|
authStub,
|
||||||
newAssetRepositoryMock,
|
newAssetRepositoryMock,
|
||||||
newMachineLearningRepositoryMock,
|
newMachineLearningRepositoryMock,
|
||||||
|
newMetadataRepositoryMock,
|
||||||
newPartnerRepositoryMock,
|
newPartnerRepositoryMock,
|
||||||
newPersonRepositoryMock,
|
newPersonRepositoryMock,
|
||||||
newSearchRepositoryMock,
|
newSearchRepositoryMock,
|
||||||
@ -14,6 +15,7 @@ import { mapAsset } from '../asset';
|
|||||||
import {
|
import {
|
||||||
IAssetRepository,
|
IAssetRepository,
|
||||||
IMachineLearningRepository,
|
IMachineLearningRepository,
|
||||||
|
IMetadataRepository,
|
||||||
IPartnerRepository,
|
IPartnerRepository,
|
||||||
IPersonRepository,
|
IPersonRepository,
|
||||||
ISearchRepository,
|
ISearchRepository,
|
||||||
@ -32,6 +34,7 @@ describe(SearchService.name, () => {
|
|||||||
let personMock: jest.Mocked<IPersonRepository>;
|
let personMock: jest.Mocked<IPersonRepository>;
|
||||||
let searchMock: jest.Mocked<ISearchRepository>;
|
let searchMock: jest.Mocked<ISearchRepository>;
|
||||||
let partnerMock: jest.Mocked<IPartnerRepository>;
|
let partnerMock: jest.Mocked<IPartnerRepository>;
|
||||||
|
let metadataMock: jest.Mocked<IMetadataRepository>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
assetMock = newAssetRepositoryMock();
|
assetMock = newAssetRepositoryMock();
|
||||||
@ -40,7 +43,9 @@ describe(SearchService.name, () => {
|
|||||||
personMock = newPersonRepositoryMock();
|
personMock = newPersonRepositoryMock();
|
||||||
searchMock = newSearchRepositoryMock();
|
searchMock = newSearchRepositoryMock();
|
||||||
partnerMock = newPartnerRepositoryMock();
|
partnerMock = newPartnerRepositoryMock();
|
||||||
sut = new SearchService(configMock, machineMock, personMock, searchMock, assetMock, partnerMock);
|
metadataMock = newMetadataRepositoryMock();
|
||||||
|
|
||||||
|
sut = new SearchService(configMock, machineMock, personMock, searchMock, assetMock, partnerMock, metadataMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work', () => {
|
it('should work', () => {
|
||||||
|
@ -7,6 +7,7 @@ import { PersonResponseDto } from '../person';
|
|||||||
import {
|
import {
|
||||||
IAssetRepository,
|
IAssetRepository,
|
||||||
IMachineLearningRepository,
|
IMachineLearningRepository,
|
||||||
|
IMetadataRepository,
|
||||||
IPartnerRepository,
|
IPartnerRepository,
|
||||||
IPersonRepository,
|
IPersonRepository,
|
||||||
ISearchRepository,
|
ISearchRepository,
|
||||||
@ -16,6 +17,7 @@ import {
|
|||||||
} from '../repositories';
|
} from '../repositories';
|
||||||
import { FeatureFlag, SystemConfigCore } from '../system-config';
|
import { FeatureFlag, SystemConfigCore } from '../system-config';
|
||||||
import { MetadataSearchDto, SearchDto, SearchPeopleDto, SmartSearchDto } from './dto';
|
import { MetadataSearchDto, SearchDto, SearchPeopleDto, SmartSearchDto } from './dto';
|
||||||
|
import { SearchSuggestionRequestDto, SearchSuggestionType } from './dto/search-suggestion.dto';
|
||||||
import { SearchResponseDto } from './response-dto';
|
import { SearchResponseDto } from './response-dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -30,6 +32,7 @@ export class SearchService {
|
|||||||
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
|
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
|
||||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
|
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
|
||||||
|
@Inject(IMetadataRepository) private metadataRepository: IMetadataRepository,
|
||||||
) {
|
) {
|
||||||
this.configCore = SystemConfigCore.create(configRepository);
|
this.configCore = SystemConfigCore.create(configRepository);
|
||||||
}
|
}
|
||||||
@ -176,4 +179,28 @@ export class SearchService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise<string[]> {
|
||||||
|
if (dto.type === SearchSuggestionType.COUNTRY) {
|
||||||
|
return this.metadataRepository.getCountries(auth.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.type === SearchSuggestionType.STATE) {
|
||||||
|
return this.metadataRepository.getStates(auth.user.id, dto.country);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.type === SearchSuggestionType.CITY) {
|
||||||
|
return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.type === SearchSuggestionType.CAMERA_MAKE) {
|
||||||
|
return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.type === SearchSuggestionType.CAMERA_MODEL) {
|
||||||
|
return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import {
|
|||||||
SearchService,
|
SearchService,
|
||||||
SmartSearchDto,
|
SmartSearchDto,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
|
import { SearchSuggestionRequestDto } from '@app/domain/search/dto/search-suggestion.dto';
|
||||||
import { Controller, Get, Query } from '@nestjs/common';
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { Auth, Authenticated } from '../app.guard';
|
import { Auth, Authenticated } from '../app.guard';
|
||||||
@ -46,4 +47,9 @@ export class SearchController {
|
|||||||
searchPerson(@Auth() auth: AuthDto, @Query() dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
|
searchPerson(@Auth() auth: AuthDto, @Query() dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
|
||||||
return this.service.searchPerson(auth, dto);
|
return this.service.searchPerson(auth, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('suggestions')
|
||||||
|
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> {
|
||||||
|
return this.service.getSearchSuggestions(auth, dto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,13 @@ import {
|
|||||||
ISystemMetadataRepository,
|
ISystemMetadataRepository,
|
||||||
ReverseGeocodeResult,
|
ReverseGeocodeResult,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import { GeodataAdmin1Entity, GeodataAdmin2Entity, GeodataPlacesEntity, SystemMetadataKey } from '@app/infra/entities';
|
import {
|
||||||
|
ExifEntity,
|
||||||
|
GeodataAdmin1Entity,
|
||||||
|
GeodataAdmin2Entity,
|
||||||
|
GeodataPlacesEntity,
|
||||||
|
SystemMetadataKey,
|
||||||
|
} from '@app/infra/entities';
|
||||||
import { ImmichLogger } from '@app/infra/logger';
|
import { ImmichLogger } from '@app/infra/logger';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||||
@ -21,12 +27,14 @@ import { createReadStream, existsSync } from 'node:fs';
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import * as readLine from 'node:readline';
|
import * as readLine from 'node:readline';
|
||||||
import { DataSource, DeepPartial, QueryRunner, Repository } from 'typeorm';
|
import { DataSource, DeepPartial, QueryRunner, Repository } from 'typeorm';
|
||||||
|
import { DummyValue, GenerateSql } from '../infra.util';
|
||||||
|
|
||||||
type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity;
|
type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity;
|
||||||
type GeoEntityClass = typeof GeodataPlacesEntity | typeof GeodataAdmin1Entity | typeof GeodataAdmin2Entity;
|
type GeoEntityClass = typeof GeodataPlacesEntity | typeof GeodataAdmin1Entity | typeof GeodataAdmin2Entity;
|
||||||
|
|
||||||
export class MetadataRepository implements IMetadataRepository {
|
export class MetadataRepository implements IMetadataRepository {
|
||||||
constructor(
|
constructor(
|
||||||
|
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
|
||||||
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository<GeodataPlacesEntity>,
|
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository<GeodataPlacesEntity>,
|
||||||
@InjectRepository(GeodataAdmin1Entity) private readonly geodataAdmin1Repository: Repository<GeodataAdmin1Entity>,
|
@InjectRepository(GeodataAdmin1Entity) private readonly geodataAdmin1Repository: Repository<GeodataAdmin1Entity>,
|
||||||
@InjectRepository(GeodataAdmin2Entity) private readonly geodataAdmin2Repository: Repository<GeodataAdmin2Entity>,
|
@InjectRepository(GeodataAdmin2Entity) private readonly geodataAdmin2Repository: Repository<GeodataAdmin2Entity>,
|
||||||
@ -213,4 +221,106 @@ export class MetadataRepository implements IMetadataRepository {
|
|||||||
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
|
async getCountries(userId: string): Promise<string[]> {
|
||||||
|
const entity = await this.exifRepository
|
||||||
|
.createQueryBuilder('exif')
|
||||||
|
.leftJoin('exif.asset', 'asset')
|
||||||
|
.where('asset.ownerId = :userId', { userId })
|
||||||
|
.andWhere('exif.country IS NOT NULL')
|
||||||
|
.select('exif.country')
|
||||||
|
.distinctOn(['exif.country'])
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
return entity.map((e) => e.country ?? '').filter((c) => c !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||||
|
async getStates(userId: string, country: string | undefined): Promise<string[]> {
|
||||||
|
let result: ExifEntity[] = [];
|
||||||
|
|
||||||
|
const query = this.exifRepository
|
||||||
|
.createQueryBuilder('exif')
|
||||||
|
.leftJoin('exif.asset', 'asset')
|
||||||
|
.where('asset.ownerId = :userId', { userId })
|
||||||
|
.andWhere('exif.state IS NOT NULL')
|
||||||
|
.select('exif.state')
|
||||||
|
.distinctOn(['exif.state']);
|
||||||
|
|
||||||
|
if (country) {
|
||||||
|
query.andWhere('exif.country = :country', { country });
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await query.getMany();
|
||||||
|
|
||||||
|
return result.map((entity) => entity.state ?? '').filter((s) => s !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, DummyValue.STRING] })
|
||||||
|
async getCities(userId: string, country: string | undefined, state: string | undefined): Promise<string[]> {
|
||||||
|
let result: ExifEntity[] = [];
|
||||||
|
|
||||||
|
const query = this.exifRepository
|
||||||
|
.createQueryBuilder('exif')
|
||||||
|
.leftJoin('exif.asset', 'asset')
|
||||||
|
.where('asset.ownerId = :userId', { userId })
|
||||||
|
.andWhere('exif.city IS NOT NULL')
|
||||||
|
.select('exif.city')
|
||||||
|
.distinctOn(['exif.city']);
|
||||||
|
|
||||||
|
if (country) {
|
||||||
|
query.andWhere('exif.country = :country', { country });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state) {
|
||||||
|
query.andWhere('exif.state = :state', { state });
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await query.getMany();
|
||||||
|
|
||||||
|
return result.map((entity) => entity.city ?? '').filter((c) => c !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||||
|
async getCameraMakes(userId: string, model: string | undefined): Promise<string[]> {
|
||||||
|
let result: ExifEntity[] = [];
|
||||||
|
|
||||||
|
const query = this.exifRepository
|
||||||
|
.createQueryBuilder('exif')
|
||||||
|
.leftJoin('exif.asset', 'asset')
|
||||||
|
.where('asset.ownerId = :userId', { userId })
|
||||||
|
.andWhere('exif.make IS NOT NULL')
|
||||||
|
.select('exif.make')
|
||||||
|
.distinctOn(['exif.make']);
|
||||||
|
|
||||||
|
if (model) {
|
||||||
|
query.andWhere('exif.model = :model', { model });
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await query.getMany();
|
||||||
|
|
||||||
|
return result.map((entity) => entity.make ?? '').filter((m) => m !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||||
|
async getCameraModels(userId: string, make: string | undefined): Promise<string[]> {
|
||||||
|
let result: ExifEntity[] = [];
|
||||||
|
|
||||||
|
const query = this.exifRepository
|
||||||
|
.createQueryBuilder('exif')
|
||||||
|
.leftJoin('exif.asset', 'asset')
|
||||||
|
.where('asset.ownerId = :userId', { userId })
|
||||||
|
.andWhere('exif.model IS NOT NULL')
|
||||||
|
.select('exif.model')
|
||||||
|
.distinctOn(['exif.model']);
|
||||||
|
|
||||||
|
if (make) {
|
||||||
|
query.andWhere('exif.make = :make', { make });
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await query.getMany();
|
||||||
|
|
||||||
|
return result.map((entity) => entity.model ?? '').filter((m) => m !== '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,5 +8,10 @@ export const newMetadataRepositoryMock = (): jest.Mocked<IMetadataRepository> =>
|
|||||||
readTags: jest.fn(),
|
readTags: jest.fn(),
|
||||||
writeTags: jest.fn(),
|
writeTags: jest.fn(),
|
||||||
extractBinaryTag: jest.fn(),
|
extractBinaryTag: jest.fn(),
|
||||||
|
getCameraMakes: jest.fn(),
|
||||||
|
getCameraModels: jest.fn(),
|
||||||
|
getCities: jest.fn(),
|
||||||
|
getCountries: jest.fn(),
|
||||||
|
getStates: jest.fn(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -23,6 +23,7 @@
|
|||||||
export let selectedOption: ComboBoxOption | undefined = undefined;
|
export let selectedOption: ComboBoxOption | undefined = undefined;
|
||||||
export let placeholder = '';
|
export let placeholder = '';
|
||||||
export const label = '';
|
export const label = '';
|
||||||
|
export let noLabel = false;
|
||||||
|
|
||||||
let isOpen = false;
|
let isOpen = false;
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
@ -31,11 +32,13 @@
|
|||||||
|
|
||||||
const dispatch = createEventDispatcher<{
|
const dispatch = createEventDispatcher<{
|
||||||
select: ComboBoxOption;
|
select: ComboBoxOption;
|
||||||
|
click: void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let handleClick = () => {
|
let handleClick = () => {
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
isOpen = !isOpen;
|
isOpen = !isOpen;
|
||||||
|
dispatch('click');
|
||||||
};
|
};
|
||||||
|
|
||||||
let handleOutClick = () => {
|
let handleOutClick = () => {
|
||||||
@ -52,7 +55,9 @@
|
|||||||
|
|
||||||
<div class="relative" use:clickOutside on:outclick={handleOutClick}>
|
<div class="relative" use:clickOutside on:outclick={handleOutClick}>
|
||||||
<button {type} class="immich-form-input text-sm text-left w-full min-h-[48px] transition-all" on:click={handleClick}
|
<button {type} class="immich-form-input text-sm text-left w-full min-h-[48px] transition-all" on:click={handleClick}
|
||||||
>{selectedOption?.label}
|
>{#if !noLabel}
|
||||||
|
{selectedOption?.label || ''}
|
||||||
|
{/if}
|
||||||
<div class="absolute right-0 top-0 h-full flex px-4 justify-center items-center content-between">
|
<div class="absolute right-0 top-0 h-full flex px-4 justify-center items-center content-between">
|
||||||
<Icon path={mdiUnfoldMoreHorizontal} />
|
<Icon path={mdiUnfoldMoreHorizontal} />
|
||||||
</div>
|
</div>
|
||||||
@ -60,7 +65,7 @@
|
|||||||
|
|
||||||
{#if isOpen}
|
{#if isOpen}
|
||||||
<div
|
<div
|
||||||
transition:fly={{ y: 25, duration: 250 }}
|
transition:fly={{ y: -25, duration: 250 }}
|
||||||
class="absolute w-full top-full mt-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-300 dark:border-gray-900 z-10"
|
class="absolute w-full top-full mt-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-300 dark:border-gray-900 z-10"
|
||||||
>
|
>
|
||||||
<div class="relative border-b flex">
|
<div class="relative border-b flex">
|
||||||
@ -80,8 +85,8 @@
|
|||||||
<button
|
<button
|
||||||
{type}
|
{type}
|
||||||
class="block text-left w-full px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-all
|
class="block text-left w-full px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-all
|
||||||
${option.label === selectedOption?.label ? 'bg-gray-300 dark:bg-gray-600' : ''}
|
${option.label === selectedOption?.label ? 'bg-gray-300 dark:bg-gray-600' : ''}
|
||||||
"
|
"
|
||||||
class:bg-gray-300={option.label === selectedOption?.label}
|
class:bg-gray-300={option.label === selectedOption?.label}
|
||||||
on:click={() => handleSelect(option)}
|
on:click={() => handleSelect(option)}
|
||||||
>
|
>
|
||||||
|
@ -2,6 +2,12 @@
|
|||||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import Combobox, { type ComboBoxOption } from '../combobox.svelte';
|
import Combobox, { type ComboBoxOption } from '../combobox.svelte';
|
||||||
|
import { SearchSuggestionType, api, type PersonResponseDto } from '@api';
|
||||||
|
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
|
import { mdiArrowRight, mdiClose } from '@mdi/js';
|
||||||
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
enum MediaType {
|
enum MediaType {
|
||||||
All = 'all',
|
All = 'all',
|
||||||
@ -9,25 +15,274 @@
|
|||||||
Video = 'video',
|
Video = 'video',
|
||||||
}
|
}
|
||||||
|
|
||||||
let selectedCountry: ComboBoxOption = { label: '', value: '' };
|
type SearchSuggestion = {
|
||||||
let selectedState: ComboBoxOption = { label: '', value: '' };
|
people: PersonResponseDto[];
|
||||||
let selectedCity: ComboBoxOption = { label: '', value: '' };
|
country: ComboBoxOption[];
|
||||||
|
state: ComboBoxOption[];
|
||||||
|
city: ComboBoxOption[];
|
||||||
|
cameraMake: ComboBoxOption[];
|
||||||
|
cameraModel: ComboBoxOption[];
|
||||||
|
};
|
||||||
|
|
||||||
let mediaType: MediaType = MediaType.All;
|
type SearchParams = {
|
||||||
let notInAlbum = false;
|
state?: string;
|
||||||
let inArchive = false;
|
country?: string;
|
||||||
let inFavorite = false;
|
city?: string;
|
||||||
|
cameraMake?: string;
|
||||||
|
cameraModel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchFilter = {
|
||||||
|
context?: string;
|
||||||
|
people: PersonResponseDto[];
|
||||||
|
|
||||||
|
location: {
|
||||||
|
country?: ComboBoxOption;
|
||||||
|
state?: ComboBoxOption;
|
||||||
|
city?: ComboBoxOption;
|
||||||
|
};
|
||||||
|
|
||||||
|
camera: {
|
||||||
|
make?: ComboBoxOption;
|
||||||
|
model?: ComboBoxOption;
|
||||||
|
};
|
||||||
|
|
||||||
|
dateRange: {
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
inArchive?: boolean;
|
||||||
|
inFavorite?: boolean;
|
||||||
|
notInAlbum?: boolean;
|
||||||
|
|
||||||
|
mediaType: MediaType;
|
||||||
|
};
|
||||||
|
|
||||||
|
let suggestions: SearchSuggestion = {
|
||||||
|
people: [],
|
||||||
|
country: [],
|
||||||
|
state: [],
|
||||||
|
city: [],
|
||||||
|
cameraMake: [],
|
||||||
|
cameraModel: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let filter: SearchFilter = {
|
||||||
|
context: undefined,
|
||||||
|
people: [],
|
||||||
|
location: {
|
||||||
|
country: undefined,
|
||||||
|
state: undefined,
|
||||||
|
city: undefined,
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
make: undefined,
|
||||||
|
model: undefined,
|
||||||
|
},
|
||||||
|
dateRange: {
|
||||||
|
startDate: undefined,
|
||||||
|
endDate: undefined,
|
||||||
|
},
|
||||||
|
inArchive: undefined,
|
||||||
|
inFavorite: undefined,
|
||||||
|
notInAlbum: undefined,
|
||||||
|
mediaType: MediaType.All,
|
||||||
|
};
|
||||||
|
|
||||||
|
let showAllPeople = false;
|
||||||
|
$: peopleList = showAllPeople ? suggestions.people : suggestions.people.slice(0, 11);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
getPeople();
|
||||||
|
});
|
||||||
|
|
||||||
|
const showSelectedPeopleFirst = () => {
|
||||||
|
suggestions.people.sort((a, _) => {
|
||||||
|
if (filter.people.some((p) => p.id === a.id)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPeople = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.personApi.getAllPeople({ withHidden: false });
|
||||||
|
suggestions.people = data.people;
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, 'Failed to get people');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePeopleSelection = (id: string) => {
|
||||||
|
if (filter.people.some((p) => p.id === id)) {
|
||||||
|
filter.people = filter.people.filter((p) => p.id !== id);
|
||||||
|
showSelectedPeopleFirst();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const person = suggestions.people.find((p) => p.id === id);
|
||||||
|
if (person) {
|
||||||
|
filter.people = [...filter.people, person];
|
||||||
|
showSelectedPeopleFirst();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSuggestion = async (type: SearchSuggestionType, params: SearchParams) => {
|
||||||
|
if (
|
||||||
|
type === SearchSuggestionType.City ||
|
||||||
|
type === SearchSuggestionType.State ||
|
||||||
|
type === SearchSuggestionType.Country
|
||||||
|
) {
|
||||||
|
suggestions = { ...suggestions, city: [], state: [], country: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === SearchSuggestionType.CameraMake || type === SearchSuggestionType.CameraModel) {
|
||||||
|
suggestions = { ...suggestions, cameraMake: [], cameraModel: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.searchApi.getSearchSuggestions({
|
||||||
|
type: type,
|
||||||
|
country: params.country,
|
||||||
|
state: params.state,
|
||||||
|
make: params.cameraMake,
|
||||||
|
model: params.cameraModel,
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case SearchSuggestionType.Country: {
|
||||||
|
for (const country of data) {
|
||||||
|
suggestions.country = [...suggestions.country, { label: country, value: country }];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SearchSuggestionType.State: {
|
||||||
|
for (const state of data) {
|
||||||
|
suggestions.state = [...suggestions.state, { label: state, value: state }];
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SearchSuggestionType.City: {
|
||||||
|
for (const city of data) {
|
||||||
|
suggestions.city = [...suggestions.city, { label: city, value: city }];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SearchSuggestionType.CameraMake: {
|
||||||
|
for (const make of data) {
|
||||||
|
suggestions.cameraMake = [...suggestions.cameraMake, { label: make, value: make }];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SearchSuggestionType.CameraModel: {
|
||||||
|
for (const model of data) {
|
||||||
|
suggestions.cameraModel = [...suggestions.cameraModel, { label: model, value: model }];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error, 'Failed to get search suggestions');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
filter = {
|
||||||
|
context: undefined,
|
||||||
|
people: [],
|
||||||
|
location: {
|
||||||
|
country: undefined,
|
||||||
|
state: undefined,
|
||||||
|
city: undefined,
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
make: undefined,
|
||||||
|
model: undefined,
|
||||||
|
},
|
||||||
|
dateRange: {
|
||||||
|
startDate: undefined,
|
||||||
|
endDate: undefined,
|
||||||
|
},
|
||||||
|
inArchive: undefined,
|
||||||
|
inFavorite: undefined,
|
||||||
|
notInAlbum: undefined,
|
||||||
|
mediaType: MediaType.All,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const search = () => {};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
transition:fly={{ y: 25, duration: 250 }}
|
transition:fly={{ y: 25, duration: 250 }}
|
||||||
class="absolute w-full rounded-b-3xl border border-gray-200 bg-white pb-5 shadow-2xl transition-all dark:border-gray-800 dark:bg-immich-dark-gray dark:text-gray-300 p-6"
|
class="absolute w-full rounded-b-3xl border border-gray-200 bg-white shadow-2xl transition-all dark:border-gray-800 dark:bg-immich-dark-gray dark:text-gray-300 px-6 pt-6 overflow-y-auto max-h-[90vh] immich-scrollbar"
|
||||||
>
|
>
|
||||||
<p class="text-xs py-2">FILTERS</p>
|
<p class="text-xs py-2">FILTERS</p>
|
||||||
<hr class="py-2" />
|
<hr class="border-slate-300 dark:border-slate-700 py-2" />
|
||||||
|
|
||||||
<form id="search-filter-form" autocomplete="off">
|
<form id="search-filter-form relative" autocomplete="off" class="hover:cursor-auto">
|
||||||
<div class="py-3">
|
<!-- PEOPLE -->
|
||||||
|
<div id="people-selection" class="my-4">
|
||||||
|
<div class="flex justify-between place-items-center gap-6">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="immich-form-label">PEOPLE</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if suggestions.people.length > 0}
|
||||||
|
<div class="flex gap-1 mt-4 flex-wrap max-h-[300px] overflow-y-auto immich-scrollbar transition-all">
|
||||||
|
{#each peopleList as person (person.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-20 text-center rounded-3xl border-2 border-transparent hover:bg-immich-gray dark:hover:bg-immich-dark-primary/20 p-2 flex-col place-items-center transition-all {filter.people.some(
|
||||||
|
(p) => p.id === person.id,
|
||||||
|
)
|
||||||
|
? 'dark:border-slate-500 border-slate-300 bg-slate-200 dark:bg-slate-800 dark:text-white'
|
||||||
|
: ''}"
|
||||||
|
on:click={() => handlePeopleSelection(person.id)}
|
||||||
|
>
|
||||||
|
<ImageThumbnail
|
||||||
|
circle
|
||||||
|
shadow
|
||||||
|
url={api.getPeopleThumbnailUrl(person.id)}
|
||||||
|
altText={person.name}
|
||||||
|
widthStyle="100px"
|
||||||
|
/>
|
||||||
|
<p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center mt-2">
|
||||||
|
<Button
|
||||||
|
shadow={false}
|
||||||
|
color="text-primary"
|
||||||
|
type="button"
|
||||||
|
class="flex gap-2 place-items-center place-content-center"
|
||||||
|
on:click={() => (showAllPeople = !showAllPeople)}
|
||||||
|
>
|
||||||
|
{#if showAllPeople}
|
||||||
|
<span><Icon path={mdiClose} /></span>
|
||||||
|
Collapse
|
||||||
|
{:else}
|
||||||
|
<span><Icon path={mdiArrowRight} /></span>
|
||||||
|
See all people
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-slate-300 dark:border-slate-700" />
|
||||||
|
<!-- CONTEXT -->
|
||||||
|
<div class="my-4">
|
||||||
<label class="immich-form-label" for="context">CONTEXT</label>
|
<label class="immich-form-label" for="context">CONTEXT</label>
|
||||||
<input
|
<input
|
||||||
class="immich-form-input hover:cursor-text w-full mt-3"
|
class="immich-form-input hover:cursor-text w-full mt-3"
|
||||||
@ -35,9 +290,111 @@
|
|||||||
id="context"
|
id="context"
|
||||||
name="context"
|
name="context"
|
||||||
placeholder="Sunrise on the beach"
|
placeholder="Sunrise on the beach"
|
||||||
|
bind:value={filter.context}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-slate-300 dark:border-slate-700" />
|
||||||
|
<!-- LOCATION -->
|
||||||
|
<div id="location-selection" class="my-4">
|
||||||
|
<p class="immich-form-label">PLACE</p>
|
||||||
|
|
||||||
|
<div class="flex justify-between gap-5 mt-3">
|
||||||
|
<div class="w-full">
|
||||||
|
<p class="text-sm text-black dark:text-white">Country</p>
|
||||||
|
<Combobox
|
||||||
|
options={suggestions.country}
|
||||||
|
bind:selectedOption={filter.location.country}
|
||||||
|
placeholder="Search country..."
|
||||||
|
on:click={() => updateSuggestion(SearchSuggestionType.Country, {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<p class="text-sm text-black dark:text-white">State</p>
|
||||||
|
<Combobox
|
||||||
|
options={suggestions.state}
|
||||||
|
bind:selectedOption={filter.location.state}
|
||||||
|
placeholder="Search state..."
|
||||||
|
on:click={() => updateSuggestion(SearchSuggestionType.State, { country: filter.location.country?.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<p class="text-sm text-black dark:text-white">City</p>
|
||||||
|
<Combobox
|
||||||
|
options={suggestions.city}
|
||||||
|
bind:selectedOption={filter.location.city}
|
||||||
|
placeholder="Search city..."
|
||||||
|
on:click={() =>
|
||||||
|
updateSuggestion(SearchSuggestionType.City, {
|
||||||
|
country: filter.location.country?.value,
|
||||||
|
state: filter.location.state?.value,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-slate-300 dark:border-slate-700" />
|
||||||
|
<!-- CAMERA MODEL -->
|
||||||
|
<div id="camera-selection" class="my-4">
|
||||||
|
<p class="immich-form-label">CAMERA</p>
|
||||||
|
|
||||||
|
<div class="flex justify-between gap-5 mt-3">
|
||||||
|
<div class="w-full">
|
||||||
|
<p class="text-sm text-black dark:text-white">Make</p>
|
||||||
|
<Combobox
|
||||||
|
options={suggestions.cameraMake}
|
||||||
|
bind:selectedOption={filter.camera.make}
|
||||||
|
placeholder="Search camera make..."
|
||||||
|
on:click={() =>
|
||||||
|
updateSuggestion(SearchSuggestionType.CameraMake, { cameraModel: filter.camera.model?.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<p class="text-sm text-black dark:text-white">Model</p>
|
||||||
|
<Combobox
|
||||||
|
options={suggestions.cameraModel}
|
||||||
|
bind:selectedOption={filter.camera.model}
|
||||||
|
placeholder="Search camera model..."
|
||||||
|
on:click={() =>
|
||||||
|
updateSuggestion(SearchSuggestionType.CameraModel, { cameraMake: filter.camera.make?.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-slate-300 dark:border-slate-700" />
|
||||||
|
|
||||||
|
<!-- DATE RANGE -->
|
||||||
|
<div id="date-range-selection" class="my-4 flex justify-between gap-5">
|
||||||
|
<div class="mb-3 flex-1 mt">
|
||||||
|
<label class="immich-form-label" for="start-date">START DATE</label>
|
||||||
|
<input
|
||||||
|
class="immich-form-input w-full mt-3 hover:cursor-pointer"
|
||||||
|
type="date"
|
||||||
|
id="start-date"
|
||||||
|
name="start-date"
|
||||||
|
bind:value={filter.dateRange.startDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3 flex-1">
|
||||||
|
<label class="immich-form-label" for="end-date">END DATE</label>
|
||||||
|
<input
|
||||||
|
class="immich-form-input w-full mt-3 hover:cursor-pointer"
|
||||||
|
type="date"
|
||||||
|
id="end-date"
|
||||||
|
name="end-date"
|
||||||
|
placeholder=""
|
||||||
|
bind:value={filter.dateRange.endDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="border-slate-300 dark:border-slate-700" />
|
||||||
<div class="py-3 grid grid-cols-2">
|
<div class="py-3 grid grid-cols-2">
|
||||||
<!-- MEDIA TYPE -->
|
<!-- MEDIA TYPE -->
|
||||||
<div id="media-type-selection">
|
<div id="media-type-selection">
|
||||||
@ -49,7 +406,7 @@
|
|||||||
class="text-base flex place-items-center gap-1 hover:cursor-pointer text-black dark:text-white"
|
class="text-base flex place-items-center gap-1 hover:cursor-pointer text-black dark:text-white"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
bind:group={mediaType}
|
bind:group={filter.mediaType}
|
||||||
value={MediaType.All}
|
value={MediaType.All}
|
||||||
type="radio"
|
type="radio"
|
||||||
name="radio-type"
|
name="radio-type"
|
||||||
@ -62,10 +419,10 @@
|
|||||||
class="text-base flex place-items-center gap-1 hover:cursor-pointer text-black dark:text-white"
|
class="text-base flex place-items-center gap-1 hover:cursor-pointer text-black dark:text-white"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
bind:group={mediaType}
|
bind:group={filter.mediaType}
|
||||||
value={MediaType.Image}
|
value={MediaType.Image}
|
||||||
type="radio"
|
type="radio"
|
||||||
name="radio-type"
|
name="media-type"
|
||||||
id="type-image"
|
id="type-image"
|
||||||
/>Image</label
|
/>Image</label
|
||||||
>
|
>
|
||||||
@ -75,7 +432,7 @@
|
|||||||
class="text-base flex place-items-center gap-1 hover:cursor-pointer text-black dark:text-white"
|
class="text-base flex place-items-center gap-1 hover:cursor-pointer text-black dark:text-white"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
bind:group={mediaType}
|
bind:group={filter.mediaType}
|
||||||
value={MediaType.Video}
|
value={MediaType.Video}
|
||||||
type="radio"
|
type="radio"
|
||||||
name="radio-type"
|
name="radio-type"
|
||||||
@ -91,108 +448,29 @@
|
|||||||
|
|
||||||
<div class="flex gap-5 mt-3">
|
<div class="flex gap-5 mt-3">
|
||||||
<label class="flex items-center mb-2">
|
<label class="flex items-center mb-2">
|
||||||
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={notInAlbum} />
|
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.notInAlbum} />
|
||||||
<span class="ml-2 text-sm text-black dark:text-white pt-1">Not in any album</span>
|
<span class="ml-2 text-sm text-black dark:text-white pt-1">Not in any album</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="flex items-center mb-2">
|
<label class="flex items-center mb-2">
|
||||||
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={inArchive} />
|
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.inArchive} />
|
||||||
<span class="ml-2 text-sm text-black dark:text-white pt-1">Archive</span>
|
<span class="ml-2 text-sm text-black dark:text-white pt-1">Archive</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="flex items-center mb-2">
|
<label class="flex items-center mb-2">
|
||||||
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={inFavorite} />
|
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.inFavorite} />
|
||||||
<span class="ml-2 text-sm text-black dark:text-white pt-1">Favorite</span>
|
<span class="ml-2 text-sm text-black dark:text-white pt-1">Favorite</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<div
|
||||||
|
id="button-row"
|
||||||
<!-- PEOPLE -->
|
class="flex justify-end gap-4 py-4 sticky bottom-0 dark:border-gray-800 dark:bg-immich-dark-gray"
|
||||||
<div id="people-selection" class="my-4">
|
>
|
||||||
<div class="flex justify-between place-items-center gap-6">
|
<Button color="gray" on:click={resetForm}>CLEAR ALL</Button>
|
||||||
<div class="flex-1">
|
<Button type="button" on:click={search}>SEARCH</Button>
|
||||||
<p class="immich-form-label">PEOPLE</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1">
|
|
||||||
<Combobox options={[]} selectedOption={selectedCountry} placeholder="Search people..." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
<!-- LOCATION -->
|
|
||||||
<div id="location-selection" class="my-4">
|
|
||||||
<p class="immich-form-label">PLACE</p>
|
|
||||||
|
|
||||||
<div class="flex justify-between gap-5 mt-3">
|
|
||||||
<div class="w-full">
|
|
||||||
<p class="text-sm text-black dark:text-white">Country</p>
|
|
||||||
<Combobox options={[]} selectedOption={selectedCountry} placeholder="Search country..." />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full">
|
|
||||||
<p class="text-sm text-black dark:text-white">State</p>
|
|
||||||
<Combobox options={[]} selectedOption={selectedState} placeholder="Search state..." />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full">
|
|
||||||
<p class="text-sm text-black dark:text-white">City</p>
|
|
||||||
<Combobox options={[]} selectedOption={selectedCity} placeholder="Search city..." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
<!-- CAMERA MODEL -->
|
|
||||||
<div id="camera-selection" class="my-4">
|
|
||||||
<p class="immich-form-label">CAMERA</p>
|
|
||||||
|
|
||||||
<div class="flex justify-between gap-5 mt-3">
|
|
||||||
<div class="w-full">
|
|
||||||
<p class="text-sm text-black dark:text-white">Make</p>
|
|
||||||
<Combobox options={[]} selectedOption={selectedCountry} placeholder="Search country..." />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full">
|
|
||||||
<p class="text-sm text-black dark:text-white">Model</p>
|
|
||||||
<Combobox options={[]} selectedOption={selectedState} placeholder="Search state..." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<!-- DATE RANGE -->
|
|
||||||
<div id="date-range-selection" class="my-4 flex justify-between gap-5">
|
|
||||||
<div class="mb-3 flex-1 mt">
|
|
||||||
<label class="immich-form-label" for="start-date">START DATE</label>
|
|
||||||
<input
|
|
||||||
class="immich-form-input w-full mt-3 hover:cursor-pointer"
|
|
||||||
type="date"
|
|
||||||
id="start-date"
|
|
||||||
name="start-date"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3 flex-1">
|
|
||||||
<label class="immich-form-label" for="end-date">END DATE</label>
|
|
||||||
<input
|
|
||||||
class="immich-form-input w-full mt-3 hover:cursor-pointer"
|
|
||||||
type="date"
|
|
||||||
id="end-date"
|
|
||||||
name="end-date"
|
|
||||||
placeholder=""
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="button-row" class="flex justify-end gap-4 mt-5">
|
|
||||||
<Button color="gray">CLEAR ALL</Button>
|
|
||||||
<Button type="submit">SEARCH</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user