From 7702560b12dc8f975e71aed10918daefda6b2ede Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Tue, 5 Dec 2023 16:43:15 +0100 Subject: [PATCH] feat(web): re-assign person faces (2) (#4949) * feat: unassign person faces * multiple improvements * chore: regenerate api * feat: improve face interactions in photos * fix: tests * fix: tests * optimize * fix: wrong assignment on complex-multiple re-assignments * fix: thumbnails with large photos * fix: complex reassign * fix: don't send people with faces * fix: person thumbnail generation * chore: regenerate api * add tess * feat: face box even when zoomed * fix: change feature photo * feat: make the blue icon hoverable * chore: regenerate api * feat: use websocket * fix: loading spinner when clicking on the done button * fix: use the svelte way * fix: tests * simplify * fix: unused vars * fix: remove unused code * fix: add migration * chore: regenerate api * ci: add unit tests * chore: regenerate api * feat: if a new person is created for a face and the server takes more than 15 seconds to generate the person thumbnail, don't wait for it * reorganize * chore: regenerate api * feat: global edit * pr feedback * pr feedback * simplify * revert test * fix: face generation * fix: tests * fix: face generation * fix merge * feat: search names in unmerge face selector modal * fix: merge face selector * simplify feature photo generation * fix: change endpoint * pr feedback * chore: fix merge * chore: fix merge * fix: tests * fix: edit & hide buttons * fix: tests * feat: show if person is hidden * feat: rename face to person * feat: split in new panel * copy-paste-error * pr feedback * fix: feature photo * do not leak faces * fix: unmerge modal * fix: merge modal event * feat(server): remove duplicates * fix: title for image thumbnails * fix: disable side panel when there's no face until next PR --------- Co-authored-by: Alex Tran --- cli/src/api/open-api/api.ts | 588 +++++++++++++++++- mobile/openapi/.openapi-generator/FILES | 21 + mobile/openapi/README.md | Bin 23369 -> 24067 bytes mobile/openapi/doc/AssetFaceResponseDto.md | Bin 0 -> 676 bytes mobile/openapi/doc/AssetFaceUpdateDto.md | Bin 0 -> 478 bytes mobile/openapi/doc/AssetFaceUpdateItem.md | Bin 0 -> 448 bytes .../doc/AssetFaceWithoutPersonResponseDto.md | Bin 0 -> 624 bytes mobile/openapi/doc/AssetResponseDto.md | Bin 1916 -> 1934 bytes mobile/openapi/doc/FaceApi.md | Bin 0 -> 4324 bytes mobile/openapi/doc/FaceDto.md | Bin 0 -> 399 bytes mobile/openapi/doc/PersonApi.md | Bin 16783 -> 20853 bytes .../openapi/doc/PersonWithFacesResponseDto.md | Bin 0 -> 686 bytes mobile/openapi/lib/api.dart | Bin 7537 -> 7825 bytes mobile/openapi/lib/api/face_api.dart | Bin 0 -> 3749 bytes mobile/openapi/lib/api/person_api.dart | Bin 13734 -> 17005 bytes mobile/openapi/lib/api_client.dart | Bin 22200 -> 22758 bytes .../lib/model/asset_face_response_dto.dart | Bin 0 -> 4909 bytes .../lib/model/asset_face_update_dto.dart | Bin 0 -> 2832 bytes .../lib/model/asset_face_update_item.dart | Bin 0 -> 3104 bytes ...sset_face_without_person_response_dto.dart | Bin 0 -> 4800 bytes .../openapi/lib/model/asset_response_dto.dart | Bin 12875 -> 12893 bytes mobile/openapi/lib/model/face_dto.dart | Bin 0 -> 2580 bytes .../model/person_with_faces_response_dto.dart | Bin 0 -> 4344 bytes .../test/asset_face_response_dto_test.dart | Bin 0 -> 1318 bytes .../test/asset_face_update_dto_test.dart | Bin 0 -> 615 bytes .../test/asset_face_update_item_test.dart | Bin 0 -> 680 bytes ...face_without_person_response_dto_test.dart | Bin 0 -> 1249 bytes .../openapi/test/asset_response_dto_test.dart | Bin 3970 -> 3979 bytes mobile/openapi/test/face_api_test.dart | Bin 0 -> 729 bytes mobile/openapi/test/face_dto_test.dart | Bin 0 -> 533 bytes mobile/openapi/test/person_api_test.dart | Bin 1606 -> 1896 bytes .../person_with_faces_response_dto_test.dart | Bin 0 -> 1152 bytes server/immich-openapi-specs.json | 344 +++++++++- server/src/domain/access/access.core.ts | 8 + .../asset/response-dto/asset-response.dto.ts | 35 +- server/src/domain/job/job.service.ts | 2 +- server/src/domain/person/person.dto.ts | 63 +- .../src/domain/person/person.service.spec.ts | 186 ++++-- server/src/domain/person/person.service.ts | 107 +++- .../domain/repositories/access.repository.ts | 1 + .../domain/repositories/person.repository.ts | 6 +- server/src/immich/app.module.ts | 2 + .../src/immich/controllers/face.controller.ts | 28 + server/src/immich/controllers/index.ts | 1 + .../immich/controllers/person.controller.ts | 15 + server/src/infra/entities/person.entity.ts | 5 +- .../1699727044012-EditFaceAssetForeignKey.ts | 18 + .../infra/repositories/access.repository.ts | 18 + .../infra/repositories/person.repository.ts | 42 ++ server/src/infra/sql/person.repository.sql | 145 ++++- server/test/fixtures/person.stub.ts | 7 +- .../repositories/access.repository.mock.ts | 1 + .../repositories/person.repository.mock.ts | 6 +- web/src/api/api.ts | 3 + web/src/api/open-api/api.ts | 588 +++++++++++++++++- .../asset-viewer/asset-viewer.svelte | 2 +- .../asset-viewer/detail-panel.svelte | 141 +++-- .../asset-viewer/photo-viewer.svelte | 26 +- .../assets/thumbnail/image-thumbnail.svelte | 2 +- web/src/lib/components/elements/icon.svelte | 2 +- .../faces-page/assign-face-side-panel.svelte | 246 ++++++++ .../faces-page/merge-face-selector.svelte | 101 +-- .../faces-page/merge-suggestion-modal.svelte | 4 +- .../components/faces-page/people-card.svelte | 10 +- .../components/faces-page/people-list.svelte | 106 ++++ .../faces-page/person-side-panel.svelte | 278 +++++++++ .../components/faces-page/show-hide.svelte | 4 +- .../faces-page/unmerge-face-selector.svelte | 190 ++++++ web/src/lib/stores/assets.store.ts | 2 + web/src/lib/stores/people.store.ts | 12 + web/src/lib/utils/people-utils.ts | 71 +++ web/src/lib/utils/person.ts | 4 + web/src/routes/(user)/people/+page.svelte | 20 +- .../(user)/people/[personId]/+page.svelte | 57 +- 74 files changed, 3239 insertions(+), 279 deletions(-) create mode 100644 mobile/openapi/doc/AssetFaceResponseDto.md create mode 100644 mobile/openapi/doc/AssetFaceUpdateDto.md create mode 100644 mobile/openapi/doc/AssetFaceUpdateItem.md create mode 100644 mobile/openapi/doc/AssetFaceWithoutPersonResponseDto.md create mode 100644 mobile/openapi/doc/FaceApi.md create mode 100644 mobile/openapi/doc/FaceDto.md create mode 100644 mobile/openapi/doc/PersonWithFacesResponseDto.md create mode 100644 mobile/openapi/lib/api/face_api.dart create mode 100644 mobile/openapi/lib/model/asset_face_response_dto.dart create mode 100644 mobile/openapi/lib/model/asset_face_update_dto.dart create mode 100644 mobile/openapi/lib/model/asset_face_update_item.dart create mode 100644 mobile/openapi/lib/model/asset_face_without_person_response_dto.dart create mode 100644 mobile/openapi/lib/model/face_dto.dart create mode 100644 mobile/openapi/lib/model/person_with_faces_response_dto.dart create mode 100644 mobile/openapi/test/asset_face_response_dto_test.dart create mode 100644 mobile/openapi/test/asset_face_update_dto_test.dart create mode 100644 mobile/openapi/test/asset_face_update_item_test.dart create mode 100644 mobile/openapi/test/asset_face_without_person_response_dto_test.dart create mode 100644 mobile/openapi/test/face_api_test.dart create mode 100644 mobile/openapi/test/face_dto_test.dart create mode 100644 mobile/openapi/test/person_with_faces_response_dto_test.dart create mode 100644 server/src/immich/controllers/face.controller.ts create mode 100644 server/src/infra/migrations/1699727044012-EditFaceAssetForeignKey.ts create mode 100644 web/src/lib/components/faces-page/assign-face-side-panel.svelte create mode 100644 web/src/lib/components/faces-page/people-list.svelte create mode 100644 web/src/lib/components/faces-page/person-side-panel.svelte create mode 100644 web/src/lib/components/faces-page/unmerge-face-selector.svelte create mode 100644 web/src/lib/stores/people.store.ts create mode 100644 web/src/lib/utils/people-utils.ts diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 54cc921499..f8b8188ac9 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -586,6 +586,142 @@ export const AssetBulkUploadCheckResultReasonEnum = { export type AssetBulkUploadCheckResultReasonEnum = typeof AssetBulkUploadCheckResultReasonEnum[keyof typeof AssetBulkUploadCheckResultReasonEnum]; +/** + * + * @export + * @interface AssetFaceResponseDto + */ +export interface AssetFaceResponseDto { + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'boundingBoxX1': number; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'boundingBoxX2': number; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'boundingBoxY1': number; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'boundingBoxY2': number; + /** + * + * @type {string} + * @memberof AssetFaceResponseDto + */ + 'id': string; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'imageHeight': number; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'imageWidth': number; + /** + * + * @type {PersonResponseDto} + * @memberof AssetFaceResponseDto + */ + 'person': PersonResponseDto | null; +} +/** + * + * @export + * @interface AssetFaceUpdateDto + */ +export interface AssetFaceUpdateDto { + /** + * + * @type {Array} + * @memberof AssetFaceUpdateDto + */ + 'data': Array; +} +/** + * + * @export + * @interface AssetFaceUpdateItem + */ +export interface AssetFaceUpdateItem { + /** + * + * @type {string} + * @memberof AssetFaceUpdateItem + */ + 'assetId': string; + /** + * + * @type {string} + * @memberof AssetFaceUpdateItem + */ + 'personId': string; +} +/** + * + * @export + * @interface AssetFaceWithoutPersonResponseDto + */ +export interface AssetFaceWithoutPersonResponseDto { + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'boundingBoxX1': number; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'boundingBoxX2': number; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'boundingBoxY1': number; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'boundingBoxY2': number; + /** + * + * @type {string} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'id': string; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'imageHeight': number; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'imageWidth': number; +} /** * * @export @@ -842,10 +978,10 @@ export interface AssetResponseDto { 'ownerId': string; /** * - * @type {Array} + * @type {Array} * @memberof AssetResponseDto */ - 'people'?: Array; + 'people'?: Array; /** * * @type {boolean} @@ -1672,6 +1808,19 @@ export interface ExifResponseDto { */ 'timeZone'?: string | null; } +/** + * + * @export + * @interface FaceDto + */ +export interface FaceDto { + /** + * + * @type {string} + * @memberof FaceDto + */ + 'id': string; +} /** * * @export @@ -2564,6 +2713,49 @@ export interface PersonUpdateDto { */ 'name'?: string; } +/** + * + * @export + * @interface PersonWithFacesResponseDto + */ +export interface PersonWithFacesResponseDto { + /** + * + * @type {string} + * @memberof PersonWithFacesResponseDto + */ + 'birthDate': string | null; + /** + * + * @type {Array} + * @memberof PersonWithFacesResponseDto + */ + 'faces': Array; + /** + * + * @type {string} + * @memberof PersonWithFacesResponseDto + */ + 'id': string; + /** + * + * @type {boolean} + * @memberof PersonWithFacesResponseDto + */ + 'isHidden': boolean; + /** + * + * @type {string} + * @memberof PersonWithFacesResponseDto + */ + 'name': string; + /** + * + * @type {string} + * @memberof PersonWithFacesResponseDto + */ + 'thumbnailPath': string; +} /** * * @export @@ -11349,6 +11541,233 @@ export class AuthenticationApi extends BaseAPI { } +/** + * FaceApi - axios parameter creator + * @export + */ +export const FaceApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getFaces: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getFaces', 'id', id) + const localVarPath = `/face`; + // 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 (id !== undefined) { + localVarQueryParameter['id'] = id; + } + + + + 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 {FaceDto} faceDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reassignFacesById: async (id: string, faceDto: FaceDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('reassignFacesById', 'id', id) + // verify required parameter 'faceDto' is not null or undefined + assertParamExists('reassignFacesById', 'faceDto', faceDto) + const localVarPath = `/face/{id}` + .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: 'PUT', ...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) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(faceDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * FaceApi - functional programming interface + * @export + */ +export const FaceApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = FaceApiAxiosParamCreator(configuration) + return { + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getFaces(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getFaces(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {FaceDto} faceDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async reassignFacesById(id: string, faceDto: FaceDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFacesById(id, faceDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * FaceApi - factory interface + * @export + */ +export const FaceApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = FaceApiFp(configuration) + return { + /** + * + * @param {FaceApiGetFacesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getFaces(requestParameters: FaceApiGetFacesRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getFaces(requestParameters.id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {FaceApiReassignFacesByIdRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for getFaces operation in FaceApi. + * @export + * @interface FaceApiGetFacesRequest + */ +export interface FaceApiGetFacesRequest { + /** + * + * @type {string} + * @memberof FaceApiGetFaces + */ + readonly id: string +} + +/** + * Request parameters for reassignFacesById operation in FaceApi. + * @export + * @interface FaceApiReassignFacesByIdRequest + */ +export interface FaceApiReassignFacesByIdRequest { + /** + * + * @type {string} + * @memberof FaceApiReassignFacesById + */ + readonly id: string + + /** + * + * @type {FaceDto} + * @memberof FaceApiReassignFacesById + */ + readonly faceDto: FaceDto +} + +/** + * FaceApi - object-oriented interface + * @export + * @class FaceApi + * @extends {BaseAPI} + */ +export class FaceApi extends BaseAPI { + /** + * + * @param {FaceApiGetFacesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof FaceApi + */ + public getFaces(requestParameters: FaceApiGetFacesRequest, options?: AxiosRequestConfig) { + return FaceApiFp(this.configuration).getFaces(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {FaceApiReassignFacesByIdRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof FaceApi + */ + public reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig) { + return FaceApiFp(this.configuration).reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * JobApi - axios parameter creator * @export @@ -13180,6 +13599,44 @@ export class PartnerApi extends BaseAPI { */ export const PersonApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createPerson: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/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: 'POST', ...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}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {boolean} [withHidden] @@ -13439,6 +13896,54 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio options: localVarRequestOptions, }; }, + /** + * + * @param {string} id + * @param {AssetFaceUpdateDto} assetFaceUpdateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reassignFaces: async (id: string, assetFaceUpdateDto: AssetFaceUpdateDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('reassignFaces', 'id', id) + // verify required parameter 'assetFaceUpdateDto' is not null or undefined + assertParamExists('reassignFaces', 'assetFaceUpdateDto', assetFaceUpdateDto) + const localVarPath = `/person/{id}/reassign` + .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: 'PUT', ...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) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(assetFaceUpdateDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {PeopleUpdateDto} peopleUpdateDto @@ -13541,6 +14046,15 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio export const PersonApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = PersonApiAxiosParamCreator(configuration) return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createPerson(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createPerson(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {boolean} [withHidden] @@ -13602,6 +14116,17 @@ export const PersonApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id + * @param {AssetFaceUpdateDto} assetFaceUpdateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async reassignFaces(id: string, assetFaceUpdateDto: AssetFaceUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFaces(id, assetFaceUpdateDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {PeopleUpdateDto} peopleUpdateDto @@ -13633,6 +14158,14 @@ export const PersonApiFp = function(configuration?: Configuration) { export const PersonApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = PersonApiFp(configuration) return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createPerson(options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.createPerson(options).then((request) => request(axios, basePath)); + }, /** * * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters. @@ -13687,6 +14220,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {PersonApiReassignFacesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reassignFaces(requestParameters: PersonApiReassignFacesRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.reassignFaces(requestParameters.id, requestParameters.assetFaceUpdateDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters. @@ -13799,6 +14341,27 @@ export interface PersonApiMergePersonRequest { readonly mergePersonDto: MergePersonDto } +/** + * Request parameters for reassignFaces operation in PersonApi. + * @export + * @interface PersonApiReassignFacesRequest + */ +export interface PersonApiReassignFacesRequest { + /** + * + * @type {string} + * @memberof PersonApiReassignFaces + */ + readonly id: string + + /** + * + * @type {AssetFaceUpdateDto} + * @memberof PersonApiReassignFaces + */ + readonly assetFaceUpdateDto: AssetFaceUpdateDto +} + /** * Request parameters for updatePeople operation in PersonApi. * @export @@ -13841,6 +14404,16 @@ export interface PersonApiUpdatePersonRequest { * @extends {BaseAPI} */ export class PersonApi extends BaseAPI { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PersonApi + */ + public createPerson(options?: AxiosRequestConfig) { + return PersonApiFp(this.configuration).createPerson(options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters. @@ -13907,6 +14480,17 @@ export class PersonApi extends BaseAPI { return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {PersonApiReassignFacesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PersonApi + */ + public reassignFaces(requestParameters: PersonApiReassignFacesRequest, options?: AxiosRequestConfig) { + return PersonApiFp(this.configuration).reassignFaces(requestParameters.id, requestParameters.assetFaceUpdateDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters. diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index f54b788a4e..1d72f22499 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -24,6 +24,10 @@ doc/AssetBulkUploadCheckDto.md doc/AssetBulkUploadCheckItem.md doc/AssetBulkUploadCheckResponseDto.md doc/AssetBulkUploadCheckResult.md +doc/AssetFaceResponseDto.md +doc/AssetFaceUpdateDto.md +doc/AssetFaceUpdateItem.md +doc/AssetFaceWithoutPersonResponseDto.md doc/AssetFileUploadResponseDto.md doc/AssetIdsDto.md doc/AssetIdsResponseDto.md @@ -60,6 +64,8 @@ doc/DownloadInfoDto.md doc/DownloadResponseDto.md doc/EntityType.md doc/ExifResponseDto.md +doc/FaceApi.md +doc/FaceDto.md doc/FileChecksumDto.md doc/FileChecksumResponseDto.md doc/FileReportDto.md @@ -100,6 +106,7 @@ doc/PersonApi.md doc/PersonResponseDto.md doc/PersonStatisticsResponseDto.md doc/PersonUpdateDto.md +doc/PersonWithFacesResponseDto.md doc/QueueStatusDto.md doc/ReactionLevel.md doc/ReactionType.md @@ -177,6 +184,7 @@ lib/api/api_key_api.dart lib/api/asset_api.dart lib/api/audit_api.dart lib/api/authentication_api.dart +lib/api/face_api.dart lib/api/job_api.dart lib/api/library_api.dart lib/api/o_auth_api.dart @@ -213,6 +221,10 @@ lib/model/asset_bulk_upload_check_dto.dart lib/model/asset_bulk_upload_check_item.dart lib/model/asset_bulk_upload_check_response_dto.dart lib/model/asset_bulk_upload_check_result.dart +lib/model/asset_face_response_dto.dart +lib/model/asset_face_update_dto.dart +lib/model/asset_face_update_item.dart +lib/model/asset_face_without_person_response_dto.dart lib/model/asset_file_upload_response_dto.dart lib/model/asset_ids_dto.dart lib/model/asset_ids_response_dto.dart @@ -247,6 +259,7 @@ lib/model/download_info_dto.dart lib/model/download_response_dto.dart lib/model/entity_type.dart lib/model/exif_response_dto.dart +lib/model/face_dto.dart lib/model/file_checksum_dto.dart lib/model/file_checksum_response_dto.dart lib/model/file_report_dto.dart @@ -282,6 +295,7 @@ 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/person_with_faces_response_dto.dart lib/model/queue_status_dto.dart lib/model/reaction_level.dart lib/model/reaction_type.dart @@ -367,6 +381,10 @@ test/asset_bulk_upload_check_dto_test.dart test/asset_bulk_upload_check_item_test.dart test/asset_bulk_upload_check_response_dto_test.dart test/asset_bulk_upload_check_result_test.dart +test/asset_face_response_dto_test.dart +test/asset_face_update_dto_test.dart +test/asset_face_update_item_test.dart +test/asset_face_without_person_response_dto_test.dart test/asset_file_upload_response_dto_test.dart test/asset_ids_dto_test.dart test/asset_ids_response_dto_test.dart @@ -403,6 +421,8 @@ test/download_info_dto_test.dart test/download_response_dto_test.dart test/entity_type_test.dart test/exif_response_dto_test.dart +test/face_api_test.dart +test/face_dto_test.dart test/file_checksum_dto_test.dart test/file_checksum_response_dto_test.dart test/file_report_dto_test.dart @@ -443,6 +463,7 @@ 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/person_with_faces_response_dto_test.dart test/queue_status_dto_test.dart test/reaction_level_test.dart test/reaction_type_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 42680e67975dad7ab5b4d21b0a0852f113568eee..7eb7f7f56a417e2d672e5979b8dd9a5d31a55c9b 100644 GIT binary patch delta 528 zcmX@Pjj?$Tk*1=5KnlhD-Q2Gc!NiMV+vIl|VtOzU zu*oQ<7Niyx=jVaV)rYB?%%~+g*;bol^Ls4;QD&f%Ctq|F<|rb%ke^WaO8Y1c2Qdlv-RcIoyX& z3~nfjG{||I|Jhft2>>0Aa2zn?H)ptYv++R;0cin6#^gR9NlqB27^qid^JkwhJ^+Z? BxdH$H delta 39 vcmZqP!+3HV+5n8bv$3r zKUqVYlf&^#Mszu~P->P@%Q~f&-9N#qF+IL~y1!T~hU}yyl#}GY+}cRXWw)yO^YLIb v88f#qSnnEeq?0q{8-_Cff!LEj)q~mHKWq6%l;Dg<_TiVrzTr{xo)F?2p9ju7 literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/AssetFaceUpdateDto.md b/mobile/openapi/doc/AssetFaceUpdateDto.md new file mode 100644 index 0000000000000000000000000000000000000000..eb4e4d3872d3c1fee8f9d6c6be41eb65a6e91bd7 GIT binary patch literal 478 zcma)2O-lnY5WVMD4D6xZK(==kp=B!++EPSM%fg1uRD-*jkW3GPKi*_5Sc{;!gn9Em zm{$OKblTf$B!fe>j1l=k_xS9MW~`ZnC<@q;Ho&h46AQfV-}DQLuIr2rEbQnhFwD;Q z&DnF2eKTQpo2ir2A*5wsi&5>Ju#y(ijmRy?l#EGFUUIH&BrT&_Ql<5L3 zWg<3GZf)edtLrw5oA>6; z%xi!WMw@&a$llc4a4AUX5~Yo%rHCE zFHfe6YFq`gt16wGwpqx`9*f%8fbb3vZ>fGuEMxYGJw-tqVo{j0C&e>l%wJe(ZR<6? z(^|Az-*fiyFag0r%N~W|Z@k*=uH{T%f_2RXj=u1If6=y!yH&SbuiD@mN8@Y2$hNL+ io+z6spfZQA?rz)vvsdCW7+oSC`J2V(z#qfsN~sU`IEi5Z literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/AssetFaceWithoutPersonResponseDto.md b/mobile/openapi/doc/AssetFaceWithoutPersonResponseDto.md new file mode 100644 index 0000000000000000000000000000000000000000..5d421dbeffa696a69f94ce5fe3049cb3cf2abec4 GIT binary patch literal 624 zcma)3-%G$>*m*l&A zKaRUdk_DqIrVVM>r&I5N?^OwFdFP5qS`7K|*Y*XuYAia18Y-7A0-#?2+h=Ay^uya(#u`k7s5XONFg7~=zA zRUf-L?XUY^qvrZdb7H%lg+)jvxoCGO_vm@IyGql^YCg>t^R&^Crt;8$Vbu;cc_){2 fEA(UetDfeM|I~(sCSbIsFvdR?$H0^Du@K@1TFbql literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/AssetResponseDto.md b/mobile/openapi/doc/AssetResponseDto.md index 8c4d1db4a74fe02e8f0cc91bbf7aee4cadd16ee6..7c79f741837b7cd7238bd832e3b9dcfeeaf66679 100644 GIT binary patch delta 61 zcmeyv*T=u1mPI2xvn0bUF*&t3D7CmCKd(5|r6k`@ODk3*AhoDCKMyWFc|N1UQwWv5hZ}NObg~_v7B{tt>*~kO{Us(^> diff --git a/mobile/openapi/doc/FaceApi.md b/mobile/openapi/doc/FaceApi.md new file mode 100644 index 0000000000000000000000000000000000000000..84793a5c908805a15a710e737d09ae1683d13536 GIT binary patch literal 4324 zcmeHK?@t>?5dD6C#YnE?^NBk{5>+WANCun)5x_F$Lqrjr@5W}!dG~s^Zg2_zd*9ri z!I&l@Z7QWLmXLk3v-9Ka?0ZLpOwKyaRV06J;)FZ7^1Mca-kUfjd%@J{?NS~mm+_dr z^YimGF07{}xhX6)Yx{Z~q%A?jp}8Thp4Z7TI_~e56c~eD<8fwbkKI zKk^G6t*)jUbl0m;k}YO*?GZjMP{AecK7uV~6b7jHPq=8K^3HLd)_2}8-4K`o!1@f7 z795)0tAs1U2J)=om1Qyrbpzq%Jn}mIz&~p_xf>@ELU+Bmh$n2hC_V20zd)jcSuS4- zNI&tqM&R`90Bv&oj3O#Gg?*)CNQ@ zeaS@MGB2Vz20itosNL^&cJ{j>OwQO^p_BTGF0I#Aad1~-Y26v?jQ51}g5$KpltT0h zVX)xkP>>CRb-|J#0ZM~#D+jr3xs|0j&$C(r+P{<#VejN*3oik}=GNq=r+%YFcP0^< z3<3xHz0OjTOhpPREeXN(A$kc}r@6sUL5c4|?3d z*P+Kf;9~TG4&&`(N&j&NR%nC^2$g;k<|kqPr(Rv^`+fV?@FdLtCt-GKT+>Xg_#hl8 zz6iLG7M?0`dYpoPnZ~}Yra#E_J&QY}phmgUekgkl?)?8e;4I4hg;#+Gq9I!!82=^m M|3KsaqwfHJ0QL5M7mdc-()M)_R{7OzL_^~ z^3otDiZ*!@*wMv2*q&NpfTqDGwhrZmaBN|+{i{6_bY16c7Vsf)7Mxt`i>uMRnpMH% zu1ZIzZ7$6$o(9<1K=^_C_f)TzhCcfwo@1d6OHt^XQ;IjF^}5r%XYmo!8MNiw?I*BeUUzK)?@ZzhTqf6?c+aZ%TH*E RF0qgD!{Te&rxt5z z#cBkk78U2`ITmE<<)$dZWYa*hnm|2TS^=RUT3QPF1z=_R)tM=^`Y=_Kw=qqH+4h!8 zmKS0%#6XSB3x(uZCKvImqY6)+s20c!R5YF(+_-eBsv!R{~%n?wJaM>yNWEPj$ zKx_|6EiTBESu{M1q1+KFUW8J delta 27 jcmeymh_Sz!al-Y@(u~g-Cx diff --git a/mobile/openapi/doc/PersonWithFacesResponseDto.md b/mobile/openapi/doc/PersonWithFacesResponseDto.md new file mode 100644 index 0000000000000000000000000000000000000000..ddef73618cee876c89242ffa2028492bfa47b504 GIT binary patch literal 686 zcma)4QES355Pr|E2=qY>w7zd+;HYB*6>-8IO2OEiHmuEsPK0D-+;GhJmiFoB?(`kRGezyJ)2uuy~!i6-EOC( zBc@x6j`-+&esk8IM*T<_-9~D!w00rrNP}l9*#iII+jkp(Irz$HOBzi;E2O|k2VXcG zpftUPnGk#JsLmyU5P@9?5%*i&23(FW`>a)y5DhD29OC6K?1lDpH}f7SdJ?_Cj!s4m z{JD=}S6N?G3Izw*HN**d6+9+hlw~NbRbN+#7joJhdvIz>`zNgwm~(uO*bJg3xcFBl z)m^(cQa5W!_3tEB6c6DwV>in<3A_onF3WM2O*ivdzMO~76NOdRf*~1SCq1A``#JCv d{*^sjKmRi~OhpPtS=5HUNt_D)C_Z6~eF9IQ*VF(2 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 894162693372afd81f2547fbe1b7006855966978..c0caf20e4e6e8e36f2bc65fed644c15a507f64d2 100644 GIT binary patch delta 126 zcmexpHPLp%L}r$>#N^b;ip*l0Co=1@FeN5W78FpL{Em$cB5|Hgl^LjFa=M@*NKhE0 zI=(!!BqP7HB)%ZEs5n1wav={`Zu4GtDOQjH4_PHA|K{P_{Den|b@F;ab}o?mc#zIw LklM{`LfPB^!8j{n delta 35 tcmV+;0NnqPJ@GoQkpr`<11$uzkO&zClj;o(lhg|rv*`^B1+yj-YYX@r40Heh diff --git a/mobile/openapi/lib/api/face_api.dart b/mobile/openapi/lib/api/face_api.dart new file mode 100644 index 0000000000000000000000000000000000000000..bce6814071e045fd1ba927e1a8297e021b456e5f GIT binary patch literal 3749 zcmeHK-*4MC5PtVxaqUB851!lwdl=Hho|D*ZfG%-U2gMKsDr22vb~34wbX_mV|Gqm? zl4ZnhfNTSXVh@oeiuZ$e-*=zV=`=bW=wFV{e|$YS8(j3qgA*8@z8$pTXx>- znNZ0uFO$hk7~5!2I0t$PI!7gP8PngZMx!ywgtagf6YS>_?ix_uH?L^nIFl5%2edid zUbkR>{RD@#`(6)HoQg%}DnkkYFqKMVP_3;k{M!zdd4z^aJhvbeycv&25WOCZp);kV zBF@(cC$*xtDHwDMqb#@?!qo-Zoa%$uF@^Dts*>)9m16w|xO(r5RJYe4Q)rfrXz{M$ zf;{3u>8lI3=(rsAgLJbu&gG|Ss++-_tlKRRcBLl1R2;Pc$v)%;r&M;|u_Q$*Y{=VE zkg^K7a5E~JiNfK01-3cyHKRsOL>A1?Z$9CK8j)-3j&!;zEJJNSFVL(;e0EFD58hRe z-K*F zgMI2Tg>xS%0J#ZAq)GMsQsLK}ScxehjV7{|istN2`4)TMJL)BAs4VrT@=33(8Z5V5upeXoJ#jcy*3BZDN+^daIk>1cJD?$9N#+w8CK z`%em0s8i9U*kGPf?mX!uUqCU6L>j@~Q4?ikjP1H!!9^kzuZjWGmbHG)@?SaP)29fNQ@cUo?zH9v}1maNz f3mbMzBB|N~;6{Gj@_#a$fDZEMC+7~8x^MgiuO!2= literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api/person_api.dart b/mobile/openapi/lib/api/person_api.dart index e4ab011a6b99b213c4a44d9a0e40d5f3d9b6af44..b603df6d3b7b14e7df2c9784344eaa81fbcfb678 100644 GIT binary patch delta 468 zcmZ3M{kDa1!yZQF0RQ00`xuQUt1t;lCKsh9mZS!x78U2`g=dyzc$AbBc;=<$YfL`C z;X8Q))8WYlVqueel&4MZHj?H=R;@Alo{=hU{hL2(-(#H2XF6^29tFwC0({(?k2A)r z@#$A*rqt>KEiEq2OwXIF%aJtM--uVnvA8(3#4Ry7HMAfF=pL7n{8$B`60q208*PQj z6Zp7zVY)!-iYLcOi_0ir>O*L|s}V7|UX+akrfu>?=|~<0TeyPM&7B6983|df>In&) zpw!}m{Jdh2)9mc+6kvve-J%g(Qk0pOu8^6cL%>CnliwJLtD$H{33ZSOFn@1;rk%{h Z2C;pzzn&V5S*@2e`8cEJ=6nrvUI6{ct;zrZ delta 26 icmaFc!niDZ!yd-T@xt7bPcUk2c3?ME+x*s~ffoRkrV2d( diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 42a0e5cbb34d5e3177445bf8d965dc1793bd0dd1..7d376949e7426fb73c3584a36641177aa1b2c1a1 100644 GIT binary patch delta 204 zcmdn7mhss}#tr}EnG=&!CpX&2Pp(&BWpM*>HcIPEPEcfla2`m>O>R(ta`ff&I6+E6 z3sMqGCNo+|f;2G$MJ9WhX-|@46$D9!XO?8-mzD&i78U2`!AurFmzjLgKnJK}^I!QL u+)O|NCQ3l;q$)ZQn&5ShveRrf} zQ8t};D-hSZSMu(;7y0O@b94l6f4d&O_~rca{OaxX`59ci|9Bq3*$^&=*YIh0_WmM0 zKrxbhlQU%oN!(95^eCpKR6LtVl}$v!k6~FZHP16%@HsC{R6mRPLMju~V8tC<>1?qy znc{yhg+ld`&GB==6#j3x(r8@iu=-e*+OX1OB1eZ}BDk{ZI_RtrCD&OY%PESP87s3N zUuNSOQ@YbZcLwAHK?@=e z*%fm(*D~I@ z+cbMvOZse2=Oy|S+<`6kjr_)yLa^bnZ>1~FcI)uRqdL~eg zS<$0>Mm6XAclQm%4ro1pZju#unjas1^JZn8hEzBluZqnkW{T3J7mam((b+Kn5$Og}_OMw>}Bs9pX zI5sX7!+wvd>kpo}*xYpO?LV=_Yj@cQ@)eFEF+Kq!KVZwk+q*VB5hW|&E0SOUThjLr zuUz?BiIM8ILeafJo$dDih)NPx!5&8=0|H*seo%#)Q<1*4>YF!^RNe2<_JsLeKZLC`^JhT*Dz6XUfCO267B~R6zI|tF+u>|MD~)d|KAC1 z*9Na;Efk;N24j~WwH)L4(HRY<40j=%HyN%?GnrEhbA;f^aca+Dab4luov#ipMc$r{ z#uOf^rUSI#Z$;uU(`Ks>dTzWEM5=g(8b=S0>Hf9q~AFL?l6+}_cnrMx68=>CrR5U5&4#T*PWVJU`hIJy&~LFZN<X*-qX^pSB^g8QS!d5nss#bMAV|o%?*Egc@yIbv- zS8FV`vR;;+$S($Lx7hu9mGlm`_(Yo@KN-LyZ?iwJz=sBgr3HomSM`5 z2$fq7xS?otOQpf!!(vGLyMSnw~07^1H`o? Y&dG}HPd=aIhFL|arV~BkLXXYvU+Up69smFU literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/asset_face_update_dto.dart b/mobile/openapi/lib/model/asset_face_update_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..89a11b0ab9138c8518b4e34ab77e2d646313bc64 GIT binary patch literal 2832 zcmbVOU2oeq6n*!vxCMq-0aSVG(~wkNgT)!rwK3494})O{j6_@PWKtuk8b<2>zI#bg zk!`ijX27;Y-OqE*B{i9hCKI^&bU%Oj$LwzQ;p%>N4L7&%W-(mP;ck8p@8{RIH-EiA zGqQYBFm2OU$uF-)bSu_UX`Zc=&Q_x07tqMY@FL?C-*9Q;a4$Bs(ss~;Ra>?*S>4!7 z^M4wl(Ot3){?<(6zvbFsaBYU&b0v*s(q^K-gkmMQcJ5{{StTSlS*7F}&1}hJ_Uq4C zUNUV)BTQ#Otw7aWvRWkgy&R45iWvh}#&CPXa{j3IJ1cQN`kWoq2J+0tA$*HRsJG! zO^uHbLZTKFC;f?jmt%+1dse^pPB9ca)SAer80W1~5~FxFRzk80KI0P7abo?rzw|IT zi-jIj^zjl+I~KixcH(eWw6G^8|D$Mt$pZZR7!cOL7gjagz)Gu48*Sm1P-iPN7?Tti z*yAz=*;J?kqI@filV1<~TJsf7fzz69a@2{*NI@CSlNEV}qgSee$nXmT(*4a<;ecy_ z^wk9vv7pG+Mry*6!aBeKzRo1c87lmkm&g-O1JYu7%5a1w`VY}?aa<=&NmAkX+#q7E zR-&~d?VN^;KA4IA_U^Y$MgVriwJ;1#BI;}k2RQ;Blq-fuH;-$rw(Oz8Nq`lr43GB# z5D$murl~9m?zGA2V(IH6OerWqN~OTrnbTJy%$>5cO>K7}D#DDzAGN{N7B?bi`t&>y z;Ymu5p~JQk(wRdIi*F$!n4YM8kvg6AgA!TPO2Uf53!vxe0S-VPz}Ov*Xvd7O4m_@J_-tlj~qb<+*du>ws z@6d~`-7sO}cuRYg!%6w$X)A1rOEgJz6rVEj(i@^*yAk5XjSloyLh*2R2b4i-8XBo7 zxzNKwjXZa1F-BiK-%R#@H1te)JV$EnF4x-;eyAku?2zhHJ`Ok5o}l7xXrIP6?o{SQ zaxnXttS62M+?Tx5L3l9)l$^mcmLb^X9y3&8ImOd)iHdAYgC!hugUVj?T|ZGVnc0(>$`RBLFR`b*x} zuDz9Cxl)J~y4*`y(ry(qQb8OC^ZPG6MGQ>A)j7mdaG0j745*^efBE*!J%JcT=J|75 zvorGhzy!WJI5zMFl7)XhDOOYl*4#kBbn%b+oaAn?HPSr<7Mt+^kIObo{H%QlyA8$9e4^FZCb(tw8y7ExY=?>$ z=@g87hs0t6Cx~2Btb{MP1TmagKkQ#SoUO%DjoYN-6#(Dpx0@=>(^M5ov-0qmUVq?iPmH?N?idMY=`amFHo{iUM042^?$k>i|cD zSd%Da+MG}mxTbVXP2*vxi~x&tKP_<=uwnHd1k^lV>}uYIod%?U5Do;NpSh_(-k!Th zoxS1s;Eh#Mh`s&W(oX1iw6_?35V*(7(-}C062Dv{r$=DAav$t`Su43^_a*i#68cV>*`_u-9~Mc%X%@86K<2Nu=6Xku~j6^a!~K*U~xu#dkDft-sSJ<^KW0 z=-Rau?i^of+*zQcJ9D?f6u3m~M0bz@E?zoIbZmzy9v0|9_c0217P4tN-9OiNwC;N7SB9o;Y6Az@F*?n?Qd be7ifu^uZ$1T^QVHGVV?YddJHJ&YXV$(}wKa literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart b/mobile/openapi/lib/model/asset_face_without_person_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..8e1a9c66ad8364f9e30318d20702f73c95a89765 GIT binary patch literal 4800 zcmbtYZExE)5dQ98aRG|j!CYt7ry;GgW=pcQYvLiz)(J)+FcxjIlSPlD>ltbO`|e0d zCT%$~x&m>n>aWLP(}*OOz7T&T;{3Br$5Pm|LS)e z7@`J-xJg4?t3jl7dc~Yg`LA3|XJ+s5Cy|;Nojx67yaD53An>S`8r4zbel6AUel!YW z1R||?yxWB|dnHSHE5C515Lj+2h)TzK-3-ev8}E9J8{+&-zG&Cu-;aZzuxGQv?TX4;lpi zePP*>YnUiGFO-ig3Fqhp8QL^?M+l4iF67G$`!zM^mC$y)dVOJ`_yl(VyFRGp7(2R7 zsMjaBO<)U8aPgSQl=_4tG>_&V?WHXA3MjhEmN>-wb?jAX4B!n*IzWI>%hDS&ZFaEQ zaRZXFSVX$9usi60`xoH)6y|L4Cxvo2xmz#z8|?2Nb(mFLFEc}Z zwb|O}-rb(+fNW41&_!`eupOf(^o=x^^MzTLMzAz+)9MtqDq{~K`l^{2Tl#%3TrwryPl;5lt+a-&{ z`SYwfqEy7S=_~K<0*74w`c(Z2{WivW(iQLz`a26DAjHgh&+$BtFe2t{n zMYoj&Nk^($muc_Oli<2^kMibR{D$;f%{?pVC9?qr7|M|o{F|?7H?Z;!*L0g_uz;df znPMF9kw%pbn{@9HD}1*^5)~!Ahc@Qvm6Jm?#aP762j%Gvoz!ne*R{7L5fTq5)-%#T zytVl1!eQ+Jb@<3T)b_XF??7lB0@Gq-dab7sh3IIAi^k;jD9u%HJur5w?y%_--3p-& zU_p~X+a9n^;BLk%3{}4qAY3&rvJ8>2M0h=Lzy(O7S*k4t?>`Q2==X=B4cV=z`jO!` slC4(fvqu}Btv%VT2mvu_?-Fr{*AOdC9Ja-=KR$hun}|fgy&XH}e+8!{*8l(j literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 7461186810246b56bf26bca192f948597856c9d0..bb7330d3ec630c4ad7ff7e582629037c7903cdee 100644 GIT binary patch delta 37 jcmX?|ayMneLr%`{%#sYZ#N^cC%?~-7(mbz}&MVRIOXy^0c$xEtZ@IK-d=}eQX?xOxH9NL9dE41s z^Zz=b(Ot4F{%@JaZ_Bm8;M$yaFO)QvNt=rj6N*Z3?cB}DWQ~yAaN+)wefnD70#o|B<*J#sR67D5po@281>6g*6>FP-(RdAy#e)MNy%_n8dQerq(IQu0dLf!<{frD+6#_%_|hA)3xpj zB;MpCi5z8)Oy@WewJHg0zYuly!licb1h{-j5i6`BaRreku#UJ6aD<{W$qHudH+UwV zPp@Xue@vZVG5#aUOW3mZZ^B|xE%z-CQDzCTl!7DS;*8W!AB;#}2lqcvLFn{UgdX1s ztS8811`R&lAkIh4`uO*5WHwsu*jv1_TI&2Zqk3P9wue zbb?UP87oWAFSYI>hNoz+;6T1hg8?Vf2o3 zIK&pS{195l!PG;~H-zx_$hCbVL)b>U|_CqbI>lY=SuOjeM6d z=byRl0sKE94q;h&$A9^r4j||6^+q{=z#h8xqk!GwBON0S9ObWqov<}7(MZtc6)=D5 zchNW;On7pl6O&8yIQ+`|ggU zMENaU3pAF%JD!oQ&O|K*?L&1DrvH^u5MAx z7NW|2d6rEJp-r!c$62UnP%mU97AnR6FM7RcDU5*+=tN!prEGC7rqXk^cAG~Oo%!7@`M3#vMo$_Qk#!8 zA&=Y8g}%0_a#F>=UBe*}yy=s&CPI0kZT{_QcTz+$orR5@smj8wv``3B{95N|b;Ln7 zg-`WfNo1c9dPVh$87!nxhQ$UA5!jt&S;8xL1}AMo4$@%y2!3)poRrOFm2X+|@o(-> z=$osZVB77Dus#25?H&fqj{fdKoiuR{51Yrtp*lR|fD%Mq{n<_jN7x^zkv)Jcjr9H@ zg|=I!!@WFfHDa7cKD=#uPOuSCdAm>DgHCNXeYztNzp^GsIJ83awkFWQq?PO}I~C0{ zfdi$52kMhQI4eL@Ik+)uKv@G{MY+VbKhyQxTb=L{j+Yq<^l1`JkSf;+1L_*noiaRi zq6%JUIm2Pd!$U8p*d0wqeKW(citRAN30%}URkT~!(*$3!H_SuNOXp=E6VZ)*q+W`y zg>`@qU0EcZ3Y;73G#|KGL$LbX?k>v#y6pZn+&zK0ScE|}pz25o#1|J=?4ulr5p-0m!(hAg$ zb&0qedM=->CUZU3>X7(R^^SDpd8l|szylQun~T6xiSGux5gb7$v4K+#w|k!Gx4OO) zHzkfHL<`$X@(@VnD5a%YmX>TgeSHVV-!!DJvpSxYDyqRDrCf^3Z>$Kw8vmPqXH=C%U(=aZs;)E)jzNS z`_GmWXudEqyyWAz41Ke>yGsXOT-B?9a7u4Vnh+O3;bQwgNVCb5H6q*|K^&7eaf<)Q z6^&um57UX}3po>hJFuf_G=T^#nh%_>+FgY2@MexnRB`kbv>TbHP674(5W>3$D$<7` z*+n0GbzemH6k^-H5*(2#32o(20^e8dXd`CVqlf+o$G($3-$O+YSJX)!Wg14G44T+$SKTlPuA!Yw@m4~@uk3|ED0c+z}kA|(ypUImh1i4?k#fHw<+$5P=4c=u_1 zN#DDk8}3^*DE~pa%{_H=&xwhyoNglek?LP?REDQ8{`@-+HUHe5P_&3wUuLI~SJ;5RtmD zhd8()>+Sk4BdR!`=DIM%H!9LywHtqWp&!i zozVvo_hFb`SQA(qR#b{1?FfCOhe5N33~xla4klkQ`bO>r?u3sZnH6NG%jr~8Lk5qE~Fq;S+E`3fBB_tU#t$-W>Hiz#ir$ JVsX6hlAn~0l-B?N literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/asset_face_update_dto_test.dart b/mobile/openapi/test/asset_face_update_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..5338a0bad94a300962cc84f16050ae5714f01ac3 GIT binary patch literal 615 zcmZuuO-lnY5WVlO7*B1Xy6Q>%!os#vP#2^WexEYD%KUX}aT^Sk-|Y&9=nvAmg2p(x?5T)}NwEEf-Z0*lD28a-be9~>QLvDF)6 zJ*mce)##Q^p*MY?T2V_K8J_<3x^vd^AOqWX+y&)&R-Rsa?S*czljzV(X7WLX`!Gze ztO<;Um99llH=2B+he5Gc8w#qmW+O1(qERP@s@kFtS@yjnj2u=kxS><)$e`1?yo2a^ zboLLz6gZFJ7Z62bA3#n5vk-_ESkcnLv6+r_{abR-)Ms^=wzcJxEP>~ zCuf;}H{SMcJcbJYk|pVe4FC7NRE5EM@X$05 zKHncNaU8`l%s1=o_9q0oL4Le4^L-pHy literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/asset_face_without_person_response_dto_test.dart b/mobile/openapi/test/asset_face_without_person_response_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..5eb7e5d939c80ec67931c6e18bcda0276a065ab2 GIT binary patch literal 1249 zcmbu7QE$^Q6oudOD=ts%q_V7iLYpR0ly*#l1&yN7C(lc6ldC1Rv2TEC+JEQ9WkQHb zUD!jMSUTr?_c%_|Bu!!dWtG3aSX?cx=c`2q%gYao31m53Jf5j-RSV&9O_4Nz=VAex+<4UE4|_%v_>GKEG7NXNGbNeoo&EWB zDtQo_NT7`$z%_#Naw?v;aQm0~b{D6^))cWv@0KpBJj-L!<8H*gtl1#e>?N%ApkC|g zi&%qtt%vpc%wGN!PXyTvEGwCW5vgrF&aNudcW0bz0jXVS z53yzEoB2OGNz)`vVfwVrPruEUv(ZI9)0dWOM`6*%t`6 zdv-XwNrb4qZQXEi&}uM-zdd4?c^g`flSkq(nKEODfh%Muw}M%)cXv?tn>_p(JeeA( z9D(MoDI^3s)js6|>=QbqbB&nzuJbJKN_M^Am-rv@iLCh=3m!GI{2kGUAGV9~Gk)Bi VROFzNmoopu=b*)ZibcO&@(uu0>=6I} literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/face_dto_test.dart b/mobile/openapi/test/face_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..ea8091f2e3767a477f12c03bab501a4b6b14278e GIT binary patch literal 533 zcmZvYF;BxV5QTUDiepMEl?FN?s!CK!iYh@PK^T}^D{;~jiEV5LRH6QN&MukgVEaz~ z-o0n1DN9pWzm>)HW4_B@)@7c-X8V-SAS+;3l<-_++s*qGVS#+8h4rh&{C2@Yi>=aD z@%H2h?4wZDg)2b!RAB5s}H@rFs>liy{gv5yIL$vZ<8>xiBl-4}F zI$8chymUSd+Xw{H&^w?nfsLqS<~94$SVJ#RO{Va}00?SOi4HJkuOzHSb~0TuB3i2l zGfBdz@g|wUpF9qD^%hkNSQ8D=lW-ji@COOTQhw`?u*Z6T)(Ur2RSXa$6rwD$BFay} Jm8y82*e}ystResa literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/person_api_test.dart b/mobile/openapi/test/person_api_test.dart index b0feeb1160831e8ac87e48bd1d9f60289ee0186e..dd112eeaaee34310b5d830ae341fe0f8aca2f327 100644 GIT binary patch delta 142 zcmX@c^MY@~ImXHBSOs~Li&7IyQUg+piu3a{CjVy?X9kK)7GN`&{D7Hz^BG2OW)FyD zP-<~OeqM2^OG&<+odQr-adBpPo?BvaYOzLeNl|8AxA}@-@%@0`87y-8tG)Djc delta 21 dcmaFCcZ_GlImXS-OrFe>U$cm8_GOc11OQ(92Sfk> diff --git a/mobile/openapi/test/person_with_faces_response_dto_test.dart b/mobile/openapi/test/person_with_faces_response_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..7f7e0f89acbd177f3775d6aa1daca0d72e283cd4 GIT binary patch literal 1152 zcmbVLUvJYe5P#37IG)-{Wr01RjUg(fg-K(XMi811%G~6VoJj0oUxq5Q@6Orn5EIox zd5DvXe*b(v$8i+Lu=tdv&)+RCm)DDInZWA&VmXH-h08R94{36~x_Krrk9=Dy%V)<& zFOMT%Qmu`}yfQYgsKpZ)^x&|}vBeHG&mU^)dSiJnL$yED#^wFMxyA1TS)psy$=WL` zZ$|4x+`9er!f3}zb55lgQblM3-R(7NNu$eKqicb2qjdi2MP4+@x+vOagh{W8Z>q)< z)1y{B#qu2aT~AK`f-pLc{qg|troMI{ErBbEZuDp3W~B;r*XVjliwT?P&QJ-RB0ZhK z&j>(x6=~H1h3ymwXHU?lv(rdOwKYROop{41z>_)bOoC0K7&Gb+)?~Tyl_)_-Em9_Y z05=G|l@3_m!0m73?N&>fwwLBqZ4nS z^1Xp+iIo~!hC9^`GDzWE;mghcKvZ+4;?Cgnc*1qRD#@c;k- literal 0 HcmV?d00001 diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 55b4f81abe..d6355e8944 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -3220,6 +3220,103 @@ ] } }, + "/face": { + "get": { + "operationId": "getFaces", + "parameters": [ + { + "name": "id", + "required": true, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetFaceResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Face" + ] + } + }, + "/face/{id}": { + "put": { + "operationId": "reassignFacesById", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FaceDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Face" + ] + } + }, "/jobs": { "get": { "operationId": "getAllJobsStatus", @@ -4022,6 +4119,36 @@ "Person" ] }, + "post": { + "operationId": "createPerson", + "parameters": [], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Person" + ] + }, "put": { "operationId": "updatePeople", "parameters": [], @@ -4258,6 +4385,61 @@ ] } }, + "/person/{id}/reassign": { + "put": { + "operationId": "reassignFaces", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetFaceUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PersonResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Person" + ] + } + }, "/person/{id}/statistics": { "get": { "operationId": "getPersonStatistics", @@ -6557,6 +6739,118 @@ ], "type": "object" }, + "AssetFaceResponseDto": { + "properties": { + "boundingBoxX1": { + "type": "integer" + }, + "boundingBoxX2": { + "type": "integer" + }, + "boundingBoxY1": { + "type": "integer" + }, + "boundingBoxY2": { + "type": "integer" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "imageHeight": { + "type": "integer" + }, + "imageWidth": { + "type": "integer" + }, + "person": { + "allOf": [ + { + "$ref": "#/components/schemas/PersonResponseDto" + } + ], + "nullable": true + } + }, + "required": [ + "id", + "imageHeight", + "imageWidth", + "boundingBoxX1", + "boundingBoxX2", + "boundingBoxY1", + "boundingBoxY2", + "person" + ], + "type": "object" + }, + "AssetFaceUpdateDto": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/AssetFaceUpdateItem" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "AssetFaceUpdateItem": { + "properties": { + "assetId": { + "format": "uuid", + "type": "string" + }, + "personId": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "personId", + "assetId" + ], + "type": "object" + }, + "AssetFaceWithoutPersonResponseDto": { + "properties": { + "boundingBoxX1": { + "type": "integer" + }, + "boundingBoxX2": { + "type": "integer" + }, + "boundingBoxY1": { + "type": "integer" + }, + "boundingBoxY2": { + "type": "integer" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "imageHeight": { + "type": "integer" + }, + "imageWidth": { + "type": "integer" + } + }, + "required": [ + "id", + "imageHeight", + "imageWidth", + "boundingBoxX1", + "boundingBoxX2", + "boundingBoxY1", + "boundingBoxY2" + ], + "type": "object" + }, "AssetFileUploadResponseDto": { "properties": { "duplicate": { @@ -6719,7 +7013,7 @@ }, "people": { "items": { - "$ref": "#/components/schemas/PersonResponseDto" + "$ref": "#/components/schemas/PersonWithFacesResponseDto" }, "type": "array" }, @@ -7452,6 +7746,18 @@ }, "type": "object" }, + "FaceDto": { + "properties": { + "id": { + "format": "uuid", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "FileChecksumDto": { "properties": { "filenames": { @@ -8147,6 +8453,42 @@ }, "type": "object" }, + "PersonWithFacesResponseDto": { + "properties": { + "birthDate": { + "format": "date", + "nullable": true, + "type": "string" + }, + "faces": { + "items": { + "$ref": "#/components/schemas/AssetFaceWithoutPersonResponseDto" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "isHidden": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "thumbnailPath": { + "type": "string" + } + }, + "required": [ + "birthDate", + "faces", + "id", + "name", + "thumbnailPath", + "isHidden" + ], + "type": "object" + }, "QueueStatusDto": { "properties": { "isActive": { diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index 862fafc322..f70d3d548c 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -41,6 +41,8 @@ export enum Permission { PERSON_READ = 'person.read', PERSON_WRITE = 'person.write', PERSON_MERGE = 'person.merge', + PERSON_CREATE = 'person.create', + PERSON_REASSIGN = 'person.reassign', PARTNER_UPDATE = 'partner.update', } @@ -247,6 +249,12 @@ export class AccessCore { case Permission.PERSON_MERGE: return await this.repository.person.checkOwnerAccess(authUser.id, ids); + case Permission.PERSON_CREATE: + return this.repository.person.hasFaceOwnerAccess(authUser.id, ids); + + case Permission.PERSON_REASSIGN: + return this.repository.person.hasFaceOwnerAccess(authUser.id, ids); + case Permission.PARTNER_UPDATE: return await this.repository.partner.checkUpdateAccess(authUser.id, ids); } diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index 72c2572563..c3a7491cfb 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-response.dto.ts @@ -1,6 +1,6 @@ -import { AssetEntity, AssetType } from '@app/infra/entities'; +import { AssetEntity, AssetFaceEntity, AssetType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; -import { PersonResponseDto, mapFace } from '../../person/person.dto'; +import { PersonWithFacesResponseDto } from '../../person/person.dto'; import { TagResponseDto, mapTag } from '../../tag'; import { UserResponseDto, mapUser } from '../../user/response-dto/user-response.dto'; import { ExifResponseDto, mapExif } from './exif-response.dto'; @@ -39,7 +39,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { exifInfo?: ExifResponseDto; smartInfo?: SmartInfoResponseDto; tags?: TagResponseDto[]; - people?: PersonResponseDto[]; + people?: PersonWithFacesResponseDto[]; /**base64 encoded sha1 hash */ checksum!: string; stackParentId?: string | null; @@ -53,6 +53,24 @@ export type AssetMapOptions = { withStack?: boolean; }; +const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => { + const result: PersonWithFacesResponseDto[] = []; + if (faces) { + faces.forEach((face) => { + if (face.person) { + const existingPersonEntry = result.find((item) => item.id === face.person!.id); + if (existingPersonEntry) { + existingPersonEntry.faces.push(face); + } else { + result.push({ ...face.person!, faces: [face] }); + } + } + }); + } + + return result; +}; + export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { const { stripMetadata = false, withStack = false } = options; @@ -96,16 +114,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, tags: entity.tags?.map(mapTag), - people: entity.faces - ?.map(mapFace) - .filter((person): person is PersonResponseDto => person !== null) - .reduce((people, person) => { - const existingPerson = people.find((p) => p.id === person.id); - if (!existingPerson) { - people.push(person); - } - return people; - }, [] as PersonResponseDto[]), + people: peopleWithFaces(entity.faces), checksum: entity.checksum.toString('base64'), stackParentId: entity.stackParentId, stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined, diff --git a/server/src/domain/job/job.service.ts b/server/src/domain/job/job.service.ts index 4082d5e901..d313302eac 100644 --- a/server/src/domain/job/job.service.ts +++ b/server/src/domain/job/job.service.ts @@ -201,7 +201,7 @@ export class JobService { const { id } = item.data; const person = await this.personRepository.getById(id); if (person) { - this.communicationRepository.send(CommunicationEvent.PERSON_THUMBNAIL, person.ownerId, id); + this.communicationRepository.send(CommunicationEvent.PERSON_THUMBNAIL, person.ownerId, person.id); } break; diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts index b7acde73a1..ed46933939 100644 --- a/server/src/domain/person/person.dto.ts +++ b/server/src/domain/person/person.dto.ts @@ -2,6 +2,7 @@ import { AssetFaceEntity, PersonEntity } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { IsArray, IsBoolean, IsDate, IsNotEmpty, IsString, ValidateNested } from 'class-validator'; +import { AuthUserDto } from '../auth'; import { Optional, ValidateUUID, toBoolean } from '../domain.util'; export class PersonUpdateDto { @@ -73,6 +74,51 @@ export class PersonResponseDto { isHidden!: boolean; } +export class PersonWithFacesResponseDto extends PersonResponseDto { + faces!: AssetFaceWithoutPersonResponseDto[]; +} + +export class AssetFaceWithoutPersonResponseDto { + @ValidateUUID() + id!: string; + @ApiProperty({ type: 'integer' }) + imageHeight!: number; + @ApiProperty({ type: 'integer' }) + imageWidth!: number; + @ApiProperty({ type: 'integer' }) + boundingBoxX1!: number; + @ApiProperty({ type: 'integer' }) + boundingBoxX2!: number; + @ApiProperty({ type: 'integer' }) + boundingBoxY1!: number; + @ApiProperty({ type: 'integer' }) + boundingBoxY2!: number; +} + +export class AssetFaceResponseDto extends AssetFaceWithoutPersonResponseDto { + person!: PersonResponseDto | null; +} + +export class AssetFaceUpdateDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AssetFaceUpdateItem) + data!: AssetFaceUpdateItem[]; +} + +export class FaceDto { + @ValidateUUID() + id!: string; +} + +export class AssetFaceUpdateItem { + @ValidateUUID() + personId!: string; + + @ValidateUUID() + assetId!: string; +} + export class PersonStatisticsResponseDto { @ApiProperty({ type: 'integer' }) assets!: number; @@ -98,10 +144,15 @@ export function mapPerson(person: PersonEntity): PersonResponseDto { }; } -export function mapFace(face: AssetFaceEntity): PersonResponseDto | null { - if (face.person) { - return mapPerson(face.person); - } - - return null; +export function mapFaces(face: AssetFaceEntity, authUser: AuthUserDto): AssetFaceResponseDto { + return { + id: face.id, + imageHeight: face.imageHeight, + imageWidth: face.imageWidth, + boundingBoxX1: face.boundingBoxX1, + boundingBoxX2: face.boundingBoxX2, + boundingBoxY1: face.boundingBoxY1, + boundingBoxY2: face.boundingBoxY2, + person: face.person?.ownerId === authUser.id ? mapPerson(face.person) : null, + }; } diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 44c20712bc..0ce15f5aec 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -31,7 +31,7 @@ import { ISystemConfigRepository, WithoutProperty, } from '../repositories'; -import { PersonResponseDto } from './person.dto'; +import { PersonResponseDto, mapFaces } from './person.dto'; import { PersonService } from './person.service'; const responseDto: PersonResponseDto = { @@ -339,7 +339,7 @@ describe(PersonService.name, () => { ).resolves.toEqual(responseDto); expect(personMock.getById).toHaveBeenCalledWith('person-1'); - expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.assetId }); + expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: faceStub.face1.id }); expect(personMock.getFacesByIds).toHaveBeenCalledWith([ { assetId: faceStub.face1.assetId, @@ -375,6 +375,139 @@ describe(PersonService.name, () => { }); }); + describe('reassignFaces', () => { + it('should throw an error if user has no access to the person', async () => { + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set()); + + await expect( + sut.reassignFaces(authStub.admin, personStub.noName.id, { + data: [{ personId: 'asset-face-1', assetId: '' }], + }), + ).rejects.toBeInstanceOf(BadRequestException); + expect(jobMock.queue).not.toHaveBeenCalledWith(); + }); + it('should reassign a face', async () => { + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id])); + personMock.getById.mockResolvedValue(personStub.noName); + accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); + personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); + personMock.reassignFace.mockResolvedValue(1); + personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + await expect( + sut.reassignFaces(authStub.admin, personStub.noName.id, { + data: [{ personId: personStub.withName.id, assetId: assetStub.image.id }], + }), + ).resolves.toEqual([personStub.noName]); + + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.GENERATE_PERSON_THUMBNAIL, + data: { id: personStub.newThumbnail.id }, + }); + }); + }); + + describe('handlePersonMigration', () => { + it('should not move person files', async () => { + personMock.getById.mockResolvedValue(null); + await expect(sut.handlePersonMigration(personStub.noName)).resolves.toStrictEqual(false); + }); + }); + + describe('getFacesById', () => { + it('should get the bounding boxes for an asset', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([faceStub.face1.assetId])); + personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]); + await expect(sut.getFacesById(authStub.admin, { id: faceStub.face1.assetId })).resolves.toStrictEqual([ + mapFaces(faceStub.primaryFace1, authStub.admin), + ]); + }); + it('should reject if the user has not access to the asset', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set()); + personMock.getFaces.mockResolvedValue([faceStub.primaryFace1]); + await expect(sut.getFacesById(authStub.admin, { id: faceStub.primaryFace1.assetId })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + }); + + describe('createNewFeaturePhoto', () => { + it('should change person feature photo', async () => { + personMock.getRandomFace.mockResolvedValue(faceStub.primaryFace1); + await sut.createNewFeaturePhoto([personStub.newThumbnail.id]); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.GENERATE_PERSON_THUMBNAIL, + data: { id: personStub.newThumbnail.id }, + }); + }); + }); + + describe('reassignFacesById', () => { + it('should create a new person', async () => { + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); + accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); + personMock.getFaceById.mockResolvedValue(faceStub.face1); + personMock.reassignFace.mockResolvedValue(1); + personMock.getById.mockResolvedValue(personStub.noName); + personMock.getRandomFace.mockResolvedValue(null); + await expect( + sut.reassignFacesById(authStub.admin, personStub.noName.id, { + id: faceStub.face1.id, + }), + ).resolves.toEqual({ + birthDate: personStub.noName.birthDate, + isHidden: personStub.noName.isHidden, + id: personStub.noName.id, + name: personStub.noName.name, + thumbnailPath: personStub.noName.thumbnailPath, + }); + + expect(jobMock.queue).not.toHaveBeenCalledWith(); + }); + + it('should fail if user has not the correct permissions on the asset', async () => { + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); + accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set()); + personMock.getFaceById.mockResolvedValue(faceStub.face1); + personMock.reassignFace.mockResolvedValue(1); + personMock.getById.mockResolvedValue(personStub.noName); + personMock.getRandomFace.mockResolvedValue(null); + await expect( + sut.reassignFacesById(authStub.admin, personStub.noName.id, { + id: faceStub.face1.id, + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(jobMock.queue).not.toHaveBeenCalledWith(); + }); + }); + + describe('createPerson', () => { + it('should create a new person', async () => { + personMock.create.mockResolvedValue(personStub.primaryPerson); + personMock.getFaceById.mockResolvedValue(faceStub.face1); + accessMock.person.hasFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); + + await expect(sut.createPerson(authStub.admin)).resolves.toBe(personStub.primaryPerson); + }); + }); + + describe('handlePersonDelete', () => { + it('should stop if a person has not be found', async () => { + personMock.getById.mockResolvedValue(null); + + await expect(sut.handlePersonDelete({ id: 'person-1' })).resolves.toBe(false); + expect(personMock.update).not.toHaveBeenCalled(); + expect(storageMock.unlink).not.toHaveBeenCalled(); + }); + it('should delete a person', async () => { + personMock.getById.mockResolvedValue(personStub.primaryPerson); + + await expect(sut.handlePersonDelete({ id: 'person-1' })).resolves.toBe(true); + expect(personMock.delete).toHaveBeenCalledWith(personStub.primaryPerson); + expect(storageMock.unlink).toHaveBeenCalledWith(personStub.primaryPerson.thumbnailPath); + }); + }); + describe('handlePersonCleanup', () => { it('should delete people without faces', async () => { personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]); @@ -515,6 +648,7 @@ describe(PersonService.name, () => { searchMock.searchFaces.mockResolvedValue(faceSearch.oneRemoteMatch); personMock.create.mockResolvedValue(personStub.noName); assetMock.getByIds.mockResolvedValue([assetStub.image]); + personMock.createFace.mockResolvedValue(faceStub.primaryFace1); await sut.handleRecognizeFaces({ id: assetStub.image.id }); @@ -557,16 +691,16 @@ describe(PersonService.name, () => { expect(mediaMock.crop).not.toHaveBeenCalled(); }); - it('should skip an person with a face asset id not found', async () => { - personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); - personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); + it('should skip a person with a face asset id not found', async () => { + personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.id }); + personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); expect(mediaMock.crop).not.toHaveBeenCalled(); }); it('should skip a person with a face asset id without a thumbnail', async () => { personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); - personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); + personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); assetMock.getByIds.mockResolvedValue([assetStub.noResizePath]); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); expect(mediaMock.crop).not.toHaveBeenCalled(); @@ -574,7 +708,7 @@ describe(PersonService.name, () => { it('should generate a thumbnail', async () => { personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.middle.assetId }); - personMock.getFacesByIds.mockResolvedValue([faceStub.middle]); + personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.middle); assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); @@ -601,7 +735,7 @@ describe(PersonService.name, () => { it('should generate a thumbnail without going negative', async () => { personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.start.assetId }); - personMock.getFacesByIds.mockResolvedValue([faceStub.start]); + personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.start); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); @@ -622,7 +756,7 @@ describe(PersonService.name, () => { it('should generate a thumbnail without overflowing', async () => { personMock.getById.mockResolvedValue({ ...personStub.primaryPerson, faceAssetId: faceStub.end.assetId }); - personMock.getFacesByIds.mockResolvedValue([faceStub.end]); + personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.end); assetMock.getByIds.mockResolvedValue([assetStub.primaryImage]); await sut.handleGeneratePersonThumbnail({ id: 'person-1' }); @@ -646,15 +780,12 @@ describe(PersonService.name, () => { it('should require person.write and person.merge permission', async () => { personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); personMock.getById.mockResolvedValueOnce(personStub.mergePerson); - personMock.prepareReassignFaces.mockResolvedValue([]); personMock.delete.mockResolvedValue(personStub.mergePerson); await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf( BadRequestException, ); - expect(personMock.prepareReassignFaces).not.toHaveBeenCalled(); - expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.delete).not.toHaveBeenCalled(); @@ -664,7 +795,6 @@ describe(PersonService.name, () => { it('should merge two people', async () => { personMock.getById.mockResolvedValueOnce(personStub.primaryPerson); personMock.getById.mockResolvedValueOnce(personStub.mergePerson); - personMock.prepareReassignFaces.mockResolvedValue([]); personMock.delete.mockResolvedValue(personStub.mergePerson); accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); @@ -673,11 +803,6 @@ describe(PersonService.name, () => { { id: 'person-2', success: true }, ]); - expect(personMock.prepareReassignFaces).toHaveBeenCalledWith({ - newPersonId: personStub.primaryPerson.id, - oldPersonId: personStub.mergePerson.id, - }); - expect(personMock.reassignFaces).toHaveBeenCalledWith({ newPersonId: personStub.primaryPerson.id, oldPersonId: personStub.mergePerson.id, @@ -690,29 +815,6 @@ describe(PersonService.name, () => { expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); - it('should delete conflicting faces before merging', async () => { - personMock.getById.mockResolvedValue(personStub.primaryPerson); - personMock.getById.mockResolvedValue(personStub.mergePerson); - personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); - accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); - - await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([ - { id: 'person-2', success: true }, - ]); - - expect(personMock.prepareReassignFaces).toHaveBeenCalledWith({ - newPersonId: personStub.primaryPerson.id, - oldPersonId: personStub.mergePerson.id, - }); - - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.SEARCH_REMOVE_FACE, - data: { assetId: assetStub.image.id, personId: personStub.mergePerson.id }, - }); - expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); - }); - it('should throw an error when the primary person is not found', async () => { personMock.getById.mockResolvedValue(null); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); @@ -735,7 +837,6 @@ describe(PersonService.name, () => { { id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND }, ]); - expect(personMock.prepareReassignFaces).not.toHaveBeenCalled(); expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.delete).not.toHaveBeenCalled(); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); @@ -744,7 +845,6 @@ describe(PersonService.name, () => { it('should handle an error reassigning faces', async () => { personMock.getById.mockResolvedValue(personStub.primaryPerson); personMock.getById.mockResolvedValue(personStub.mergePerson); - personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]); personMock.reassignFaces.mockRejectedValue(new Error('update failed')); accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1'])); accessMock.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2'])); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 3452807f66..79fdcbafe6 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -28,6 +28,9 @@ import { import { StorageCore } from '../storage'; import { SystemConfigCore } from '../system-config'; import { + AssetFaceResponseDto, + AssetFaceUpdateDto, + FaceDto, MergePersonDto, PeopleResponseDto, PeopleUpdateDto, @@ -35,6 +38,7 @@ import { PersonSearchDto, PersonStatisticsResponseDto, PersonUpdateDto, + mapFaces, mapPerson, } from './person.dto'; @@ -80,6 +84,86 @@ export class PersonService { }; } + createPerson(authUser: AuthUserDto): Promise { + return this.repository.create({ ownerId: authUser.id }); + } + + async reassignFaces(authUser: AuthUserDto, personId: string, dto: AssetFaceUpdateDto): Promise { + await this.access.requirePermission(authUser, Permission.PERSON_WRITE, personId); + const person = await this.findOrFail(personId); + const result: PersonResponseDto[] = []; + const changeFeaturePhoto: string[] = []; + for (const data of dto.data) { + const faces = await this.repository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]); + + for (const face of faces) { + await this.access.requirePermission(authUser, Permission.PERSON_CREATE, face.id); + if (person.faceAssetId === null) { + changeFeaturePhoto.push(person.id); + } + if (face.person && face.person.faceAssetId === face.id) { + changeFeaturePhoto.push(face.person.id); + } + + await this.repository.reassignFace(face.id, personId); + } + + result.push(person); + } + if (changeFeaturePhoto.length > 0) { + // Remove duplicates + await this.createNewFeaturePhoto(Array.from(new Set(changeFeaturePhoto))); + } + return result; + } + + async reassignFacesById(authUser: AuthUserDto, personId: string, dto: FaceDto): Promise { + await this.access.requirePermission(authUser, Permission.PERSON_WRITE, personId); + + await this.access.requirePermission(authUser, Permission.PERSON_CREATE, dto.id); + const face = await this.repository.getFaceById(dto.id); + const person = await this.findOrFail(personId); + + await this.repository.reassignFace(face.id, personId); + if (person.faceAssetId === null) { + await this.createNewFeaturePhoto([person.id]); + } + if (face.person && face.person.faceAssetId === face.id) { + await this.createNewFeaturePhoto([face.person.id]); + } + + return await this.findOrFail(personId).then(mapPerson); + } + + async getFacesById(authUser: AuthUserDto, dto: FaceDto): Promise { + await this.access.requirePermission(authUser, Permission.ASSET_READ, dto.id); + const faces = await this.repository.getFaces(dto.id); + return faces.map((asset) => mapFaces(asset, authUser)); + } + + async createNewFeaturePhoto(changeFeaturePhoto: string[]) { + this.logger.debug( + `Changing feature photos for ${changeFeaturePhoto.length} ${changeFeaturePhoto.length > 1 ? 'people' : 'person'}`, + ); + for (const personId of changeFeaturePhoto) { + const assetFace = await this.repository.getRandomFace(personId); + + if (assetFace !== null) { + await this.repository.update({ + id: personId, + faceAssetId: assetFace.id, + }); + + await this.jobRepository.queue({ + name: JobName.GENERATE_PERSON_THUMBNAIL, + data: { + id: personId, + }, + }); + } + } + } + async getById(authUser: AuthUserDto, id: string): Promise { await this.access.requirePermission(authUser, Permission.PERSON_READ, id); return this.findOrFail(id).then(mapPerson); @@ -128,7 +212,7 @@ export class PersonService { throw new BadRequestException('Invalid assetId for feature face'); } - person = await this.repository.update({ id, faceAssetId: assetId }); + person = await this.repository.update({ id, faceAssetId: face.id }); await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }); } @@ -255,9 +339,9 @@ export class PersonService { personId = newPerson.id; } - const faceId: AssetFaceId = { assetId: asset.id, personId }; - await this.repository.createFace({ - ...faceId, + const face = await this.repository.createFace({ + assetId: asset.id, + personId, embedding, imageHeight: rest.imageHeight, imageWidth: rest.imageWidth, @@ -266,10 +350,11 @@ export class PersonService { boundingBoxY1: rest.boundingBox.y1, boundingBoxY2: rest.boundingBox.y2, }); + const faceId: AssetFaceId = { assetId: asset.id, personId }; await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId }); if (newPerson) { - await this.repository.update({ id: personId, faceAssetId: asset.id }); + await this.repository.update({ id: personId, faceAssetId: face.id }); await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } }); } } @@ -304,14 +389,13 @@ export class PersonService { return false; } - const [face] = await this.repository.getFacesByIds([{ personId: person.id, assetId: person.faceAssetId }]); - if (!face) { + const face = await this.repository.getFaceByIdWithAssets(person.faceAssetId); + if (face === null) { return false; } const { assetId, - personId, boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, @@ -324,8 +408,7 @@ export class PersonService { if (!asset?.resizePath) { return false; } - - this.logger.verbose(`Cropping face for person: ${personId}`); + this.logger.verbose(`Cropping face for person: ${person.id}`); const thumbnailPath = StorageCore.getPersonThumbnailPath(person); this.storageCore.ensureFolders(thumbnailPath); @@ -395,10 +478,6 @@ export class PersonService { const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id }; this.logger.log(`Merging ${mergeName} into ${primaryName}`); - const assetIds = await this.repository.prepareReassignFaces(mergeData); - for (const assetId of assetIds) { - await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId: mergeId } }); - } await this.repository.reassignFaces(mergeData); await this.jobRepository.queue({ name: JobName.PERSON_DELETE, data: { id: mergePerson.id } }); diff --git a/server/src/domain/repositories/access.repository.ts b/server/src/domain/repositories/access.repository.ts index b6a71daf87..a501e4d6d4 100644 --- a/server/src/domain/repositories/access.repository.ts +++ b/server/src/domain/repositories/access.repository.ts @@ -34,6 +34,7 @@ export interface IAccessRepository { }; person: { + hasFaceOwnerAccess(userId: string, assetFaceId: Set): Promise>; checkOwnerAccess(userId: string, personIds: Set): Promise>; }; diff --git a/server/src/domain/repositories/person.repository.ts b/server/src/domain/repositories/person.repository.ts index 2554a8a6f9..4c6dcdc9c9 100644 --- a/server/src/domain/repositories/person.repository.ts +++ b/server/src/domain/repositories/person.repository.ts @@ -34,7 +34,7 @@ export interface IPersonRepository { getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise; getAssets(personId: string): Promise; - prepareReassignFaces(data: UpdateFacesData): Promise; + reassignFaces(data: UpdateFacesData): Promise; create(entity: Partial): Promise; @@ -48,4 +48,8 @@ export interface IPersonRepository { getFacesByIds(ids: AssetFaceId[]): Promise; getRandomFace(personId: string): Promise; createFace(entity: Partial): Promise; + getFaces(assetId: string): Promise; + reassignFace(assetFaceId: string, newPersonId: string): Promise; + getFaceById(id: string): Promise; + getFaceByIdWithAssets(id: string): Promise; } diff --git a/server/src/immich/app.module.ts b/server/src/immich/app.module.ts index 7f9edfd426..cafaefb3f4 100644 --- a/server/src/immich/app.module.ts +++ b/server/src/immich/app.module.ts @@ -19,6 +19,7 @@ import { AssetsController, AuditController, AuthController, + FaceController, JobController, LibraryController, OAuthController, @@ -50,6 +51,7 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors'; APIKeyController, AuditController, AuthController, + FaceController, JobController, LibraryController, OAuthController, diff --git a/server/src/immich/controllers/face.controller.ts b/server/src/immich/controllers/face.controller.ts new file mode 100644 index 0000000000..5fd2dff276 --- /dev/null +++ b/server/src/immich/controllers/face.controller.ts @@ -0,0 +1,28 @@ +import { AssetFaceResponseDto, AuthUserDto, FaceDto, PersonResponseDto, PersonService } from '@app/domain'; +import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuthUser, Authenticated } from '../app.guard'; +import { UseValidation } from '../app.utils'; +import { UUIDParamDto } from './dto/uuid-param.dto'; + +@ApiTags('Face') +@Controller('face') +@Authenticated() +@UseValidation() +export class FaceController { + constructor(private service: PersonService) {} + + @Get() + getFaces(@AuthUser() authUser: AuthUserDto, @Query() dto: FaceDto): Promise { + return this.service.getFacesById(authUser, dto); + } + + @Put(':id') + reassignFacesById( + @AuthUser() authUser: AuthUserDto, + @Param() { id }: UUIDParamDto, + @Body() dto: FaceDto, + ): Promise { + return this.service.reassignFacesById(authUser, id, dto); + } +} diff --git a/server/src/immich/controllers/index.ts b/server/src/immich/controllers/index.ts index b54a63d860..c177144c3d 100644 --- a/server/src/immich/controllers/index.ts +++ b/server/src/immich/controllers/index.ts @@ -5,6 +5,7 @@ export * from './app.controller'; export * from './asset.controller'; export * from './audit.controller'; export * from './auth.controller'; +export * from './face.controller'; export * from './job.controller'; export * from './library.controller'; export * from './oauth.controller'; diff --git a/server/src/immich/controllers/person.controller.ts b/server/src/immich/controllers/person.controller.ts index e581fddb1a..51f222c7d6 100644 --- a/server/src/immich/controllers/person.controller.ts +++ b/server/src/immich/controllers/person.controller.ts @@ -1,4 +1,5 @@ import { + AssetFaceUpdateDto, AssetResponseDto, AuthUserDto, BulkIdResponseDto, @@ -34,6 +35,20 @@ export class PersonController { return this.service.getAll(authUser, withHidden); } + @Post() + createPerson(@AuthUser() authUser: AuthUserDto): Promise { + return this.service.createPerson(authUser); + } + + @Put(':id/reassign') + reassignFaces( + @AuthUser() authUser: AuthUserDto, + @Param() { id }: UUIDParamDto, + @Body() dto: AssetFaceUpdateDto, + ): Promise { + return this.service.reassignFaces(authUser, id, dto); + } + @Put() updatePeople(@AuthUser() authUser: AuthUserDto, @Body() dto: PeopleUpdateDto): Promise { return this.service.updatePeople(authUser, dto); diff --git a/server/src/infra/entities/person.entity.ts b/server/src/infra/entities/person.entity.ts index 2735204e64..3b4545e45c 100644 --- a/server/src/infra/entities/person.entity.ts +++ b/server/src/infra/entities/person.entity.ts @@ -8,7 +8,6 @@ import { UpdateDateColumn, } from 'typeorm'; import { AssetFaceEntity } from './asset-face.entity'; -import { AssetEntity } from './asset.entity'; import { UserEntity } from './user.entity'; @Entity('person') @@ -40,8 +39,8 @@ export class PersonEntity { @Column({ nullable: true }) faceAssetId!: string | null; - @ManyToOne(() => AssetEntity, { onDelete: 'SET NULL', nullable: true }) - faceAsset!: AssetEntity | null; + @ManyToOne(() => AssetFaceEntity, { onDelete: 'SET NULL', nullable: true }) + faceAsset!: AssetFaceEntity | null; @OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.person) faces!: AssetFaceEntity[]; diff --git a/server/src/infra/migrations/1699727044012-EditFaceAssetForeignKey.ts b/server/src/infra/migrations/1699727044012-EditFaceAssetForeignKey.ts new file mode 100644 index 0000000000..fdff7ea062 --- /dev/null +++ b/server/src/infra/migrations/1699727044012-EditFaceAssetForeignKey.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class EditFaceAssetForeignKey1699727044012 implements MigrationInterface { + name = 'EditFaceAssetForeignKey1699727044012' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" DROP CONSTRAINT "FK_2bbabe31656b6778c6b87b61023"`); + await queryRunner.query(`UPDATE person SET "faceAssetId" = asset_faces."id" FROM asset_faces WHERE person."faceAssetId" = asset_faces."assetId" AND person."id" = asset_faces."personId"`) + await queryRunner.query(`ALTER TABLE "person" ADD CONSTRAINT "FK_2bbabe31656b6778c6b87b61023" FOREIGN KEY ("faceAssetId") REFERENCES "asset_faces"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" DROP CONSTRAINT "FK_2bbabe31656b6778c6b87b61023"`); + await queryRunner.query(`UPDATE person SET "faceAssetId" = assets."id" FROM assets, asset_faces WHERE person."faceAssetId" = asset_faces."id" AND asset_faces."assetId" = assets."id"`); + await queryRunner.query(`ALTER TABLE "person" ADD CONSTRAINT "FK_2bbabe31656b6778c6b87b61023" FOREIGN KEY ("faceAssetId") REFERENCES "assets"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + } + +} diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index 208b7095c4..5a3f9925b1 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -5,6 +5,7 @@ import { ActivityEntity, AlbumEntity, AssetEntity, + AssetFaceEntity, LibraryEntity, PartnerEntity, PersonEntity, @@ -20,6 +21,7 @@ export class AccessRepository implements IAccessRepository { @InjectRepository(LibraryEntity) private libraryRepository: Repository, @InjectRepository(PartnerEntity) private partnerRepository: Repository, @InjectRepository(PersonEntity) private personRepository: Repository, + @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository, @InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository, @InjectRepository(UserTokenEntity) private tokenRepository: Repository, ) {} @@ -318,6 +320,22 @@ export class AccessRepository implements IAccessRepository { }) .then((persons) => new Set(persons.map((person) => person.id))); }, + hasFaceOwnerAccess: async (userId: string, assetFaceIds: Set): Promise> => { + if (assetFaceIds.size === 0) { + return new Set(); + } + return this.assetFaceRepository + .find({ + select: { id: true }, + where: { + id: In([...assetFaceIds]), + asset: { + ownerId: userId, + }, + }, + }) + .then((faces) => new Set(faces.map((face) => face.id))); + }, }; partner = { diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts index e3c0ac26a6..038b955a1a 100644 --- a/server/src/infra/repositories/person.repository.ts +++ b/server/src/infra/repositories/person.repository.ts @@ -107,6 +107,48 @@ export class PersonRepository implements IPersonRepository { } @GenerateSql({ params: [DummyValue.UUID] }) + getFaces(assetId: string): Promise { + return this.assetFaceRepository.find({ + where: { assetId }, + relations: { + person: true, + }, + }); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getFaceById(id: string): Promise { + return this.assetFaceRepository.findOneOrFail({ + where: { id }, + relations: { + person: true, + }, + }); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getFaceByIdWithAssets(id: string): Promise { + return this.assetFaceRepository.findOne({ + where: { id }, + relations: { + person: true, + asset: true, + }, + }); + } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) + async reassignFace(assetFaceId: string, newPersonId: string): Promise { + const result = await this.assetFaceRepository + .createQueryBuilder() + .update() + .set({ personId: newPersonId }) + .where({ id: assetFaceId }) + .execute(); + + return result.affected ?? 0; + } + getById(personId: string): Promise { return this.personRepository.findOne({ where: { id: personId } }); } diff --git a/server/src/infra/sql/person.repository.sql b/server/src/infra/sql/person.repository.sql index 9332837460..68d049ae0a 100644 --- a/server/src/infra/sql/person.repository.sql +++ b/server/src/infra/sql/person.repository.sql @@ -133,24 +133,145 @@ GROUP BY HAVING COUNT("face"."assetId") = 0 --- PersonRepository.getById +-- PersonRepository.getFaces SELECT - "PersonEntity"."id" AS "PersonEntity_id", - "PersonEntity"."createdAt" AS "PersonEntity_createdAt", - "PersonEntity"."updatedAt" AS "PersonEntity_updatedAt", - "PersonEntity"."ownerId" AS "PersonEntity_ownerId", - "PersonEntity"."name" AS "PersonEntity_name", - "PersonEntity"."birthDate" AS "PersonEntity_birthDate", - "PersonEntity"."thumbnailPath" AS "PersonEntity_thumbnailPath", - "PersonEntity"."faceAssetId" AS "PersonEntity_faceAssetId", - "PersonEntity"."isHidden" AS "PersonEntity_isHidden" + "AssetFaceEntity"."id" AS "AssetFaceEntity_id", + "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", + "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", + "AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding", + "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", + "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", + "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", + "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", + "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", + "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", + "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", + "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", + "AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId", + "AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name", + "AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate", + "AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath", + "AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId", + "AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden" FROM - "person" "PersonEntity" + "asset_faces" "AssetFaceEntity" + LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" WHERE - ("PersonEntity"."id" = $1) + ("AssetFaceEntity"."assetId" = $1) + +-- PersonRepository.getFaceById +SELECT DISTINCT + "distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id" +FROM + ( + SELECT + "AssetFaceEntity"."id" AS "AssetFaceEntity_id", + "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", + "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", + "AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding", + "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", + "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", + "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", + "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", + "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", + "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", + "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", + "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", + "AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId", + "AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name", + "AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate", + "AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath", + "AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId", + "AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden" + FROM + "asset_faces" "AssetFaceEntity" + LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" + WHERE + ("AssetFaceEntity"."id" = $1) + ) "distinctAlias" +ORDER BY + "AssetFaceEntity_id" ASC LIMIT 1 +-- PersonRepository.getFaceByIdWithAssets +SELECT DISTINCT + "distinctAlias"."AssetFaceEntity_id" AS "ids_AssetFaceEntity_id" +FROM + ( + SELECT + "AssetFaceEntity"."id" AS "AssetFaceEntity_id", + "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", + "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", + "AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding", + "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", + "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", + "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", + "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", + "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", + "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", + "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", + "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", + "AssetFaceEntity__AssetFaceEntity_person"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_person_ownerId", + "AssetFaceEntity__AssetFaceEntity_person"."name" AS "AssetFaceEntity__AssetFaceEntity_person_name", + "AssetFaceEntity__AssetFaceEntity_person"."birthDate" AS "AssetFaceEntity__AssetFaceEntity_person_birthDate", + "AssetFaceEntity__AssetFaceEntity_person"."thumbnailPath" AS "AssetFaceEntity__AssetFaceEntity_person_thumbnailPath", + "AssetFaceEntity__AssetFaceEntity_person"."faceAssetId" AS "AssetFaceEntity__AssetFaceEntity_person_faceAssetId", + "AssetFaceEntity__AssetFaceEntity_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden", + "AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id", + "AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId", + "AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId", + "AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId", + "AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId", + "AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type", + "AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath", + "AssetFaceEntity__AssetFaceEntity_asset"."resizePath" AS "AssetFaceEntity__AssetFaceEntity_asset_resizePath", + "AssetFaceEntity__AssetFaceEntity_asset"."webpPath" AS "AssetFaceEntity__AssetFaceEntity_asset_webpPath", + "AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash", + "AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath", + "AssetFaceEntity__AssetFaceEntity_asset"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_asset_createdAt", + "AssetFaceEntity__AssetFaceEntity_asset"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_updatedAt", + "AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_deletedAt", + "AssetFaceEntity__AssetFaceEntity_asset"."fileCreatedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileCreatedAt", + "AssetFaceEntity__AssetFaceEntity_asset"."localDateTime" AS "AssetFaceEntity__AssetFaceEntity_asset_localDateTime", + "AssetFaceEntity__AssetFaceEntity_asset"."fileModifiedAt" AS "AssetFaceEntity__AssetFaceEntity_asset_fileModifiedAt", + "AssetFaceEntity__AssetFaceEntity_asset"."isFavorite" AS "AssetFaceEntity__AssetFaceEntity_asset_isFavorite", + "AssetFaceEntity__AssetFaceEntity_asset"."isArchived" AS "AssetFaceEntity__AssetFaceEntity_asset_isArchived", + "AssetFaceEntity__AssetFaceEntity_asset"."isExternal" AS "AssetFaceEntity__AssetFaceEntity_asset_isExternal", + "AssetFaceEntity__AssetFaceEntity_asset"."isReadOnly" AS "AssetFaceEntity__AssetFaceEntity_asset_isReadOnly", + "AssetFaceEntity__AssetFaceEntity_asset"."isOffline" AS "AssetFaceEntity__AssetFaceEntity_asset_isOffline", + "AssetFaceEntity__AssetFaceEntity_asset"."checksum" AS "AssetFaceEntity__AssetFaceEntity_asset_checksum", + "AssetFaceEntity__AssetFaceEntity_asset"."duration" AS "AssetFaceEntity__AssetFaceEntity_asset_duration", + "AssetFaceEntity__AssetFaceEntity_asset"."isVisible" AS "AssetFaceEntity__AssetFaceEntity_asset_isVisible", + "AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId", + "AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName", + "AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath", + "AssetFaceEntity__AssetFaceEntity_asset"."stackParentId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackParentId" + FROM + "asset_faces" "AssetFaceEntity" + LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" + LEFT JOIN "assets" "AssetFaceEntity__AssetFaceEntity_asset" ON "AssetFaceEntity__AssetFaceEntity_asset"."id" = "AssetFaceEntity"."assetId" + AND ( + "AssetFaceEntity__AssetFaceEntity_asset"."deletedAt" IS NULL + ) + WHERE + ("AssetFaceEntity"."id" = $1) + ) "distinctAlias" +ORDER BY + "AssetFaceEntity_id" ASC +LIMIT + 1 + +-- PersonRepository.reassignFace +UPDATE "asset_faces" +SET + "personId" = $1 +WHERE + "id" = $2 + -- PersonRepository.getByName SELECT "person"."id" AS "person_id", diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts index d6e5b741df..76e2d14f72 100644 --- a/server/test/fixtures/person.stub.ts +++ b/server/test/fixtures/person.stub.ts @@ -1,5 +1,4 @@ import { PersonEntity } from '@app/infra/entities'; -import { assetStub } from '@test/fixtures/asset.stub'; import { userStub } from './user.stub'; export const personStub = { @@ -41,7 +40,7 @@ export const personStub = { birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', faces: [], - faceAssetId: null, + faceAssetId: 'assetFaceId', faceAsset: null, isHidden: false, }), @@ -97,8 +96,8 @@ export const personStub = { birthDate: null, thumbnailPath: '/new/path/to/thumbnail.jpg', faces: [], - faceAssetId: assetStub.image.id, - faceAsset: assetStub.image, + faceAssetId: 'asset-id', + faceAsset: null, isHidden: false, }), primaryPerson: Object.freeze({ diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index 4c2a5ed8d9..52fa21252f 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -50,6 +50,7 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => }, person: { + hasFaceOwnerAccess: jest.fn(), checkOwnerAccess: jest.fn().mockResolvedValue(new Set()), }, diff --git a/server/test/repositories/person.repository.mock.ts b/server/test/repositories/person.repository.mock.ts index 90a15221d5..4b805fa655 100644 --- a/server/test/repositories/person.repository.mock.ts +++ b/server/test/repositories/person.repository.mock.ts @@ -20,8 +20,12 @@ export const newPersonRepositoryMock = (): jest.Mocked => { getAllFaces: jest.fn(), getFacesByIds: jest.fn(), getRandomFace: jest.fn(), - prepareReassignFaces: jest.fn(), + reassignFaces: jest.fn(), createFace: jest.fn(), + getFaces: jest.fn(), + reassignFace: jest.fn(), + getFaceById: jest.fn(), + getFaceByIdWithAssets: jest.fn(), }; }; diff --git a/web/src/api/api.ts b/web/src/api/api.ts index fc6f49f021..aecf28e12e 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -21,6 +21,7 @@ import { UserApiFp, AuditApi, ActivityApi, + FaceApi, } from './open-api'; import { BASE_PATH } from './open-api/base'; import { DUMMY_BASE_URL, toPathString } from './open-api/common'; @@ -33,6 +34,7 @@ class ImmichApi { public assetApi: AssetApi; public auditApi: AuditApi; public authenticationApi: AuthenticationApi; + public faceApi: FaceApi; public jobApi: JobApi; public keyApi: APIKeyApi; public oauthApi: OAuthApi; @@ -60,6 +62,7 @@ class ImmichApi { this.libraryApi = new LibraryApi(this.config); this.assetApi = new AssetApi(this.config); this.authenticationApi = new AuthenticationApi(this.config); + this.faceApi = new FaceApi(this.config); this.jobApi = new JobApi(this.config); this.keyApi = new APIKeyApi(this.config); this.oauthApi = new OAuthApi(this.config); diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 54cc921499..f8b8188ac9 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -586,6 +586,142 @@ export const AssetBulkUploadCheckResultReasonEnum = { export type AssetBulkUploadCheckResultReasonEnum = typeof AssetBulkUploadCheckResultReasonEnum[keyof typeof AssetBulkUploadCheckResultReasonEnum]; +/** + * + * @export + * @interface AssetFaceResponseDto + */ +export interface AssetFaceResponseDto { + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'boundingBoxX1': number; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'boundingBoxX2': number; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'boundingBoxY1': number; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'boundingBoxY2': number; + /** + * + * @type {string} + * @memberof AssetFaceResponseDto + */ + 'id': string; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'imageHeight': number; + /** + * + * @type {number} + * @memberof AssetFaceResponseDto + */ + 'imageWidth': number; + /** + * + * @type {PersonResponseDto} + * @memberof AssetFaceResponseDto + */ + 'person': PersonResponseDto | null; +} +/** + * + * @export + * @interface AssetFaceUpdateDto + */ +export interface AssetFaceUpdateDto { + /** + * + * @type {Array} + * @memberof AssetFaceUpdateDto + */ + 'data': Array; +} +/** + * + * @export + * @interface AssetFaceUpdateItem + */ +export interface AssetFaceUpdateItem { + /** + * + * @type {string} + * @memberof AssetFaceUpdateItem + */ + 'assetId': string; + /** + * + * @type {string} + * @memberof AssetFaceUpdateItem + */ + 'personId': string; +} +/** + * + * @export + * @interface AssetFaceWithoutPersonResponseDto + */ +export interface AssetFaceWithoutPersonResponseDto { + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'boundingBoxX1': number; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'boundingBoxX2': number; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'boundingBoxY1': number; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'boundingBoxY2': number; + /** + * + * @type {string} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'id': string; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'imageHeight': number; + /** + * + * @type {number} + * @memberof AssetFaceWithoutPersonResponseDto + */ + 'imageWidth': number; +} /** * * @export @@ -842,10 +978,10 @@ export interface AssetResponseDto { 'ownerId': string; /** * - * @type {Array} + * @type {Array} * @memberof AssetResponseDto */ - 'people'?: Array; + 'people'?: Array; /** * * @type {boolean} @@ -1672,6 +1808,19 @@ export interface ExifResponseDto { */ 'timeZone'?: string | null; } +/** + * + * @export + * @interface FaceDto + */ +export interface FaceDto { + /** + * + * @type {string} + * @memberof FaceDto + */ + 'id': string; +} /** * * @export @@ -2564,6 +2713,49 @@ export interface PersonUpdateDto { */ 'name'?: string; } +/** + * + * @export + * @interface PersonWithFacesResponseDto + */ +export interface PersonWithFacesResponseDto { + /** + * + * @type {string} + * @memberof PersonWithFacesResponseDto + */ + 'birthDate': string | null; + /** + * + * @type {Array} + * @memberof PersonWithFacesResponseDto + */ + 'faces': Array; + /** + * + * @type {string} + * @memberof PersonWithFacesResponseDto + */ + 'id': string; + /** + * + * @type {boolean} + * @memberof PersonWithFacesResponseDto + */ + 'isHidden': boolean; + /** + * + * @type {string} + * @memberof PersonWithFacesResponseDto + */ + 'name': string; + /** + * + * @type {string} + * @memberof PersonWithFacesResponseDto + */ + 'thumbnailPath': string; +} /** * * @export @@ -11349,6 +11541,233 @@ export class AuthenticationApi extends BaseAPI { } +/** + * FaceApi - axios parameter creator + * @export + */ +export const FaceApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getFaces: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getFaces', 'id', id) + const localVarPath = `/face`; + // 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 (id !== undefined) { + localVarQueryParameter['id'] = id; + } + + + + 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 {FaceDto} faceDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reassignFacesById: async (id: string, faceDto: FaceDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('reassignFacesById', 'id', id) + // verify required parameter 'faceDto' is not null or undefined + assertParamExists('reassignFacesById', 'faceDto', faceDto) + const localVarPath = `/face/{id}` + .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: 'PUT', ...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) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(faceDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * FaceApi - functional programming interface + * @export + */ +export const FaceApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = FaceApiAxiosParamCreator(configuration) + return { + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getFaces(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getFaces(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {FaceDto} faceDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async reassignFacesById(id: string, faceDto: FaceDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFacesById(id, faceDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * FaceApi - factory interface + * @export + */ +export const FaceApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = FaceApiFp(configuration) + return { + /** + * + * @param {FaceApiGetFacesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getFaces(requestParameters: FaceApiGetFacesRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getFaces(requestParameters.id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {FaceApiReassignFacesByIdRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for getFaces operation in FaceApi. + * @export + * @interface FaceApiGetFacesRequest + */ +export interface FaceApiGetFacesRequest { + /** + * + * @type {string} + * @memberof FaceApiGetFaces + */ + readonly id: string +} + +/** + * Request parameters for reassignFacesById operation in FaceApi. + * @export + * @interface FaceApiReassignFacesByIdRequest + */ +export interface FaceApiReassignFacesByIdRequest { + /** + * + * @type {string} + * @memberof FaceApiReassignFacesById + */ + readonly id: string + + /** + * + * @type {FaceDto} + * @memberof FaceApiReassignFacesById + */ + readonly faceDto: FaceDto +} + +/** + * FaceApi - object-oriented interface + * @export + * @class FaceApi + * @extends {BaseAPI} + */ +export class FaceApi extends BaseAPI { + /** + * + * @param {FaceApiGetFacesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof FaceApi + */ + public getFaces(requestParameters: FaceApiGetFacesRequest, options?: AxiosRequestConfig) { + return FaceApiFp(this.configuration).getFaces(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {FaceApiReassignFacesByIdRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof FaceApi + */ + public reassignFacesById(requestParameters: FaceApiReassignFacesByIdRequest, options?: AxiosRequestConfig) { + return FaceApiFp(this.configuration).reassignFacesById(requestParameters.id, requestParameters.faceDto, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * JobApi - axios parameter creator * @export @@ -13180,6 +13599,44 @@ export class PartnerApi extends BaseAPI { */ export const PersonApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createPerson: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/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: 'POST', ...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}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {boolean} [withHidden] @@ -13439,6 +13896,54 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio options: localVarRequestOptions, }; }, + /** + * + * @param {string} id + * @param {AssetFaceUpdateDto} assetFaceUpdateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reassignFaces: async (id: string, assetFaceUpdateDto: AssetFaceUpdateDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('reassignFaces', 'id', id) + // verify required parameter 'assetFaceUpdateDto' is not null or undefined + assertParamExists('reassignFaces', 'assetFaceUpdateDto', assetFaceUpdateDto) + const localVarPath = `/person/{id}/reassign` + .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: 'PUT', ...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) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(assetFaceUpdateDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {PeopleUpdateDto} peopleUpdateDto @@ -13541,6 +14046,15 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio export const PersonApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = PersonApiAxiosParamCreator(configuration) return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createPerson(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createPerson(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {boolean} [withHidden] @@ -13602,6 +14116,17 @@ export const PersonApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {string} id + * @param {AssetFaceUpdateDto} assetFaceUpdateDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async reassignFaces(id: string, assetFaceUpdateDto: AssetFaceUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.reassignFaces(id, assetFaceUpdateDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {PeopleUpdateDto} peopleUpdateDto @@ -13633,6 +14158,14 @@ export const PersonApiFp = function(configuration?: Configuration) { export const PersonApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = PersonApiFp(configuration) return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createPerson(options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.createPerson(options).then((request) => request(axios, basePath)); + }, /** * * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters. @@ -13687,6 +14220,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {PersonApiReassignFacesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + reassignFaces(requestParameters: PersonApiReassignFacesRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.reassignFaces(requestParameters.id, requestParameters.assetFaceUpdateDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters. @@ -13799,6 +14341,27 @@ export interface PersonApiMergePersonRequest { readonly mergePersonDto: MergePersonDto } +/** + * Request parameters for reassignFaces operation in PersonApi. + * @export + * @interface PersonApiReassignFacesRequest + */ +export interface PersonApiReassignFacesRequest { + /** + * + * @type {string} + * @memberof PersonApiReassignFaces + */ + readonly id: string + + /** + * + * @type {AssetFaceUpdateDto} + * @memberof PersonApiReassignFaces + */ + readonly assetFaceUpdateDto: AssetFaceUpdateDto +} + /** * Request parameters for updatePeople operation in PersonApi. * @export @@ -13841,6 +14404,16 @@ export interface PersonApiUpdatePersonRequest { * @extends {BaseAPI} */ export class PersonApi extends BaseAPI { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PersonApi + */ + public createPerson(options?: AxiosRequestConfig) { + return PersonApiFp(this.configuration).createPerson(options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters. @@ -13907,6 +14480,17 @@ export class PersonApi extends BaseAPI { return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {PersonApiReassignFacesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PersonApi + */ + public reassignFaces(requestParameters: PersonApiReassignFacesRequest, options?: AxiosRequestConfig) { + return PersonApiFp(this.configuration).reassignFaces(requestParameters.id, requestParameters.assetFaceUpdateDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters. diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index b93cc1379d..f2ebd5c8dc 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -560,7 +560,7 @@
{#if $slideshowState === SlideshowState.None} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 415aa2a3ce..aa36f9195e 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -15,16 +15,18 @@ mdiCalendar, mdiCameraIris, mdiClose, - mdiPencil, + mdiEye, + mdiEyeOff, mdiImageOutline, mdiMapMarkerOutline, mdiInformationOutline, - mdiEye, - mdiEyeOff, + mdiPencil, } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; + import PersonSidePanel from '../faces-page/person-side-panel.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import Map from '../shared-components/map/map.svelte'; + import { boundingBoxesArray } from '$lib/stores/people.store'; import { websocketStore } from '$lib/stores/websocket'; import { AppRoute } from '$lib/constants'; import ChangeLocation from '../shared-components/change-location.svelte'; @@ -35,8 +37,21 @@ export let albums: AlbumResponseDto[] = []; export let albumId: string | null = null; + let showAssetPath = false; let textarea: HTMLTextAreaElement; let description: string; + let showEditFaces = false; + let previousId: string; + + $: { + if (!previousId) { + previousId = asset.id; + } + if (asset.id !== previousId) { + showEditFaces = false; + previousId = asset.id; + } + } $: isOwner = $page?.data?.user?.id === asset.ownerId; @@ -84,6 +99,14 @@ return undefined; }; + const handleRefreshPeople = async () => { + await api.assetApi.getAssetById({ id: asset.id }).then((res) => { + people = res.data?.people || []; + textarea.value = res.data?.exifInfo?.description || ''; + }); + showEditFaces = false; + }; + const autoGrowHeight = (e: Event) => { const target = e.target as HTMLTextAreaElement; target.style.height = 'auto'; @@ -106,7 +129,6 @@ } }; - let showAssetPath = false; const toggleAssetPath = () => (showAssetPath = !showAssetPath); let isShowChangeDate = false; @@ -139,7 +161,7 @@ } -
+
@@ -589,3 +628,13 @@ {/each}
{/if} + +{#if showEditFaces} + { + showEditFaces = false; + }} + on:refresh={handleRefreshPeople} + /> +{/if} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index 9b34823322..68be9c12b2 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -8,6 +8,9 @@ import { photoZoomState } from '$lib/stores/zoom-image.store'; import { isWebCompatibleImage } from '$lib/utils/asset-utils'; import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; + import { photoViewer } from '$lib/stores/assets.store'; + import { getBoundingBox } from '$lib/utils/people-utils'; + import { boundingBoxesArray } from '$lib/stores/people.store'; export let asset: AssetResponseDto; export let element: HTMLDivElement | undefined = undefined; @@ -20,6 +23,13 @@ let copyImageToClipboard: (src: string) => Promise; let canCopyImagesToClipboard: () => boolean; + $: if (imgElement) { + createZoomImageWheel(imgElement, { + maxZoom: 10, + wheelZoomRatio: 0.2, + }); + } + onMount(async () => { // Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295 // TODO: Move to regular import once the package correctly supports ESM. @@ -29,6 +39,7 @@ }); onDestroy(() => { + $boundingBoxesArray = []; abortController?.abort(); }); @@ -105,16 +116,10 @@ if (state.currentZoom > 1 && isWebCompatibleImage(asset) && !hasZoomed) { hasZoomed = true; + loadAssetData({ loadOriginal: true }); } }); - - $: if (imgElement) { - createZoomImageWheel(imgElement, { - maxZoom: 10, - wheelZoomRatio: 0.2, - }); - } @@ -129,12 +134,19 @@ {:then}
{asset.id} + {#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox} +
+ {/each}
{/await}
diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 5e57cb02e8..1be7e8ad21 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -52,7 +52,7 @@ {#if hidden}
- +
{/if} diff --git a/web/src/lib/components/elements/icon.svelte b/web/src/lib/components/elements/icon.svelte index 94fe5ec93e..b15043449f 100644 --- a/web/src/lib/components/elements/icon.svelte +++ b/web/src/lib/components/elements/icon.svelte @@ -4,7 +4,7 @@ export let size: string | number = '1em'; export let color = 'currentColor'; export let path: string; - export let title = ''; + export let title: string | null = null; export let desc = ''; export let flipped = false; let className = ''; diff --git a/web/src/lib/components/faces-page/assign-face-side-panel.svelte b/web/src/lib/components/faces-page/assign-face-side-panel.svelte new file mode 100644 index 0000000000..9b391e9389 --- /dev/null +++ b/web/src/lib/components/faces-page/assign-face-side-panel.svelte @@ -0,0 +1,246 @@ + + +
+
+ {#if !searchFaces} +
+ +

Select face

+
+
+ + {#if !isShowLoadingNewPerson} + + {:else} +
+ +
+ {/if} +
+ {:else} + +
+ + {#if isShowLoadingSearch} +
+ +
+ {/if} +
+ + {/if} +
+
+

All people

+
+ {#if searchName == ''} + {#each allPeople as person (person.id)} + {#if person.id !== peopleWithFaces[editedPersonIndex].person?.id} +
+ +
+ {/if} + {/each} + {:else} + {#each searchedPeople as person (person.id)} + {#if person.id !== peopleWithFaces[editedPersonIndex].person?.id} +
+ +
+ {/if} + {/each} + {/if} +
+
+
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 a8a6a204c0..d21f0de98b 100644 --- a/web/src/lib/components/faces-page/merge-face-selector.svelte +++ b/web/src/lib/components/faces-page/merge-face-selector.svelte @@ -12,23 +12,18 @@ import { handleError } from '$lib/utils/handle-error'; import { goto } from '$app/navigation'; import { AppRoute } from '$lib/constants'; - import { mdiCallMerge, mdiClose, mdiMagnify, mdiMerge, mdiSwapHorizontal } from '@mdi/js'; + import { mdiCallMerge, mdiMerge, mdiSwapHorizontal } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; - import { cloneDeep } from 'lodash-es'; - import LoadingSpinner from '../shared-components/loading-spinner.svelte'; - import { searchNameLocal } from '$lib/utils/person'; + import PeopleList from './people-list.svelte'; import { page } from '$app/stores'; export let person: PersonResponseDto; let people: PersonResponseDto[] = []; - let peopleCopy: PersonResponseDto[] = []; let selectedPeople: PersonResponseDto[] = []; let screenHeight: number; let isShowConfirmation = false; - let name = ''; - let searchWord: string; - let isSearchingPeople = false; + let dispatch = createEventDispatcher(); $: hasSelection = selectedPeople.length > 0; @@ -39,44 +34,12 @@ onMount(async () => { const { data } = await api.personApi.getAllPeople({ withHidden: false }); people = data.people; - peopleCopy = cloneDeep(people); }); const onClose = () => { dispatch('go-back'); }; - const resetSearch = () => { - name = ''; - people = peopleCopy; - }; - - const searchPeople = async (force: boolean) => { - if (name === '') { - people = peopleCopy; - return; - } - if (!force) { - if (people.length < 20 && name.startsWith(searchWord)) { - people = searchNameLocal(name, peopleCopy, 10); - return; - } - } - - const timeout = setTimeout(() => (isSearchingPeople = true), 100); - try { - const { data } = await api.searchApi.searchPerson({ name }); - people = data; - searchWord = name; - } catch (error) { - handleError(error, "Can't search people"); - } finally { - clearTimeout(timeout); - } - - isSearchingPeople = false; - }; - const handleSwapPeople = () => { [person, selectedPeople[0]] = [selectedPeople[0], person]; $page.url.searchParams.set('action', 'merge'); @@ -113,7 +76,7 @@ }); dispatch('merge'); } catch (error) { - handleError(error, 'Cannot merge faces'); + handleError(error, 'Cannot merge people'); } finally { isShowConfirmation = false; } @@ -131,7 +94,7 @@ {#if hasSelection} Selected {selectedPeople.length} {:else} - Merge faces + Merge people {/if}
@@ -151,7 +114,7 @@
-

Choose matching faces to merge

+

Choose matching people to merge

{#each selectedPeople as person (person.id)} @@ -178,57 +141,25 @@
-
- - - searchPeople(false)} - /> - {#if name} - - {/if} - {#if isSearchingPeople} -
- -
- {/if} -
- -
-
- {#each unselectedPeople as person (person.id)} - onSelect(person)} circle border selectable /> - {/each} -
-
+ onSelect(detail)} + />
{#if isShowConfirmation} (isShowConfirmation = false)} > -

Are you sure you want merge these faces?
This action is irreversible.

-
+

Are you sure you want merge these people ?

{/if}
diff --git a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte index 4300708ae3..cb5022d23d 100644 --- a/web/src/lib/components/faces-page/merge-suggestion-modal.svelte +++ b/web/src/lib/components/faces-page/merge-suggestion-modal.svelte @@ -36,7 +36,7 @@ >

- Merge faces - {title} + Merge People - {title}

dispatch('close')} /> @@ -108,7 +108,7 @@
-

Are these the same face?

+

Are these the same person?

They will be merged together

diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index 92d339d02a..a14630a466 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -14,12 +14,12 @@ export let person: PersonResponseDto; export let preload = false; - type MenuItemEvent = 'change-name' | 'set-birth-date' | 'merge-faces' | 'hide-face'; + type MenuItemEvent = 'change-name' | 'set-birth-date' | 'merge-people' | 'hide-person'; let dispatch = createEventDispatcher<{ 'change-name': void; 'set-birth-date': void; - 'merge-faces': void; - 'hide-face': void; + 'merge-people': void; + 'hide-person': void; }>(); let showVerticalDots = false; @@ -82,10 +82,10 @@ {#if showContextMenu} onMenuExit()}> - onMenuClick('hide-face')} text="Hide face" /> + onMenuClick('hide-person')} text="Hide Person" /> onMenuClick('change-name')} text="Change name" /> onMenuClick('set-birth-date')} text="Set date of birth" /> - onMenuClick('merge-faces')} text="Merge faces" /> + onMenuClick('merge-people')} text="Merge People" /> {/if} diff --git a/web/src/lib/components/faces-page/people-list.svelte b/web/src/lib/components/faces-page/people-list.svelte new file mode 100644 index 0000000000..adf59c40c1 --- /dev/null +++ b/web/src/lib/components/faces-page/people-list.svelte @@ -0,0 +1,106 @@ + + +
+ + + searchPeople(false)} + /> + {#if name} + + {/if} + {#if isSearchingPeople} +
+ +
+ {/if} +
+ +
+
+ {#each people as person (person.id)} + { + dispatch('select', person); + }} + circle + border + selectable + /> + {/each} +
+
diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte new file mode 100644 index 0000000000..ecd98fbfbc --- /dev/null +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -0,0 +1,278 @@ + + +
+
+
+ +

Edit faces

+
+ {#if !isShowLoadingDone} + + {:else} + + {/if} +
+ +
+
+ {#if isShowLoadingPeople} +
+ +
+ {:else} + {#each peopleWithFaces as face, index} + {#if face.person} +
+
($boundingBoxesArray = [peopleWithFaces[index]])} + on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} + on:mouseleave={() => ($boundingBoxesArray = [])} + > +
+
+ {#if !selectedPersonToCreate[index]} +

+ {#if selectedPersonToReassign[index]?.id} + {selectedPersonToReassign[index]?.name} + {:else} + {face.person?.name} + {/if} +

+ {/if} + +
+ {#if selectedPersonToCreate[index] || selectedPersonToReassign[index]} + + {:else} + + {/if} +
+
+
+ {/if} + {/each} + {/if} +
+
+
+ +{#if showSeletecFaces} + (showSeletecFaces = false)} + on:createPerson={(event) => handleCreatePerson(event.detail)} + on:reassign={(event) => handleReassignFace(event.detail)} + /> +{/if} diff --git a/web/src/lib/components/faces-page/show-hide.svelte b/web/src/lib/components/faces-page/show-hide.svelte index 93fd26c769..8210c710b4 100644 --- a/web/src/lib/components/faces-page/show-hide.svelte +++ b/web/src/lib/components/faces-page/show-hide.svelte @@ -22,12 +22,12 @@ >
dispatch('closeClick')} /> - +
dispatch('reset-visibility')} /> diff --git a/web/src/lib/components/faces-page/unmerge-face-selector.svelte b/web/src/lib/components/faces-page/unmerge-face-selector.svelte new file mode 100644 index 0000000000..8a7f49cdc3 --- /dev/null +++ b/web/src/lib/components/faces-page/unmerge-face-selector.svelte @@ -0,0 +1,190 @@ + + + + +
+ + + +
+ + +
+ + +
+
+ + +
+
+ {#if selectedPerson !== null} +
+

Choose matching faces to re assign

+ +
+ +
+
+ {/if} + handleSelectedPerson(detail)} + /> +
+
+
diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 1134aa7101..7b1ff8f7a1 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -58,6 +58,8 @@ interface TrashAsset { value: string; } +export const photoViewer = writable(null); + type PendingChange = AddAsset | DeleteAsset | TrashAsset; export class AssetStore { diff --git a/web/src/lib/stores/people.store.ts b/web/src/lib/stores/people.store.ts new file mode 100644 index 0000000000..4aeb044b1c --- /dev/null +++ b/web/src/lib/stores/people.store.ts @@ -0,0 +1,12 @@ +import { writable } from 'svelte/store'; + +export interface Faces { + imageHeight: number; + imageWidth: number; + boundingBoxX1: number; + boundingBoxX2: number; + boundingBoxY1: number; + boundingBoxY2: number; +} + +export const boundingBoxesArray = writable([]); diff --git a/web/src/lib/utils/people-utils.ts b/web/src/lib/utils/people-utils.ts new file mode 100644 index 0000000000..1d630c8c32 --- /dev/null +++ b/web/src/lib/utils/people-utils.ts @@ -0,0 +1,71 @@ +import type { Faces } from '$lib/stores/people.store'; +import type { ZoomImageWheelState } from '@zoom-image/core'; + +const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => { + const ratio = img.naturalWidth / img.naturalHeight; + let width = img.height * ratio; + let height = img.height; + if (width > img.width) { + width = img.width; + height = img.width / ratio; + } + return { width, height }; +}; + +export interface boundingBox { + top: number; + left: number; + width: number; + height: number; +} + +export const getBoundingBox = ( + faces: Faces[], + zoom: ZoomImageWheelState, + photoViewer: HTMLImageElement | null, +): boundingBox[] => { + const boxes: boundingBox[] = []; + + if (photoViewer === null) { + return boxes; + } + const clientHeight = photoViewer.clientHeight; + const clientWidth = photoViewer.clientWidth; + + const { width, height } = getContainedSize(photoViewer); + + for (const face of faces) { + /* + * + * Create the coordinates of the box based on the displayed image. + * The coordinates must take into account margins due to the 'object-fit: contain;' css property of the photo-viewer. + * + */ + const coordinates = { + x1: + (width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX1 + + ((clientWidth - width) / 2) * zoom.currentZoom + + zoom.currentPositionX, + x2: + (width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX2 + + ((clientWidth - width) / 2) * zoom.currentZoom + + zoom.currentPositionX, + y1: + (height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY1 + + ((clientHeight - height) / 2) * zoom.currentZoom + + zoom.currentPositionY, + y2: + (height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY2 + + ((clientHeight - height) / 2) * zoom.currentZoom + + zoom.currentPositionY, + }; + + boxes.push({ + top: Math.round(coordinates.y1), + left: Math.round(coordinates.x1), + width: Math.round(coordinates.x2 - coordinates.x1), + height: Math.round(coordinates.y2 - coordinates.y1), + }); + } + return boxes; +}; diff --git a/web/src/lib/utils/person.ts b/web/src/lib/utils/person.ts index f769ccf376..e26d6c6936 100644 --- a/web/src/lib/utils/person.ts +++ b/web/src/lib/utils/person.ts @@ -30,3 +30,7 @@ export const searchNameLocal = ( }) .slice(0, slice); }; + +export const getPersonNameWithHiddenValue = (name: string, isHidden: boolean) => { + return `${name ? name + (isHidden ? ' ' : '') : ''}${isHidden ? '(hidden)' : ''}`; +}; diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 6a2c709468..2738eeabb6 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -79,7 +79,7 @@ // trigger reactivity people = people; - // Reset variables used on the "Show & hide faces" modal + // Reset variables used on the "Show & hide people" modal showLoadingSpinner = false; selectHidden = false; toggleVisibility = false; @@ -145,13 +145,13 @@ `Unable to change the visibility for ${changed.length} ${changed.length <= 1 ? 'person' : 'people'}`, ); } - // Reset variables used on the "Show & hide faces" modal + // Reset variables used on the "Show & hide people" modal showLoadingSpinner = false; selectHidden = false; toggleVisibility = false; }; - const handleMergeSameFace = async (response: [PersonResponseDto, PersonResponseDto]) => { + const handleMergeSamePerson = async (response: [PersonResponseDto, PersonResponseDto]) => { const [personToMerge, personToBeMergedIn] = response; showMergeModal = false; @@ -167,7 +167,7 @@ people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id); notificationController.show({ - message: 'Merge faces succesfully', + message: 'Merge people succesfully', type: NotificationType.Info, }); } catch (error) { @@ -213,7 +213,7 @@ edittingPerson = detail; }; - const handleHideFace = async (detail: PersonResponseDto) => { + const handleHidePerson = async (detail: PersonResponseDto) => { try { const { data: updatedPerson } = await api.personApi.updatePerson({ id: detail.id, @@ -244,7 +244,7 @@ } }; - const handleMergeFaces = (detail: PersonResponseDto) => { + const handleMergePeople = (detail: PersonResponseDto) => { goto(`${AppRoute.PEOPLE}/${detail.id}?action=merge&previousRoute=${AppRoute.PEOPLE}`); }; @@ -352,7 +352,7 @@ {potentialMergePeople} on:close={() => (showMergeModal = false)} on:reject={() => changeName()} - on:confirm={(event) => handleMergeSameFace(event.detail)} + on:confirm={(event) => handleMergeSamePerson(event.detail)} /> {/if} @@ -363,7 +363,7 @@ (selectHidden = !selectHidden)}>
-

Show & hide faces

+

Show & hide people

{/if} @@ -379,8 +379,8 @@ preload={idx < 20} on:change-name={() => handleChangeName(person)} on:set-birth-date={() => handleSetBirthDate(person)} - on:merge-faces={() => handleMergeFaces(person)} - on:hide-face={() => handleHideFace(person)} + on:merge-people={() => handleMergePeople(person)} + on:hide-person={() => handleHidePerson(person)} /> {/if} {/each} diff --git a/web/src/routes/(user)/people/[personId]/+page.svelte b/web/src/routes/(user)/people/[personId]/+page.svelte index ca2ba98138..6eb3a0a3eb 100644 --- a/web/src/routes/(user)/people/[personId]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/+page.svelte @@ -4,6 +4,7 @@ import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte'; import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte'; import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte'; + import UnMergeFaceSelector from '$lib/components/faces-page/unmerge-face-selector.svelte'; import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte'; import SetBirthDateModal from '$lib/components/faces-page/set-birth-date-modal.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; @@ -46,10 +47,11 @@ enum ViewMode { VIEW_ASSETS = 'view-assets', - SELECT_FACE = 'select-face', - MERGE_FACES = 'merge-faces', + SELECT_PERSON = 'select-person', + MERGE_PEOPLE = 'merge-people', SUGGEST_MERGE = 'suggest-merge', BIRTH_DATE = 'birth-date', + UNASSIGN_ASSETS = 'unassign-faces', } let assetStore = new AssetStore({ @@ -124,7 +126,7 @@ previousRoute = getPreviousRoute; } if (action == 'merge') { - viewMode = ViewMode.MERGE_FACES; + viewMode = ViewMode.MERGE_PEOPLE; } }); const handleEscape = () => { @@ -155,7 +157,17 @@ } }); - const toggleHideFace = async () => { + const handleUnmerge = () => { + $assetStore.removeAssets(Array.from($selectedAssets).map((a) => a.id)); + assetInteractionStore.clearMultiselect(); + viewMode = ViewMode.VIEW_ASSETS; + }; + + const handleReassignAssets = () => { + viewMode = ViewMode.UNASSIGN_ASSETS; + }; + + const toggleHidePerson = async () => { try { await api.personApi.updatePerson({ id: data.person.id, @@ -179,7 +191,7 @@ }; const handleSelectFeaturePhoto = async (asset: AssetResponseDto) => { - if (viewMode !== ViewMode.SELECT_FACE) { + if (viewMode !== ViewMode.SELECT_PERSON) { return; } @@ -202,7 +214,7 @@ } }; - const handleMergeSameFace = async (response: [PersonResponseDto, PersonResponseDto]) => { + const handleMergeSamePerson = async (response: [PersonResponseDto, PersonResponseDto]) => { const [personToMerge, personToBeMergedIn] = response; viewMode = ViewMode.VIEW_ASSETS; isEditingName = false; @@ -212,7 +224,7 @@ mergePersonDto: { ids: [personToMerge.id] }, }); notificationController.show({ - message: 'Merge faces succesfully', + message: 'Merge people succesfully', type: NotificationType.Info, }); people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id); @@ -333,6 +345,15 @@ }; +{#if viewMode === ViewMode.UNASSIGN_ASSETS} + a.id)} + personAssets={data.person} + on:close={() => (viewMode = ViewMode.VIEW_ASSETS)} + on:confirm={handleUnmerge} + /> +{/if} + {#if viewMode === ViewMode.SUGGEST_MERGE} (viewMode = ViewMode.VIEW_ASSETS)} on:reject={() => changeName()} - on:confirm={(event) => handleMergeSameFace(event.detail)} + on:confirm={(event) => handleMergeSamePerson(event.detail)} /> {/if} @@ -352,7 +373,7 @@ /> {/if} -{#if viewMode === ViewMode.MERGE_FACES} +{#if viewMode === ViewMode.MERGE_PEOPLE} {/if} @@ -370,6 +391,7 @@ $assetStore.removeAssets(ids)} /> + @@ -379,16 +401,19 @@ goto(previousRoute)}> - (viewMode = ViewMode.SELECT_FACE)} /> + (viewMode = ViewMode.SELECT_PERSON)} /> (viewMode = ViewMode.BIRTH_DATE)} /> - (viewMode = ViewMode.MERGE_FACES)} /> - toggleHideFace()} /> + (viewMode = ViewMode.MERGE_PEOPLE)} /> + toggleHidePerson()} + /> {/if} - {#if viewMode === ViewMode.SELECT_FACE} + {#if viewMode === ViewMode.SELECT_PERSON} (viewMode = ViewMode.VIEW_ASSETS)}> Select feature photo @@ -401,13 +426,13 @@ handleSelectFeaturePhoto(asset)} on:escape={handleEscape} > {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE} - +