From 3e3598fd92a518bdcbc122abfea5acbb96a6aaf2 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Tue, 24 Oct 2023 17:53:49 +0200 Subject: [PATCH] fix: suggest people (#4566) * fix: suggest people * feat: remove hidden people * add hidden people when merging faces * pr feedback * fix: don't use reactive statement * fixed section height * improve merging * fix: migration * fix migration * feat: add asset count * fix: test * rename endpoint * add server test * improve responsive design * fix: remove videos from live photos in the asset count * pr feedback * fix: rename asset count endpoint * fix: return firstname and lastname * fix: reset people only on error * fix: search * fix: responsive design & div flickering * fix: cleanup * chore: open api --------- Co-authored-by: Jason Rasmussen --- cli/src/api/open-api/api.ts | 122 +++++++++++++++++- mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | Bin 21991 -> 22177 bytes mobile/openapi/doc/PersonApi.md | Bin 14702 -> 16783 bytes .../doc/PersonStatisticsResponseDto.md | Bin 0 -> 420 bytes mobile/openapi/doc/SearchApi.md | Bin 8119 -> 8222 bytes mobile/openapi/lib/api.dart | Bin 7214 -> 7264 bytes mobile/openapi/lib/api/person_api.dart | Bin 12129 -> 13734 bytes mobile/openapi/lib/api/search_api.dart | Bin 9227 -> 9469 bytes mobile/openapi/lib/api_client.dart | Bin 21482 -> 21588 bytes .../model/person_statistics_response_dto.dart | Bin 0 -> 2982 bytes mobile/openapi/test/person_api_test.dart | Bin 1456 -> 1606 bytes .../person_statistics_response_dto_test.dart | Bin 0 -> 598 bytes mobile/openapi/test/search_api_test.dart | Bin 1186 -> 1207 bytes server/immich-openapi-specs.json | 61 +++++++++ server/src/domain/person/person.dto.ts | 5 + .../src/domain/person/person.service.spec.ts | 19 +++ server/src/domain/person/person.service.ts | 6 + .../domain/repositories/person.repository.ts | 13 +- server/src/domain/search/dto/search.dto.ts | 5 + server/src/domain/search/search.service.ts | 4 +- .../immich/controllers/person.controller.ts | 9 ++ .../infra/repositories/person.repository.ts | 42 +++++- .../repositories/person.repository.mock.ts | 1 + web/src/api/open-api/api.ts | 122 +++++++++++++++++- .../faces-page/edit-name-input.svelte | 6 +- web/src/routes/(user)/people/+page.svelte | 2 +- .../(user)/people/[personId]/+page.server.ts | 2 + .../(user)/people/[personId]/+page.svelte | 119 +++++++++-------- 29 files changed, 467 insertions(+), 74 deletions(-) create mode 100644 mobile/openapi/doc/PersonStatisticsResponseDto.md create mode 100644 mobile/openapi/lib/model/person_statistics_response_dto.dart create mode 100644 mobile/openapi/test/person_statistics_response_dto_test.dart diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index fffb9914a..799eb9d38 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -2465,6 +2465,19 @@ export interface PersonResponseDto { */ 'thumbnailPath': string; } +/** + * + * @export + * @interface PersonStatisticsResponseDto + */ +export interface PersonStatisticsResponseDto { + /** + * + * @type {number} + * @memberof PersonStatisticsResponseDto + */ + 'assets': number; +} /** * * @export @@ -12010,6 +12023,48 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getPersonStatistics: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getPersonStatistics', 'id', id) + const localVarPath = `/person/{id}/statistics` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -12241,6 +12296,16 @@ export const PersonApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonAssets(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getPersonStatistics(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonStatistics(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id @@ -12320,6 +12385,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat getPersonAssets(requestParameters: PersonApiGetPersonAssetsRequest, options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.getPersonAssets(requestParameters.id, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {PersonApiGetPersonStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getPersonStatistics(requestParameters: PersonApiGetPersonStatisticsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getPersonStatistics(requestParameters.id, options).then((request) => request(axios, basePath)); + }, /** * * @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters. @@ -12401,6 +12475,20 @@ export interface PersonApiGetPersonAssetsRequest { readonly id: string } +/** + * Request parameters for getPersonStatistics operation in PersonApi. + * @export + * @interface PersonApiGetPersonStatisticsRequest + */ +export interface PersonApiGetPersonStatisticsRequest { + /** + * + * @type {string} + * @memberof PersonApiGetPersonStatistics + */ + readonly id: string +} + /** * Request parameters for getPersonThumbnail operation in PersonApi. * @export @@ -12511,6 +12599,17 @@ export class PersonApi extends BaseAPI { return PersonApiFp(this.configuration).getPersonAssets(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {PersonApiGetPersonStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PersonApi + */ + public getPersonStatistics(requestParameters: PersonApiGetPersonStatisticsRequest, options?: AxiosRequestConfig) { + return PersonApiFp(this.configuration).getPersonStatistics(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters. @@ -12722,10 +12821,11 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio /** * * @param {string} name + * @param {boolean} [withHidden] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - searchPerson: async (name: string, options: AxiosRequestConfig = {}): Promise => { + searchPerson: async (name: string, withHidden?: boolean, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'name' is not null or undefined assertParamExists('searchPerson', 'name', name) const localVarPath = `/search/person`; @@ -12753,6 +12853,10 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio localVarQueryParameter['name'] = name; } + if (withHidden !== undefined) { + localVarQueryParameter['withHidden'] = withHidden; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -12811,11 +12915,12 @@ export const SearchApiFp = function(configuration?: Configuration) { /** * * @param {string} name + * @param {boolean} [withHidden] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async searchPerson(name: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, options); + async searchPerson(name: string, withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, withHidden, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, } @@ -12852,7 +12957,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat * @throws {RequiredError} */ searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.searchPerson(requestParameters.name, options).then((request) => request(axios, basePath)); + return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath)); }, }; }; @@ -12988,6 +13093,13 @@ export interface SearchApiSearchPersonRequest { * @memberof SearchApiSearchPerson */ readonly name: string + + /** + * + * @type {boolean} + * @memberof SearchApiSearchPerson + */ + readonly withHidden?: boolean } /** @@ -13026,7 +13138,7 @@ export class SearchApi extends BaseAPI { * @memberof SearchApi */ public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) { - return SearchApiFp(this.configuration).searchPerson(requestParameters.name, options).then((request) => request(this.axios, this.basePath)); + return SearchApiFp(this.configuration).searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath)); } } diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 3bb00bb24..b014ae6ad 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -96,6 +96,7 @@ doc/PeopleUpdateDto.md doc/PeopleUpdateItem.md doc/PersonApi.md doc/PersonResponseDto.md +doc/PersonStatisticsResponseDto.md doc/PersonUpdateDto.md doc/QueueStatusDto.md doc/RecognitionConfig.md @@ -269,6 +270,7 @@ lib/model/people_response_dto.dart lib/model/people_update_dto.dart lib/model/people_update_item.dart lib/model/person_response_dto.dart +lib/model/person_statistics_response_dto.dart lib/model/person_update_dto.dart lib/model/queue_status_dto.dart lib/model/recognition_config.dart @@ -421,6 +423,7 @@ test/people_update_dto_test.dart test/people_update_item_test.dart test/person_api_test.dart test/person_response_dto_test.dart +test/person_statistics_response_dto_test.dart test/person_update_dto_test.dart test/queue_status_dto_test.dart test/recognition_config_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 80735dcd012e154ff5c0902aedd1d75b79840215..125be810d016e85b492b0c997b5a20f4c332b31d 100644 GIT binary patch delta 116 zcmaF9nsMP;#tkm&oWUiDC7Hz~naPtMswwCe!+6D-3N;E^TJEkPT3QPF1*t{F`FZ-) rnJKmU2qlyEsmX12QMXn^GNjO6cyhc48yDQz;>j0nMK)J@F_BfLV0FMhDT;fN^0Kb*Nk#3lPAh(Og0c;|}E&u=k delta 41 xcmbQ|u-$$`vBc(j2{)F>hJw1>0jWjB`FR?7iMgqh{e{9Ox5zHp94vR55db^u4+H=J diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index b030be56f4bb7e612892bce491927b78fbd498d9..b5779b537d3134c741b14eae5417906f8b7346b9 100644 GIT binary patch delta 27 icmZ2y@xWq3vmjS-Nn%N6aY<%!@#IDUrOma18@T|WXA0T? delta 12 TcmaE0vCd*cv*6~ff~&azCm98C diff --git a/mobile/openapi/lib/api/person_api.dart b/mobile/openapi/lib/api/person_api.dart index 0d5b60182669e82998f555ed36414fc3522c07f8..e4ab011a6b99b213c4a44d9a0e40d5f3d9b6af44 100644 GIT binary patch delta 188 zcmaDDw=8=@w+L5pNn%N6aY<%!@#Ke!o|6msjJbjlLh;h#lMnC(BddBV9l1H1a|fdq zLRnC1aY24wajHv6zP+77dTL2PYEf~19$dj>Z$T?0Q#bz>w`4LxQ&Ws&h`NrZLSnH3 Qx{ArCc*8e$i(Hce0FpsU0RR91 delta 12 TcmZ3M{V;Arx5(xTQrDyaD;)+c diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 870c5dcd3afedf51eed5bb853c7ef674eb32a9f7..7c01d5e9b9f296742a7c4987c9a5965fddbaa4ad 100644 GIT binary patch delta 191 zcmeD7`0KerTyC-}hcH)pW=V!eW=cwG-eh4pDSSd_IdKJq TF?m)9_GUrphm4cumEQmWU!*}6 delta 45 zcmV+|0Mh^cNsCCZ7ao&19yXID0#1|C9{;mDASwZqh9NqWUj!wS`yn2)p9EL}lZzyB Di8v6U diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index c391658e66a1c0f919a0440c5b92f7695ada7dc4..3f34642b8789399caf810bbf524c9296cb713ee3 100644 GIT binary patch delta 49 ycmaF0obk#E#tj{2T)`!YC7Hz~naRbI8>Qv>15%5M^Yh?>lQ-DtY;H2kkOcq$?i1Po delta 14 Wcmcbzg7MXI#tj{2n?IQ)$pQd49R}C{ diff --git a/mobile/openapi/lib/model/person_statistics_response_dto.dart b/mobile/openapi/lib/model/person_statistics_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..b4fbef723557611490094954ce60fdf564a9ae0f GIT binary patch literal 2982 zcmbVOVQJfV-{t9Lhl zpTLYQ-^_(^!`I1AuY3HcmW4JlU1*aoR4&hHQ*5l9r!tpoS-7}&}Es1`~Z?{1H7ma9VAG}pxvW^N^l z^yeSbY$c5C^{|~Iy&zr6LX;}O-$k#N<-%I}14-!O-U+9yQ<=S!w#3Hr%4vG+5eyr3$qZ$>l{9BG98rKR6M4kJ zF*W@VzdwUEpuz41EVUHX;fP&rY}p`Gc+aYw!P8Tr!jmmlYr%Fgz|hv^b(17B?X30GY7wqwnRV(OdD5 zqj6{fiT0DB7h^j*&p|FO5%YNvN6Rx{UvqH4MCIi) zR~Xd*o>TqHNAWFJ(bR7KE)-cLN6D(Q4q(BmJs`Rw)j~U?s$57 z|Da#aql0<4HZ2GQ@W`{y1E#|{xK(b2C2l)D`a8IJ6+952ny5Gq;K|n^4vf}6q8?UU zXCsc-m#vQKD5jS+Jz}&K_WZ8jqq7%E=p}Nj#%61vJgYX8c&LI7suHHN0kkE$xas+) z?vEQK1|c1c4(!?z(10c@*=VJ?ltGpjN4kIxHjvSSB%G8M+j92^dVfE@I8YEf~1UT{fbNoH|LW^!>*YH>k+UU8~RNxq#zdTI%}{Nw^AEiQ!e O$+MV@Hit4tGXVhULLN&1 delta 12 TcmX@cvw?dxY=})kOC&FGF=Ll}IkSPCHn!V?p9DaNrLnpO)811c zj4$!Ky-^~pD!9gXJJIqIy3>b0o7Tu!22F%aA^~k+(QxemyhZRqjt{(t^%1r{3TWEf WqD>oVl6aM8c`I>g4mx3X}6$i~+}t3y%N* delta 11 ScmdnaxrlQ^5X { expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); }); }); + + describe('getStatistics', () => { + it('should get correct number of person', async () => { + personMock.getById.mockResolvedValue(personStub.primaryPerson); + personMock.getStatistics.mockResolvedValue(statistics); + accessMock.person.hasOwnerAccess.mockResolvedValue(true); + await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 }); + expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + }); + + it('should require person.read permission', async () => { + personMock.getById.mockResolvedValue(personStub.primaryPerson); + accessMock.person.hasOwnerAccess.mockResolvedValue(false); + await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); + expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); + }); + }); }); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 26e80229d..dcf5dbb78 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -33,6 +33,7 @@ import { PeopleUpdateDto, PersonResponseDto, PersonSearchDto, + PersonStatisticsResponseDto, PersonUpdateDto, mapPerson, } from './person.dto'; @@ -84,6 +85,11 @@ export class PersonService { return this.findOrFail(id).then(mapPerson); } + async getStatistics(authUser: AuthUserDto, id: string): Promise { + await this.access.requirePermission(authUser, Permission.PERSON_READ, id); + return this.repository.getStatistics(id); + } + async getThumbnail(authUser: AuthUserDto, id: string): Promise { await this.access.requirePermission(authUser, Permission.PERSON_READ, id); const person = await this.repository.getById(id); diff --git a/server/src/domain/repositories/person.repository.ts b/server/src/domain/repositories/person.repository.ts index d39b4468b..2554a8a6f 100644 --- a/server/src/domain/repositories/person.repository.ts +++ b/server/src/domain/repositories/person.repository.ts @@ -1,4 +1,5 @@ import { AssetEntity, AssetFaceEntity, PersonEntity } from '@app/infra/entities'; + export const IPersonRepository = 'IPersonRepository'; export interface PersonSearchOptions { @@ -6,6 +7,10 @@ export interface PersonSearchOptions { withHidden: boolean; } +export interface PersonNameSearchOptions { + withHidden?: boolean; +} + export interface AssetFaceId { assetId: string; personId: string; @@ -16,13 +21,17 @@ export interface UpdateFacesData { newPersonId: string; } +export interface PersonStatistics { + assets: number; +} + export interface IPersonRepository { getAll(): Promise; getAllWithoutThumbnail(): Promise; getAllForUser(userId: string, options: PersonSearchOptions): Promise; getAllWithoutFaces(): Promise; getById(personId: string): Promise; - getByName(userId: string, personName: string): Promise; + getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise; getAssets(personId: string): Promise; prepareReassignFaces(data: UpdateFacesData): Promise; @@ -33,6 +42,8 @@ export interface IPersonRepository { delete(entity: PersonEntity): Promise; deleteAll(): Promise; + getStatistics(personId: string): Promise; + getAllFaces(): Promise; getFacesByIds(ids: AssetFaceId[]): Promise; getRandomFace(personId: string): Promise; diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index 0d6def96c..85d2b55f9 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -90,4 +90,9 @@ export class SearchPeopleDto { @IsString() @IsNotEmpty() name!: string; + + @IsBoolean() + @Transform(toBoolean) + @Optional() + withHidden?: boolean; } diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 445d6b89d..be88f29e6 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -159,8 +159,8 @@ export class SearchService { }; } - async searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise { - return await this.personRepository.getByName(authUser.id, dto.name); + searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise { + return this.personRepository.getByName(authUser.id, dto.name, { withHidden: dto.withHidden }); } async handleIndexAlbums() { diff --git a/server/src/immich/controllers/person.controller.ts b/server/src/immich/controllers/person.controller.ts index db4d378f3..e581fddb1 100644 --- a/server/src/immich/controllers/person.controller.ts +++ b/server/src/immich/controllers/person.controller.ts @@ -9,6 +9,7 @@ import { PersonResponseDto, PersonSearchDto, PersonService, + PersonStatisticsResponseDto, PersonUpdateDto, } from '@app/domain'; import { Body, Controller, Get, Param, Post, Put, Query, StreamableFile } from '@nestjs/common'; @@ -52,6 +53,14 @@ export class PersonController { return this.service.update(authUser, id, dto); } + @Get(':id/statistics') + getPersonStatistics( + @AuthUser() authUser: AuthUserDto, + @Param() { id }: UUIDParamDto, + ): Promise { + return this.service.getStatistics(authUser, id); + } + @Get(':id/thumbnail') @ApiOkResponse({ content: { diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts index d651b3380..12bd47605 100644 --- a/server/src/infra/repositories/person.repository.ts +++ b/server/src/infra/repositories/person.repository.ts @@ -1,4 +1,11 @@ -import { AssetFaceId, IPersonRepository, PersonSearchOptions, UpdateFacesData } from '@app/domain'; +import { + AssetFaceId, + IPersonRepository, + PersonNameSearchOptions, + PersonSearchOptions, + PersonStatistics, + UpdateFacesData, +} from '@app/domain'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities'; @@ -96,14 +103,37 @@ export class PersonRepository implements IPersonRepository { return this.personRepository.findOne({ where: { id: personId } }); } - getByName(userId: string, personName: string): Promise { - return this.personRepository + getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise { + const queryBuilder = this.personRepository .createQueryBuilder('person') .leftJoin('person.faces', 'face') .where('person.ownerId = :userId', { userId }) - .andWhere('LOWER(person.name) LIKE :name', { name: `${personName.toLowerCase()}%` }) - .limit(20) - .getMany(); + .andWhere('LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere', { + nameStart: `${personName.toLowerCase()}%`, + nameAnywhere: `% ${personName.toLowerCase()}%`, + }) + .groupBy('person.id') + .orderBy('COUNT(face.assetId)', 'DESC') + .limit(20); + + if (!withHidden) { + queryBuilder.andWhere('person.isHidden = false'); + } + return queryBuilder.getMany(); + } + + async getStatistics(personId: string): Promise { + return { + assets: await this.assetFaceRepository + .createQueryBuilder('face') + .leftJoin('face.asset', 'asset') + .where('face.personId = :personId', { personId }) + .andWhere('asset.isArchived = false') + .andWhere('asset.deletedAt IS NULL') + .andWhere('asset.livePhotoVideoId IS NULL') + .distinct(true) + .getCount(), + }; } getAssets(personId: string): Promise { diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index d942bafd6..90a15221d 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -16,6 +16,7 @@ export const newPersonRepositoryMock = (): jest.Mocked => { deleteAll: jest.fn(), delete: jest.fn(), + getStatistics: jest.fn(), getAllFaces: jest.fn(), getFacesByIds: jest.fn(), getRandomFace: jest.fn(), diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index fffb9914a..799eb9d38 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -2465,6 +2465,19 @@ export interface PersonResponseDto { */ 'thumbnailPath': string; } +/** + * + * @export + * @interface PersonStatisticsResponseDto + */ +export interface PersonStatisticsResponseDto { + /** + * + * @type {number} + * @memberof PersonStatisticsResponseDto + */ + 'assets': number; +} /** * * @export @@ -12010,6 +12023,48 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getPersonStatistics: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getPersonStatistics', 'id', id) + const localVarPath = `/person/{id}/statistics` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -12241,6 +12296,16 @@ export const PersonApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonAssets(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getPersonStatistics(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonStatistics(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id @@ -12320,6 +12385,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat getPersonAssets(requestParameters: PersonApiGetPersonAssetsRequest, options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.getPersonAssets(requestParameters.id, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {PersonApiGetPersonStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getPersonStatistics(requestParameters: PersonApiGetPersonStatisticsRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getPersonStatistics(requestParameters.id, options).then((request) => request(axios, basePath)); + }, /** * * @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters. @@ -12401,6 +12475,20 @@ export interface PersonApiGetPersonAssetsRequest { readonly id: string } +/** + * Request parameters for getPersonStatistics operation in PersonApi. + * @export + * @interface PersonApiGetPersonStatisticsRequest + */ +export interface PersonApiGetPersonStatisticsRequest { + /** + * + * @type {string} + * @memberof PersonApiGetPersonStatistics + */ + readonly id: string +} + /** * Request parameters for getPersonThumbnail operation in PersonApi. * @export @@ -12511,6 +12599,17 @@ export class PersonApi extends BaseAPI { return PersonApiFp(this.configuration).getPersonAssets(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {PersonApiGetPersonStatisticsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PersonApi + */ + public getPersonStatistics(requestParameters: PersonApiGetPersonStatisticsRequest, options?: AxiosRequestConfig) { + return PersonApiFp(this.configuration).getPersonStatistics(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters. @@ -12722,10 +12821,11 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio /** * * @param {string} name + * @param {boolean} [withHidden] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - searchPerson: async (name: string, options: AxiosRequestConfig = {}): Promise => { + searchPerson: async (name: string, withHidden?: boolean, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'name' is not null or undefined assertParamExists('searchPerson', 'name', name) const localVarPath = `/search/person`; @@ -12753,6 +12853,10 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio localVarQueryParameter['name'] = name; } + if (withHidden !== undefined) { + localVarQueryParameter['withHidden'] = withHidden; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -12811,11 +12915,12 @@ export const SearchApiFp = function(configuration?: Configuration) { /** * * @param {string} name + * @param {boolean} [withHidden] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async searchPerson(name: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, options); + async searchPerson(name: string, withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, withHidden, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, } @@ -12852,7 +12957,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat * @throws {RequiredError} */ searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.searchPerson(requestParameters.name, options).then((request) => request(axios, basePath)); + return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath)); }, }; }; @@ -12988,6 +13093,13 @@ export interface SearchApiSearchPersonRequest { * @memberof SearchApiSearchPerson */ readonly name: string + + /** + * + * @type {boolean} + * @memberof SearchApiSearchPerson + */ + readonly withHidden?: boolean } /** @@ -13026,7 +13138,7 @@ export class SearchApi extends BaseAPI { * @memberof SearchApi */ public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) { - return SearchApiFp(this.configuration).searchPerson(requestParameters.name, options).then((request) => request(this.axios, this.basePath)); + return SearchApiFp(this.configuration).searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath)); } } diff --git a/web/src/lib/components/faces-page/edit-name-input.svelte b/web/src/lib/components/faces-page/edit-name-input.svelte index 7b197b98f..10a78f2ae 100644 --- a/web/src/lib/components/faces-page/edit-name-input.svelte +++ b/web/src/lib/components/faces-page/edit-name-input.svelte @@ -11,12 +11,13 @@ const dispatch = createEventDispatcher<{ change: string; cancel: void; + input: void; }>();
dispatch('input')} /> diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 06e683727..9845d1f06 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -258,7 +258,7 @@ changeName(); return; } - const { data } = await api.searchApi.searchPerson({ name: personName }); + const { data } = await api.searchApi.searchPerson({ name: personName, withHidden: true }); // We check if another person has the same name as the name entered by the user diff --git a/web/src/routes/(user)/people/[personId]/+page.server.ts b/web/src/routes/(user)/people/[personId]/+page.server.ts index faa3b8031..d81f893ab 100644 --- a/web/src/routes/(user)/people/[personId]/+page.server.ts +++ b/web/src/routes/(user)/people/[personId]/+page.server.ts @@ -9,10 +9,12 @@ export const load = (async ({ locals, parent, params }) => { } const { data: person } = await locals.api.personApi.getPerson({ id: params.personId }); + const { data: statistics } = await locals.api.personApi.getPersonStatistics({ id: params.personId }); return { user, person, + statistics, meta: { title: person.name || 'Person', }, diff --git a/web/src/routes/(user)/people/[personId]/+page.svelte b/web/src/routes/(user)/people/[personId]/+page.svelte index dc1bf6398..198d34690 100644 --- a/web/src/routes/(user)/people/[personId]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/+page.svelte @@ -1,5 +1,5 @@