1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

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 <jrasm91@gmail.com>
This commit is contained in:
martin 2023-10-24 17:53:49 +02:00 committed by GitHub
parent 1aae29a0b8
commit 3e3598fd92
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 467 additions and 74 deletions

View File

@ -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<RequestArgs> => {
// 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<PersonStatisticsResponseDto>> {
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<Array<AssetResponseDto>> {
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<PersonStatisticsResponseDto> {
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<RequestArgs> => {
searchPerson: async (name: string, withHidden?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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<Array<PersonResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, options);
async searchPerson(name: string, withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
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<Array<PersonResponseDto>> {
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));
}
}

View File

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

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -3685,6 +3685,48 @@
]
}
},
"/person/{id}/statistics": {
"get": {
"operationId": "getPersonStatistics",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonStatisticsResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Person"
]
}
},
"/person/{id}/thumbnail": {
"get": {
"operationId": "getPersonThumbnail",
@ -3947,6 +3989,14 @@
"schema": {
"type": "string"
}
},
{
"name": "withHidden",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
@ -7401,6 +7451,17 @@
],
"type": "object"
},
"PersonStatisticsResponseDto": {
"properties": {
"assets": {
"type": "integer"
}
},
"required": [
"assets"
],
"type": "object"
},
"PersonUpdateDto": {
"properties": {
"birthDate": {

View File

@ -73,6 +73,11 @@ export class PersonResponseDto {
isHidden!: boolean;
}
export class PersonStatisticsResponseDto {
@ApiProperty({ type: 'integer' })
assets!: number;
}
export class PeopleResponseDto {
@ApiProperty({ type: 'integer' })
total!: number;

View File

@ -42,6 +42,8 @@ const responseDto: PersonResponseDto = {
isHidden: false,
};
const statistics = { assets: 3 };
const croppedFace = Buffer.from('Cropped Face');
const detectFaceMock = {
@ -731,4 +733,21 @@ describe(PersonService.name, () => {
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
});
});
describe('getStatistics', () => {
it('should get correct number of person', async () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson);
personMock.getStatistics.mockResolvedValue(statistics);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 });
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
});
it('should require person.read permission', async () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson);
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
});
});
});

View File

