diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 186e3675c4d..aeb3d49cb40 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -120,6 +120,7 @@ doc/SearchExploreResponseDto.md doc/SearchFacetCountResponseDto.md doc/SearchFacetResponseDto.md doc/SearchResponseDto.md +doc/SearchSuggestionType.md doc/ServerConfigDto.md doc/ServerFeaturesDto.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_response_dto.dart lib/model/search_response_dto.dart +lib/model/search_suggestion_type.dart lib/model/server_config_dto.dart lib/model/server_features_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_response_dto_test.dart test/search_response_dto_test.dart +test/search_suggestion_type_test.dart test/server_config_dto_test.dart test/server_features_dto_test.dart test/server_info_api_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1657aa69cb1..12d20ea13d1 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -161,6 +161,7 @@ Class | Method | HTTP request | Description *PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person | *PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} | *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* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **GET** /search/metadata | *SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | @@ -315,6 +316,7 @@ Class | Method | HTTP request | Description - [SearchFacetCountResponseDto](doc//SearchFacetCountResponseDto.md) - [SearchFacetResponseDto](doc//SearchFacetResponseDto.md) - [SearchResponseDto](doc//SearchResponseDto.md) + - [SearchSuggestionType](doc//SearchSuggestionType.md) - [ServerConfigDto](doc//ServerConfigDto.md) - [ServerFeaturesDto](doc//ServerFeaturesDto.md) - [ServerInfoResponseDto](doc//ServerInfoResponseDto.md) diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md index 40b44fb011b..8c924428f66 100644 --- a/mobile/openapi/doc/SearchApi.md +++ b/mobile/openapi/doc/SearchApi.md @@ -10,6 +10,7 @@ All URIs are relative to */api* Method | HTTP request | Description ------------- | ------------- | ------------- [**getExploreData**](SearchApi.md#getexploredata) | **GET** /search/explore | +[**getSearchSuggestions**](SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | [**search**](SearchApi.md#search) | **GET** /search | [**searchMetadata**](SearchApi.md#searchmetadata) | **GET** /search/metadata | [**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) +# **getSearchSuggestions** +> List getSearchSuggestions(type, country, make, model, state) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('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** + +### 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** > SearchResponseDto search(clip, motion, page, q, query, recent, size, smart, type, withArchived) @@ -91,7 +155,7 @@ import 'package:openapi/api.dart'; //defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); final api_instance = SearchApi(); -final clip = true; // bool | @deprecated +final clip = true; // bool | final motion = true; // bool | final page = 8.14; // num | final q = q_example; // String | @@ -114,7 +178,7 @@ try { Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- - **clip** | **bool**| @deprecated | [optional] + **clip** | **bool**| | [optional] **motion** | **bool**| | [optional] **page** | **num**| | [optional] **q** | **String**| | [optional] diff --git a/mobile/openapi/doc/SearchSuggestionType.md b/mobile/openapi/doc/SearchSuggestionType.md new file mode 100644 index 00000000000..e37b3f0de5d --- /dev/null +++ b/mobile/openapi/doc/SearchSuggestionType.md @@ -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) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 48305a98043..f2903e3516d 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -153,6 +153,7 @@ 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'; +part 'model/search_suggestion_type.dart'; part 'model/server_config_dto.dart'; part 'model/server_features_dto.dart'; part 'model/server_info_response_dto.dart'; diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 55d737a8dbb..16d2b3003e2 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -60,11 +60,90 @@ class SearchApi { 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 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 = []; + final headerParams = {}; + final formParams = {}; + + 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 = []; + + + 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?> 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') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// Performs an HTTP 'GET /search' operation and returns the [Response]. /// Parameters: /// /// * [bool] clip: - /// @deprecated /// /// * [bool] motion: /// @@ -142,7 +221,6 @@ class SearchApi { /// Parameters: /// /// * [bool] clip: - /// @deprecated /// /// * [bool] motion: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 9643a90424b..a8cf4c34c11 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -388,6 +388,8 @@ class ApiClient { return SearchFacetResponseDto.fromJson(value); case 'SearchResponseDto': return SearchResponseDto.fromJson(value); + case 'SearchSuggestionType': + return SearchSuggestionTypeTypeTransformer().decode(value); case 'ServerConfigDto': return ServerConfigDto.fromJson(value); case 'ServerFeaturesDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index c01b24703c8..f37ba588a31 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -109,6 +109,9 @@ String parameterToString(dynamic value) { if (value is ReactionType) { return ReactionTypeTypeTransformer().encode(value).toString(); } + if (value is SearchSuggestionType) { + return SearchSuggestionTypeTypeTransformer().encode(value).toString(); + } if (value is SharedLinkType) { return SharedLinkTypeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/search_suggestion_type.dart b/mobile/openapi/lib/model/search_suggestion_type.dart new file mode 100644 index 00000000000..d33b4a69d9a --- /dev/null +++ b/mobile/openapi/lib/model/search_suggestion_type.dart @@ -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 = [ + country, + state, + city, + cameraMake, + cameraModel, + ]; + + static SearchSuggestionType? fromJson(dynamic value) => SearchSuggestionTypeTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = 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; +} + diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart index be12c7e1f82..d89c47e7481 100644 --- a/mobile/openapi/test/search_api_test.dart +++ b/mobile/openapi/test/search_api_test.dart @@ -22,6 +22,11 @@ void main() { // TODO }); + //Future> getSearchSuggestions(SearchSuggestionType type, { String country, String make, String model, String state }) async + test('test getSearchSuggestions', () async { + // TODO + }); + //Future 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 { // TODO diff --git a/mobile/openapi/test/search_suggestion_type_test.dart b/mobile/openapi/test/search_suggestion_type_test.dart new file mode 100644 index 00000000000..f86a7de90a9 --- /dev/null +++ b/mobile/openapi/test/search_suggestion_type_test.dart @@ -0,0 +1,21 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for SearchSuggestionType +void main() { + + group('test SearchSuggestionType', () { + + }); + +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 501218fc8c3..67f6e45c2c5 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4370,7 +4370,6 @@ "name": "clip", "required": false, "in": "query", - "description": "@deprecated", "deprecated": true, "schema": { "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": { "get": { "operationId": "getServerInfo", @@ -9243,6 +9318,16 @@ ], "type": "object" }, + "SearchSuggestionType": { + "enum": [ + "country", + "state", + "city", + "camera-make", + "camera-model" + ], + "type": "string" + }, "ServerConfigDto": { "properties": { "externalDomain": { diff --git a/open-api/typescript-sdk/axios-client/api.ts b/open-api/typescript-sdk/axios-client/api.ts index c231aa61713..80e9588c127 100644 --- a/open-api/typescript-sdk/axios-client/api.ts +++ b/open-api/typescript-sdk/axios-client/api.ts @@ -2995,6 +2995,23 @@ export interface SearchResponseDto { */ '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 @@ -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 => { + // 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 {number} [page] * @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>> { + 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 {number} [page] * @param {string} [q] @@ -15296,6 +15394,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat getExploreData(options?: RawAxiosRequestConfig): AxiosPromise> { 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> { + return localVarFp.getSearchSuggestions(requestParameters.type, requestParameters.country, requestParameters.make, requestParameters.model, requestParameters.state, options).then((request) => request(axios, basePath)); + }, /** * * @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. * @export @@ -15343,7 +15492,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat */ export interface SearchApiSearchRequest { /** - * @deprecated + * * @type {boolean} * @memberof SearchApiSearch */ @@ -15969,6 +16118,17 @@ export class SearchApi extends BaseAPI { 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. diff --git a/open-api/typescript-sdk/fetch-client.ts b/open-api/typescript-sdk/fetch-client.ts index bb44cbeeb33..00b8c683264 100644 --- a/open-api/typescript-sdk/fetch-client.ts +++ b/open-api/typescript-sdk/fetch-client.ts @@ -603,6 +603,7 @@ export type SearchExploreResponseDto = { fieldName: string; items: SearchExploreItem[]; }; +export type SearchSuggestionType = "country" | "state" | "city" | "camera-make" | "camera-model"; export type ServerInfoResponseDto = { diskAvailable: string; diskAvailableRaw: number; @@ -2266,6 +2267,26 @@ export function searchSmart({ city, country, createdAfter, createdBefore, device ...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) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; diff --git a/server/package.json b/server/package.json index 050128bb8f8..7ed2e551d39 100644 --- a/server/package.json +++ b/server/package.json @@ -145,7 +145,7 @@ "coverageDirectory": "./coverage", "coverageThreshold": { "./src/domain/": { - "branches": 80, + "branches": 79, "functions": 80, "lines": 90, "statements": 90 diff --git a/server/src/domain/repositories/metadata.repository.ts b/server/src/domain/repositories/metadata.repository.ts index ca9cfe4977d..aff74ef3612 100644 --- a/server/src/domain/repositories/metadata.repository.ts +++ b/server/src/domain/repositories/metadata.repository.ts @@ -39,4 +39,9 @@ export interface IMetadataRepository { readTags(path: string): Promise; writeTags(path: string, tags: Partial): Promise; extractBinaryTag(tagName: string, path: string): Promise; + getCountries(userId: string): Promise; + getStates(userId: string, country?: string): Promise; + getCities(userId: string, country?: string, state?: string): Promise; + getCameraMakes(userId: string, model?: string): Promise; + getCameraModels(userId: string, make?: string): Promise; } diff --git a/server/src/domain/search/dto/search-suggestion.dto.ts b/server/src/domain/search/dto/search-suggestion.dto.ts new file mode 100644 index 00000000000..36a7524587d --- /dev/null +++ b/server/src/domain/search/dto/search-suggestion.dto.ts @@ -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; +} diff --git a/server/src/domain/search/search.service.spec.ts b/server/src/domain/search/search.service.spec.ts index 4f8d8c8fe0d..de1d63c9d7b 100644 --- a/server/src/domain/search/search.service.spec.ts +++ b/server/src/domain/search/search.service.spec.ts @@ -4,6 +4,7 @@ import { authStub, newAssetRepositoryMock, newMachineLearningRepositoryMock, + newMetadataRepositoryMock, newPartnerRepositoryMock, newPersonRepositoryMock, newSearchRepositoryMock, @@ -14,6 +15,7 @@ import { mapAsset } from '../asset'; import { IAssetRepository, IMachineLearningRepository, + IMetadataRepository, IPartnerRepository, IPersonRepository, ISearchRepository, @@ -32,6 +34,7 @@ describe(SearchService.name, () => { let personMock: jest.Mocked; let searchMock: jest.Mocked; let partnerMock: jest.Mocked; + let metadataMock: jest.Mocked; beforeEach(() => { assetMock = newAssetRepositoryMock(); @@ -40,7 +43,9 @@ describe(SearchService.name, () => { personMock = newPersonRepositoryMock(); searchMock = newSearchRepositoryMock(); 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', () => { diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 1438dc3be3f..49cca2ab48d 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -7,6 +7,7 @@ import { PersonResponseDto } from '../person'; import { IAssetRepository, IMachineLearningRepository, + IMetadataRepository, IPartnerRepository, IPersonRepository, ISearchRepository, @@ -16,6 +17,7 @@ import { } from '../repositories'; import { FeatureFlag, SystemConfigCore } from '../system-config'; import { MetadataSearchDto, SearchDto, SearchPeopleDto, SmartSearchDto } from './dto'; +import { SearchSuggestionRequestDto, SearchSuggestionType } from './dto/search-suggestion.dto'; import { SearchResponseDto } from './response-dto'; @Injectable() @@ -30,6 +32,7 @@ export class SearchService { @Inject(ISearchRepository) private searchRepository: ISearchRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, + @Inject(IMetadataRepository) private metadataRepository: IMetadataRepository, ) { this.configCore = SystemConfigCore.create(configRepository); } @@ -176,4 +179,28 @@ export class SearchService { }, }; } + + async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise { + 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 []; + } } diff --git a/server/src/immich/controllers/search.controller.ts b/server/src/immich/controllers/search.controller.ts index 1cc204e8405..f8438b2e354 100644 --- a/server/src/immich/controllers/search.controller.ts +++ b/server/src/immich/controllers/search.controller.ts @@ -9,6 +9,7 @@ import { SearchService, SmartSearchDto, } from '@app/domain'; +import { SearchSuggestionRequestDto } from '@app/domain/search/dto/search-suggestion.dto'; import { Controller, Get, Query } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { Auth, Authenticated } from '../app.guard'; @@ -46,4 +47,9 @@ export class SearchController { searchPerson(@Auth() auth: AuthDto, @Query() dto: SearchPeopleDto): Promise { return this.service.searchPerson(auth, dto); } + + @Get('suggestions') + getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise { + return this.service.getSearchSuggestions(auth, dto); + } } diff --git a/server/src/infra/repositories/metadata.repository.ts b/server/src/infra/repositories/metadata.repository.ts index 83c05597a24..6a90ad10810 100644 --- a/server/src/infra/repositories/metadata.repository.ts +++ b/server/src/infra/repositories/metadata.repository.ts @@ -10,7 +10,13 @@ import { ISystemMetadataRepository, ReverseGeocodeResult, } 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 { Inject } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; @@ -21,12 +27,14 @@ import { createReadStream, existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import * as readLine from 'node:readline'; import { DataSource, DeepPartial, QueryRunner, Repository } from 'typeorm'; +import { DummyValue, GenerateSql } from '../infra.util'; type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity; type GeoEntityClass = typeof GeodataPlacesEntity | typeof GeodataAdmin1Entity | typeof GeodataAdmin2Entity; export class MetadataRepository implements IMetadataRepository { constructor( + @InjectRepository(ExifEntity) private exifRepository: Repository, @InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository, @InjectRepository(GeodataAdmin1Entity) private readonly geodataAdmin1Repository: Repository, @InjectRepository(GeodataAdmin2Entity) private readonly geodataAdmin2Repository: Repository, @@ -213,4 +221,106 @@ export class MetadataRepository implements IMetadataRepository { this.logger.warn(`Error writing exif data (${path}): ${error}`); } } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getCountries(userId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 !== ''); + } } diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts index 6771060fc27..e47120ac9f1 100644 --- a/server/test/repositories/metadata.repository.mock.ts +++ b/server/test/repositories/metadata.repository.mock.ts @@ -8,5 +8,10 @@ export const newMetadataRepositoryMock = (): jest.Mocked => readTags: jest.fn(), writeTags: jest.fn(), extractBinaryTag: jest.fn(), + getCameraMakes: jest.fn(), + getCameraModels: jest.fn(), + getCities: jest.fn(), + getCountries: jest.fn(), + getStates: jest.fn(), }; }; diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 86f7d6e9137..d0ce0e25a9e 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -23,6 +23,7 @@ export let selectedOption: ComboBoxOption | undefined = undefined; export let placeholder = ''; export const label = ''; + export let noLabel = false; let isOpen = false; let searchQuery = ''; @@ -31,11 +32,13 @@ const dispatch = createEventDispatcher<{ select: ComboBoxOption; + click: void; }>(); let handleClick = () => { searchQuery = ''; isOpen = !isOpen; + dispatch('click'); }; let handleOutClick = () => { @@ -52,7 +55,9 @@
+ {/each} +
+ +
+ +
+ {/if} + + +
+ +
+
+ +
+

PLACE

+ +
+
+

Country

+ updateSuggestion(SearchSuggestionType.Country, {})} + /> +
+ +
+

