diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 405bcd1d5a..824dd38835 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -12139,6 +12139,51 @@ export const SearchApiAxiosParamCreator = 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} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchPerson: async (name: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists('searchPerson', 'name', name) + const localVarPath = `/search/person`; + // 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) + + if (name !== undefined) { + localVarQueryParameter['name'] = name; + } + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -12192,6 +12237,16 @@ export const SearchApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, isArchived, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, exifInfoProjectionType, smartInfoObjects, smartInfoTags, recent, motion, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} name + * @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); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, } }; @@ -12219,6 +12274,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {SearchApiSearchPersonRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.searchPerson(requestParameters.name, options).then((request) => request(axios, basePath)); + }, }; }; @@ -12341,6 +12405,20 @@ export interface SearchApiSearchRequest { readonly motion?: boolean } +/** + * Request parameters for searchPerson operation in SearchApi. + * @export + * @interface SearchApiSearchPersonRequest + */ +export interface SearchApiSearchPersonRequest { + /** + * + * @type {string} + * @memberof SearchApiSearchPerson + */ + readonly name: string +} + /** * SearchApi - object-oriented interface * @export @@ -12368,6 +12446,17 @@ export class SearchApi extends BaseAPI { public search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig) { return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath)); } + + /** + * + * @param {SearchApiSearchPersonRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SearchApi + */ + public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) { + return SearchApiFp(this.configuration).searchPerson(requestParameters.name, options).then((request) => request(this.axios, this.basePath)); + } } diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 8cf1cca47e..2865b1308a 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md index 5cc36956a0..6bb2c0e938 100644 Binary files a/mobile/openapi/doc/SearchApi.md and b/mobile/openapi/doc/SearchApi.md differ diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 9393b5c61d..870c5dcd3a 100644 Binary files a/mobile/openapi/lib/api/search_api.dart and b/mobile/openapi/lib/api/search_api.dart differ diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart index 8365eff076..4905513282 100644 Binary files a/mobile/openapi/test/search_api_test.dart and b/mobile/openapi/test/search_api_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index cb3d107fcb..5a5ecd9adf 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -3789,6 +3789,50 @@ ] } }, + "/search/person": { + "get": { + "operationId": "searchPerson", + "parameters": [ + { + "name": "name", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PersonResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Search" + ] + } + }, "/server-info": { "get": { "operationId": "getServerInfo", diff --git a/server/src/domain/repositories/person.repository.ts b/server/src/domain/repositories/person.repository.ts index 667d780e0a..d39b4468b1 100644 --- a/server/src/domain/repositories/person.repository.ts +++ b/server/src/domain/repositories/person.repository.ts @@ -22,6 +22,7 @@ export interface IPersonRepository { getAllForUser(userId: string, options: PersonSearchOptions): Promise; getAllWithoutFaces(): Promise; getById(personId: string): Promise; + getByName(userId: string, personName: string): Promise; getAssets(personId: string): Promise; prepareReassignFaces(data: UpdateFacesData): Promise; diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index 44d54f7a71..0d6def96cc 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -85,3 +85,9 @@ export class SearchDto { @Transform(toBoolean) motion?: boolean; } + +export class SearchPeopleDto { + @IsString() + @IsNotEmpty() + name!: string; +} diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 049e0fe00a..5100e1b4ac 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -5,6 +5,7 @@ import { AssetResponseDto, mapAsset } from '../asset'; import { AuthUserDto } from '../auth'; import { usePagination } from '../domain.util'; import { IAssetFaceJob, IBulkEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; +import { PersonResponseDto } from '../person/person.dto'; import { AssetFaceId, IAlbumRepository, @@ -21,7 +22,7 @@ import { SearchStrategy, } from '../repositories'; import { FeatureFlag, SystemConfigCore } from '../system-config'; -import { SearchDto } from './dto'; +import { SearchDto, SearchPeopleDto } from './dto'; import { SearchResponseDto } from './response-dto'; interface SyncQueue { @@ -158,6 +159,10 @@ export class SearchService { }; } + async searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise { + return await this.personRepository.getByName(authUser.id, dto.name); + } + async handleIndexAlbums() { if (!this.enabled) { return false; diff --git a/server/src/immich/controllers/search.controller.ts b/server/src/immich/controllers/search.controller.ts index 9351669251..ffa454cd50 100644 --- a/server/src/immich/controllers/search.controller.ts +++ b/server/src/immich/controllers/search.controller.ts @@ -1,4 +1,12 @@ -import { AuthUserDto, SearchDto, SearchExploreResponseDto, SearchResponseDto, SearchService } from '@app/domain'; +import { + AuthUserDto, + PersonResponseDto, + SearchDto, + SearchExploreResponseDto, + SearchPeopleDto, + SearchResponseDto, + SearchService, +} from '@app/domain'; import { Controller, Get, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthUser, Authenticated } from '../app.guard'; @@ -11,6 +19,11 @@ import { UseValidation } from '../app.utils'; export class SearchController { constructor(private service: SearchService) {} + @Get('person') + searchPerson(@AuthUser() authUser: AuthUserDto, @Query() dto: SearchPeopleDto): Promise { + return this.service.searchPerson(authUser, dto); + } + @Get() search(@AuthUser() authUser: AuthUserDto, @Query() dto: SearchDto): Promise { return this.service.search(authUser, dto); diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts index c444ee694f..bfd101c6bf 100644 --- a/server/src/infra/repositories/person.repository.ts +++ b/server/src/infra/repositories/person.repository.ts @@ -95,6 +95,16 @@ export class PersonRepository implements IPersonRepository { return this.personRepository.findOne({ where: { id: personId } }); } + getByName(userId: string, personName: string): Promise { + return 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(); + } + getAssets(personId: string): Promise { return this.assetRepository.find({ where: { diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 52a8e5d9a5..d942bafd63 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -9,6 +9,8 @@ export const newPersonRepositoryMock = (): jest.Mocked => { getAssets: jest.fn(), getAllWithoutFaces: jest.fn(), + getByName: jest.fn(), + create: jest.fn(), update: jest.fn(), deleteAll: jest.fn(), diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 405bcd1d5a..824dd38835 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -12139,6 +12139,51 @@ export const SearchApiAxiosParamCreator = 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} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchPerson: async (name: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists('searchPerson', 'name', name) + const localVarPath = `/search/person`; + // 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) + + if (name !== undefined) { + localVarQueryParameter['name'] = name; + } + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -12192,6 +12237,16 @@ export const SearchApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, isArchived, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, exifInfoProjectionType, smartInfoObjects, smartInfoTags, recent, motion, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} name + * @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); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, } }; @@ -12219,6 +12274,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {SearchApiSearchPersonRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.searchPerson(requestParameters.name, options).then((request) => request(axios, basePath)); + }, }; }; @@ -12341,6 +12405,20 @@ export interface SearchApiSearchRequest { readonly motion?: boolean } +/** + * Request parameters for searchPerson operation in SearchApi. + * @export + * @interface SearchApiSearchPersonRequest + */ +export interface SearchApiSearchPersonRequest { + /** + * + * @type {string} + * @memberof SearchApiSearchPerson + */ + readonly name: string +} + /** * SearchApi - object-oriented interface * @export @@ -12368,6 +12446,17 @@ export class SearchApi extends BaseAPI { public search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig) { return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath)); } + + /** + * + * @param {SearchApiSearchPersonRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SearchApi + */ + public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) { + return SearchApiFp(this.configuration).searchPerson(requestParameters.name, options).then((request) => request(this.axios, this.basePath)); + } } diff --git a/web/src/lib/components/faces-page/merge-face-selector.svelte b/web/src/lib/components/faces-page/merge-face-selector.svelte index 3fbfa9c7a2..070f143a33 100644 --- a/web/src/lib/components/faces-page/merge-face-selector.svelte +++ b/web/src/lib/components/faces-page/merge-face-selector.svelte @@ -1,5 +1,5 @@