From 3e3598fd92a518bdcbc122abfea5acbb96a6aaf2 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Tue, 24 Oct 2023 17:53:49 +0200 Subject: [PATCH] fix: suggest people (#4566) * fix: suggest people * feat: remove hidden people * add hidden people when merging faces * pr feedback * fix: don't use reactive statement * fixed section height * improve merging * fix: migration * fix migration * feat: add asset count * fix: test * rename endpoint * add server test * improve responsive design * fix: remove videos from live photos in the asset count * pr feedback * fix: rename asset count endpoint * fix: return firstname and lastname * fix: reset people only on error * fix: search * fix: responsive design & div flickering * fix: cleanup * chore: open api --------- Co-authored-by: Jason Rasmussen --- cli/src/api/open-api/api.ts | 122 +++++++++++++++++- mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | 2 + mobile/openapi/doc/PersonApi.md | 56 ++++++++ .../doc/PersonStatisticsResponseDto.md | 15 +++ mobile/openapi/doc/SearchApi.md | 6 +- mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/person_api.dart | 48 +++++++ mobile/openapi/lib/api/search_api.dart | 13 +- mobile/openapi/lib/api_client.dart | 2 + .../model/person_statistics_response_dto.dart | 98 ++++++++++++++ mobile/openapi/test/person_api_test.dart | 5 + .../person_statistics_response_dto_test.dart | 27 ++++ mobile/openapi/test/search_api_test.dart | 2 +- server/immich-openapi-specs.json | 61 +++++++++ server/src/domain/person/person.dto.ts | 5 + .../src/domain/person/person.service.spec.ts | 19 +++ server/src/domain/person/person.service.ts | 6 + .../domain/repositories/person.repository.ts | 13 +- server/src/domain/search/dto/search.dto.ts | 5 + server/src/domain/search/search.service.ts | 4 +- .../immich/controllers/person.controller.ts | 9 ++ .../infra/repositories/person.repository.ts | 42 +++++- .../repositories/person.repository.mock.ts | 1 + web/src/api/open-api/api.ts | 122 +++++++++++++++++- .../faces-page/edit-name-input.svelte | 6 +- web/src/routes/(user)/people/+page.svelte | 2 +- .../(user)/people/[personId]/+page.server.ts | 2 + .../(user)/people/[personId]/+page.svelte | 119 +++++++++-------- 29 files changed, 736 insertions(+), 80 deletions(-) create mode 100644 mobile/openapi/doc/PersonStatisticsResponseDto.md create mode 100644 mobile/openapi/lib/model/person_statistics_response_dto.dart create mode 100644 mobile/openapi/test/person_statistics_response_dto_test.dart diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index fffb9914a5..799eb9d385 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -2465,6 +2465,19 @@ export interface PersonResponseDto { */ 'thumbnailPath': string; } +/** + * + * @export + * @interface PersonStatisticsResponseDto + */ +export interface PersonStatisticsResponseDto { + /** + * + * @type {number} + * @memberof PersonStatisticsResponseDto + */ + 'assets': number; +} /** * * @export @@ -12010,6 +12023,48 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getPersonStatistics: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getPersonStatistics', 'id', id) + const localVarPath = `/person/{id}/statistics` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // 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) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -12241,6 +12296,16 @@ export const PersonApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonAssets(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getPersonStatistics(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonStatistics(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id @@ -12320,6 +12385,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat getPersonAssets(requestParameters: PersonApiGetPersonAssetsRequest, options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.getPersonAssets(requestParameters.id, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {PersonApiGetPersonStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getPersonStatistics(requestParameters: PersonApiGetPersonStatisticsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getPersonStatistics(requestParameters.id, options).then((request) => request(axios, basePath)); + }, /** * * @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters. @@ -12401,6 +12475,20 @@ export interface PersonApiGetPersonAssetsRequest { readonly id: string } +/** + * Request parameters for getPersonStatistics operation in PersonApi. + * @export + * @interface PersonApiGetPersonStatisticsRequest + */ +export interface PersonApiGetPersonStatisticsRequest { + /** + * + * @type {string} + * @memberof PersonApiGetPersonStatistics + */ + readonly id: string +} + /** * Request parameters for getPersonThumbnail operation in PersonApi. * @export @@ -12511,6 +12599,17 @@ export class PersonApi extends BaseAPI { return PersonApiFp(this.configuration).getPersonAssets(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {PersonApiGetPersonStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PersonApi + */ + public getPersonStatistics(requestParameters: PersonApiGetPersonStatisticsRequest, options?: AxiosRequestConfig) { + return PersonApiFp(this.configuration).getPersonStatistics(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters. @@ -12722,10 +12821,11 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio /** * * @param {string} name + * @param {boolean} [withHidden] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - searchPerson: async (name: string, options: AxiosRequestConfig = {}): Promise => { + searchPerson: async (name: string, withHidden?: boolean, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'name' is not null or undefined assertParamExists('searchPerson', 'name', name) const localVarPath = `/search/person`; @@ -12753,6 +12853,10 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio localVarQueryParameter['name'] = name; } + if (withHidden !== undefined) { + localVarQueryParameter['withHidden'] = withHidden; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -12811,11 +12915,12 @@ export const SearchApiFp = function(configuration?: Configuration) { /** * * @param {string} name + * @param {boolean} [withHidden] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async searchPerson(name: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, options); + async searchPerson(name: string, withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, withHidden, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, } @@ -12852,7 +12957,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat * @throws {RequiredError} */ searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.searchPerson(requestParameters.name, options).then((request) => request(axios, basePath)); + return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath)); }, }; }; @@ -12988,6 +13093,13 @@ export interface SearchApiSearchPersonRequest { * @memberof SearchApiSearchPerson */ readonly name: string + + /** + * + * @type {boolean} + * @memberof SearchApiSearchPerson + */ + readonly withHidden?: boolean } /** @@ -13026,7 +13138,7 @@ export class SearchApi extends BaseAPI { * @memberof SearchApi */ public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) { - return SearchApiFp(this.configuration).searchPerson(requestParameters.name, options).then((request) => request(this.axios, this.basePath)); + return SearchApiFp(this.configuration).searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath)); } } diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 3bb00bb242..b014ae6ad5 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -96,6 +96,7 @@ doc/PeopleUpdateDto.md doc/PeopleUpdateItem.md doc/PersonApi.md doc/PersonResponseDto.md +doc/PersonStatisticsResponseDto.md doc/PersonUpdateDto.md doc/QueueStatusDto.md doc/RecognitionConfig.md @@ -269,6 +270,7 @@ lib/model/people_response_dto.dart lib/model/people_update_dto.dart lib/model/people_update_item.dart lib/model/person_response_dto.dart +lib/model/person_statistics_response_dto.dart lib/model/person_update_dto.dart lib/model/queue_status_dto.dart lib/model/recognition_config.dart @@ -421,6 +423,7 @@ test/people_update_dto_test.dart test/people_update_item_test.dart test/person_api_test.dart test/person_response_dto_test.dart +test/person_statistics_response_dto_test.dart test/person_update_dto_test.dart test/queue_status_dto_test.dart test/recognition_config_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 80735dcd01..125be810d0 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -152,6 +152,7 @@ Class | Method | HTTP request | Description *PersonApi* | [**getAllPeople**](doc//PersonApi.md#getallpeople) | **GET** /person | *PersonApi* | [**getPerson**](doc//PersonApi.md#getperson) | **GET** /person/{id} | *PersonApi* | [**getPersonAssets**](doc//PersonApi.md#getpersonassets) | **GET** /person/{id}/assets | +*PersonApi* | [**getPersonStatistics**](doc//PersonApi.md#getpersonstatistics) | **GET** /person/{id}/statistics | *PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail | *PersonApi* | [**mergePerson**](doc//PersonApi.md#mergeperson) | **POST** /person/{id}/merge | *PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person | @@ -283,6 +284,7 @@ Class | Method | HTTP request | Description - [PeopleUpdateDto](doc//PeopleUpdateDto.md) - [PeopleUpdateItem](doc//PeopleUpdateItem.md) - [PersonResponseDto](doc//PersonResponseDto.md) + - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) - [PersonUpdateDto](doc//PersonUpdateDto.md) - [QueueStatusDto](doc//QueueStatusDto.md) - [RecognitionConfig](doc//RecognitionConfig.md) diff --git a/mobile/openapi/doc/PersonApi.md b/mobile/openapi/doc/PersonApi.md index 0f6f2030cf..73a35f0f33 100644 --- a/mobile/openapi/doc/PersonApi.md +++ b/mobile/openapi/doc/PersonApi.md @@ -12,6 +12,7 @@ Method | HTTP request | Description [**getAllPeople**](PersonApi.md#getallpeople) | **GET** /person | [**getPerson**](PersonApi.md#getperson) | **GET** /person/{id} | [**getPersonAssets**](PersonApi.md#getpersonassets) | **GET** /person/{id}/assets | +[**getPersonStatistics**](PersonApi.md#getpersonstatistics) | **GET** /person/{id}/statistics | [**getPersonThumbnail**](PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail | [**mergePerson**](PersonApi.md#mergeperson) | **POST** /person/{id}/merge | [**updatePeople**](PersonApi.md#updatepeople) | **PUT** /person | @@ -183,6 +184,61 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **getPersonStatistics** +> PersonStatisticsResponseDto getPersonStatistics(id) + + + +### 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 = PersonApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + final result = api_instance.getPersonStatistics(id); + print(result); +} catch (e) { + print('Exception when calling PersonApi->getPersonStatistics: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + +### Return type + +[**PersonStatisticsResponseDto**](PersonStatisticsResponseDto.md) + +### 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) + # **getPersonThumbnail** > MultipartFile getPersonThumbnail(id) diff --git a/mobile/openapi/doc/PersonStatisticsResponseDto.md b/mobile/openapi/doc/PersonStatisticsResponseDto.md new file mode 100644 index 0000000000..2dda35b11a --- /dev/null +++ b/mobile/openapi/doc/PersonStatisticsResponseDto.md @@ -0,0 +1,15 @@ +# openapi.model.PersonStatisticsResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**assets** | **int** | | + +[[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/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md index 6bb2c0e938..b5c6c9de8d 100644 --- a/mobile/openapi/doc/SearchApi.md +++ b/mobile/openapi/doc/SearchApi.md @@ -151,7 +151,7 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **searchPerson** -> List searchPerson(name) +> List searchPerson(name, withHidden) @@ -175,9 +175,10 @@ import 'package:openapi/api.dart'; final api_instance = SearchApi(); final name = name_example; // String | +final withHidden = true; // bool | try { - final result = api_instance.searchPerson(name); + final result = api_instance.searchPerson(name, withHidden); print(result); } catch (e) { print('Exception when calling SearchApi->searchPerson: $e\n'); @@ -189,6 +190,7 @@ try { Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- **name** | **String**| | + **withHidden** | **bool**| | [optional] ### Return type diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index b030be56f4..b5779b537d 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -128,6 +128,7 @@ part 'model/people_response_dto.dart'; part 'model/people_update_dto.dart'; part 'model/people_update_item.dart'; part 'model/person_response_dto.dart'; +part 'model/person_statistics_response_dto.dart'; part 'model/person_update_dto.dart'; part 'model/queue_status_dto.dart'; part 'model/recognition_config.dart'; diff --git a/mobile/openapi/lib/api/person_api.dart b/mobile/openapi/lib/api/person_api.dart index 0d5b601826..e4ab011a6b 100644 --- a/mobile/openapi/lib/api/person_api.dart +++ b/mobile/openapi/lib/api/person_api.dart @@ -166,6 +166,54 @@ class PersonApi { return null; } + /// Performs an HTTP 'GET /person/{id}/statistics' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future getPersonStatisticsWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/person/{id}/statistics' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future getPersonStatistics(String id,) async { + final response = await getPersonStatisticsWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PersonStatisticsResponseDto',) as PersonStatisticsResponseDto; + + } + return null; + } + /// Performs an HTTP 'GET /person/{id}/thumbnail' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 870c5dcd3a..7c01d5e9b9 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -220,7 +220,9 @@ class SearchApi { /// Parameters: /// /// * [String] name (required): - Future searchPersonWithHttpInfo(String name,) async { + /// + /// * [bool] withHidden: + Future searchPersonWithHttpInfo(String name, { bool? withHidden, }) async { // ignore: prefer_const_declarations final path = r'/search/person'; @@ -232,6 +234,9 @@ class SearchApi { final formParams = {}; queryParams.addAll(_queryParams('', 'name', name)); + if (withHidden != null) { + queryParams.addAll(_queryParams('', 'withHidden', withHidden)); + } const contentTypes = []; @@ -250,8 +255,10 @@ class SearchApi { /// Parameters: /// /// * [String] name (required): - Future?> searchPerson(String name,) async { - final response = await searchPersonWithHttpInfo(name,); + /// + /// * [bool] withHidden: + Future?> searchPerson(String name, { bool? withHidden, }) async { + final response = await searchPersonWithHttpInfo(name, withHidden: withHidden, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index c391658e66..3f34642b87 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -347,6 +347,8 @@ class ApiClient { return PeopleUpdateItem.fromJson(value); case 'PersonResponseDto': return PersonResponseDto.fromJson(value); + case 'PersonStatisticsResponseDto': + return PersonStatisticsResponseDto.fromJson(value); case 'PersonUpdateDto': return PersonUpdateDto.fromJson(value); case 'QueueStatusDto': diff --git a/mobile/openapi/lib/model/person_statistics_response_dto.dart b/mobile/openapi/lib/model/person_statistics_response_dto.dart new file mode 100644 index 0000000000..b4fbef7235 --- /dev/null +++ b/mobile/openapi/lib/model/person_statistics_response_dto.dart @@ -0,0 +1,98 @@ +// +// 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 PersonStatisticsResponseDto { + /// Returns a new [PersonStatisticsResponseDto] instance. + PersonStatisticsResponseDto({ + required this.assets, + }); + + int assets; + + @override + bool operator ==(Object other) => identical(this, other) || other is PersonStatisticsResponseDto && + other.assets == assets; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assets.hashCode); + + @override + String toString() => 'PersonStatisticsResponseDto[assets=$assets]'; + + Map toJson() { + final json = {}; + json[r'assets'] = this.assets; + return json; + } + + /// Returns a new [PersonStatisticsResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PersonStatisticsResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return PersonStatisticsResponseDto( + assets: mapValueOfType(json, r'assets')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PersonStatisticsResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PersonStatisticsResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PersonStatisticsResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PersonStatisticsResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assets', + }; +} + diff --git a/mobile/openapi/test/person_api_test.dart b/mobile/openapi/test/person_api_test.dart index abdde5367f..b0feeb1160 100644 --- a/mobile/openapi/test/person_api_test.dart +++ b/mobile/openapi/test/person_api_test.dart @@ -32,6 +32,11 @@ void main() { // TODO }); + //Future getPersonStatistics(String id) async + test('test getPersonStatistics', () async { + // TODO + }); + //Future getPersonThumbnail(String id) async test('test getPersonThumbnail', () async { // TODO diff --git a/mobile/openapi/test/person_statistics_response_dto_test.dart b/mobile/openapi/test/person_statistics_response_dto_test.dart new file mode 100644 index 0000000000..a58310d933 --- /dev/null +++ b/mobile/openapi/test/person_statistics_response_dto_test.dart @@ -0,0 +1,27 @@ +// +// 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 PersonStatisticsResponseDto +void main() { + // final instance = PersonStatisticsResponseDto(); + + group('test PersonStatisticsResponseDto', () { + // int assets + test('to test the property `assets`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart index 4905513282..a2fba73e7d 100644 --- a/mobile/openapi/test/search_api_test.dart +++ b/mobile/openapi/test/search_api_test.dart @@ -27,7 +27,7 @@ void main() { // TODO }); - //Future> searchPerson(String name) async + //Future> searchPerson(String name, { bool withHidden }) async test('test searchPerson', () async { // TODO }); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 7230951384..ca0f77f9b6 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -3685,6 +3685,48 @@ ] } }, + "/person/{id}/statistics": { + "get": { + "operationId": "getPersonStatistics", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonStatisticsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Person" + ] + } + }, "/person/{id}/thumbnail": { "get": { "operationId": "getPersonThumbnail", @@ -3947,6 +3989,14 @@ "schema": { "type": "string" } + }, + { + "name": "withHidden", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -7401,6 +7451,17 @@ ], "type": "object" }, + "PersonStatisticsResponseDto": { + "properties": { + "assets": { + "type": "integer" + } + }, + "required": [ + "assets" + ], + "type": "object" + }, "PersonUpdateDto": { "properties": { "birthDate": { diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts index cae05fa408..b7acde73a1 100644 --- a/server/src/domain/person/person.dto.ts +++ b/server/src/domain/person/person.dto.ts @@ -73,6 +73,11 @@ export class PersonResponseDto { isHidden!: boolean; } +export class PersonStatisticsResponseDto { + @ApiProperty({ type: 'integer' }) + assets!: number; +} + export class PeopleResponseDto { @ApiProperty({ type: 'integer' }) total!: number; diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 34e305526d..9d966460e1 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -42,6 +42,8 @@ const responseDto: PersonResponseDto = { isHidden: false, }; +const statistics = { assets: 3 }; + const croppedFace = Buffer.from('Cropped Face'); const detectFaceMock = { @@ -731,4 +733,21 @@ describe(PersonService.name, () => { expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); }); }); + + describe('getStatistics', () => { + it('should get correct number of person', async () => { + personMock.getById.mockResolvedValue(personStub.primaryPerson); + personMock.getStatistics.mockResolvedValue(statistics); + accessMock.person.hasOwnerAccess.mockResolvedValue(true); + await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 }); + expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + }); + + it('should require person.read permission', async () => { + personMock.getById.mockResolvedValue(personStub.primaryPerson); + accessMock.person.hasOwnerAccess.mockResolvedValue(false); + await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); + expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + }); + }); }); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 26e80229d5..dcf5dbb78d 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -33,6 +33,7 @@ import { PeopleUpdateDto, PersonResponseDto, PersonSearchDto, + PersonStatisticsResponseDto, PersonUpdateDto, mapPerson, } from './person.dto'; @@ -84,6 +85,11 @@ export class PersonService { return this.findOrFail(id).then(mapPerson); } + async getStatistics(authUser: AuthUserDto, id: string): Promise { + await this.access.requirePermission(authUser, Permission.PERSON_READ, id); + return this.repository.getStatistics(id); + } + async getThumbnail(authUser: AuthUserDto, id: string): Promise { await this.access.requirePermission(authUser, Permission.PERSON_READ, id); const person = await this.repository.getById(id); diff --git a/server/src/domain/repositories/person.repository.ts b/server/src/domain/repositories/person.repository.ts index d39b4468b1..2554a8a6f9 100644 --- a/server/src/domain/repositories/person.repository.ts +++ b/server/src/domain/repositories/person.repository.ts @@ -1,4 +1,5 @@ import { AssetEntity, AssetFaceEntity, PersonEntity } from '@app/infra/entities'; + export const IPersonRepository = 'IPersonRepository'; export interface PersonSearchOptions { @@ -6,6 +7,10 @@ export interface PersonSearchOptions { withHidden: boolean; } +export interface PersonNameSearchOptions { + withHidden?: boolean; +} + export interface AssetFaceId { assetId: string; personId: string; @@ -16,13 +21,17 @@ export interface UpdateFacesData { newPersonId: string; } +export interface PersonStatistics { + assets: number; +} + export interface IPersonRepository { getAll(): Promise; getAllWithoutThumbnail(): Promise; getAllForUser(userId: string, options: PersonSearchOptions): Promise; getAllWithoutFaces(): Promise; getById(personId: string): Promise; - getByName(userId: string, personName: string): Promise; + getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise; getAssets(personId: string): Promise; prepareReassignFaces(data: UpdateFacesData): Promise; @@ -33,6 +42,8 @@ export interface IPersonRepository { delete(entity: PersonEntity): Promise; deleteAll(): Promise; + getStatistics(personId: string): Promise; + getAllFaces(): Promise; getFacesByIds(ids: AssetFaceId[]): Promise; getRandomFace(personId: string): Promise; diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index 0d6def96cc..85d2b55f93 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -90,4 +90,9 @@ export class SearchPeopleDto { @IsString() @IsNotEmpty() name!: string; + + @IsBoolean() + @Transform(toBoolean) + @Optional() + withHidden?: boolean; } diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 445d6b89d0..be88f29e60 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -159,8 +159,8 @@ export class SearchService { }; } - async searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise { - return await this.personRepository.getByName(authUser.id, dto.name); + searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise { + return this.personRepository.getByName(authUser.id, dto.name, { withHidden: dto.withHidden }); } async handleIndexAlbums() { diff --git a/server/src/immich/controllers/person.controller.ts b/server/src/immich/controllers/person.controller.ts index db4d378f31..e581fddb1a 100644 --- a/server/src/immich/controllers/person.controller.ts +++ b/server/src/immich/controllers/person.controller.ts @@ -9,6 +9,7 @@ import { PersonResponseDto, PersonSearchDto, PersonService, + PersonStatisticsResponseDto, PersonUpdateDto, } from '@app/domain'; import { Body, Controller, Get, Param, Post, Put, Query, StreamableFile } from '@nestjs/common'; @@ -52,6 +53,14 @@ export class PersonController { return this.service.update(authUser, id, dto); } + @Get(':id/statistics') + getPersonStatistics( + @AuthUser() authUser: AuthUserDto, + @Param() { id }: UUIDParamDto, + ): Promise { + return this.service.getStatistics(authUser, id); + } + @Get(':id/thumbnail') @ApiOkResponse({ content: { diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts index d651b3380a..12bd476053 100644 --- a/server/src/infra/repositories/person.repository.ts +++ b/server/src/infra/repositories/person.repository.ts @@ -1,4 +1,11 @@ -import { AssetFaceId, IPersonRepository, PersonSearchOptions, UpdateFacesData } from '@app/domain'; +import { + AssetFaceId, + IPersonRepository, + PersonNameSearchOptions, + PersonSearchOptions, + PersonStatistics, + UpdateFacesData, +} from '@app/domain'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities'; @@ -96,14 +103,37 @@ export class PersonRepository implements IPersonRepository { return this.personRepository.findOne({ where: { id: personId } }); } - getByName(userId: string, personName: string): Promise { - return this.personRepository + getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise { + const queryBuilder = this.personRepository .createQueryBuilder('person') .leftJoin('person.faces', 'face') .where('person.ownerId = :userId', { userId }) - .andWhere('LOWER(person.name) LIKE :name', { name: `${personName.toLowerCase()}%` }) - .limit(20) - .getMany(); + .andWhere('LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere', { + nameStart: `${personName.toLowerCase()}%`, + nameAnywhere: `% ${personName.toLowerCase()}%`, + }) + .groupBy('person.id') + .orderBy('COUNT(face.assetId)', 'DESC') + .limit(20); + + if (!withHidden) { + queryBuilder.andWhere('person.isHidden = false'); + } + return queryBuilder.getMany(); + } + + async getStatistics(personId: string): Promise { + return { + assets: await this.assetFaceRepository + .createQueryBuilder('face') + .leftJoin('face.asset', 'asset') + .where('face.personId = :personId', { personId }) + .andWhere('asset.isArchived = false') + .andWhere('asset.deletedAt IS NULL') + .andWhere('asset.livePhotoVideoId IS NULL') + .distinct(true) + .getCount(), + }; } getAssets(personId: string): Promise { diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index d942bafd63..90a15221d5 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -16,6 +16,7 @@ export const newPersonRepositoryMock = (): jest.Mocked => { deleteAll: jest.fn(), delete: jest.fn(), + getStatistics: jest.fn(), getAllFaces: jest.fn(), getFacesByIds: jest.fn(), getRandomFace: jest.fn(), diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index fffb9914a5..799eb9d385 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -2465,6 +2465,19 @@ export interface PersonResponseDto { */ 'thumbnailPath': string; } +/** + * + * @export + * @interface PersonStatisticsResponseDto + */ +export interface PersonStatisticsResponseDto { + /** + * + * @type {number} + * @memberof PersonStatisticsResponseDto + */ + 'assets': number; +} /** * * @export @@ -12010,6 +12023,48 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getPersonStatistics: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getPersonStatistics', 'id', id) + const localVarPath = `/person/{id}/statistics` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // 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) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -12241,6 +12296,16 @@ export const PersonApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonAssets(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getPersonStatistics(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonStatistics(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id @@ -12320,6 +12385,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat getPersonAssets(requestParameters: PersonApiGetPersonAssetsRequest, options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.getPersonAssets(requestParameters.id, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {PersonApiGetPersonStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getPersonStatistics(requestParameters: PersonApiGetPersonStatisticsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getPersonStatistics(requestParameters.id, options).then((request) => request(axios, basePath)); + }, /** * * @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters. @@ -12401,6 +12475,20 @@ export interface PersonApiGetPersonAssetsRequest { readonly id: string } +/** + * Request parameters for getPersonStatistics operation in PersonApi. + * @export + * @interface PersonApiGetPersonStatisticsRequest + */ +export interface PersonApiGetPersonStatisticsRequest { + /** + * + * @type {string} + * @memberof PersonApiGetPersonStatistics + */ + readonly id: string +} + /** * Request parameters for getPersonThumbnail operation in PersonApi. * @export @@ -12511,6 +12599,17 @@ export class PersonApi extends BaseAPI { return PersonApiFp(this.configuration).getPersonAssets(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {PersonApiGetPersonStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PersonApi + */ + public getPersonStatistics(requestParameters: PersonApiGetPersonStatisticsRequest, options?: AxiosRequestConfig) { + return PersonApiFp(this.configuration).getPersonStatistics(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters. @@ -12722,10 +12821,11 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio /** * * @param {string} name + * @param {boolean} [withHidden] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - searchPerson: async (name: string, options: AxiosRequestConfig = {}): Promise => { + searchPerson: async (name: string, withHidden?: boolean, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'name' is not null or undefined assertParamExists('searchPerson', 'name', name) const localVarPath = `/search/person`; @@ -12753,6 +12853,10 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio localVarQueryParameter['name'] = name; } + if (withHidden !== undefined) { + localVarQueryParameter['withHidden'] = withHidden; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -12811,11 +12915,12 @@ export const SearchApiFp = function(configuration?: Configuration) { /** * * @param {string} name + * @param {boolean} [withHidden] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async searchPerson(name: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, options); + async searchPerson(name: string, withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, withHidden, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, } @@ -12852,7 +12957,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat * @throws {RequiredError} */ searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.searchPerson(requestParameters.name, options).then((request) => request(axios, basePath)); + return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath)); }, }; }; @@ -12988,6 +13093,13 @@ export interface SearchApiSearchPersonRequest { * @memberof SearchApiSearchPerson */ readonly name: string + + /** + * + * @type {boolean} + * @memberof SearchApiSearchPerson + */ + readonly withHidden?: boolean } /** @@ -13026,7 +13138,7 @@ export class SearchApi extends BaseAPI { * @memberof SearchApi */ public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) { - return SearchApiFp(this.configuration).searchPerson(requestParameters.name, options).then((request) => request(this.axios, this.basePath)); + return SearchApiFp(this.configuration).searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath)); } } diff --git a/web/src/lib/components/faces-page/edit-name-input.svelte b/web/src/lib/components/faces-page/edit-name-input.svelte index 7b197b98f3..10a78f2ae5 100644 --- a/web/src/lib/components/faces-page/edit-name-input.svelte +++ b/web/src/lib/components/faces-page/edit-name-input.svelte @@ -11,12 +11,13 @@ const dispatch = createEventDispatcher<{ change: string; cancel: void; + input: void; }>();
dispatch('input')} /> diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 06e683727d..9845d1f068 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -258,7 +258,7 @@ changeName(); return; } - const { data } = await api.searchApi.searchPerson({ name: personName }); + const { data } = await api.searchApi.searchPerson({ name: personName, withHidden: true }); // We check if another person has the same name as the name entered by the user diff --git a/web/src/routes/(user)/people/[personId]/+page.server.ts b/web/src/routes/(user)/people/[personId]/+page.server.ts index faa3b80317..d81f893abf 100644 --- a/web/src/routes/(user)/people/[personId]/+page.server.ts +++ b/web/src/routes/(user)/people/[personId]/+page.server.ts @@ -9,10 +9,12 @@ export const load = (async ({ locals, parent, params }) => { } const { data: person } = await locals.api.personApi.getPerson({ id: params.personId }); + const { data: statistics } = await locals.api.personApi.getPersonStatistics({ id: params.personId }); return { user, person, + statistics, meta: { title: person.name || 'Person', }, diff --git a/web/src/routes/(user)/people/[personId]/+page.svelte b/web/src/routes/(user)/people/[personId]/+page.svelte index dc1bf6398a..198d346909 100644 --- a/web/src/routes/(user)/people/[personId]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/+page.svelte @@ -1,5 +1,5 @@