1
0
Fork 0
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:
martabal 2024-10-14 21:10:56 +02:00
parent e8015dc7d7
commit cb70c79546
No known key found for this signature in database
GPG key ID: C00196E3148A52BD
23 changed files with 952 additions and 45 deletions

View file

@ -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';

View file

@ -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;
}

View file

@ -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':

View 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',
};
}

View 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',
};
}

View file

@ -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": {

View file

@ -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

View file

@ -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);
}
}

View file

@ -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);

View file

@ -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 {

View file

@ -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>;
}

View file

@ -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>;

View file

@ -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

View file

@ -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

View file

@ -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,
});
}
}

View file

@ -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 }] })

View file

@ -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,
);
});
});

View file

@ -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[]> {

View file

@ -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);
}

View file

@ -20,5 +20,6 @@ export const newAlbumRepositoryMock = (): Mocked<IAlbumRepository> => {
update: vitest.fn(),
delete: vitest.fn(),
updateThumbnails: vitest.fn(),
getByName: vitest.fn(),
};
};

View file

@ -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'));

View file

@ -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 &&

View file

@ -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() &&