From 1751e5fea2cc14f785df86eb819ca07d139da9ab Mon Sep 17 00:00:00 2001 From: martabal <74269598+martabal@users.noreply.github.com> Date: Mon, 14 Oct 2024 21:10:56 +0200 Subject: [PATCH] feat: search album by name --- mobile/openapi/README.md | 3 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api/search_api.dart | 107 ++++++++++++- mobile/openapi/lib/api_client.dart | 4 + .../model/search_album_name_response_dto.dart | 133 ++++++++++++++++ .../search_person_name_response_dto.dart | 133 ++++++++++++++++ open-api/immich-openapi-specs.json | 144 +++++++++++++++++- open-api/typescript-sdk/src/fetch-client.ts | 36 ++++- server/src/controllers/search.controller.ts | 14 +- server/src/dtos/album.dto.ts | 4 +- server/src/dtos/search.dto.ts | 63 +++++++- server/src/interfaces/album.interface.ts | 2 + server/src/interfaces/person.interface.ts | 7 +- server/src/queries/album.repository.sql | 68 +++++++++ server/src/queries/person.repository.sql | 14 +- server/src/repositories/album.repository.ts | 52 +++++++ server/src/repositories/person.repository.ts | 16 +- server/src/services/search.service.spec.ts | 89 ++++++++++- server/src/services/search.service.ts | 71 ++++++++- server/src/utils/pagination.ts | 17 ++- .../repositories/album.repository.mock.ts | 1 + .../faces-page/people-search.svelte | 9 +- web/src/routes/(user)/people/+page.svelte | 4 +- .../[[assetId=id]]/+page.svelte | 7 +- 24 files changed, 955 insertions(+), 45 deletions(-) create mode 100644 mobile/openapi/lib/model/search_album_name_response_dto.dart create mode 100644 mobile/openapi/lib/model/search_person_name_response_dto.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1396062bee6b1..e0de9a32eacc2 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -166,6 +166,7 @@ Class | Method | HTTP request | Description *SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | *SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | +*SearchApi* | [**searchAlbum**](doc//SearchApi.md#searchalbum) | **GET** /search/album | *SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata | *SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | *SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places | @@ -383,12 +384,14 @@ Class | Method | HTTP request | Description - [ReactionLevel](doc//ReactionLevel.md) - [ReactionType](doc//ReactionType.md) - [ReverseGeocodingStateResponseDto](doc//ReverseGeocodingStateResponseDto.md) + - [SearchAlbumNameResponseDto](doc//SearchAlbumNameResponseDto.md) - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md) - [SearchExploreItem](doc//SearchExploreItem.md) - [SearchExploreResponseDto](doc//SearchExploreResponseDto.md) - [SearchFacetCountResponseDto](doc//SearchFacetCountResponseDto.md) - [SearchFacetResponseDto](doc//SearchFacetResponseDto.md) + - [SearchPersonNameResponseDto](doc//SearchPersonNameResponseDto.md) - [SearchResponseDto](doc//SearchResponseDto.md) - [SearchSuggestionType](doc//SearchSuggestionType.md) - [ServerAboutResponseDto](doc//ServerAboutResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 6fb7478d04bf2..a68b9813816ad 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -197,12 +197,14 @@ part 'model/ratings_update.dart'; part 'model/reaction_level.dart'; part 'model/reaction_type.dart'; part 'model/reverse_geocoding_state_response_dto.dart'; +part 'model/search_album_name_response_dto.dart'; part 'model/search_album_response_dto.dart'; part 'model/search_asset_response_dto.dart'; part 'model/search_explore_item.dart'; part 'model/search_explore_response_dto.dart'; part 'model/search_facet_count_response_dto.dart'; part 'model/search_facet_response_dto.dart'; +part 'model/search_person_name_response_dto.dart'; part 'model/search_response_dto.dart'; part 'model/search_suggestion_type.dart'; part 'model/server_about_response_dto.dart'; diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 985029f106d27..c44e51b420349 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -193,6 +193,82 @@ class SearchApi { return null; } + /// Performs an HTTP 'GET /search/album' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] name (required): + /// + /// * [num] page: + /// Page number for pagination + /// + /// * [bool] shared: + /// true: only shared albums false: only non-shared own albums undefined: shared and owned albums + /// + /// * [num] size: + /// Number of items per page + Future searchAlbumWithHttpInfo(String name, { num? page, bool? shared, num? size, }) async { + // ignore: prefer_const_declarations + final path = r'/search/album'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + queryParams.addAll(_queryParams('', 'name', name)); + if (page != null) { + queryParams.addAll(_queryParams('', 'page', page)); + } + if (shared != null) { + queryParams.addAll(_queryParams('', 'shared', shared)); + } + if (size != null) { + queryParams.addAll(_queryParams('', 'size', size)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] name (required): + /// + /// * [num] page: + /// Page number for pagination + /// + /// * [bool] shared: + /// true: only shared albums false: only non-shared own albums undefined: shared and owned albums + /// + /// * [num] size: + /// Number of items per page + Future searchAlbum(String name, { num? page, bool? shared, num? size, }) async { + final response = await searchAlbumWithHttpInfo(name, page: page, shared: shared, size: size, ); + 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), 'SearchAlbumNameResponseDto',) as SearchAlbumNameResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /search/metadata' operation and returns the [Response]. /// Parameters: /// @@ -245,8 +321,14 @@ class SearchApi { /// /// * [String] name (required): /// + /// * [num] page: + /// This property was added in v118.0.0 + /// + /// * [num] size: + /// This property was added in v118.0.0 + /// /// * [bool] withHidden: - Future searchPersonWithHttpInfo(String name, { bool? withHidden, }) async { + Future searchPersonWithHttpInfo(String name, { num? page, num? size, bool? withHidden, }) async { // ignore: prefer_const_declarations final path = r'/search/person'; @@ -258,6 +340,12 @@ class SearchApi { final formParams = {}; queryParams.addAll(_queryParams('', 'name', name)); + if (page != null) { + queryParams.addAll(_queryParams('', 'page', page)); + } + if (size != null) { + queryParams.addAll(_queryParams('', 'size', size)); + } if (withHidden != null) { queryParams.addAll(_queryParams('', 'withHidden', withHidden)); } @@ -280,9 +368,15 @@ class SearchApi { /// /// * [String] name (required): /// + /// * [num] page: + /// This property was added in v118.0.0 + /// + /// * [num] size: + /// This property was added in v118.0.0 + /// /// * [bool] withHidden: - Future?> searchPerson(String name, { bool? withHidden, }) async { - final response = await searchPersonWithHttpInfo(name, withHidden: withHidden, ); + Future searchPerson(String name, { num? page, num? size, bool? withHidden, }) async { + final response = await searchPersonWithHttpInfo(name, page: page, size: size, withHidden: withHidden, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -290,11 +384,8 @@ class SearchApi { // 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 await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchPersonNameResponseDto',) as SearchPersonNameResponseDto; + } return null; } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index c1025b0bd4820..dde80d4c4ff3b 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -448,6 +448,8 @@ class ApiClient { return ReactionTypeTypeTransformer().decode(value); case 'ReverseGeocodingStateResponseDto': return ReverseGeocodingStateResponseDto.fromJson(value); + case 'SearchAlbumNameResponseDto': + return SearchAlbumNameResponseDto.fromJson(value); case 'SearchAlbumResponseDto': return SearchAlbumResponseDto.fromJson(value); case 'SearchAssetResponseDto': @@ -460,6 +462,8 @@ class ApiClient { return SearchFacetCountResponseDto.fromJson(value); case 'SearchFacetResponseDto': return SearchFacetResponseDto.fromJson(value); + case 'SearchPersonNameResponseDto': + return SearchPersonNameResponseDto.fromJson(value); case 'SearchResponseDto': return SearchResponseDto.fromJson(value); case 'SearchSuggestionType': diff --git a/mobile/openapi/lib/model/search_album_name_response_dto.dart b/mobile/openapi/lib/model/search_album_name_response_dto.dart new file mode 100644 index 0000000000000..62dc2f229a4e9 --- /dev/null +++ b/mobile/openapi/lib/model/search_album_name_response_dto.dart @@ -0,0 +1,133 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// 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 SearchAlbumNameResponseDto { + /// Returns a new [SearchAlbumNameResponseDto] instance. + SearchAlbumNameResponseDto({ + this.albums = const [], + this.hasNextPage, + this.total, + }); + + List albums; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? hasNextPage; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + int? total; + + @override + bool operator ==(Object other) => identical(this, other) || other is SearchAlbumNameResponseDto && + _deepEquality.equals(other.albums, albums) && + other.hasNextPage == hasNextPage && + other.total == total; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (albums.hashCode) + + (hasNextPage == null ? 0 : hasNextPage!.hashCode) + + (total == null ? 0 : total!.hashCode); + + @override + String toString() => 'SearchAlbumNameResponseDto[albums=$albums, hasNextPage=$hasNextPage, total=$total]'; + + Map toJson() { + final json = {}; + json[r'albums'] = this.albums; + if (this.hasNextPage != null) { + json[r'hasNextPage'] = this.hasNextPage; + } else { + // json[r'hasNextPage'] = null; + } + if (this.total != null) { + json[r'total'] = this.total; + } else { + // json[r'total'] = null; + } + return json; + } + + /// Returns a new [SearchAlbumNameResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SearchAlbumNameResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchAlbumNameResponseDto"); + if (value is Map) { + final json = value.cast(); + + return SearchAlbumNameResponseDto( + albums: AlbumResponseDto.listFromJson(json[r'albums']), + hasNextPage: mapValueOfType(json, r'hasNextPage'), + total: mapValueOfType(json, r'total'), + ); + } + 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 = SearchAlbumNameResponseDto.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 = SearchAlbumNameResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SearchAlbumNameResponseDto-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] = SearchAlbumNameResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'albums', + }; +} + diff --git a/mobile/openapi/lib/model/search_person_name_response_dto.dart b/mobile/openapi/lib/model/search_person_name_response_dto.dart new file mode 100644 index 0000000000000..67353e9eb2f68 --- /dev/null +++ b/mobile/openapi/lib/model/search_person_name_response_dto.dart @@ -0,0 +1,133 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// 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 SearchPersonNameResponseDto { + /// Returns a new [SearchPersonNameResponseDto] instance. + SearchPersonNameResponseDto({ + this.hasNextPage, + this.people = const [], + this.total, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? hasNextPage; + + List people; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + int? total; + + @override + bool operator ==(Object other) => identical(this, other) || other is SearchPersonNameResponseDto && + other.hasNextPage == hasNextPage && + _deepEquality.equals(other.people, people) && + other.total == total; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (hasNextPage == null ? 0 : hasNextPage!.hashCode) + + (people.hashCode) + + (total == null ? 0 : total!.hashCode); + + @override + String toString() => 'SearchPersonNameResponseDto[hasNextPage=$hasNextPage, people=$people, total=$total]'; + + Map toJson() { + final json = {}; + if (this.hasNextPage != null) { + json[r'hasNextPage'] = this.hasNextPage; + } else { + // json[r'hasNextPage'] = null; + } + json[r'people'] = this.people; + if (this.total != null) { + json[r'total'] = this.total; + } else { + // json[r'total'] = null; + } + return json; + } + + /// Returns a new [SearchPersonNameResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SearchPersonNameResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SearchPersonNameResponseDto"); + if (value is Map) { + final json = value.cast(); + + return SearchPersonNameResponseDto( + hasNextPage: mapValueOfType(json, r'hasNextPage'), + people: PersonResponseDto.listFromJson(json[r'people']), + total: mapValueOfType(json, r'total'), + ); + } + 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 = SearchPersonNameResponseDto.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 = SearchPersonNameResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SearchPersonNameResponseDto-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] = SearchPersonNameResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'people', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 27691b16336a4..71e882879e61d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4337,6 +4337,82 @@ ] } }, + "/search/album": { + "get": { + "operationId": "searchAlbum", + "parameters": [ + { + "name": "name", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "required": false, + "in": "query", + "description": "Page number for pagination", + "schema": { + "minimum": 1, + "default": 1, + "type": "number" + } + }, + { + "name": "shared", + "required": false, + "in": "query", + "description": "true: only shared albums\nfalse: only non-shared own albums\nundefined: shared and owned albums", + "schema": { + "type": "boolean" + } + }, + { + "name": "size", + "required": false, + "in": "query", + "description": "Number of items per page", + "schema": { + "minimum": 1, + "maximum": 1000, + "default": 500, + "type": "number" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchAlbumNameResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Search" + ], + "x-immich-lifecycle": { + "addedAt": "v1.118.0" + } + } + }, "/search/cities": { "get": { "operationId": "getAssetsByCity", @@ -4461,6 +4537,29 @@ "type": "string" } }, + { + "name": "page", + "required": false, + "in": "query", + "description": "This property was added in v118.0.0", + "schema": { + "minimum": 1, + "default": 1, + "type": "number" + } + }, + { + "name": "size", + "required": false, + "in": "query", + "description": "This property was added in v118.0.0", + "schema": { + "minimum": 1, + "maximum": 1000, + "default": 500, + "type": "number" + } + }, { "name": "withHidden", "required": false, @@ -4475,10 +4574,7 @@ "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/PersonResponseDto" - }, - "type": "array" + "$ref": "#/components/schemas/SearchPersonNameResponseDto" } } }, @@ -10552,6 +10648,26 @@ ], "type": "object" }, + "SearchAlbumNameResponseDto": { + "properties": { + "albums": { + "items": { + "$ref": "#/components/schemas/AlbumResponseDto" + }, + "type": "array" + }, + "hasNextPage": { + "type": "boolean" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "albums" + ], + "type": "object" + }, "SearchAlbumResponseDto": { "properties": { "count": { @@ -10681,6 +10797,26 @@ ], "type": "object" }, + "SearchPersonNameResponseDto": { + "properties": { + "hasNextPage": { + "type": "boolean" + }, + "people": { + "items": { + "$ref": "#/components/schemas/PersonResponseDto" + }, + "type": "array" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "people" + ], + "type": "object" + }, "SearchResponseDto": { "properties": { "albums": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 31d1a7d7ca0d3..e726cdedbac89 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -752,6 +752,11 @@ export type FileChecksumResponseDto = { export type FileReportFixDto = { items: FileReportItemDto[]; }; +export type SearchAlbumNameResponseDto = { + albums: AlbumResponseDto[]; + hasNextPage?: boolean; + total?: number; +}; export type SearchExploreItem = { data: AssetResponseDto; value: string; @@ -828,6 +833,11 @@ export type SearchResponseDto = { albums: SearchAlbumResponseDto; assets: SearchAssetResponseDto; }; +export type SearchPersonNameResponseDto = { + hasNextPage?: boolean; + people: PersonResponseDto[]; + total?: number; +}; export type PlacesResponseDto = { admin1name?: string; admin2name?: string; @@ -2459,6 +2469,24 @@ export function fixAuditFiles({ fileReportFixDto }: { body: fileReportFixDto }))); } +export function searchAlbum({ name, page, shared, size }: { + name: string; + page?: number; + shared?: boolean; + size?: number; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SearchAlbumNameResponseDto; + }>(`/search/album${QS.query(QS.explode({ + name, + page, + shared, + size + }))}`, { + ...opts + })); +} export function getAssetsByCity(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2487,15 +2515,19 @@ export function searchMetadata({ metadataSearchDto }: { body: metadataSearchDto }))); } -export function searchPerson({ name, withHidden }: { +export function searchPerson({ name, page, size, withHidden }: { name: string; + page?: number; + size?: number; withHidden?: boolean; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: PersonResponseDto[]; + data: SearchPersonNameResponseDto; }>(`/search/person${QS.query(QS.explode({ name, + page, + size, withHidden }))}`, { ...opts diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 9fdb2746fc1d3..2d0ef26b18ea8 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -1,14 +1,17 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { EndpointLifecycle } from 'src/decorators'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { PersonResponseDto } from 'src/dtos/person.dto'; import { MetadataSearchDto, PlacesResponseDto, RandomSearchDto, + SearchAlbumNameResponseDto, + SearchAlbumsDto, SearchExploreResponseDto, SearchPeopleDto, + SearchPersonNameResponseDto, SearchPlacesDto, SearchResponseDto, SearchSuggestionRequestDto, @@ -51,7 +54,7 @@ export class SearchController { @Get('person') @Authenticated() - searchPerson(@Auth() auth: AuthDto, @Query() dto: SearchPeopleDto): Promise { + searchPerson(@Auth() auth: AuthDto, @Query() dto: SearchPeopleDto): Promise { return this.service.searchPerson(auth, dto); } @@ -73,4 +76,11 @@ export class SearchController { // TODO fix open api generation to indicate that results can be nullable return this.service.getSearchSuggestions(auth, dto) as Promise; } + + @Get('album') + @EndpointLifecycle({ addedAt: 'v1.118.0' }) + @Authenticated() + searchAlbum(@Auth() auth: AuthDto, @Query() dto: SearchAlbumsDto): Promise { + return this.service.searchAlbum(auth, dto); + } } diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index b12847ee62537..20a6969a45a51 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -192,5 +192,5 @@ export const mapAlbum = (entity: AlbumEntity, withAssets: boolean, auth?: AuthDt }; }; -export const mapAlbumWithAssets = (entity: AlbumEntity) => mapAlbum(entity, true); -export const mapAlbumWithoutAssets = (entity: AlbumEntity) => mapAlbum(entity, false); +export const mapAlbumWithAssets = (entity: AlbumEntity): AlbumResponseDto => mapAlbum(entity, true); +export const mapAlbumWithoutAssets = (entity: AlbumEntity): AlbumResponseDto => mapAlbum(entity, false); diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 5c5dce1a1190a..b0e823391df78 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -1,9 +1,10 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; import { PropertyLifecycle } from 'src/decorators'; import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; +import { PersonResponseDto } from 'src/dtos/person.dto'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; import { AssetOrder, AssetType } from 'src/enum'; import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation'; @@ -184,12 +185,53 @@ export class SmartSearchDto extends BaseSearchDto { page?: number; } -export class SearchPlacesDto { +export class SearchDto { @IsString() @IsNotEmpty() name!: string; } +export class SearchPlacesDto extends SearchDto {} + +export class SearchAlbumsDto extends SearchDto { + /** + * true: only shared albums + * false: only non-shared own albums + * undefined: shared and owned albums + */ + @ValidateBoolean({ optional: true }) + shared?: boolean; + + /** Page number for pagination */ + @ApiPropertyOptional() + @IsInt() + @Min(1) + @Type(() => Number) + page: number = 1; + + /** Number of items per page */ + @ApiPropertyOptional() + @IsInt() + @Min(1) + @Max(1000) + @Type(() => Number) + size: number = 500; +} + +export class SearchPersonNameResponseDto { + people!: PersonResponseDto[]; + @ApiProperty({ type: 'integer' }) + total?: number; + hasNextPage?: boolean; +} + +export class SearchAlbumNameResponseDto { + albums!: AlbumResponseDto[]; + @ApiProperty({ type: 'integer' }) + total?: number; + hasNextPage?: boolean; +} + export class SearchPeopleDto { @IsString() @IsNotEmpty() @@ -197,6 +239,23 @@ export class SearchPeopleDto { @ValidateBoolean({ optional: true }) withHidden?: boolean; + + /** Page number for pagination */ + @ApiPropertyOptional() + @PropertyLifecycle({ addedAt: 'v118.0.0' }) + @IsInt() + @Min(1) + @Type(() => Number) + page: number = 1; + + /** Number of items per page */ + @ApiPropertyOptional() + @PropertyLifecycle({ addedAt: 'v118.0.0' }) + @IsInt() + @Min(1) + @Max(1000) + @Type(() => Number) + size: number = 500; } export class PlacesResponseDto { diff --git a/server/src/interfaces/album.interface.ts b/server/src/interfaces/album.interface.ts index 24c64bdc9d2c0..01acdfc7aa657 100644 --- a/server/src/interfaces/album.interface.ts +++ b/server/src/interfaces/album.interface.ts @@ -1,5 +1,6 @@ import { AlbumEntity } from 'src/entities/album.entity'; import { IBulkAsset } from 'src/utils/asset.util'; +import { Paginated, PaginationOptions } from 'src/utils/pagination'; export const IAlbumRepository = 'IAlbumRepository'; @@ -29,4 +30,5 @@ export interface IAlbumRepository extends IBulkAsset { update(album: Partial): Promise; delete(id: string): Promise; updateThumbnails(): Promise; + getByName(pagination: PaginationOptions, userId: string, albumName: string, shared?: boolean): Paginated; } diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts index b3e2c0990efd1..c353fc5fcca0b 100644 --- a/server/src/interfaces/person.interface.ts +++ b/server/src/interfaces/person.interface.ts @@ -52,7 +52,12 @@ export interface IPersonRepository { getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated; getAllWithoutFaces(): Promise; getById(personId: string): Promise; - getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise; + getByName( + pagination: PaginationOptions, + userId: string, + personName: string, + options: PersonNameSearchOptions, + ): Paginated; getDistinctNames(userId: string, options: PersonNameSearchOptions): Promise; create(person: Partial): Promise; diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index c4f6fbdd3218b..e9372a18c16ca 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -520,3 +520,71 @@ WHERE "album_assets"."albumsId" = "albums"."id" AND "albums"."albumThumbnailAssetId" = "album_assets"."assetsId" ) + +-- AlbumRepository.getByName +SELECT + COUNT(DISTINCT ("album"."id")) AS "cnt" +FROM + "albums" "album" + LEFT JOIN "users" "owner" ON "owner"."id" = "album"."ownerId" + AND ("owner"."deletedAt" IS NULL) + LEFT JOIN "albums_shared_users_users" "album_users" ON "album_users"."albumsId" = "album"."id" +WHERE + ( + ( + "album"."ownerId" = $1 + OR "album_users"."usersId" = $1 + ) + AND ( + LOWER("album"."albumName") LIKE $2 + OR LOWER("album"."albumName") LIKE $3 + ) + ) + AND ("album"."deletedAt" IS NULL) +SELECT + "album"."id" AS "album_id", + "album"."ownerId" AS "album_ownerId", + "album"."albumName" AS "album_albumName", + "album"."description" AS "album_description", + "album"."createdAt" AS "album_createdAt", + "album"."updatedAt" AS "album_updatedAt", + "album"."deletedAt" AS "album_deletedAt", + "album"."albumThumbnailAssetId" AS "album_albumThumbnailAssetId", + "album"."isActivityEnabled" AS "album_isActivityEnabled", + "album"."order" AS "album_order", + "owner"."id" AS "owner_id", + "owner"."name" AS "owner_name", + "owner"."isAdmin" AS "owner_isAdmin", + "owner"."email" AS "owner_email", + "owner"."storageLabel" AS "owner_storageLabel", + "owner"."oauthId" AS "owner_oauthId", + "owner"."profileImagePath" AS "owner_profileImagePath", + "owner"."shouldChangePassword" AS "owner_shouldChangePassword", + "owner"."createdAt" AS "owner_createdAt", + "owner"."deletedAt" AS "owner_deletedAt", + "owner"."status" AS "owner_status", + "owner"."updatedAt" AS "owner_updatedAt", + "owner"."quotaSizeInBytes" AS "owner_quotaSizeInBytes", + "owner"."quotaUsageInBytes" AS "owner_quotaUsageInBytes", + "owner"."profileChangedAt" AS "owner_profileChangedAt" +FROM + "albums" "album" + LEFT JOIN "users" "owner" ON "owner"."id" = "album"."ownerId" + AND ("owner"."deletedAt" IS NULL) + LEFT JOIN "albums_shared_users_users" "album_users" ON "album_users"."albumsId" = "album"."id" +WHERE + ( + ( + "album"."ownerId" = $1 + OR "album_users"."usersId" = $1 + ) + AND ( + LOWER("album"."albumName") LIKE $2 + OR LOWER("album"."albumName") LIKE $3 + ) + ) + AND ("album"."deletedAt" IS NULL) +LIMIT + 11 +OFFSET + 10 diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 5616559d7d06d..76e11f7d056f5 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -204,6 +204,16 @@ WHERE "id" = $2 -- PersonRepository.getByName +SELECT + COUNT(1) AS "cnt" +FROM + "person" "person" +WHERE + "person"."ownerId" = $1 + AND ( + LOWER("person"."name") LIKE $2 + OR LOWER("person"."name") LIKE $3 + ) SELECT "person"."id" AS "person_id", "person"."createdAt" AS "person_createdAt", @@ -223,7 +233,9 @@ WHERE OR LOWER("person"."name") LIKE $3 ) LIMIT - 1000 + 11 +OFFSET + 10 -- PersonRepository.getDistinctNames SELECT DISTINCT diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index f7b4cb44aa976..cabd64d034322 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -3,8 +3,10 @@ import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; import { AlbumEntity } from 'src/entities/album.entity'; import { AssetEntity } from 'src/entities/asset.entity'; +import { PaginationMode } from 'src/enum'; import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface'; import { Instrumentation } from 'src/utils/instrumentation'; +import { Paginated, paginatedBuilder, PaginationOptions } from 'src/utils/pagination'; import { DataSource, EntityManager, @@ -302,4 +304,54 @@ export class AlbumRepository implements IAlbumRepository { return result.affected; } + + @GenerateSql({ params: [{ take: 10, skip: 10, withCount: true }, DummyValue.UUID, DummyValue.STRING, undefined] }) + getByName( + pagination: PaginationOptions, + userId: string, + albumName: string, + shared?: boolean, + ): Paginated { + const getAlbumSharedOptions = () => { + switch (shared) { + case true: { + return { owner: '(album_users.usersId = :userId)', options: '' }; + } + case false: { + return { + owner: '(album.ownerId = :userId)', + options: 'AND album_users.usersId IS NULL AND shared_links.id IS NULL', + }; + } + case undefined: { + return { owner: '(album.ownerId = :userId OR album_users.usersId = :userId)', options: '' }; + } + } + }; + + const albumSharedOptions = getAlbumSharedOptions(); + + let queryBuilder = this.repository + .createQueryBuilder('album') + .leftJoinAndSelect('album.owner', 'owner') + .leftJoin('albums_shared_users_users', 'album_users', 'album_users.albumsId = album.id'); + + if (shared === false) { + queryBuilder = queryBuilder.leftJoin('shared_links', 'shared_links', 'shared_links.albumId = album.id'); + } + + queryBuilder = queryBuilder.where( + `${albumSharedOptions.owner} AND (LOWER(album.albumName) LIKE :nameStart OR LOWER(album.albumName) LIKE :nameAnywhere) ${albumSharedOptions.options}`, + { + userId, + nameStart: `${albumName.toLowerCase()}%`, + nameAnywhere: `% ${albumName.toLowerCase()}%`, + }, + ); + + return paginatedBuilder(queryBuilder, { + mode: PaginationMode.LIMIT_OFFSET, + ...pagination, + }); + } } diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index c62c4b8739493..5c33e7c09d980 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -183,8 +183,15 @@ export class PersonRepository implements IPersonRepository { return this.personRepository.findOne({ where: { id: personId } }); } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] }) - getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise { + @GenerateSql({ + params: [{ take: 10, skip: 10, withCount: true }, DummyValue.UUID, DummyValue.STRING, { withHidden: true }], + }) + getByName( + pagination: PaginationOptions, + userId: string, + personName: string, + { withHidden }: PersonNameSearchOptions, + ): Paginated { const queryBuilder = this.personRepository .createQueryBuilder('person') .where( @@ -196,7 +203,10 @@ export class PersonRepository implements IPersonRepository { if (!withHidden) { queryBuilder.andWhere('person.isHidden = false'); } - return queryBuilder.getMany(); + return paginatedBuilder(queryBuilder, { + mode: PaginationMode.LIMIT_OFFSET, + ...pagination, + }); } @GenerateSql({ params: [DummyValue.UUID, { withHidden: true }] }) diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index e0b03f31aee3b..4526ed01836b7 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -1,9 +1,11 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; import { SearchSuggestionType } from 'src/dtos/search.dto'; +import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { SearchService } from 'src/services/search.service'; +import { albumStub } from 'test/fixtures/album.stub'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { personStub } from 'test/fixtures/person.stub'; @@ -15,12 +17,13 @@ vitest.useFakeTimers(); describe(SearchService.name, () => { let sut: SearchService; + let albumMock: Mocked; let assetMock: Mocked; let personMock: Mocked; let searchMock: Mocked; beforeEach(() => { - ({ sut, assetMock, personMock, searchMock } = newTestService(SearchService)); + ({ sut, albumMock, assetMock, personMock, searchMock } = newTestService(SearchService)); }); it('should work', () => { @@ -29,15 +32,91 @@ describe(SearchService.name, () => { describe('searchPerson', () => { it('should pass options to search', async () => { + personMock.getByName.mockResolvedValue({ + items: [personStub.withName], + hasNextPage: false, + }); const { name } = personStub.withName; - await sut.searchPerson(authStub.user1, { name, withHidden: false }); + await sut.searchPerson(authStub.user1, { + name, + withHidden: false, + page: 1, + size: 500, + }); + + expect(personMock.getByName).toHaveBeenCalledWith( + { + skip: 0, + take: 500, + withCount: true, + }, + authStub.user1.user.id, + name, + { withHidden: false }, + ); - expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false }); + await sut.searchPerson(authStub.user1, { name, withHidden: true, page: 1, size: 500 }); - await sut.searchPerson(authStub.user1, { name, withHidden: true }); + expect(personMock.getByName).toHaveBeenCalledWith( + { + skip: 0, + take: 500, + withCount: true, + }, + authStub.user1.user.id, + name, + { withHidden: true }, + ); + }); + }); + + describe('searchAlbum', () => { + it('should pass options to search', async () => { + albumMock.getByName.mockResolvedValue({ + items: [albumStub.twoAssets], + hasNextPage: false, + }); + albumMock.getMetadataForIds.mockResolvedValue([ + { albumId: albumStub.twoAssets.id, assetCount: 2, startDate: undefined, endDate: undefined }, + ]); + const { albumName } = albumStub.twoAssets; + + await sut.searchAlbum(authStub.user1, { + name: albumName, + shared: true, + page: 1, + size: 500, + }); + + expect(albumMock.getByName).toHaveBeenCalledWith( + { + skip: 0, + take: 500, + withCount: true, + }, + authStub.user1.user.id, + albumName, + true, + ); + + await sut.searchAlbum(authStub.user1, { + name: albumName, + shared: false, + page: 1, + size: 500, + }); - expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: true }); + expect(albumMock.getByName).toHaveBeenCalledWith( + { + skip: 0, + take: 500, + withCount: true, + }, + authStub.user1.user.id, + albumName, + false, + ); }); }); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 03ffbe97db14e..06d8ddf50129b 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -1,21 +1,27 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto'; + import { AssetMapOptions, AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { PersonResponseDto } from 'src/dtos/person.dto'; +import { mapPerson } from 'src/dtos/person.dto'; import { + mapPlaces, MetadataSearchDto, PlacesResponseDto, RandomSearchDto, + SearchAlbumNameResponseDto, + SearchAlbumsDto, SearchPeopleDto, + SearchPersonNameResponseDto, SearchPlacesDto, SearchResponseDto, SearchSuggestionRequestDto, SearchSuggestionType, SmartSearchDto, - mapPlaces, } from 'src/dtos/search.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetOrder } from 'src/enum'; +import { AlbumAssetCount } from 'src/interfaces/album.interface'; import { SearchExploreItem } from 'src/interfaces/search.interface'; import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; @@ -23,8 +29,65 @@ import { isSmartSearchEnabled } from 'src/utils/misc'; @Injectable() export class SearchService extends BaseService { - async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise { - return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden }); + async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise { + const { withHidden, name, page, size } = dto; + const pagination = { + take: size, + skip: (page - 1) * size, + withCount: true, + }; + const { items, hasNextPage, count } = await this.personRepository.getByName(pagination, auth.user.id, name, { + withHidden, + }); + + const people = items.map((person) => mapPerson(person)); + + return { + people, + hasNextPage, + total: count, + }; + } + + async searchAlbum(auth: AuthDto, dto: SearchAlbumsDto): Promise { + const { shared, name, page, size } = dto; + const pagination = { + take: size, + skip: (page - 1) * size, + withCount: true, + }; + const { items, hasNextPage, count } = await this.albumRepository.getByName(pagination, auth.user.id, name, shared); + const results = await this.albumRepository.getMetadataForIds(items.map((album) => album.id)); + const albumMetadata: Record = {}; + for (const metadata of results) { + const { albumId, assetCount, startDate, endDate } = metadata; + albumMetadata[albumId] = { + albumId, + assetCount, + startDate, + endDate, + }; + } + + const albums: AlbumResponseDto[] = await Promise.all( + items.map(async (album) => { + const lastModifiedAsset = await this.assetRepository.getLastUpdatedAssetForAlbumId(album.id); + return { + ...mapAlbumWithoutAssets(album), + sharedLinks: undefined, + startDate: albumMetadata[album.id].startDate, + endDate: albumMetadata[album.id].endDate, + assetCount: albumMetadata[album.id].assetCount, + lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt, + }; + }), + ); + + return { + albums, + total: count, + hasNextPage, + }; } async searchPlaces(dto: SearchPlacesDto): Promise { diff --git a/server/src/utils/pagination.ts b/server/src/utils/pagination.ts index 4009f219c1c96..208ac2a2225ba 100644 --- a/server/src/utils/pagination.ts +++ b/server/src/utils/pagination.ts @@ -5,17 +5,20 @@ import { FindManyOptions, ObjectLiteral, Repository, SelectQueryBuilder } from ' export interface PaginationOptions { take: number; skip?: number; + withCount?: boolean; } export interface PaginatedBuilderOptions { take: number; skip?: number; mode?: PaginationMode; + withCount?: boolean; } export interface PaginationResult { items: T[]; hasNextPage: boolean; + count?: number; } export type Paginated = Promise>; @@ -33,11 +36,15 @@ export async function* usePagination( } } -function paginationHelper(items: Entity[], take: number): PaginationResult { +function paginationHelper( + items: Entity[], + take: number, + count?: number, +): PaginationResult { const hasNextPage = items.length > take; items.splice(take); - return { items, hasNextPage }; + return { items, hasNextPage, count }; } export async function paginate( @@ -62,7 +69,7 @@ export async function paginate( export async function paginatedBuilder( qb: SelectQueryBuilder, - { take, skip, mode }: PaginatedBuilderOptions, + { take, skip, mode, withCount }: PaginatedBuilderOptions, ): Paginated { if (mode === PaginationMode.LIMIT_OFFSET) { qb.limit(take + 1).offset(skip); @@ -70,6 +77,8 @@ export async function paginatedBuilder( qb.take(take + 1).skip(skip); } + const count = withCount ? await qb.getCount() : undefined; + const items = await qb.getMany(); - return paginationHelper(items, take); + return paginationHelper(items, take, count); } diff --git a/server/test/repositories/album.repository.mock.ts b/server/test/repositories/album.repository.mock.ts index dd5c3af6a8d9a..97f86576d54e3 100644 --- a/server/test/repositories/album.repository.mock.ts +++ b/server/test/repositories/album.repository.mock.ts @@ -20,5 +20,6 @@ export const newAlbumRepositoryMock = (): Mocked => { update: vitest.fn(), delete: vitest.fn(), updateThumbnails: vitest.fn(), + getByName: vitest.fn(), }; }; diff --git a/web/src/lib/components/faces-page/people-search.svelte b/web/src/lib/components/faces-page/people-search.svelte index 2a952b8145b25..221fb48be6c26 100644 --- a/web/src/lib/components/faces-page/people-search.svelte +++ b/web/src/lib/components/faces-page/people-search.svelte @@ -22,6 +22,8 @@ let abortController: AbortController | null = null; let timeout: NodeJS.Timeout | null = null; + // TODO: use pagination + const search = () => { searchedPeopleLocal = searchNameLocal(searchName, searchedPeople, numberPeopleToSearch); }; @@ -58,8 +60,11 @@ abortController = new AbortController(); timeout = setTimeout(() => (showLoadingSpinner = true), timeBeforeShowLoadingSpinner); try { - const data = await searchPerson({ name: searchName }, { signal: abortController?.signal }); - searchedPeople = data; + const data = await searchPerson( + { name: searchName, size: maximumLengthSearchPeople }, + { signal: abortController?.signal }, + ); + searchedPeople = data.people; searchWord = searchName; } catch (error) { handleError(error, $t('errors.cant_search_people')); diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index b6d25c48bf937..fc5e4316e8268 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -208,11 +208,11 @@ await changeName(); return; } - const data = await searchPerson({ name: personName, withHidden: true }); + const data = await searchPerson({ name: personName, size: 5, withHidden: true }); // We check if another person has the same name as the name entered by the user - const existingPerson = data.find( + const existingPerson = data.people.find( (person: PersonResponseDto) => person.name.toLowerCase() === personName.toLowerCase() && edittingPerson && diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 037feaf35f6f1..b72cd9bdbade2 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -284,15 +284,16 @@ return; } - const result = await searchPerson({ name: personName, withHidden: true }); + // We need to search for 5 people: we show only 3 people for the SUGGEST_MERGE view mode + the person to merge + the person to be merged in = 5 + const result = await searchPerson({ name: personName, size: 5, withHidden: true }); - const existingPerson = result.find( + const existingPerson = result.people.find( ({ name, id }: PersonResponseDto) => name.toLowerCase() === personName.toLowerCase() && id !== person.id && name, ); if (existingPerson) { personMerge2 = existingPerson; personMerge1 = person; - potentialMergePeople = result + potentialMergePeople = result.people .filter( (person: PersonResponseDto) => personMerge2.name.toLowerCase() === person.name.toLowerCase() &&