mirror of
https://github.com/immich-app/immich.git
synced 2025-03-24 17:05:55 +01:00
feat: search album by name
This commit is contained in:
parent
e8015dc7d7
commit
cb70c79546
23 changed files with 952 additions and 45 deletions
mobile/openapi/lib
open-api
server
src
controllers
dtos
interfaces
queries
repositories
services
utils
test/repositories
web/src
lib/components/faces-page
routes/(user)/people
2
mobile/openapi/lib/api.dart
generated
2
mobile/openapi/lib/api.dart
generated
|
@ -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';
|
||||
|
|
107
mobile/openapi/lib/api/search_api.dart
generated
107
mobile/openapi/lib/api/search_api.dart
generated
|
@ -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<Response> 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 = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
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 = <String>[];
|
||||
|
||||
|
||||
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<SearchAlbumNameResponseDto?> 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<Response> searchPersonWithHttpInfo(String name, { bool? withHidden, }) async {
|
||||
Future<Response> 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 = <String, String>{};
|
||||
|
||||
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<List<PersonResponseDto>?> searchPerson(String name, { bool? withHidden, }) async {
|
||||
final response = await searchPersonWithHttpInfo(name, withHidden: withHidden, );
|
||||
Future<SearchPersonNameResponseDto?> 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<PersonResponseDto>') as List)
|
||||
.cast<PersonResponseDto>()
|
||||
.toList(growable: false);
|
||||
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchPersonNameResponseDto',) as SearchPersonNameResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
4
mobile/openapi/lib/api_client.dart
generated
4
mobile/openapi/lib/api_client.dart
generated
|
@ -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':
|
||||
|
|
133
mobile/openapi/lib/model/search_album_name_response_dto.dart
generated
Normal file
133
mobile/openapi/lib/model/search_album_name_response_dto.dart
generated
Normal file
|
@ -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<AlbumResponseDto> 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<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
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<String, dynamic>();
|
||||
|
||||
return SearchAlbumNameResponseDto(
|
||||
albums: AlbumResponseDto.listFromJson(json[r'albums']),
|
||||
hasNextPage: mapValueOfType<bool>(json, r'hasNextPage'),
|
||||
total: mapValueOfType<int>(json, r'total'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SearchAlbumNameResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SearchAlbumNameResponseDto>[];
|
||||
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<String, SearchAlbumNameResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, SearchAlbumNameResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // 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<String, List<SearchAlbumNameResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SearchAlbumNameResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
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 = <String>{
|
||||
'albums',
|
||||
};
|
||||
}
|
||||
|
133
mobile/openapi/lib/model/search_person_name_response_dto.dart
generated
Normal file
133
mobile/openapi/lib/model/search_person_name_response_dto.dart
generated
Normal file
|
@ -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<PersonResponseDto> 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<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
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<String, dynamic>();
|
||||
|
||||
return SearchPersonNameResponseDto(
|
||||
hasNextPage: mapValueOfType<bool>(json, r'hasNextPage'),
|
||||
people: PersonResponseDto.listFromJson(json[r'people']),
|
||||
total: mapValueOfType<int>(json, r'total'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SearchPersonNameResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SearchPersonNameResponseDto>[];
|
||||
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<String, SearchPersonNameResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, SearchPersonNameResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // 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<String, List<SearchPersonNameResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SearchPersonNameResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
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 = <String>{
|
||||
'people',
|
||||
};
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<PersonResponseDto[]> {
|
||||
searchPerson(@Auth() auth: AuthDto, @Query() dto: SearchPeopleDto): Promise<SearchPersonNameResponseDto> {
|
||||
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<string[]>;
|
||||
}
|
||||
|
||||
@Get('album')
|
||||
@EndpointLifecycle({ addedAt: 'v1.118.0' })
|
||||
@Authenticated()
|
||||
searchAlbum(@Auth() auth: AuthDto, @Query() dto: SearchAlbumsDto): Promise<SearchAlbumNameResponseDto> {
|
||||
return this.service.searchAlbum(auth, dto);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<AlbumEntity>): Promise<AlbumEntity>;
|
||||
delete(id: string): Promise<void>;
|
||||
updateThumbnails(): Promise<number | undefined>;
|
||||
getByName(pagination: PaginationOptions, userId: string, albumName: string, shared?: boolean): Paginated<AlbumEntity>;
|
||||
}
|
||||
|
|
|
@ -52,7 +52,12 @@ export interface IPersonRepository {
|
|||
getAllForUser(pagination: PaginationOptions, userId: string, options: PersonSearchOptions): Paginated<PersonEntity>;
|
||||
getAllWithoutFaces(): Promise<PersonEntity[]>;
|
||||
getById(personId: string): Promise<PersonEntity | null>;
|
||||
getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>;
|
||||
getByName(
|
||||
pagination: PaginationOptions,
|
||||
userId: string,
|
||||
personName: string,
|
||||
options: PersonNameSearchOptions,
|
||||
): Paginated<PersonEntity>;
|
||||
getDistinctNames(userId: string, options: PersonNameSearchOptions): Promise<PersonNameResponse[]>;
|
||||
|
||||
create(person: Partial<PersonEntity>): Promise<PersonEntity>;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<AlbumEntity> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<PersonEntity[]> {
|
||||
@GenerateSql({
|
||||
params: [{ take: 10, skip: 10, withCount: true }, DummyValue.UUID, DummyValue.STRING, { withHidden: true }],
|
||||
})
|
||||
getByName(
|
||||
pagination: PaginationOptions,
|
||||
userId: string,
|
||||
personName: string,
|
||||
{ withHidden }: PersonNameSearchOptions,
|
||||
): Paginated<PersonEntity> {
|
||||
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 }] })
|
||||
|
|
|
@ -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<IAlbumRepository>;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let personMock: Mocked<IPersonRepository>;
|
||||
let searchMock: Mocked<ISearchRepository>;
|
||||
|
||||
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(authStub.user1.user.id, name, { withHidden: false });
|
||||
expect(personMock.getByName).toHaveBeenCalledWith(
|
||||
{
|
||||
skip: 0,
|
||||
take: 500,
|
||||
withCount: true,
|
||||
},
|
||||
authStub.user1.user.id,
|
||||
name,
|
||||
{ withHidden: false },
|
||||
);
|
||||
|
||||
await sut.searchPerson(authStub.user1, { name, withHidden: true });
|
||||
await sut.searchPerson(authStub.user1, { name, withHidden: true, page: 1, size: 500 });
|
||||
|
||||
expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, 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(albumMock.getByName).toHaveBeenCalledWith(
|
||||
{
|
||||
skip: 0,
|
||||
take: 500,
|
||||
withCount: true,
|
||||
},
|
||||
authStub.user1.user.id,
|
||||
albumName,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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<PersonResponseDto[]> {
|
||||
return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden });
|
||||
async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise<SearchPersonNameResponseDto> {
|
||||
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<SearchAlbumNameResponseDto> {
|
||||
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<string, AlbumAssetCount> = {};
|
||||
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<PlacesResponseDto[]> {
|
||||
|
|
|
@ -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<T> {
|
||||
items: T[];
|
||||
hasNextPage: boolean;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export type Paginated<T> = Promise<PaginationResult<T>>;
|
||||
|
@ -33,11 +36,15 @@ export async function* usePagination<T>(
|
|||
}
|
||||
}
|
||||
|
||||
function paginationHelper<Entity extends ObjectLiteral>(items: Entity[], take: number): PaginationResult<Entity> {
|
||||
function paginationHelper<Entity extends ObjectLiteral>(
|
||||
items: Entity[],
|
||||
take: number,
|
||||
count?: number,
|
||||
): PaginationResult<Entity> {
|
||||
const hasNextPage = items.length > take;
|
||||
items.splice(take);
|
||||
|
||||
return { items, hasNextPage };
|
||||
return { items, hasNextPage, count };
|
||||
}
|
||||
|
||||
export async function paginate<Entity extends ObjectLiteral>(
|
||||
|
@ -62,7 +69,7 @@ export async function paginate<Entity extends ObjectLiteral>(
|
|||
|
||||
export async function paginatedBuilder<Entity extends ObjectLiteral>(
|
||||
qb: SelectQueryBuilder<Entity>,
|
||||
{ take, skip, mode }: PaginatedBuilderOptions,
|
||||
{ take, skip, mode, withCount }: PaginatedBuilderOptions,
|
||||
): Paginated<Entity> {
|
||||
if (mode === PaginationMode.LIMIT_OFFSET) {
|
||||
qb.limit(take + 1).offset(skip);
|
||||
|
@ -70,6 +77,8 @@ export async function paginatedBuilder<Entity extends ObjectLiteral>(
|
|||
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);
|
||||
}
|
||||
|
|
|
@ -20,5 +20,6 @@ export const newAlbumRepositoryMock = (): Mocked<IAlbumRepository> => {
|
|||
update: vitest.fn(),
|
||||
delete: vitest.fn(),
|
||||
updateThumbnails: vitest.fn(),
|
||||
getByName: vitest.fn(),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -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() &&
|
||||
|
|
Loading…
Add table
Reference in a new issue