@ -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<PersonStatisticsResponseDto> {
await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
return this.repository.getStatistics(id);
}
async getThumbnail(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {
await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
const person = await this.repository.getById(id);

View File

@ -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<PersonEntity[]>;
getAllWithoutThumbnail(): Promise<PersonEntity[]>;
getAllForUser(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>;
getAllWithoutFaces(): Promise<PersonEntity[]>;
getById(personId: string): Promise<PersonEntity | null>;
getByName(userId: string, personName: string): Promise<PersonEntity[]>;
getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>;
getAssets(personId: string): Promise<AssetEntity[]>;
prepareReassignFaces(data: UpdateFacesData): Promise<string[]>;
@ -33,6 +42,8 @@ export interface IPersonRepository {
delete(entity: PersonEntity): Promise<PersonEntity | null>;
deleteAll(): Promise<number>;
getStatistics(personId: string): Promise<PersonStatistics>;
getAllFaces(): Promise<AssetFaceEntity[]>;
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
getRandomFace(personId: string): Promise<AssetFaceEntity | null>;

View File

@ -90,4 +90,9 @@ export class SearchPeopleDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsBoolean()
@Transform(toBoolean)
@Optional()
withHidden?: boolean;
}

View File

@ -159,8 +159,8 @@ export class SearchService {
};
}
async searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return await this.personRepository.getByName(authUser.id, dto.name);
searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return this.personRepository.getByName(authUser.id, dto.name, { withHidden: dto.withHidden });
}
async handleIndexAlbums() {

View File

@ -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<PersonStatisticsResponseDto> {
return this.service.getStatistics(authUser, id);
}
@Get(':id/thumbnail')
@ApiOkResponse({
content: {

View File

@ -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<PersonEntity[]> {
return this.personRepository
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise<PersonEntity[]> {
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<PersonStatistics> {
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<AssetEntity[]> {

View File

@ -16,6 +16,7 @@ export const newPersonRepositoryMock = (): jest.Mocked<IPersonRepository> => {
deleteAll: jest.fn(),
delete: jest.fn(),
getStatistics: jest.fn(),
getAllFaces: jest.fn(),
getFacesByIds: jest.fn(),
getRandomFace: jest.fn(),

View File

@ -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<RequestArgs> => {
// 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<PersonStatisticsResponseDto>> {
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<Array<AssetResponseDto>> {
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<PersonStatisticsResponseDto> {
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<RequestArgs> => {
searchPerson: async (name: string, withHidden?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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<Array<PersonResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, options);
async searchPerson(name: string, withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
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<Array<PersonResponseDto>> {
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));
}
}

View File

@ -11,12 +11,13 @@
const dispatch = createEventDispatcher<{
change: string;
cancel: void;
input: void;
}>();
</script>
<div
class="flex w-full place-items-center {suggestedPeople
? 'rounded-t-lg border-b dark:border-immich-dark-gray'
class="flex w-full h-14 place-items-center {suggestedPeople
? 'rounded-t-lg dark:border-immich-dark-gray'
: 'rounded-lg'} bg-gray-100 p-2 dark:bg-gray-700"
>
<ImageThumbnail
@ -39,6 +40,7 @@
type="text"
placeholder="New name or nickname"
bind:value={name}
on:input={() => dispatch('input')}
/>
<Button size="sm" type="submit">Done</Button>
</form>

View File

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

View File

@ -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',
},

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { afterNavigate, goto, invalidateAll } from '$app/navigation';
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
@ -35,11 +35,11 @@
import type { PageData } from './$types';
import { clickOutside } from '$lib/utils/click-outside';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { browser } from '$app/environment';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
export let data: PageData;
let numberOfAssets = data.statistics.assets;
let { isViewing: showAssetViewer } = assetViewingStore;
enum ViewMode {
@ -63,7 +63,7 @@
let isEditingName = false;
let previousRoute: string = AppRoute.EXPLORE;
let previousPersonId: string = data.person.id;
let people: PersonResponseDto[];
let people: PersonResponseDto[] = [];
let personMerge1: PersonResponseDto;
let personMerge2: PersonResponseDto;
let potentialMergePeople: PersonResponseDto[] = [];
@ -84,34 +84,27 @@
* or if the new search word starts with another word / letter
**/
let searchWord: string;
let maxPeople = false;
let isSearchingPeople = false;
const searchPeople = async () => {
isSearchingPeople = true;
people = [];
if ((people.length < 20 && name.startsWith(searchWord)) || name === '') {
return;
}
const timeout = setTimeout(() => (isSearchingPeople = true), 300);
try {
const { data } = await api.searchApi.searchPerson({ name });
people = data;
searchWord = name;
if (data.length < 20) {
maxPeople = false;
} else {
maxPeople = true;
}
} catch (error) {
people = [];
handleError(error, "Can't search people");
} finally {
clearTimeout(timeout);
}
isSearchingPeople = false;
};
$: {
if (name !== '' && browser) {
if (maxPeople === true || (!name.startsWith(searchWord) && maxPeople === false)) searchPeople();
}
}
$: isAllArchive = Array.from($selectedAssets).every((asset) => asset.isArchived);
$: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite);
$: $onPersonThumbnail === data.person.id &&
@ -122,10 +115,13 @@
suggestedPeople = !name
? []
: people
.filter(
(person: PersonResponseDto) =>
person.name.toLowerCase().startsWith(name.toLowerCase()) && person.id !== data.person.id,
)
.filter((person: PersonResponseDto) => {
const nameParts = person.name.split(' ');
return (
nameParts.some((splitName) => splitName.toLowerCase().startsWith(name.toLowerCase())) &&
person.id !== data.person.id
);
})
.slice(0, 5);
}
}
@ -204,6 +200,17 @@
viewMode = ViewMode.VIEW_ASSETS;
};
const updateAssetCount = async () => {
try {
const { data: statistics } = await api.personApi.getPersonStatistics({
id: data.person.id,
});
numberOfAssets = statistics.assets;
} catch (error) {
handleError(error, "Can't update the asset count");
}
};
const handleMergeSameFace = async (response: [PersonResponseDto, PersonResponseDto]) => {
const [personToMerge, personToBeMergedIn] = response;
viewMode = ViewMode.VIEW_ASSETS;
@ -219,8 +226,8 @@
});
people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id);
if (personToBeMergedIn.name != personName && data.person.id === personToBeMergedIn.id) {
changeName();
invalidateAll();
await updateAssetCount();
refreshAssetGrid = !refreshAssetGrid;
return;
}
goto(`${AppRoute.PEOPLE}/${personToBeMergedIn.id}`, { replaceState: true });
@ -232,6 +239,7 @@
const handleSuggestPeople = (person: PersonResponseDto) => {
isEditingName = false;
potentialMergePeople = [];
personName = person.name;
personMerge1 = data.person;
personMerge2 = person;
viewMode = ViewMode.SUGGEST_MERGE;
@ -266,6 +274,7 @@
};
const handleNameChange = async (name: string) => {
isEditingName = false;
potentialMergePeople = [];
personName = name;
@ -277,7 +286,7 @@
return;
}
const result = await api.searchApi.searchPerson({ name: personName });
const result = await api.searchApi.searchPerson({ name: personName, withHidden: true });
const existingPerson = result.data.find(
(person: PersonResponseDto) =>
@ -413,16 +422,22 @@
on:outclick={handleCancelEditName}
on:escape={handleCancelEditName}
>
<section class="flex w-96 place-items-center border-black">
<section class="flex w-64 sm:w-96 place-items-center border-black">
{#if isEditingName}
<EditNameInput
person={data.person}
suggestedPeople={suggestedPeople.length > 0 || isSearchingPeople}
bind:name
on:change={(event) => handleNameChange(event.detail)}
on:input={searchPeople}
/>
{:else}
<button on:click={() => (viewMode = ViewMode.VIEW_ASSETS)}>
<div class="relative">
<button
class="flex items-center justify-center"
title="Edit name"
on:click={() => (isEditingName = true)}
>
<ImageThumbnail
circle
shadow
@ -431,24 +446,25 @@
widthStyle="3.375rem"
heightStyle="3.375rem"
/>
</button>
<button
title="Edit name"
class="px-4 text-immich-primary dark:text-immich-dark-primary"
on:click={() => (isEditingName = true)}
<div
class="flex flex-col justify-center text-left px-4 h-14 text-immich-primary dark:text-immich-dark-primary"
>
{#if data.person.name}
<p class="py-2 font-medium">{data.person.name}</p>
<p class="w-40 sm:w-72 font-medium truncate">{data.person.name}</p>
<p class="absolute w-fit text-sm text-gray-500 dark:text-immich-gray bottom-0">
{`${numberOfAssets} asset${numberOfAssets > 1 ? 's' : ''}`}
</p>
{:else}
<p class="w-fit font-medium">Add a name</p>
<p class="font-medium">Add a name</p>
<p class="text-sm text-gray-500 dark:text-immich-gray">Find them fast by name with search</p>
{/if}
</div>
</button>
</div>
{/if}
</section>
{#if isEditingName}
<div class="absolute z-[999] w-96">
<div class="absolute z-[999] w-64 sm:w-96">
{#if isSearchingPeople}
<div
class="flex rounded-b-lg dark:border-immich-dark-gray place-items-center bg-gray-100 p-2 dark:bg-gray-700"
@ -460,9 +476,8 @@
{:else}
{#each suggestedPeople as person, index (person.id)}
<div
class="flex {index === suggestedPeople.length - 1
? 'rounded-b-lg'
: 'border-b dark:border-immich-dark-gray'} place-items-center bg-gray-100 p-2 dark:bg-gray-700"
class="flex border-t dark:border-immich-dark-gray place-items-center bg-gray-100 p-2 dark:bg-gray-700 {index ===
suggestedPeople.length - 1 && 'rounded-b-lg'}"
>
<button class="flex w-full place-items-center" on:click={() => handleSuggestPeople(person)}>
<ImageThumbnail