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 <alex.tran1502@gmail.com>
This commit is contained in:
martin 2023-12-05 16:43:15 +01:00 committed by GitHub
parent 982183600d
commit 7702560b12
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
74 changed files with 4882 additions and 283 deletions

View file

@ -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<AssetFaceUpdateItem>}
* @memberof AssetFaceUpdateDto
*/
'data': Array<AssetFaceUpdateItem>;
}
/**
*
* @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<PersonResponseDto>}
* @type {Array<PersonWithFacesResponseDto>}
* @memberof AssetResponseDto
*/
'people'?: Array<PersonResponseDto>;
'people'?: Array<PersonWithFacesResponseDto>;
/**
*
* @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<AssetFaceWithoutPersonResponseDto>}
* @memberof PersonWithFacesResponseDto
*/
'faces': Array<AssetFaceWithoutPersonResponseDto>;
/**
*
* @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<RequestArgs> => {
// 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<RequestArgs> => {
// 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<Array<AssetFaceResponseDto>>> {
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<PersonResponseDto>> {
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<Array<AssetFaceResponseDto>> {
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<PersonResponseDto> {
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<RequestArgs> => {
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<RequestArgs> => {
// 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<PersonResponseDto>> {
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<Array<PersonResponseDto>>> {
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<PersonResponseDto> {
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<Array<BulkIdResponseDto>> {
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<Array<PersonResponseDto>> {
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.

View file

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

View file

@ -133,6 +133,8 @@ Class | Method | HTTP request | Description
*AuthenticationApi* | [**logoutAuthDevices**](doc//AuthenticationApi.md#logoutauthdevices) | **DELETE** /auth/devices |
*AuthenticationApi* | [**signUpAdmin**](doc//AuthenticationApi.md#signupadmin) | **POST** /auth/admin-sign-up |
*AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken |
*FaceApi* | [**getFaces**](doc//FaceApi.md#getfaces) | **GET** /face |
*FaceApi* | [**reassignFacesById**](doc//FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} |
*JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs |
*JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{id} |
*LibraryApi* | [**createLibrary**](doc//LibraryApi.md#createlibrary) | **POST** /library |
@ -153,12 +155,14 @@ Class | Method | HTTP request | Description
*PartnerApi* | [**getPartners**](doc//PartnerApi.md#getpartners) | **GET** /partner |
*PartnerApi* | [**removePartner**](doc//PartnerApi.md#removepartner) | **DELETE** /partner/{id} |
*PartnerApi* | [**updatePartner**](doc//PartnerApi.md#updatepartner) | **PUT** /partner/{id} |
*PersonApi* | [**createPerson**](doc//PersonApi.md#createperson) | **POST** /person |
*PersonApi* | [**getAllPeople**](doc//PersonApi.md#getallpeople) | **GET** /person |
*PersonApi* | [**getPerson**](doc//PersonApi.md#getperson) | **GET** /person/{id} |
*PersonApi* | [**getPersonAssets**](doc//PersonApi.md#getpersonassets) | **GET** /person/{id}/assets |
*PersonApi* | [**getPersonStatistics**](doc//PersonApi.md#getpersonstatistics) | **GET** /person/{id}/statistics |
*PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail |
*PersonApi* | [**mergePerson**](doc//PersonApi.md#mergeperson) | **POST** /person/{id}/merge |
*PersonApi* | [**reassignFaces**](doc//PersonApi.md#reassignfaces) | **PUT** /person/{id}/reassign |
*PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person |
*PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} |
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore |
@ -224,6 +228,10 @@ Class | Method | HTTP request | Description
- [AssetBulkUploadCheckItem](doc//AssetBulkUploadCheckItem.md)
- [AssetBulkUploadCheckResponseDto](doc//AssetBulkUploadCheckResponseDto.md)
- [AssetBulkUploadCheckResult](doc//AssetBulkUploadCheckResult.md)
- [AssetFaceResponseDto](doc//AssetFaceResponseDto.md)
- [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md)
- [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md)
- [AssetFaceWithoutPersonResponseDto](doc//AssetFaceWithoutPersonResponseDto.md)
- [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
- [AssetIdsDto](doc//AssetIdsDto.md)
- [AssetIdsResponseDto](doc//AssetIdsResponseDto.md)
@ -258,6 +266,7 @@ Class | Method | HTTP request | Description
- [DownloadResponseDto](doc//DownloadResponseDto.md)
- [EntityType](doc//EntityType.md)
- [ExifResponseDto](doc//ExifResponseDto.md)
- [FaceDto](doc//FaceDto.md)
- [FileChecksumDto](doc//FileChecksumDto.md)
- [FileChecksumResponseDto](doc//FileChecksumResponseDto.md)
- [FileReportDto](doc//FileReportDto.md)
@ -293,6 +302,7 @@ Class | Method | HTTP request | Description
- [PersonResponseDto](doc//PersonResponseDto.md)
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
- [PersonUpdateDto](doc//PersonUpdateDto.md)
- [PersonWithFacesResponseDto](doc//PersonWithFacesResponseDto.md)
- [QueueStatusDto](doc//QueueStatusDto.md)
- [ReactionLevel](doc//ReactionLevel.md)
- [ReactionType](doc//ReactionType.md)

View file

@ -0,0 +1,22 @@
# openapi.model.AssetFaceResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**boundingBoxX1** | **int** | |
**boundingBoxX2** | **int** | |
**boundingBoxY1** | **int** | |
**boundingBoxY2** | **int** | |
**id** | **String** | |
**imageHeight** | **int** | |
**imageWidth** | **int** | |
**person** | [**PersonResponseDto**](PersonResponseDto.md) | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

15
mobile/openapi/doc/AssetFaceUpdateDto.md generated Normal file
View file

@ -0,0 +1,15 @@
# openapi.model.AssetFaceUpdateDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**data** | [**List<AssetFaceUpdateItem>**](AssetFaceUpdateItem.md) | | [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -0,0 +1,16 @@
# openapi.model.AssetFaceUpdateItem
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**assetId** | **String** | |
**personId** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -0,0 +1,21 @@
# openapi.model.AssetFaceWithoutPersonResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**boundingBoxX1** | **int** | |
**boundingBoxX2** | **int** | |
**boundingBoxY1** | **int** | |
**boundingBoxY2** | **int** | |
**id** | **String** | |
**imageHeight** | **int** | |
**imageWidth** | **int** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -30,7 +30,7 @@ Name | Type | Description | Notes
**originalPath** | **String** | |
**owner** | [**UserResponseDto**](UserResponseDto.md) | | [optional]
**ownerId** | **String** | |
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [optional] [default to const []]
**people** | [**List<PersonWithFacesResponseDto>**](PersonWithFacesResponseDto.md) | | [optional] [default to const []]
**resized** | **bool** | |
**smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional]
**stack** | [**List<AssetResponseDto>**](AssetResponseDto.md) | | [optional] [default to const []]

127
mobile/openapi/doc/FaceApi.md generated Normal file
View file

@ -0,0 +1,127 @@
# openapi.api.FaceApi
## Load the API package
```dart
import 'package:openapi/api.dart';
```
All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**getFaces**](FaceApi.md#getfaces) | **GET** /face |
[**reassignFacesById**](FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} |
# **getFaces**
> List<AssetFaceResponseDto> getFaces(id)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = FaceApi();
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
try {
final result = api_instance.getFaces(id);
print(result);
} catch (e) {
print('Exception when calling FaceApi->getFaces: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
### Return type
[**List<AssetFaceResponseDto>**](AssetFaceResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **reassignFacesById**
> PersonResponseDto reassignFacesById(id, faceDto)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = FaceApi();
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final faceDto = FaceDto(); // FaceDto |
try {
final result = api_instance.reassignFacesById(id, faceDto);
print(result);
} catch (e) {
print('Exception when calling FaceApi->reassignFacesById: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
**faceDto** | [**FaceDto**](FaceDto.md)| |
### Return type
[**PersonResponseDto**](PersonResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)

15
mobile/openapi/doc/FaceDto.md generated Normal file
View file

@ -0,0 +1,15 @@
# openapi.model.FaceDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -9,16 +9,69 @@ All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**createPerson**](PersonApi.md#createperson) | **POST** /person |
[**getAllPeople**](PersonApi.md#getallpeople) | **GET** /person |
[**getPerson**](PersonApi.md#getperson) | **GET** /person/{id} |
[**getPersonAssets**](PersonApi.md#getpersonassets) | **GET** /person/{id}/assets |
[**getPersonStatistics**](PersonApi.md#getpersonstatistics) | **GET** /person/{id}/statistics |
[**getPersonThumbnail**](PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail |
[**mergePerson**](PersonApi.md#mergeperson) | **POST** /person/{id}/merge |
[**reassignFaces**](PersonApi.md#reassignfaces) | **PUT** /person/{id}/reassign |
[**updatePeople**](PersonApi.md#updatepeople) | **PUT** /person |
[**updatePerson**](PersonApi.md#updateperson) | **PUT** /person/{id} |
# **createPerson**
> PersonResponseDto createPerson()
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = PersonApi();
try {
final result = api_instance.createPerson();
print(result);
} catch (e) {
print('Exception when calling PersonApi->createPerson: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**PersonResponseDto**](PersonResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getAllPeople**
> PeopleResponseDto getAllPeople(withHidden)
@ -351,6 +404,63 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **reassignFaces**
> List<PersonResponseDto> reassignFaces(id, assetFaceUpdateDto)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = PersonApi();
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final assetFaceUpdateDto = AssetFaceUpdateDto(); // AssetFaceUpdateDto |
try {
final result = api_instance.reassignFaces(id, assetFaceUpdateDto);
print(result);
} catch (e) {
print('Exception when calling PersonApi->reassignFaces: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
**assetFaceUpdateDto** | [**AssetFaceUpdateDto**](AssetFaceUpdateDto.md)| |
### Return type
[**List<PersonResponseDto>**](PersonResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **updatePeople**
> List<BulkIdResponseDto> updatePeople(peopleUpdateDto)

View file

@ -0,0 +1,20 @@
# openapi.model.PersonWithFacesResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**birthDate** | [**DateTime**](DateTime.md) | |
**faces** | [**List<AssetFaceWithoutPersonResponseDto>**](AssetFaceWithoutPersonResponseDto.md) | | [default to const []]
**id** | **String** | |
**isHidden** | **bool** | |
**name** | **String** | |
**thumbnailPath** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View file

@ -34,6 +34,7 @@ part 'api/album_api.dart';
part 'api/asset_api.dart';
part 'api/audit_api.dart';
part 'api/authentication_api.dart';
part 'api/face_api.dart';
part 'api/job_api.dart';
part 'api/library_api.dart';
part 'api/o_auth_api.dart';
@ -63,6 +64,10 @@ part 'model/asset_bulk_upload_check_dto.dart';
part 'model/asset_bulk_upload_check_item.dart';
part 'model/asset_bulk_upload_check_response_dto.dart';
part 'model/asset_bulk_upload_check_result.dart';
part 'model/asset_face_response_dto.dart';
part 'model/asset_face_update_dto.dart';
part 'model/asset_face_update_item.dart';
part 'model/asset_face_without_person_response_dto.dart';
part 'model/asset_file_upload_response_dto.dart';
part 'model/asset_ids_dto.dart';
part 'model/asset_ids_response_dto.dart';
@ -97,6 +102,7 @@ part 'model/download_info_dto.dart';
part 'model/download_response_dto.dart';
part 'model/entity_type.dart';
part 'model/exif_response_dto.dart';
part 'model/face_dto.dart';
part 'model/file_checksum_dto.dart';
part 'model/file_checksum_response_dto.dart';
part 'model/file_report_dto.dart';
@ -132,6 +138,7 @@ part 'model/people_update_item.dart';
part 'model/person_response_dto.dart';
part 'model/person_statistics_response_dto.dart';
part 'model/person_update_dto.dart';
part 'model/person_with_faces_response_dto.dart';
part 'model/queue_status_dto.dart';
part 'model/reaction_level.dart';
part 'model/reaction_type.dart';

122
mobile/openapi/lib/api/face_api.dart generated Normal file
View file

@ -0,0 +1,122 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class FaceApi {
FaceApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Performs an HTTP 'GET /face' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
Future<Response> getFacesWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final path = r'/face';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
queryParams.addAll(_queryParams('', 'id', id));
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
Future<List<AssetFaceResponseDto>?> getFaces(String id,) async {
final response = await getFacesWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<AssetFaceResponseDto>') as List)
.cast<AssetFaceResponseDto>()
.toList();
}
return null;
}
/// Performs an HTTP 'PUT /face/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
///
/// * [FaceDto] faceDto (required):
Future<Response> reassignFacesByIdWithHttpInfo(String id, FaceDto faceDto,) async {
// ignore: prefer_const_declarations
final path = r'/face/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = faceDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
///
/// * [FaceDto] faceDto (required):
Future<PersonResponseDto?> reassignFacesById(String id, FaceDto faceDto,) async {
final response = await reassignFacesByIdWithHttpInfo(id, faceDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PersonResponseDto',) as PersonResponseDto;
}
return null;
}
}

View file

@ -16,6 +16,47 @@ class PersonApi {
final ApiClient apiClient;
/// Performs an HTTP 'POST /person' operation and returns the [Response].
Future<Response> createPersonWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/person';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<PersonResponseDto?> createPerson() async {
final response = await createPersonWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PersonResponseDto',) as PersonResponseDto;
}
return null;
}
/// Performs an HTTP 'GET /person' operation and returns the [Response].
/// Parameters:
///
@ -317,6 +358,61 @@ class PersonApi {
return null;
}
/// Performs an HTTP 'PUT /person/{id}/reassign' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
///
/// * [AssetFaceUpdateDto] assetFaceUpdateDto (required):
Future<Response> reassignFacesWithHttpInfo(String id, AssetFaceUpdateDto assetFaceUpdateDto,) async {
// ignore: prefer_const_declarations
final path = r'/person/{id}/reassign'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = assetFaceUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
///
/// * [AssetFaceUpdateDto] assetFaceUpdateDto (required):
Future<List<PersonResponseDto>?> reassignFaces(String id, AssetFaceUpdateDto assetFaceUpdateDto,) async {
final response = await reassignFacesWithHttpInfo(id, assetFaceUpdateDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<PersonResponseDto>') as List)
.cast<PersonResponseDto>()
.toList();
}
return null;
}
/// Performs an HTTP 'PUT /person' operation and returns the [Response].
/// Parameters:
///

View file

@ -215,6 +215,14 @@ class ApiClient {
return AssetBulkUploadCheckResponseDto.fromJson(value);
case 'AssetBulkUploadCheckResult':
return AssetBulkUploadCheckResult.fromJson(value);
case 'AssetFaceResponseDto':
return AssetFaceResponseDto.fromJson(value);
case 'AssetFaceUpdateDto':
return AssetFaceUpdateDto.fromJson(value);
case 'AssetFaceUpdateItem':
return AssetFaceUpdateItem.fromJson(value);
case 'AssetFaceWithoutPersonResponseDto':
return AssetFaceWithoutPersonResponseDto.fromJson(value);
case 'AssetFileUploadResponseDto':
return AssetFileUploadResponseDto.fromJson(value);
case 'AssetIdsDto':
@ -283,6 +291,8 @@ class ApiClient {
return EntityTypeTypeTransformer().decode(value);
case 'ExifResponseDto':
return ExifResponseDto.fromJson(value);
case 'FaceDto':
return FaceDto.fromJson(value);
case 'FileChecksumDto':
return FileChecksumDto.fromJson(value);
case 'FileChecksumResponseDto':
@ -353,6 +363,8 @@ class ApiClient {
return PersonStatisticsResponseDto.fromJson(value);
case 'PersonUpdateDto':
return PersonUpdateDto.fromJson(value);
case 'PersonWithFacesResponseDto':
return PersonWithFacesResponseDto.fromJson(value);
case 'QueueStatusDto':
return QueueStatusDto.fromJson(value);
case 'ReactionLevel':

View file

@ -0,0 +1,158 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetFaceResponseDto {
/// Returns a new [AssetFaceResponseDto] instance.
AssetFaceResponseDto({
required this.boundingBoxX1,
required this.boundingBoxX2,
required this.boundingBoxY1,
required this.boundingBoxY2,
required this.id,
required this.imageHeight,
required this.imageWidth,
required this.person,
});
int boundingBoxX1;
int boundingBoxX2;
int boundingBoxY1;
int boundingBoxY2;
String id;
int imageHeight;
int imageWidth;
PersonResponseDto? person;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetFaceResponseDto &&
other.boundingBoxX1 == boundingBoxX1 &&
other.boundingBoxX2 == boundingBoxX2 &&
other.boundingBoxY1 == boundingBoxY1 &&
other.boundingBoxY2 == boundingBoxY2 &&
other.id == id &&
other.imageHeight == imageHeight &&
other.imageWidth == imageWidth &&
other.person == person;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(boundingBoxX1.hashCode) +
(boundingBoxX2.hashCode) +
(boundingBoxY1.hashCode) +
(boundingBoxY2.hashCode) +
(id.hashCode) +
(imageHeight.hashCode) +
(imageWidth.hashCode) +
(person == null ? 0 : person!.hashCode);
@override
String toString() => 'AssetFaceResponseDto[boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, person=$person]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'boundingBoxX1'] = this.boundingBoxX1;
json[r'boundingBoxX2'] = this.boundingBoxX2;
json[r'boundingBoxY1'] = this.boundingBoxY1;
json[r'boundingBoxY2'] = this.boundingBoxY2;
json[r'id'] = this.id;
json[r'imageHeight'] = this.imageHeight;
json[r'imageWidth'] = this.imageWidth;
if (this.person != null) {
json[r'person'] = this.person;
} else {
// json[r'person'] = null;
}
return json;
}
/// Returns a new [AssetFaceResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetFaceResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetFaceResponseDto(
boundingBoxX1: mapValueOfType<int>(json, r'boundingBoxX1')!,
boundingBoxX2: mapValueOfType<int>(json, r'boundingBoxX2')!,
boundingBoxY1: mapValueOfType<int>(json, r'boundingBoxY1')!,
boundingBoxY2: mapValueOfType<int>(json, r'boundingBoxY2')!,
id: mapValueOfType<String>(json, r'id')!,
imageHeight: mapValueOfType<int>(json, r'imageHeight')!,
imageWidth: mapValueOfType<int>(json, r'imageWidth')!,
person: PersonResponseDto.fromJson(json[r'person']),
);
}
return null;
}
static List<AssetFaceResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetFaceResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetFaceResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetFaceResponseDto> mapFromJson(dynamic json) {
final map = <String, AssetFaceResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetFaceResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetFaceResponseDto-objects as value to a dart map
static Map<String, List<AssetFaceResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetFaceResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetFaceResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'boundingBoxX1',
'boundingBoxX2',
'boundingBoxY1',
'boundingBoxY2',
'id',
'imageHeight',
'imageWidth',
'person',
};
}

View file

@ -0,0 +1,98 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetFaceUpdateDto {
/// Returns a new [AssetFaceUpdateDto] instance.
AssetFaceUpdateDto({
this.data = const [],
});
List<AssetFaceUpdateItem> data;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetFaceUpdateDto &&
other.data == data;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(data.hashCode);
@override
String toString() => 'AssetFaceUpdateDto[data=$data]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'data'] = this.data;
return json;
}
/// Returns a new [AssetFaceUpdateDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetFaceUpdateDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetFaceUpdateDto(
data: AssetFaceUpdateItem.listFromJson(json[r'data']),
);
}
return null;
}
static List<AssetFaceUpdateDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetFaceUpdateDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetFaceUpdateDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetFaceUpdateDto> mapFromJson(dynamic json) {
final map = <String, AssetFaceUpdateDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetFaceUpdateDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetFaceUpdateDto-objects as value to a dart map
static Map<String, List<AssetFaceUpdateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetFaceUpdateDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetFaceUpdateDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'data',
};
}

View file

@ -0,0 +1,106 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetFaceUpdateItem {
/// Returns a new [AssetFaceUpdateItem] instance.
AssetFaceUpdateItem({
required this.assetId,
required this.personId,
});
String assetId;
String personId;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetFaceUpdateItem &&
other.assetId == assetId &&
other.personId == personId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetId.hashCode) +
(personId.hashCode);
@override
String toString() => 'AssetFaceUpdateItem[assetId=$assetId, personId=$personId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assetId'] = this.assetId;
json[r'personId'] = this.personId;
return json;
}
/// Returns a new [AssetFaceUpdateItem] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetFaceUpdateItem? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetFaceUpdateItem(
assetId: mapValueOfType<String>(json, r'assetId')!,
personId: mapValueOfType<String>(json, r'personId')!,
);
}
return null;
}
static List<AssetFaceUpdateItem> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetFaceUpdateItem>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetFaceUpdateItem.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetFaceUpdateItem> mapFromJson(dynamic json) {
final map = <String, AssetFaceUpdateItem>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetFaceUpdateItem.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetFaceUpdateItem-objects as value to a dart map
static Map<String, List<AssetFaceUpdateItem>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetFaceUpdateItem>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetFaceUpdateItem.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assetId',
'personId',
};
}

View file

@ -0,0 +1,146 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetFaceWithoutPersonResponseDto {
/// Returns a new [AssetFaceWithoutPersonResponseDto] instance.
AssetFaceWithoutPersonResponseDto({
required this.boundingBoxX1,
required this.boundingBoxX2,
required this.boundingBoxY1,
required this.boundingBoxY2,
required this.id,
required this.imageHeight,
required this.imageWidth,
});
int boundingBoxX1;
int boundingBoxX2;
int boundingBoxY1;
int boundingBoxY2;
String id;
int imageHeight;
int imageWidth;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetFaceWithoutPersonResponseDto &&
other.boundingBoxX1 == boundingBoxX1 &&
other.boundingBoxX2 == boundingBoxX2 &&
other.boundingBoxY1 == boundingBoxY1 &&
other.boundingBoxY2 == boundingBoxY2 &&
other.id == id &&
other.imageHeight == imageHeight &&
other.imageWidth == imageWidth;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(boundingBoxX1.hashCode) +
(boundingBoxX2.hashCode) +
(boundingBoxY1.hashCode) +
(boundingBoxY2.hashCode) +
(id.hashCode) +
(imageHeight.hashCode) +
(imageWidth.hashCode);
@override
String toString() => 'AssetFaceWithoutPersonResponseDto[boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'boundingBoxX1'] = this.boundingBoxX1;
json[r'boundingBoxX2'] = this.boundingBoxX2;
json[r'boundingBoxY1'] = this.boundingBoxY1;
json[r'boundingBoxY2'] = this.boundingBoxY2;
json[r'id'] = this.id;
json[r'imageHeight'] = this.imageHeight;
json[r'imageWidth'] = this.imageWidth;
return json;
}
/// Returns a new [AssetFaceWithoutPersonResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetFaceWithoutPersonResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetFaceWithoutPersonResponseDto(
boundingBoxX1: mapValueOfType<int>(json, r'boundingBoxX1')!,
boundingBoxX2: mapValueOfType<int>(json, r'boundingBoxX2')!,
boundingBoxY1: mapValueOfType<int>(json, r'boundingBoxY1')!,
boundingBoxY2: mapValueOfType<int>(json, r'boundingBoxY2')!,
id: mapValueOfType<String>(json, r'id')!,
imageHeight: mapValueOfType<int>(json, r'imageHeight')!,
imageWidth: mapValueOfType<int>(json, r'imageWidth')!,
);
}
return null;
}
static List<AssetFaceWithoutPersonResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetFaceWithoutPersonResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetFaceWithoutPersonResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetFaceWithoutPersonResponseDto> mapFromJson(dynamic json) {
final map = <String, AssetFaceWithoutPersonResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetFaceWithoutPersonResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetFaceWithoutPersonResponseDto-objects as value to a dart map
static Map<String, List<AssetFaceWithoutPersonResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetFaceWithoutPersonResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetFaceWithoutPersonResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'boundingBoxX1',
'boundingBoxX2',
'boundingBoxY1',
'boundingBoxY2',
'id',
'imageHeight',
'imageWidth',
};
}

View file

@ -104,7 +104,7 @@ class AssetResponseDto {
String ownerId;
List<PersonResponseDto> people;
List<PersonWithFacesResponseDto> people;
bool resized;
@ -299,7 +299,7 @@ class AssetResponseDto {
originalPath: mapValueOfType<String>(json, r'originalPath')!,
owner: UserResponseDto.fromJson(json[r'owner']),
ownerId: mapValueOfType<String>(json, r'ownerId')!,
people: PersonResponseDto.listFromJson(json[r'people']),
people: PersonWithFacesResponseDto.listFromJson(json[r'people']),
resized: mapValueOfType<bool>(json, r'resized')!,
smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']),
stack: AssetResponseDto.listFromJson(json[r'stack']),

98
mobile/openapi/lib/model/face_dto.dart generated Normal file
View file

@ -0,0 +1,98 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class FaceDto {
/// Returns a new [FaceDto] instance.
FaceDto({
required this.id,
});
String id;
@override
bool operator ==(Object other) => identical(this, other) || other is FaceDto &&
other.id == id;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(id.hashCode);
@override
String toString() => 'FaceDto[id=$id]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'id'] = this.id;
return json;
}
/// Returns a new [FaceDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static FaceDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return FaceDto(
id: mapValueOfType<String>(json, r'id')!,
);
}
return null;
}
static List<FaceDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <FaceDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = FaceDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, FaceDto> mapFromJson(dynamic json) {
final map = <String, FaceDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = FaceDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of FaceDto-objects as value to a dart map
static Map<String, List<FaceDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<FaceDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = FaceDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'id',
};
}

View file

@ -0,0 +1,142 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PersonWithFacesResponseDto {
/// Returns a new [PersonWithFacesResponseDto] instance.
PersonWithFacesResponseDto({
required this.birthDate,
this.faces = const [],
required this.id,
required this.isHidden,
required this.name,
required this.thumbnailPath,
});
DateTime? birthDate;
List<AssetFaceWithoutPersonResponseDto> faces;
String id;
bool isHidden;
String name;
String thumbnailPath;
@override
bool operator ==(Object other) => identical(this, other) || other is PersonWithFacesResponseDto &&
other.birthDate == birthDate &&
other.faces == faces &&
other.id == id &&
other.isHidden == isHidden &&
other.name == name &&
other.thumbnailPath == thumbnailPath;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(birthDate == null ? 0 : birthDate!.hashCode) +
(faces.hashCode) +
(id.hashCode) +
(isHidden.hashCode) +
(name.hashCode) +
(thumbnailPath.hashCode);
@override
String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.birthDate != null) {
json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc());
} else {
// json[r'birthDate'] = null;
}
json[r'faces'] = this.faces;
json[r'id'] = this.id;
json[r'isHidden'] = this.isHidden;
json[r'name'] = this.name;
json[r'thumbnailPath'] = this.thumbnailPath;
return json;
}
/// Returns a new [PersonWithFacesResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PersonWithFacesResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return PersonWithFacesResponseDto(
birthDate: mapDateTime(json, r'birthDate', ''),
faces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'faces']),
id: mapValueOfType<String>(json, r'id')!,
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
name: mapValueOfType<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
);
}
return null;
}
static List<PersonWithFacesResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PersonWithFacesResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PersonWithFacesResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PersonWithFacesResponseDto> mapFromJson(dynamic json) {
final map = <String, PersonWithFacesResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PersonWithFacesResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PersonWithFacesResponseDto-objects as value to a dart map
static Map<String, List<PersonWithFacesResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PersonWithFacesResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PersonWithFacesResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'birthDate',
'faces',
'id',
'isHidden',
'name',
'thumbnailPath',
};
}

View file

@ -0,0 +1,62 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for AssetFaceResponseDto
void main() {
// final instance = AssetFaceResponseDto();
group('test AssetFaceResponseDto', () {
// int boundingBoxX1
test('to test the property `boundingBoxX1`', () async {
// TODO
});
// int boundingBoxX2
test('to test the property `boundingBoxX2`', () async {
// TODO
});
// int boundingBoxY1
test('to test the property `boundingBoxY1`', () async {
// TODO
});
// int boundingBoxY2
test('to test the property `boundingBoxY2`', () async {
// TODO
});
// String id
test('to test the property `id`', () async {
// TODO
});
// int imageHeight
test('to test the property `imageHeight`', () async {
// TODO
});
// int imageWidth
test('to test the property `imageWidth`', () async {
// TODO
});
// PersonResponseDto person
test('to test the property `person`', () async {
// TODO
});
});
}

View file

@ -0,0 +1,27 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for AssetFaceUpdateDto
void main() {
// final instance = AssetFaceUpdateDto();
group('test AssetFaceUpdateDto', () {
// List<AssetFaceUpdateItem> data (default value: const [])
test('to test the property `data`', () async {
// TODO
});
});
}

View file

@ -0,0 +1,32 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for AssetFaceUpdateItem
void main() {
// final instance = AssetFaceUpdateItem();
group('test AssetFaceUpdateItem', () {
// String assetId
test('to test the property `assetId`', () async {
// TODO
});
// String personId
test('to test the property `personId`', () async {
// TODO
});
});
}

View file

@ -0,0 +1,57 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for AssetFaceWithoutPersonResponseDto
void main() {
// final instance = AssetFaceWithoutPersonResponseDto();
group('test AssetFaceWithoutPersonResponseDto', () {
// int boundingBoxX1
test('to test the property `boundingBoxX1`', () async {
// TODO
});
// int boundingBoxX2
test('to test the property `boundingBoxX2`', () async {
// TODO
});
// int boundingBoxY1
test('to test the property `boundingBoxY1`', () async {
// TODO
});
// int boundingBoxY2
test('to test the property `boundingBoxY2`', () async {
// TODO
});
// String id
test('to test the property `id`', () async {
// TODO
});
// int imageHeight
test('to test the property `imageHeight`', () async {
// TODO
});
// int imageWidth
test('to test the property `imageWidth`', () async {
// TODO
});
});
}

View file

@ -127,7 +127,7 @@ void main() {
// TODO
});
// List<PersonResponseDto> people (default value: const [])
// List<PersonWithFacesResponseDto> people (default value: const [])
test('to test the property `people`', () async {
// TODO
});

31
mobile/openapi/test/face_api_test.dart generated Normal file
View file

@ -0,0 +1,31 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
/// tests for FaceApi
void main() {
// final instance = FaceApi();
group('tests for FaceApi', () {
//Future<List<AssetFaceResponseDto>> getFaces(String id) async
test('test getFaces', () async {
// TODO
});
//Future<PersonResponseDto> reassignFacesById(String id, FaceDto faceDto) async
test('test reassignFacesById', () async {
// TODO
});
});
}

27
mobile/openapi/test/face_dto_test.dart generated Normal file
View file

@ -0,0 +1,27 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for FaceDto
void main() {
// final instance = FaceDto();
group('test FaceDto', () {
// String id
test('to test the property `id`', () async {
// TODO
});
});
}

View file

@ -17,6 +17,11 @@ void main() {
// final instance = PersonApi();
group('tests for PersonApi', () {
//Future<PersonResponseDto> createPerson() async
test('test createPerson', () async {
// TODO
});
//Future<PeopleResponseDto> getAllPeople({ bool withHidden }) async
test('test getAllPeople', () async {
// TODO
@ -47,6 +52,11 @@ void main() {
// TODO
});
//Future<List<PersonResponseDto>> reassignFaces(String id, AssetFaceUpdateDto assetFaceUpdateDto) async
test('test reassignFaces', () async {
// TODO
});
//Future<List<BulkIdResponseDto>> updatePeople(PeopleUpdateDto peopleUpdateDto) async
test('test updatePeople', () async {
// TODO

View file

@ -0,0 +1,52 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for PersonWithFacesResponseDto
void main() {
// final instance = PersonWithFacesResponseDto();
group('test PersonWithFacesResponseDto', () {
// DateTime birthDate
test('to test the property `birthDate`', () async {
// TODO
});
// List<AssetFaceWithoutPersonResponseDto> faces (default value: const [])
test('to test the property `faces`', () async {
// TODO
});
// String id
test('to test the property `id`', () async {
// TODO
});
// bool isHidden
test('to test the property `isHidden`', () async {
// TODO
});
// String name
test('to test the property `name`', () async {
// TODO
});
// String thumbnailPath
test('to test the property `thumbnailPath`', () async {
// TODO
});
});
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<PersonResponseDto> {
return this.repository.create({ ownerId: authUser.id });
}
async reassignFaces(authUser: AuthUserDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
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<PersonResponseDto> {
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<AssetFaceResponseDto[]> {
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<PersonResponseDto> {
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 } });

View file

@ -34,6 +34,7 @@ export interface IAccessRepository {
};
person: {
hasFaceOwnerAccess(userId: string, assetFaceId: Set<string>): Promise<Set<string>>;
checkOwnerAccess(userId: string, personIds: Set<string>): Promise<Set<string>>;
};

View file

@ -34,7 +34,7 @@ export interface IPersonRepository {
getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>;
getAssets(personId: string): Promise<AssetEntity[]>;
prepareReassignFaces(data: UpdateFacesData): Promise<string[]>;
reassignFaces(data: UpdateFacesData): Promise<number>;
create(entity: Partial<PersonEntity>): Promise<PersonEntity>;
@ -48,4 +48,8 @@ export interface IPersonRepository {
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
getRandomFace(personId: string): Promise<AssetFaceEntity | null>;
createFace(entity: Partial<AssetFaceEntity>): Promise<AssetFaceEntity>;
getFaces(assetId: string): Promise<AssetFaceEntity[]>;
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
getFaceById(id: string): Promise<AssetFaceEntity>;
getFaceByIdWithAssets(id: string): Promise<AssetFaceEntity | null>;
}

View file

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

View file

@ -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<AssetFaceResponseDto[]> {
return this.service.getFacesById(authUser, dto);
}
@Put(':id')
reassignFacesById(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: FaceDto,
): Promise<PersonResponseDto> {
return this.service.reassignFacesById(authUser, id, dto);
}
}

View file

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

View file

@ -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<PersonResponseDto> {
return this.service.createPerson(authUser);
}
@Put(':id/reassign')
reassignFaces(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AssetFaceUpdateDto,
): Promise<PersonResponseDto[]> {
return this.service.reassignFaces(authUser, id, dto);
}
@Put()
updatePeople(@AuthUser() authUser: AuthUserDto, @Body() dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
return this.service.updatePeople(authUser, dto);

View file

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

View file

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class EditFaceAssetForeignKey1699727044012 implements MigrationInterface {
name = 'EditFaceAssetForeignKey1699727044012'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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`);
}
}

View file

@ -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<LibraryEntity>,
@InjectRepository(PartnerEntity) private partnerRepository: Repository<PartnerEntity>,
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository<SharedLinkEntity>,
@InjectRepository(UserTokenEntity) private tokenRepository: Repository<UserTokenEntity>,
) {}
@ -318,6 +320,22 @@ export class AccessRepository implements IAccessRepository {
})
.then((persons) => new Set(persons.map((person) => person.id)));
},
hasFaceOwnerAccess: async (userId: string, assetFaceIds: Set<string>): Promise<Set<string>> => {
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 = {

View file

@ -107,6 +107,48 @@ export class PersonRepository implements IPersonRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaces(assetId: string): Promise<AssetFaceEntity[]> {
return this.assetFaceRepository.find({
where: { assetId },
relations: {
person: true,
},
});
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaceById(id: string): Promise<AssetFaceEntity> {
return this.assetFaceRepository.findOneOrFail({
where: { id },
relations: {
person: true,
},
});
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaceByIdWithAssets(id: string): Promise<AssetFaceEntity | null> {
return this.assetFaceRepository.findOne({
where: { id },
relations: {
person: true,
asset: true,
},
});
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
const result = await this.assetFaceRepository
.createQueryBuilder()
.update()
.set({ personId: newPersonId })
.where({ id: assetFaceId })
.execute();
return result.affected ?? 0;
}
getById(personId: string): Promise<PersonEntity | null> {
return this.personRepository.findOne({ where: { id: personId } });
}

View file

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

View file

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

View file

@ -50,6 +50,7 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock =>
},
person: {
hasFaceOwnerAccess: jest.fn(),
checkOwnerAccess: jest.fn().mockResolvedValue(new Set()),
},

View file

@ -20,8 +20,12 @@ export const newPersonRepositoryMock = (): jest.Mocked<IPersonRepository> => {
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(),
};
};

View file

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

View file

@ -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<AssetFaceUpdateItem>}
* @memberof AssetFaceUpdateDto
*/
'data': Array<AssetFaceUpdateItem>;
}
/**
*
* @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<PersonResponseDto>}
* @type {Array<PersonWithFacesResponseDto>}
* @memberof AssetResponseDto
*/
'people'?: Array<PersonResponseDto>;
'people'?: Array<PersonWithFacesResponseDto>;
/**
*
* @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<AssetFaceWithoutPersonResponseDto>}
* @memberof PersonWithFacesResponseDto
*/
'faces': Array<AssetFaceWithoutPersonResponseDto>;
/**
*
* @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<RequestArgs> => {
// 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<RequestArgs> => {
// 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<Array<AssetFaceResponseDto>>> {
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<PersonResponseDto>> {
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<Array<AssetFaceResponseDto>> {
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<PersonResponseDto> {
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<RequestArgs> => {
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<RequestArgs> => {
// 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<PersonResponseDto>> {
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<Array<PersonResponseDto>>> {
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<PersonResponseDto> {
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<Array<BulkIdResponseDto>> {
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<Array<PersonResponseDto>> {
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.

View file

@ -560,7 +560,7 @@
<section
id="immich-asset-viewer"
class="fixed left-0 top-0 z-[1001] grid h-screen w-screen grid-cols-4 grid-rows-[64px_1fr] overflow-y-hidden bg-black"
class="fixed left-0 top-0 z-[1001] grid h-screen w-screen grid-cols-4 grid-rows-[64px_1fr] overflow-hidden bg-black"
>
<!-- Top navigation bar -->
{#if $slideshowState === SlideshowState.None}

View file

@ -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 @@
}
</script>
<section class="p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
<section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
<div class="flex place-items-center gap-2">
<button
class="flex place-content-center place-items-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
@ -183,54 +205,71 @@
<section class="px-4 py-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<h2>PEOPLE</h2>
{#if people.some((person) => person.isHidden)}
<div class="flex gap-2">
{#if people.some((person) => person.isHidden)}
<CircleIconButton
title="Show hidden people"
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
padding="1"
on:click={() => (showingHiddenPeople = !showingHiddenPeople)}
/>
{/if}
<CircleIconButton
title="Show hidden people"
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
title="Edit people"
icon={mdiPencil}
padding="1"
on:click={() => (showingHiddenPeople = !showingHiddenPeople)}
size="20"
on:click={() => (showEditFaces = true)}
/>
{/if}
</div>
</div>
<div class="mt-2 flex flex-wrap gap-2">
{#each people as person (person.id)}
<a
href="/people/{person.id}?previousRoute={albumId ? `${AppRoute.ALBUMS}/${albumId}` : AppRoute.PHOTOS}"
class="w-[90px] {!showingHiddenPeople && person.isHidden ? 'hidden' : ''}"
on:click={() => dispatch('close-viewer')}
{#each people as person, index (person.id)}
<div
role="button"
tabindex={index}
on:focus={() => ($boundingBoxesArray = people[index].faces)}
on:mouseover={() => ($boundingBoxesArray = people[index].faces)}
on:mouseleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">
<ImageThumbnail
curve
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
title={person.name}
widthStyle="90px"
heightStyle="90px"
thumbhash={null}
hidden={person.isHidden}
/>
</div>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
{#if person.birthDate}
{@const personBirthDate = DateTime.fromISO(person.birthDate)}
<p
class="font-light"
title={personBirthDate.toLocaleString(
{
month: 'long',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
)}
>
Age {Math.floor(DateTime.fromISO(asset.fileCreatedAt).diff(personBirthDate, 'years').years)}
</p>
{/if}
</a>
<a
href="/people/{person.id}?previousRoute={albumId ? `${AppRoute.ALBUMS}/${albumId}` : AppRoute.PHOTOS}"
class="w-[90px] {!showingHiddenPeople && person.isHidden ? 'hidden' : ''}"
on:click={() => dispatch('close-viewer')}
>
<div class="relative">
<ImageThumbnail
curve
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
title={person.name}
widthStyle="90px"
heightStyle="90px"
thumbhash={null}
hidden={person.isHidden}
/>
</div>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
{#if person.birthDate}
{@const personBirthDate = DateTime.fromISO(person.birthDate)}
<p
class="font-light"
title={personBirthDate.toLocaleString(
{
month: 'long',
day: 'numeric',
year: 'numeric',
},
{ locale: $locale },
)}
>
Age {Math.floor(DateTime.fromISO(asset.fileCreatedAt).diff(personBirthDate, 'years').years)}
</p>
{/if}
</a>
</div>
{/each}
</div>
</section>
@ -589,3 +628,13 @@
{/each}
</section>
{/if}
{#if showEditFaces}
<PersonSidePanel
assetId={asset.id}
on:close={() => {
showEditFaces = false;
}}
on:refresh={handleRefreshPeople}
/>
{/if}

View file

@ -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<Blob>;
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,
});
}
</script>
<svelte:window on:keydown={handleKeypress} on:copyImage={doCopy} on:zoomImage={doZoomImage} />
@ -129,12 +134,19 @@
{:then}
<div bind:this={imgElement} class="h-full w-full">
<img
bind:this={$photoViewer}
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
src={assetData}
alt={asset.id}
class="h-full w-full object-contain"
draggable="false"
/>
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox}
<div
class="absolute border-solid border-white border-[3px] rounded-lg p-3"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
/>
{/each}
</div>
{/await}
</div>

View file

@ -52,7 +52,7 @@
{#if hidden}
<div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
<Icon path={mdiEyeOffOutline} size="2em" class="text-{eyeColor}" />
<Icon {title} path={mdiEyeOffOutline} size="2em" class="text-{eyeColor}" />
</div>
{/if}

View file

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

View file

@ -0,0 +1,246 @@
<script lang="ts">
import { api, type AssetFaceResponseDto, type PersonResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import { linear } from 'svelte/easing';
import { fly } from 'svelte/transition';
import Icon from '../elements/icon.svelte';
import { mdiArrowLeftThin, mdiClose, mdiMagnify, mdiPlus } from '@mdi/js';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import { getPersonNameWithHiddenValue, searchNameLocal } from '$lib/utils/person';
import { handleError } from '$lib/utils/handle-error';
import { photoViewer } from '$lib/stores/assets.store';
export let peopleWithFaces: AssetFaceResponseDto[];
export let allPeople: PersonResponseDto[];
export let editedPersonIndex: number;
// loading spinners
let isShowLoadingNewPerson = false;
let isShowLoadingSearch = false;
// search people
let searchedPeople: PersonResponseDto[] = [];
let searchedPeopleCopy: PersonResponseDto[] = [];
let searchWord: string;
let searchFaces = false;
let searchName = '';
const dispatch = createEventDispatcher();
const handleBackButton = () => {
dispatch('close');
};
const zoomImageToBase64 = async (face: AssetFaceResponseDto): Promise<string | null> => {
if ($photoViewer === null) {
return null;
}
const { boundingBoxX1: x1, boundingBoxX2: x2, boundingBoxY1: y1, boundingBoxY2: y2 } = face;
const coordinates = {
x1: ($photoViewer.naturalWidth / face.imageWidth) * x1,
x2: ($photoViewer.naturalWidth / face.imageWidth) * x2,
y1: ($photoViewer.naturalHeight / face.imageHeight) * y1,
y2: ($photoViewer.naturalHeight / face.imageHeight) * y2,
};
const faceWidth = coordinates.x2 - coordinates.x1;
const faceHeight = coordinates.y2 - coordinates.y1;
const faceImage = new Image();
faceImage.src = $photoViewer.src;
await new Promise((resolve) => {
faceImage.onload = resolve;
faceImage.onerror = () => resolve(null);
});
const canvas = document.createElement('canvas');
canvas.width = faceWidth;
canvas.height = faceHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(faceImage, coordinates.x1, coordinates.y1, faceWidth, faceHeight, 0, 0, faceWidth, faceHeight);
return canvas.toDataURL();
} else {
return null;
}
};
const handleCreatePerson = async () => {
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), 100);
const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id);
const newFeaturePhoto = personToUpdate ? await zoomImageToBase64(personToUpdate) : null;
dispatch('createPerson', newFeaturePhoto);
clearTimeout(timeout);
isShowLoadingNewPerson = false;
dispatch('createPerson', newFeaturePhoto);
};
const searchPeople = async () => {
if ((searchedPeople.length < 20 && searchName.startsWith(searchWord)) || searchName === '') {
return;
}
const timeout = setTimeout(() => (isShowLoadingSearch = true), 100);
try {
const { data } = await api.searchApi.searchPerson({ name: searchName });
searchedPeople = data;
searchedPeopleCopy = data;
searchWord = searchName;
} catch (error) {
handleError(error, "Can't search people");
} finally {
clearTimeout(timeout);
}
isShowLoadingSearch = false;
};
$: {
searchedPeople = searchNameLocal(searchName, searchedPeopleCopy, 10);
}
const initInput = (element: HTMLInputElement) => {
element.focus();
};
</script>
<section
transition:fly={{ x: 360, duration: 100, easing: linear }}
class="absolute top-0 z-[2001] h-full w-[360px] overflow-x-hidden p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg"
>
<div class="flex place-items-center justify-between gap-2">
{#if !searchFaces}
<div class="flex items-center gap-2">
<button
class="flex place-content-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
on:click={handleBackButton}
>
<div>
<Icon path={mdiArrowLeftThin} size="24" />
</div>
</button>
<p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">Select face</p>
</div>
<div class="flex justify-end gap-2">
<button
class="flex place-content-center place-items-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
title="Search existing person"
on:click={() => {
searchFaces = true;
}}
>
<div>
<Icon path={mdiMagnify} size="24" />
</div>
</button>
{#if !isShowLoadingNewPerson}
<button
class="flex place-content-center place-items-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
on:click={handleCreatePerson}
title="Create new person"
>
<div>
<Icon path={mdiPlus} size="24" />
</div>
</button>
{:else}
<div class="flex place-content-center place-items-center">
<LoadingSpinner />
</div>
{/if}
</div>
{:else}
<button
class="flex place-content-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
on:click={handleBackButton}
>
<div>
<Icon path={mdiArrowLeftThin} size="24" />
</div>
</button>
<div class="w-full flex">
<input
class="w-full gap-2 bg-immich-bg dark:bg-immich-dark-bg"
type="text"
placeholder="Name or nickname"
bind:value={searchName}
on:input={searchPeople}
use:initInput
/>
{#if isShowLoadingSearch}
<div>
<LoadingSpinner />
</div>
{/if}
</div>
<button
class="flex place-content-center place-items-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
on:click={() => (searchFaces = false)}
>
<div>
<Icon path={mdiClose} size="24" />
</div>
</button>
{/if}
</div>
<div class="px-4 py-4 text-sm">
<h2 class="mb-8 mt-4 uppercase">All people</h2>
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
{#if searchName == ''}
{#each allPeople as person (person.id)}
{#if person.id !== peopleWithFaces[editedPersonIndex].person?.id}
<div class="w-fit">
<button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
<div class="relative">
<ImageThumbnail
curve
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={getPersonNameWithHiddenValue(person.name, person.isHidden)}
title={getPersonNameWithHiddenValue(person.name, person.isHidden)}
widthStyle="90px"
heightStyle="90px"
thumbhash={null}
hidden={person.isHidden}
/>
</div>
<p class="mt-1 truncate font-medium" title={getPersonNameWithHiddenValue(person.name, person.isHidden)}>
{person.name}
</p>
</button>
</div>
{/if}
{/each}
{:else}
{#each searchedPeople as person (person.id)}
{#if person.id !== peopleWithFaces[editedPersonIndex].person?.id}
<div class="w-fit">
<button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
<div class="relative">
<ImageThumbnail
curve
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={getPersonNameWithHiddenValue(person.name, person.isHidden)}
title={getPersonNameWithHiddenValue(person.name, person.isHidden)}
widthStyle="90px"
heightStyle="90px"
thumbhash={null}
hidden={person.isHidden}
/>
</div>
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
</button>
</div>
{/if}
{/each}
{/if}
</div>
</div>
</section>

View file

@ -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}
<div />
</svelte:fragment>
@ -151,7 +114,7 @@
<section class="bg-immich-bg px-[70px] pt-[100px] dark:bg-immich-dark-bg">
<section id="merge-face-selector relative">
<div class="mb-10 h-[200px] place-content-center place-items-center">
<p class="mb-4 text-center uppercase dark:text-white">Choose matching faces to merge</p>
<p class="mb-4 text-center uppercase dark:text-white">Choose matching people to merge</p>
<div class="grid grid-flow-col-dense place-content-center place-items-center gap-4">
{#each selectedPeople as person (person.id)}
@ -178,57 +141,25 @@
</div>
</div>
<div
class="flex w-40 sm:w-48 md:w-96 h-14 rounded-lg bg-gray-100 p-2 dark:bg-gray-700 mb-8 gap-2 place-items-center"
>
<button on:click={() => searchPeople(true)}>
<div class="w-fit">
<Icon path={mdiMagnify} size="24" />
</div>
</button>
<!-- svelte-ignore a11y-autofocus -->
<input
autofocus
class="w-full gap-2 bg-gray-100 dark:bg-gray-700 dark:text-white"
type="text"
placeholder="Search names"
bind:value={name}
on:input={() => searchPeople(false)}
/>
{#if name}
<button on:click={resetSearch}>
<Icon path={mdiClose} />
</button>
{/if}
{#if isSearchingPeople}
<div class="flex place-items-center">
<LoadingSpinner />
</div>
{/if}
</div>
<div
class="immich-scrollbar overflow-y-auto rounded-3xl bg-gray-200 pt-8 px-8 pb-10 dark:bg-immich-dark-gray"
style:max-height={screenHeight - 250 - 250 + 'px'}
>
<div class="grid-col-2 grid gap-8 md:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
{#each unselectedPeople as person (person.id)}
<FaceThumbnail {person} on:click={() => onSelect(person)} circle border selectable />
{/each}
</div>
</div>
<PeopleList
people={unselectedPeople}
peopleCopy={unselectedPeople}
unselectedPeople={selectedPeople}
{screenHeight}
on:select={({ detail }) => onSelect(detail)}
/>
</section>
{#if isShowConfirmation}
<ConfirmDialogue
title="Merge faces"
title="Merge people"
confirmText="Merge"
on:confirm={handleMerge}
on:cancel={() => (isShowConfirmation = false)}
>
<svelte:fragment slot="prompt">
<p>Are you sure you want merge these faces? <br />This action is <strong>irreversible</strong>.</p>
</svelte:fragment>
<p>Are you sure you want merge these people ?</p></svelte:fragment
>
</ConfirmDialogue>
{/if}
</section>

View file

@ -36,7 +36,7 @@
>
<div class="relative flex items-center justify-between">
<h1 class="truncate px-4 py-4 font-medium text-immich-primary dark:text-immich-dark-primary">
Merge faces - {title}
Merge People - {title}
</h1>
<div class="p-2">
<CircleIconButton icon={mdiClose} on:click={() => dispatch('close')} />
@ -108,7 +108,7 @@
</div>
<div class="flex px-4 md:px-8 md:pt-4">
<h1 class="text-xl text-gray-500 dark:text-gray-300">Are these the same face?</h1>
<h1 class="text-xl text-gray-500 dark:text-gray-300">Are these the same person?</h1>
</div>
<div class="flex px-4 pt-2 md:px-8">
<p class="text-sm text-gray-500 dark:text-gray-300">They will be merged together</p>

View file

@ -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}
<Portal target="body">
<ContextMenu {...contextMenuPosition} on:outclick={() => onMenuExit()}>
<MenuOption on:click={() => onMenuClick('hide-face')} text="Hide face" />
<MenuOption on:click={() => onMenuClick('hide-person')} text="Hide Person" />
<MenuOption on:click={() => onMenuClick('change-name')} text="Change name" />
<MenuOption on:click={() => onMenuClick('set-birth-date')} text="Set date of birth" />
<MenuOption on:click={() => onMenuClick('merge-faces')} text="Merge faces" />
<MenuOption on:click={() => onMenuClick('merge-people')} text="Merge People" />
</ContextMenu>
</Portal>
{/if}

View file

@ -0,0 +1,106 @@
<script lang="ts">
import { api, type PersonResponseDto } from '@api';
import FaceThumbnail from './face-thumbnail.svelte';
import { createEventDispatcher } from 'svelte';
import Icon from '../elements/icon.svelte';
import { mdiClose, mdiMagnify } from '@mdi/js';
import { handleError } from '$lib/utils/handle-error';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { searchNameLocal } from '$lib/utils/person';
export let screenHeight: number;
export let people: PersonResponseDto[];
export let peopleCopy: PersonResponseDto[];
export let unselectedPeople: PersonResponseDto[];
let name = '';
let searchWord: string;
let isSearchingPeople = false;
let dispatch = createEventDispatcher<{
select: PersonResponseDto;
}>();
const resetSearch = () => {
name = '';
people = peopleCopy;
};
$: {
people = peopleCopy.filter(
(person) => !unselectedPeople.some((unselectedPerson) => unselectedPerson.id === person.id),
);
people = searchNameLocal(name, people, 10);
}
const searchPeople = async (force: boolean) => {
if (name === '') {
people = peopleCopy;
return;
}
if (!force) {
if (people.length < 20 && name.startsWith(searchWord)) {
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;
};
</script>
<div class="flex w-40 sm:w-48 md:w-96 h-14 rounded-lg bg-gray-100 p-2 dark:bg-gray-700 mb-8 gap-2 place-items-center">
<button on:click={() => searchPeople(true)}>
<div class="w-fit">
<Icon path={mdiMagnify} size="24" />
</div>
</button>
<!-- svelte-ignore a11y-autofocus -->
<input
autofocus
class="w-full gap-2 bg-gray-100 dark:bg-gray-700 dark:text-white"
type="text"
placeholder="Search names"
bind:value={name}
on:input={() => searchPeople(false)}
/>
{#if name}
<button on:click={resetSearch}>
<Icon path={mdiClose} />
</button>
{/if}
{#if isSearchingPeople}
<div class="flex place-items-center">
<LoadingSpinner />
</div>
{/if}
</div>
<div
class="immich-scrollbar overflow-y-auto rounded-3xl bg-gray-200 p-10 dark:bg-immich-dark-gray"
style:max-height={screenHeight - 400 + 'px'}
>
<div class="grid-col-2 grid gap-8 md:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10">
{#each people as person (person.id)}
<FaceThumbnail
{person}
on:click={() => {
dispatch('select', person);
}}
circle
border
selectable
/>
{/each}
</div>
</div>

View file

@ -0,0 +1,278 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { linear } from 'svelte/easing';
import { api, type PersonResponseDto, AssetFaceResponseDto } from '@api';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import { handleError } from '$lib/utils/handle-error';
import { createEventDispatcher, onMount } from 'svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import { mdiArrowLeftThin, mdiRestart } from '@mdi/js';
import Icon from '../elements/icon.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { websocketStore } from '$lib/stores/websocket';
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
import { getPersonNameWithHiddenValue } from '$lib/utils/person';
export let assetId: string;
// keep track of the changes
let numberOfPersonToCreate: string[] = [];
let numberOfAssetFaceGenerated: string[] = [];
// faces
let peopleWithFaces: AssetFaceResponseDto[] = [];
let selectedPersonToReassign: (PersonResponseDto | null)[];
let selectedPersonToCreate: (string | null)[];
let editedPersonIndex: number;
// loading spinners
let isShowLoadingDone = false;
let isShowLoadingPeople = false;
// search people
let showSeletecFaces = false;
let allPeople: PersonResponseDto[] = [];
// timers
let loaderLoadingDoneTimeout: NodeJS.Timeout;
let automaticRefreshTimeout: NodeJS.Timeout;
const { onPersonThumbnail } = websocketStore;
const dispatch = createEventDispatcher();
// Reset value
$onPersonThumbnail = '';
$: {
if ($onPersonThumbnail) {
numberOfAssetFaceGenerated.push($onPersonThumbnail);
if (
isEqual(numberOfAssetFaceGenerated, numberOfPersonToCreate) &&
loaderLoadingDoneTimeout &&
automaticRefreshTimeout &&
selectedPersonToCreate.filter((person) => person !== null).length === numberOfPersonToCreate.length
) {
clearTimeout(loaderLoadingDoneTimeout);
clearTimeout(automaticRefreshTimeout);
dispatch('refresh');
}
}
}
onMount(async () => {
const timeout = setTimeout(() => (isShowLoadingPeople = true), 100);
try {
const { data } = await api.personApi.getAllPeople({ withHidden: true });
allPeople = data.people;
const result = await api.faceApi.getFaces({ id: assetId });
peopleWithFaces = result.data;
selectedPersonToCreate = new Array<string | null>(peopleWithFaces.length);
selectedPersonToReassign = new Array<PersonResponseDto | null>(peopleWithFaces.length);
} catch (error) {
handleError(error, "Can't get faces");
} finally {
clearTimeout(timeout);
}
isShowLoadingPeople = false;
});
const isEqual = (a: string[], b: string[]): boolean => {
return b.every((valueB) => a.includes(valueB));
};
const handleBackButton = () => {
dispatch('close');
};
const handleReset = (index: number) => {
if (selectedPersonToReassign[index]) {
selectedPersonToReassign[index] = null;
}
if (selectedPersonToCreate[index]) {
selectedPersonToCreate[index] = null;
}
};
const handleEditFaces = async () => {
loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), 100);
const numberOfChanges =
selectedPersonToCreate.filter((person) => person !== null).length +
selectedPersonToReassign.filter((person) => person !== null).length;
if (numberOfChanges > 0) {
try {
for (let i = 0; i < peopleWithFaces.length; i++) {
const personId = selectedPersonToReassign[i]?.id;
if (personId) {
await api.faceApi.reassignFacesById({
id: personId,
faceDto: { id: peopleWithFaces[i].id },
});
} else if (selectedPersonToCreate[i]) {
const { data } = await api.personApi.createPerson();
numberOfPersonToCreate.push(data.id);
await api.faceApi.reassignFacesById({
id: data.id,
faceDto: { id: peopleWithFaces[i].id },
});
}
}
notificationController.show({
message: `Edited ${numberOfChanges} ${numberOfChanges > 1 ? 'people' : 'person'}`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, "Can't apply changes");
}
}
isShowLoadingDone = false;
if (numberOfPersonToCreate.length === 0) {
clearTimeout(loaderLoadingDoneTimeout);
dispatch('refresh');
} else {
automaticRefreshTimeout = setTimeout(() => dispatch('refresh'), 15000);
}
};
const handleCreatePerson = (newFeaturePhoto: string | null) => {
const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id);
if (newFeaturePhoto && personToUpdate) {
selectedPersonToCreate[peopleWithFaces.indexOf(personToUpdate)] = newFeaturePhoto;
}
showSeletecFaces = false;
};
const handleReassignFace = (person: PersonResponseDto | null) => {
if (person) {
selectedPersonToReassign[editedPersonIndex] = person;
showSeletecFaces = false;
}
};
const handlePersonPicker = async (index: number) => {
editedPersonIndex = index;
showSeletecFaces = true;
};
</script>
<section
transition:fly={{ x: 360, duration: 100, easing: linear }}
class="absolute top-0 z-[2000] h-full w-[360px] overflow-x-hidden p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg"
>
<div class="flex place-items-center justify-between gap-2">
<div class="flex items-center gap-2">
<button
class="flex place-content-center rounded-full p-3 transition-colors hover:bg-gray-200 dark:text-immich-dark-fg dark:hover:bg-gray-900"
on:click={handleBackButton}
>
<div>
<Icon path={mdiArrowLeftThin} size="24" />
</div>
</button>
<p class="flex text-lg text-immich-fg dark:text-immich-dark-fg">Edit faces</p>
</div>
{#if !isShowLoadingDone}
<button
class="justify-self-end rounded-lg p-2 hover:bg-immich-dark-primary hover:dark:bg-immich-dark-primary/50"
on:click={() => handleEditFaces()}
>
Done
</button>
{:else}
<LoadingSpinner />
{/if}
</div>
<div class="px-4 py-4 text-sm">
<div class="mt-4 flex flex-wrap gap-2">
{#if isShowLoadingPeople}
<div class="flex w-full justify-center">
<LoadingSpinner />
</div>
{:else}
{#each peopleWithFaces as face, index}
{#if face.person}
<div class="relative z-[20001] h-[115px] w-[95px]">
<div
role="button"
tabindex={index}
class="absolute left-0 top-0 h-[90px] w-[90px] cursor-default"
on:focus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
on:mouseleave={() => ($boundingBoxesArray = [])}
>
<div class="relative">
<ImageThumbnail
curve
shadow
url={selectedPersonToCreate[index] ||
api.getPeopleThumbnailUrl(selectedPersonToReassign[index]?.id || face.person.id)}
altText={selectedPersonToReassign[index]
? selectedPersonToReassign[index]?.name || ''
: selectedPersonToCreate[index]
? 'new person'
: getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)}
title={selectedPersonToReassign[index]
? selectedPersonToReassign[index]?.name || ''
: selectedPersonToCreate[index]
? 'new person'
: getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)}
widthStyle="90px"
heightStyle="90px"
thumbhash={null}
hidden={selectedPersonToReassign[index]
? selectedPersonToReassign[index]?.isHidden
: selectedPersonToCreate[index]
? false
: face.person?.isHidden}
/>
</div>
{#if !selectedPersonToCreate[index]}
<p class="relative mt-1 truncate font-medium" title={face.person?.name}>
{#if selectedPersonToReassign[index]?.id}
{selectedPersonToReassign[index]?.name}
{:else}
{face.person?.name}
{/if}
</p>
{/if}
<div class="absolute -right-[5px] -top-[5px] h-[20px] w-[20px] rounded-full bg-blue-700">
{#if selectedPersonToCreate[index] || selectedPersonToReassign[index]}
<button on:click={() => handleReset(index)} class="flex h-full w-full">
<div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
<div>
<Icon path={mdiRestart} size={18} />
</div>
</div>
</button>
{:else}
<button on:click={() => handlePersonPicker(index)} class="flex h-full w-full">
<div
class="absolute left-1/2 top-1/2 h-[2px] w-[14px] translate-x-[-50%] translate-y-[-50%] transform bg-white"
/>
</button>
{/if}
</div>
</div>
</div>
{/if}
{/each}
{/if}
</div>
</div>
</section>
{#if showSeletecFaces}
<AssignFaceSidePanel
{peopleWithFaces}
{allPeople}
{editedPersonIndex}
on:close={() => (showSeletecFaces = false)}
on:createPerson={(event) => handleCreatePerson(event.detail)}
on:reassign={(event) => handleReassignFace(event.detail)}
/>
{/if}

View file

@ -22,12 +22,12 @@
>
<div class="flex items-center">
<CircleIconButton icon={mdiClose} on:click={() => dispatch('closeClick')} />
<p class="ml-4 hidden sm:block">Show & hide faces</p>
<p class="ml-4 hidden sm:block">Show & hide people</p>
</div>
<div class="flex items-center justify-end">
<div class="flex items-center md:mr-8">
<CircleIconButton
title="Reset faces visibility"
title="Reset people visibility"
icon={mdiRestart}
on:click={() => dispatch('reset-visibility')}
/>

View file

@ -0,0 +1,190 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import FaceThumbnail from './face-thumbnail.svelte';
import { quintOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
import { api, AssetFaceUpdateItem, type PersonResponseDto } from '@api';
import ControlAppBar from '../shared-components/control-app-bar.svelte';
import Button from '../elements/buttons/button.svelte';
import { mdiPlus, mdiMerge } from '@mdi/js';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { handleError } from '$lib/utils/handle-error';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import PeopleList from './people-list.svelte';
import Icon from '$lib/components/elements/icon.svelte';
export let assetIds: string[];
export let personAssets: PersonResponseDto;
let people: PersonResponseDto[] = [];
let selectedPerson: PersonResponseDto | null = null;
let disableButtons = false;
let showLoadingSpinnerCreate = false;
let showLoadingSpinnerReassign = false;
let hasSelection = false;
let screenHeight: number;
$: unselectedPeople = selectedPerson
? people.filter((person) => selectedPerson && person.id !== selectedPerson.id && personAssets.id !== person.id)
: people;
let dispatch = createEventDispatcher();
const selectedPeople: AssetFaceUpdateItem[] = [];
for (const assetId of assetIds) {
selectedPeople.push({ assetId, personId: personAssets.id });
}
onMount(async () => {
const { data } = await api.personApi.getAllPeople({ withHidden: false });
people = data.people;
});
const onClose = () => {
dispatch('close');
};
const handleSelectedPerson = (person: PersonResponseDto) => {
if (selectedPerson && selectedPerson.id === person.id) {
handleRemoveSelectedPerson();
return;
}
selectedPerson = person;
hasSelection = true;
};
const handleRemoveSelectedPerson = () => {
selectedPerson = null;
hasSelection = false;
};
const handleCreate = async () => {
const timeout = setTimeout(() => (showLoadingSpinnerCreate = true), 100);
try {
disableButtons = true;
const { data } = await api.personApi.createPerson();
await api.personApi.reassignFaces({
id: data.id,
assetFaceUpdateDto: { data: selectedPeople },
});
notificationController.show({
message: `Re-assigned ${assetIds.length} asset${assetIds.length > 1 ? 's' : ''} to a new person`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to reassign assets to a new person');
} finally {
clearTimeout(timeout);
}
showLoadingSpinnerCreate = false;
dispatch('confirm');
};
const handleReassign = async () => {
const timeout = setTimeout(() => (showLoadingSpinnerReassign = true), 100);
try {
disableButtons = true;
if (selectedPerson) {
await api.personApi.reassignFaces({
id: selectedPerson.id,
assetFaceUpdateDto: { data: selectedPeople },
});
notificationController.show({
message: `Re-assigned ${assetIds.length} asset${assetIds.length > 1 ? 's' : ''} to ${
selectedPerson.name || 'an existing person'
}`,
type: NotificationType.Info,
});
}
} catch (error) {
handleError(error, `Unable to reassign assets to ${selectedPerson?.name || 'an existing person'}`);
} finally {
clearTimeout(timeout);
}
showLoadingSpinnerReassign = false;
dispatch('confirm');
};
</script>
<svelte:window bind:innerHeight={screenHeight} />
<section
transition:fly={{ y: 500, duration: 100, easing: quintOut }}
class="absolute left-0 top-0 z-[9999] h-full w-full bg-immich-bg dark:bg-immich-dark-bg"
>
<ControlAppBar on:close-button-click={onClose}>
<svelte:fragment slot="leading">
<slot name="header" />
<div />
</svelte:fragment>
<svelte:fragment slot="trailing">
<div class="flex gap-4">
<Button
title={'Assign selected assets to a new person'}
size={'sm'}
disabled={disableButtons || hasSelection}
on:click={() => {
handleCreate();
}}
>
{#if !showLoadingSpinnerCreate}
<Icon path={mdiPlus} size={18} />
{:else}
<LoadingSpinner />
{/if}
<span class="ml-2"> Create new Person</span></Button
>
<Button
size={'sm'}
title={'Assign selected assets to an existing person'}
disabled={disableButtons || !hasSelection}
on:click={() => {
handleReassign();
}}
>
{#if !showLoadingSpinnerReassign}
<div>
<Icon path={mdiMerge} size={18} class="rotate-180" />
</div>
{:else}
<LoadingSpinner />
{/if}
<span class="ml-2"> Reassign</span></Button
>
</div>
</svelte:fragment>
</ControlAppBar>
<slot name="merge" />
<section class="bg-immich-bg px-[70px] pt-[100px] dark:bg-immich-dark-bg">
<section id="merge-face-selector relative">
{#if selectedPerson !== null}
<div class="mb-10 h-[200px] place-content-center place-items-center">
<p class="mb-4 text-center uppercase dark:text-white">Choose matching faces to re assign</p>
<div class="grid grid-flow-col-dense place-content-center place-items-center gap-4">
<FaceThumbnail
person={selectedPerson}
border
circle
selectable
thumbnailSize={180}
on:click={handleRemoveSelectedPerson}
/>
</div>
</div>
{/if}
<PeopleList
people={unselectedPeople}
peopleCopy={unselectedPeople}
unselectedPeople={selectedPerson ? [selectedPerson, personAssets] : [personAssets]}
{screenHeight}
on:select={({ detail }) => handleSelectedPerson(detail)}
/>
</section>
</section>
</section>

View file

@ -58,6 +58,8 @@ interface TrashAsset {
value: string;
}
export const photoViewer = writable<HTMLImageElement | null>(null);
type PendingChange = AddAsset | DeleteAsset | TrashAsset;
export class AssetStore {

View file

@ -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<Faces[]>([]);

View file

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

View file

@ -30,3 +30,7 @@ export const searchNameLocal = (
})
.slice(0, slice);
};
export const getPersonNameWithHiddenValue = (name: string, isHidden: boolean) => {
return `${name ? name + (isHidden ? ' ' : '') : ''}${isHidden ? '(hidden)' : ''}`;
};

View file

@ -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)}
/>
</FullScreenModal>
{/if}
@ -363,7 +363,7 @@
<IconButton on:click={() => (selectHidden = !selectHidden)}>
<div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm">
<Icon path={mdiEyeOutline} size="18" />
<p class="ml-2">Show & hide faces</p>
<p class="ml-2">Show & hide people</p>
</div>
</IconButton>
{/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}

View file

@ -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 @@
};
</script>
{#if viewMode === ViewMode.UNASSIGN_ASSETS}
<UnMergeFaceSelector
assetIds={Array.from($selectedAssets).map((a) => a.id)}
personAssets={data.person}
on:close={() => (viewMode = ViewMode.VIEW_ASSETS)}
on:confirm={handleUnmerge}
/>
{/if}
{#if viewMode === ViewMode.SUGGEST_MERGE}
<MergeSuggestionModal
{personMerge1}
@ -340,7 +361,7 @@
{potentialMergePeople}
on:close={() => (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}
<MergeFaceSelector person={data.person} on:go-back={handleGoBack} on:merge={handleMerge} />
{/if}
@ -370,6 +391,7 @@
<DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(ids) => $assetStore.removeAssets(ids)} />
<MenuOption text="Fix incorrect match" on:click={handleReassignAssets} />
<ChangeDate menuItem />
<ChangeLocation menuItem />
</AssetSelectContextMenu>
@ -379,16 +401,19 @@
<ControlAppBar showBackButton backIcon={mdiArrowLeft} on:close-button-click={() => goto(previousRoute)}>
<svelte:fragment slot="trailing">
<AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
<MenuOption text="Change feature photo" on:click={() => (viewMode = ViewMode.SELECT_FACE)} />
<MenuOption text="Change feature photo" on:click={() => (viewMode = ViewMode.SELECT_PERSON)} />
<MenuOption text="Set date of birth" on:click={() => (viewMode = ViewMode.BIRTH_DATE)} />
<MenuOption text="Merge face" on:click={() => (viewMode = ViewMode.MERGE_FACES)} />
<MenuOption text={data.person.isHidden ? 'Unhide face' : 'Hide face'} on:click={() => toggleHideFace()} />
<MenuOption text="Merge person" on:click={() => (viewMode = ViewMode.MERGE_PEOPLE)} />
<MenuOption
text={data.person.isHidden ? 'Unhide person' : 'Hide person'}
on:click={() => toggleHidePerson()}
/>
</AssetSelectContextMenu>
</svelte:fragment>
</ControlAppBar>
{/if}
{#if viewMode === ViewMode.SELECT_FACE}
{#if viewMode === ViewMode.SELECT_PERSON}
<ControlAppBar on:close-button-click={() => (viewMode = ViewMode.VIEW_ASSETS)}>
<svelte:fragment slot="leading">Select feature photo</svelte:fragment>
</ControlAppBar>
@ -401,13 +426,13 @@
<AssetGrid
{assetStore}
{assetInteractionStore}
isSelectionMode={viewMode === ViewMode.SELECT_FACE}
singleSelect={viewMode === ViewMode.SELECT_FACE}
isSelectionMode={viewMode === ViewMode.SELECT_PERSON}
singleSelect={viewMode === ViewMode.SELECT_PERSON}
on:select={({ detail: asset }) => handleSelectFeaturePhoto(asset)}
on:escape={handleEscape}
>
{#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
<!-- Face information block -->
<!-- Person information block -->
<div
role="button"
class="relative w-fit p-4 sm:px-6"