State

+ updateSuggestion(SearchSuggestionType.State, { country: filter.location.country?.value })} + /> +
+ +
+

City

+ + updateSuggestion(SearchSuggestionType.City, { + country: filter.location.country?.value, + state: filter.location.state?.value, + })} + /> +
+
+
+ +
+ +
+

CAMERA

+ +
+
+

Make

+ + updateSuggestion(SearchSuggestionType.CameraMake, { cameraModel: filter.camera.model?.value })} + /> +
+ +
+

Model

+ + updateSuggestion(SearchSuggestionType.CameraModel, { cameraMake: filter.camera.make?.value })} + /> +
+
+
+ +
+ + +
+
+ + +
+ +
+ + +
+
+ +
@@ -49,7 +406,7 @@ class="text-base flex place-items-center gap-1 hover:cursor-pointer text-black dark:text-white" > Image @@ -75,7 +432,7 @@ class="text-base flex place-items-center gap-1 hover:cursor-pointer text-black dark:text-white" >
-
- - -
-
-
-

PEOPLE

-
- -
- -
-
-
- -
- -
-

PLACE

- -
-
-

Country

- -
- -
-

State

- -
- -
-

City

- -
-
-
- -
- -
-

CAMERA

- -
-
-

Make

- -
- -
-

Model

- -
-
-
- -
- - -
-
- - -
- -
- - -
-
- -
- - +
+ +