diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index c4d4707f3..d5251cbc9 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -1840,6 +1840,12 @@ export interface PeopleUpdateDto { * @interface PeopleUpdateItem */ export interface PeopleUpdateItem { + /** + * Person date of birth. + * @type {string} + * @memberof PeopleUpdateItem + */ + 'birthDate'?: string | null; /** * Asset is used to get the feature face thumbnail. * @type {string} @@ -1871,6 +1877,12 @@ export interface PeopleUpdateItem { * @interface PersonResponseDto */ export interface PersonResponseDto { + /** + * + * @type {string} + * @memberof PersonResponseDto + */ + 'birthDate': string | null; /** * * @type {string} @@ -1902,6 +1914,12 @@ export interface PersonResponseDto { * @interface PersonUpdateDto */ export interface PersonUpdateDto { + /** + * Person date of birth. + * @type {string} + * @memberof PersonUpdateDto + */ + 'birthDate'?: string | null; /** * Asset is used to get the feature face thumbnail. * @type {string} diff --git a/mobile/openapi/doc/PeopleUpdateItem.md b/mobile/openapi/doc/PeopleUpdateItem.md index 43a1b0225..25152c4e4 100644 --- a/mobile/openapi/doc/PeopleUpdateItem.md +++ b/mobile/openapi/doc/PeopleUpdateItem.md @@ -8,6 +8,7 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- +**birthDate** | [**DateTime**](DateTime.md) | Person date of birth. | [optional] **featureFaceAssetId** | **String** | Asset is used to get the feature face thumbnail. | [optional] **id** | **String** | Person id. | **isHidden** | **bool** | Person visibility | [optional] diff --git a/mobile/openapi/doc/PersonResponseDto.md b/mobile/openapi/doc/PersonResponseDto.md index e43d67a61..c2acbacd1 100644 --- a/mobile/openapi/doc/PersonResponseDto.md +++ b/mobile/openapi/doc/PersonResponseDto.md @@ -8,6 +8,7 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- +**birthDate** | [**DateTime**](DateTime.md) | | **id** | **String** | | **isHidden** | **bool** | | **name** | **String** | | diff --git a/mobile/openapi/doc/PersonUpdateDto.md b/mobile/openapi/doc/PersonUpdateDto.md index 935b4348c..a4df66878 100644 --- a/mobile/openapi/doc/PersonUpdateDto.md +++ b/mobile/openapi/doc/PersonUpdateDto.md @@ -8,6 +8,7 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- +**birthDate** | [**DateTime**](DateTime.md) | Person date of birth. | [optional] **featureFaceAssetId** | **String** | Asset is used to get the feature face thumbnail. | [optional] **isHidden** | **bool** | Person visibility | [optional] **name** | **String** | Person name. | [optional] diff --git a/mobile/openapi/lib/model/people_update_item.dart b/mobile/openapi/lib/model/people_update_item.dart index 3a35c8a58..0abb7a474 100644 --- a/mobile/openapi/lib/model/people_update_item.dart +++ b/mobile/openapi/lib/model/people_update_item.dart @@ -13,12 +13,16 @@ part of openapi.api; class PeopleUpdateItem { /// Returns a new [PeopleUpdateItem] instance. PeopleUpdateItem({ + this.birthDate, this.featureFaceAssetId, required this.id, this.isHidden, this.name, }); + /// Person date of birth. + DateTime? birthDate; + /// Asset is used to get the feature face thumbnail. /// /// Please note: This property should have been non-nullable! Since the specification file @@ -51,6 +55,7 @@ class PeopleUpdateItem { @override bool operator ==(Object other) => identical(this, other) || other is PeopleUpdateItem && + other.birthDate == birthDate && other.featureFaceAssetId == featureFaceAssetId && other.id == id && other.isHidden == isHidden && @@ -59,16 +64,22 @@ class PeopleUpdateItem { @override int get hashCode => // ignore: unnecessary_parenthesis + (birthDate == null ? 0 : birthDate!.hashCode) + (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + (id.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'PeopleUpdateItem[featureFaceAssetId=$featureFaceAssetId, id=$id, isHidden=$isHidden, name=$name]'; + String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isHidden=$isHidden, name=$name]'; Map toJson() { final json = {}; + if (this.birthDate != null) { + json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc()); + } else { + // json[r'birthDate'] = null; + } if (this.featureFaceAssetId != null) { json[r'featureFaceAssetId'] = this.featureFaceAssetId; } else { @@ -96,6 +107,7 @@ class PeopleUpdateItem { final json = value.cast(); return PeopleUpdateItem( + birthDate: mapDateTime(json, r'birthDate', ''), featureFaceAssetId: mapValueOfType(json, r'featureFaceAssetId'), id: mapValueOfType(json, r'id')!, isHidden: mapValueOfType(json, r'isHidden'), diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index 21120f23b..5e65d947a 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -13,12 +13,15 @@ part of openapi.api; class PersonResponseDto { /// Returns a new [PersonResponseDto] instance. PersonResponseDto({ + required this.birthDate, required this.id, required this.isHidden, required this.name, required this.thumbnailPath, }); + DateTime? birthDate; + String id; bool isHidden; @@ -29,6 +32,7 @@ class PersonResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto && + other.birthDate == birthDate && other.id == id && other.isHidden == isHidden && other.name == name && @@ -37,16 +41,22 @@ class PersonResponseDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (birthDate == null ? 0 : birthDate!.hashCode) + (id.hashCode) + (isHidden.hashCode) + (name.hashCode) + (thumbnailPath.hashCode); @override - String toString() => 'PersonResponseDto[id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath]'; + String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath]'; Map toJson() { final json = {}; + if (this.birthDate != null) { + json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc()); + } else { + // json[r'birthDate'] = null; + } json[r'id'] = this.id; json[r'isHidden'] = this.isHidden; json[r'name'] = this.name; @@ -62,6 +72,7 @@ class PersonResponseDto { final json = value.cast(); return PersonResponseDto( + birthDate: mapDateTime(json, r'birthDate', ''), id: mapValueOfType(json, r'id')!, isHidden: mapValueOfType(json, r'isHidden')!, name: mapValueOfType(json, r'name')!, @@ -113,6 +124,7 @@ class PersonResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'birthDate', 'id', 'isHidden', 'name', diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index baa985b1c..fc384c842 100644 --- a/mobile/openapi/lib/model/person_update_dto.dart +++ b/mobile/openapi/lib/model/person_update_dto.dart @@ -13,11 +13,15 @@ part of openapi.api; class PersonUpdateDto { /// Returns a new [PersonUpdateDto] instance. PersonUpdateDto({ + this.birthDate, this.featureFaceAssetId, this.isHidden, this.name, }); + /// Person date of birth. + DateTime? birthDate; + /// Asset is used to get the feature face thumbnail. /// /// Please note: This property should have been non-nullable! Since the specification file @@ -47,6 +51,7 @@ class PersonUpdateDto { @override bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto && + other.birthDate == birthDate && other.featureFaceAssetId == featureFaceAssetId && other.isHidden == isHidden && other.name == name; @@ -54,15 +59,21 @@ class PersonUpdateDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (birthDate == null ? 0 : birthDate!.hashCode) + (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'PersonUpdateDto[featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden, name=$name]'; + String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden, name=$name]'; Map toJson() { final json = {}; + if (this.birthDate != null) { + json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc()); + } else { + // json[r'birthDate'] = null; + } if (this.featureFaceAssetId != null) { json[r'featureFaceAssetId'] = this.featureFaceAssetId; } else { @@ -89,6 +100,7 @@ class PersonUpdateDto { final json = value.cast(); return PersonUpdateDto( + birthDate: mapDateTime(json, r'birthDate', ''), featureFaceAssetId: mapValueOfType(json, r'featureFaceAssetId'), isHidden: mapValueOfType(json, r'isHidden'), name: mapValueOfType(json, r'name'), diff --git a/mobile/openapi/test/people_update_item_test.dart b/mobile/openapi/test/people_update_item_test.dart index 9c366e4eb..4c91143bd 100644 --- a/mobile/openapi/test/people_update_item_test.dart +++ b/mobile/openapi/test/people_update_item_test.dart @@ -16,6 +16,12 @@ void main() { // final instance = PeopleUpdateItem(); group('test PeopleUpdateItem', () { + // Person date of birth. + // DateTime birthDate + test('to test the property `birthDate`', () async { + // TODO + }); + // Asset is used to get the feature face thumbnail. // String featureFaceAssetId test('to test the property `featureFaceAssetId`', () async { diff --git a/mobile/openapi/test/person_response_dto_test.dart b/mobile/openapi/test/person_response_dto_test.dart index 8b9f7bec8..0ba730611 100644 --- a/mobile/openapi/test/person_response_dto_test.dart +++ b/mobile/openapi/test/person_response_dto_test.dart @@ -16,6 +16,11 @@ void main() { // final instance = PersonResponseDto(); group('test PersonResponseDto', () { + // DateTime birthDate + test('to test the property `birthDate`', () async { + // TODO + }); + // String id test('to test the property `id`', () async { // TODO diff --git a/mobile/openapi/test/person_update_dto_test.dart b/mobile/openapi/test/person_update_dto_test.dart index b515c2c8e..80c46e44f 100644 --- a/mobile/openapi/test/person_update_dto_test.dart +++ b/mobile/openapi/test/person_update_dto_test.dart @@ -16,6 +16,12 @@ void main() { // final instance = PersonUpdateDto(); group('test PersonUpdateDto', () { + // Person date of birth. + // DateTime birthDate + test('to test the property `birthDate`', () async { + // TODO + }); + // Asset is used to get the feature face thumbnail. // String featureFaceAssetId test('to test the property `featureFaceAssetId`', () async { diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index ba6c5cb7a..5e561b910 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -6176,6 +6176,12 @@ }, "PeopleUpdateItem": { "properties": { + "birthDate": { + "description": "Person date of birth.", + "format": "date", + "nullable": true, + "type": "string" + }, "featureFaceAssetId": { "description": "Asset is used to get the feature face thumbnail.", "type": "string" @@ -6200,6 +6206,11 @@ }, "PersonResponseDto": { "properties": { + "birthDate": { + "format": "date", + "nullable": true, + "type": "string" + }, "id": { "type": "string" }, @@ -6214,6 +6225,7 @@ } }, "required": [ + "birthDate", "id", "name", "thumbnailPath", @@ -6223,6 +6235,12 @@ }, "PersonUpdateDto": { "properties": { + "birthDate": { + "description": "Person date of birth.", + "format": "date", + "nullable": true, + "type": "string" + }, "featureFaceAssetId": { "description": "Asset is used to get the feature face thumbnail.", "type": "string" diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts index f9557fbae..71fe0bd41 100644 --- a/server/src/domain/person/person.dto.ts +++ b/server/src/domain/person/person.dto.ts @@ -1,7 +1,16 @@ import { AssetFaceEntity, PersonEntity } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; -import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { + IsArray, + IsBoolean, + IsDate, + IsNotEmpty, + IsOptional, + IsString, + ValidateIf, + ValidateNested, +} from 'class-validator'; import { toBoolean, ValidateUUID } from '../domain.util'; export class PersonUpdateDto { @@ -12,6 +21,16 @@ export class PersonUpdateDto { @IsString() name?: string; + /** + * Person date of birth. + */ + @IsOptional() + @IsDate() + @Type(() => Date) + @ValidateIf((value) => value !== null) + @ApiProperty({ format: 'date' }) + birthDate?: Date | null; + /** * Asset is used to get the feature face thumbnail. */ @@ -49,6 +68,15 @@ export class PeopleUpdateItem { @IsString() name?: string; + /** + * Person date of birth. + */ + @IsOptional() + @IsDate() + @Type(() => Date) + @ApiProperty({ format: 'date' }) + birthDate?: Date | null; + /** * Asset is used to get the feature face thumbnail. */ @@ -78,6 +106,8 @@ export class PersonSearchDto { export class PersonResponseDto { id!: string; name!: string; + @ApiProperty({ format: 'date' }) + birthDate!: Date | null; thumbnailPath!: string; isHidden!: boolean; } @@ -96,6 +126,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto { return { id: person.id, name: person.name, + birthDate: person.birthDate, thumbnailPath: person.thumbnailPath, isHidden: person.isHidden, }; diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index e5bca7c83..b75bea23f 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -18,6 +18,7 @@ import { PersonService } from './person.service'; const responseDto: PersonResponseDto = { id: 'person-1', name: 'Person 1', + birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', isHidden: false, }; @@ -68,6 +69,7 @@ describe(PersonService.name, () => { { id: 'person-1', name: '', + birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', isHidden: true, }, @@ -142,6 +144,24 @@ describe(PersonService.name, () => { }); }); + it("should update a person's date of birth", async () => { + personMock.getById.mockResolvedValue(personStub.noBirthDate); + personMock.update.mockResolvedValue(personStub.withBirthDate); + personMock.getAssets.mockResolvedValue([assetStub.image]); + + await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({ + id: 'person-1', + name: 'Person 1', + birthDate: new Date('1976-06-30'), + thumbnailPath: '/path/to/thumbnail.jpg', + isHidden: false, + }); + + expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1'); + expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') }); + expect(jobMock.queue).not.toHaveBeenCalled(); + }); + it('should update a person visibility', async () => { personMock.getById.mockResolvedValue(personStub.hidden); personMock.update.mockResolvedValue(personStub.withName); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 187ef3358..07a41400b 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -63,11 +63,13 @@ export class PersonService { async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise { let person = await this.findOrFail(authUser, id); - if (dto.name != undefined || dto.isHidden !== undefined) { - person = await this.repository.update({ id, name: dto.name, isHidden: dto.isHidden }); - const assets = await this.repository.getAssets(authUser.id, id); - const ids = assets.map((asset) => asset.id); - await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); + if (dto.name !== undefined || dto.birthDate !== undefined || dto.isHidden !== undefined) { + person = await this.repository.update({ id, name: dto.name, birthDate: dto.birthDate, isHidden: dto.isHidden }); + if (this.needsSearchIndexUpdate(dto)) { + const assets = await this.repository.getAssets(authUser.id, id); + const ids = assets.map((asset) => asset.id); + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); + } } if (dto.featureFaceAssetId) { @@ -104,6 +106,7 @@ export class PersonService { await this.update(authUser, person.id, { isHidden: person.isHidden, name: person.name, + birthDate: person.birthDate, featureFaceAssetId: person.featureFaceAssetId, }), results.push({ id: person.id, success: true }); @@ -170,6 +173,15 @@ export class PersonService { return results; } + /** + * Returns true if the given person update is going to require an update of the search index. + * @param dto the Person going to be updated + * @private + */ + private needsSearchIndexUpdate(dto: PersonUpdateDto): boolean { + return dto.name !== undefined || dto.isHidden !== undefined; + } + private async findOrFail(authUser: AuthUserDto, id: string) { const person = await this.repository.getById(authUser.id, id); if (!person) { diff --git a/server/src/infra/entities/person.entity.ts b/server/src/infra/entities/person.entity.ts index b93c4bbf9..b0da2f63d 100644 --- a/server/src/infra/entities/person.entity.ts +++ b/server/src/infra/entities/person.entity.ts @@ -30,6 +30,9 @@ export class PersonEntity { @Column({ default: '' }) name!: string; + @Column({ type: 'date', nullable: true }) + birthDate!: Date | null; + @Column({ default: '' }) thumbnailPath!: string; diff --git a/server/src/infra/migrations/1692112147855-AddPersonBirthDate.ts b/server/src/infra/migrations/1692112147855-AddPersonBirthDate.ts new file mode 100644 index 000000000..db2ba35da --- /dev/null +++ b/server/src/infra/migrations/1692112147855-AddPersonBirthDate.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class AddPersonBirthDate1692112147855 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" ADD "birthDate" date`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "birthDate"`); + } + +} diff --git a/server/test/e2e/person.e2e-spec.ts b/server/test/e2e/person.e2e-spec.ts new file mode 100644 index 000000000..6395e78b0 --- /dev/null +++ b/server/test/e2e/person.e2e-spec.ts @@ -0,0 +1,81 @@ +import { IPersonRepository, LoginResponseDto } from '@app/domain'; +import { AppModule, PersonController } from '@app/immich'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { errorStub, uuidStub } from '../fixtures'; +import { api, db } from '../test-utils'; + +describe(`${PersonController.name}`, () => { + let app: INestApplication; + let server: any; + let loginResponse: LoginResponseDto; + let accessToken: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = await moduleFixture.createNestApplication().init(); + server = app.getHttpServer(); + }); + + beforeEach(async () => { + await db.reset(); + await api.adminSignUp(server); + loginResponse = await api.adminLogin(server); + accessToken = loginResponse.accessToken; + }); + + afterAll(async () => { + await db.disconnect(); + await app.close(); + }); + + describe('PUT /person/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(server).put(`/person/${uuidStub.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should not accept invalid dates', async () => { + for (const birthDate of [false, 'false', '123567', 123456]) { + const { status, body } = await request(server) + .put(`/person/${uuidStub.notFound}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ birthDate }); + expect(status).toBe(400); + expect(body).toEqual(errorStub.badRequest); + } + }); + it('should update a date of birth', async () => { + const personRepository = app.get(IPersonRepository); + const person = await personRepository.create({ ownerId: loginResponse.userId }); + const { status, body } = await request(server) + .put(`/person/${person.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ birthDate: '1990-01-01T05:00:00.000Z' }); + expect(status).toBe(200); + expect(body).toMatchObject({ birthDate: '1990-01-01' }); + }); + + it('should clear a date of birth', async () => { + const personRepository = app.get(IPersonRepository); + const person = await personRepository.create({ + birthDate: new Date('1990-01-01'), + ownerId: loginResponse.userId, + }); + + expect(person.birthDate).toBeDefined(); + + const { status, body } = await request(server) + .put(`/person/${person.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ birthDate: null }); + expect(status).toBe(200); + expect(body).toMatchObject({ birthDate: null }); + }); + }); +}); diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts index f2b512b88..2d419425d 100644 --- a/server/test/fixtures/person.stub.ts +++ b/server/test/fixtures/person.stub.ts @@ -9,6 +9,7 @@ export const personStub = { ownerId: userStub.admin.id, owner: userStub.admin, name: '', + birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', faces: [], isHidden: false, @@ -20,6 +21,7 @@ export const personStub = { ownerId: userStub.admin.id, owner: userStub.admin, name: '', + birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', faces: [], isHidden: true, @@ -31,6 +33,31 @@ export const personStub = { ownerId: userStub.admin.id, owner: userStub.admin, name: 'Person 1', + birthDate: null, + thumbnailPath: '/path/to/thumbnail.jpg', + faces: [], + isHidden: false, + }), + noBirthDate: Object.freeze({ + id: 'person-1', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + ownerId: userStub.admin.id, + owner: userStub.admin, + name: 'Person 1', + birthDate: null, + thumbnailPath: '/path/to/thumbnail.jpg', + faces: [], + isHidden: false, + }), + withBirthDate: Object.freeze({ + id: 'person-1', + createdAt: new Date('2021-01-01'), + updatedAt: new Date('2021-01-01'), + ownerId: userStub.admin.id, + owner: userStub.admin, + name: 'Person 1', + birthDate: new Date('1976-06-30'), thumbnailPath: '/path/to/thumbnail.jpg', faces: [], isHidden: false, @@ -42,6 +69,7 @@ export const personStub = { ownerId: userStub.admin.id, owner: userStub.admin, name: '', + birthDate: null, thumbnailPath: '', faces: [], isHidden: false, @@ -53,6 +81,7 @@ export const personStub = { ownerId: userStub.admin.id, owner: userStub.admin, name: '', + birthDate: null, thumbnailPath: '/new/path/to/thumbnail.jpg', faces: [], isHidden: false, @@ -64,6 +93,7 @@ export const personStub = { ownerId: userStub.admin.id, owner: userStub.admin, name: 'Person 1', + birthDate: null, thumbnailPath: '/path/to/thumbnail', faces: [], isHidden: false, @@ -75,6 +105,7 @@ export const personStub = { ownerId: userStub.admin.id, owner: userStub.admin, name: 'Person 2', + birthDate: null, thumbnailPath: '/path/to/thumbnail', faces: [], isHidden: false, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index c4d4707f3..d5251cbc9 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1840,6 +1840,12 @@ export interface PeopleUpdateDto { * @interface PeopleUpdateItem */ export interface PeopleUpdateItem { + /** + * Person date of birth. + * @type {string} + * @memberof PeopleUpdateItem + */ + 'birthDate'?: string | null; /** * Asset is used to get the feature face thumbnail. * @type {string} @@ -1871,6 +1877,12 @@ export interface PeopleUpdateItem { * @interface PersonResponseDto */ export interface PersonResponseDto { + /** + * + * @type {string} + * @memberof PersonResponseDto + */ + 'birthDate': string | null; /** * * @type {string} @@ -1902,6 +1914,12 @@ export interface PersonResponseDto { * @interface PersonUpdateDto */ export interface PersonUpdateDto { + /** + * Person date of birth. + * @type {string} + * @memberof PersonUpdateDto + */ + 'birthDate'?: string | null; /** * Asset is used to get the feature face thumbnail. * @type {string} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 3f574cce7..2c77cd8af 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -121,6 +121,13 @@ thumbhash={null} />

{person.name}

+

+ {#if person.birthDate} + Age {Math.floor( + DateTime.fromISO(asset.fileCreatedAt).diff(DateTime.fromISO(person.birthDate), 'years').years, + )} + {/if} +

{/each} diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index 9edeb2776..3b1dc0217 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -11,19 +11,12 @@ export let person: PersonResponseDto; let showContextMenu = false; - let dispatch = createEventDispatcher(); - - const onChangeNameClicked = () => { - dispatch('change-name', person); - }; - - const onMergeFacesClicked = () => { - dispatch('merge-faces', person); - }; - - const onHideFaceClicked = () => { - dispatch('hide-face', person); - }; + let dispatch = createEventDispatcher<{ + 'change-name': void; + 'set-birth-date': void; + 'merge-faces': void; + 'hide-face': void; + }>();
@@ -52,9 +45,10 @@ {#if showContextMenu} (showContextMenu = false)}> - onHideFaceClicked()} text="Hide face" /> - onChangeNameClicked()} text="Change name" /> - onMergeFacesClicked()} text="Merge faces" /> + dispatch('hide-face')} text="Hide face" /> + dispatch('change-name')} text="Change name" /> + dispatch('set-birth-date')} text="Set date of birth" /> + dispatch('merge-faces')} text="Merge faces" /> {/if} diff --git a/web/src/lib/components/faces-page/set-birth-date-modal.svelte b/web/src/lib/components/faces-page/set-birth-date-modal.svelte new file mode 100644 index 000000000..20ce4d382 --- /dev/null +++ b/web/src/lib/components/faces-page/set-birth-date-modal.svelte @@ -0,0 +1,43 @@ + + + handleCancel()}> +
+
+ +

Set date of birth

+ +

+ Date of birth is used to calculate the age of this person at the time of a photo. +

+
+ +
handleSubmit()} autocomplete="off"> +
+ +
+
+ + +
+
+
+
diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 63a59ab98..4fe4576f1 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -20,6 +20,7 @@ import { onDestroy, onMount } from 'svelte'; import { browser } from '$app/environment'; import MergeSuggestionModal from '$lib/components/faces-page/merge-suggestion-modal.svelte'; + import SetBirthDateModal from '$lib/components/faces-page/set-birth-date-modal.svelte'; export let data: PageData; let selectHidden = false; @@ -35,6 +36,7 @@ let toggleVisibility = false; let showChangeNameModal = false; + let showSetBirthDateModal = false; let showMergeModal = false; let personName = ''; let personMerge1: PersonResponseDto; @@ -194,17 +196,22 @@ } }; - const handleChangeName = ({ detail }: CustomEvent) => { + const handleChangeName = (detail: PersonResponseDto) => { showChangeNameModal = true; personName = detail.name; personMerge1 = detail; edittingPerson = detail; }; - const handleHideFace = async (event: CustomEvent) => { + const handleSetBirthDate = (detail: PersonResponseDto) => { + showSetBirthDateModal = true; + edittingPerson = detail; + }; + + const handleHideFace = async (detail: PersonResponseDto) => { try { const { data: updatedPerson } = await api.personApi.updatePerson({ - id: event.detail.id, + id: detail.id, personUpdateDto: { isHidden: true }, }); @@ -232,16 +239,13 @@ } }; - const handleMergeFaces = (event: CustomEvent) => { - goto(`${AppRoute.PEOPLE}/${event.detail.id}?action=merge`); + const handleMergeFaces = (detail: PersonResponseDto) => { + goto(`${AppRoute.PEOPLE}/${detail.id}?action=merge`); }; const submitNameChange = async () => { showChangeNameModal = false; - if (!edittingPerson) { - return; - } - if (personName === edittingPerson.name) { + if (!edittingPerson || personName === edittingPerson.name) { return; } // We check if another person has the same name as the name entered by the user @@ -261,6 +265,34 @@ changeName(); }; + const submitBirthDateChange = async (value: string) => { + showSetBirthDateModal = false; + if (!edittingPerson || value === edittingPerson.birthDate) { + return; + } + + try { + const { data: updatedPerson } = await api.personApi.updatePerson({ + id: edittingPerson.id, + personUpdateDto: { birthDate: value.length > 0 ? value : null }, + }); + + people = people.map((person: PersonResponseDto) => { + if (person.id === updatedPerson.id) { + return updatedPerson; + } + return person; + }); + + notificationController.show({ + message: 'Date of birth saved succesfully', + type: NotificationType.Info, + }); + } catch (error) { + handleError(error, 'Unable to save name'); + } + }; + const changeName = async () => { showMergeModal = false; showChangeNameModal = false; @@ -323,9 +355,10 @@ {#if !person.isHidden} handleChangeName(person)} + on:set-birth-date={() => handleSetBirthDate(person)} + on:merge-faces={() => handleMergeFaces(person)} + on:hide-face={() => handleHideFace(person)} /> {/if} {/each} @@ -372,6 +405,14 @@
{/if} + + {#if showSetBirthDateModal} + (showSetBirthDateModal = false)} + on:updated={(event) => submitBirthDateChange(event.detail)} + /> + {/if} {#if selectHidden} { + try { + viewMode = ViewMode.VIEW_ASSETS; + data.person.birthDate = birthDate; + + const { data: updatedPerson } = await api.personApi.updatePerson({ + id: data.person.id, + personUpdateDto: { birthDate: birthDate.length > 0 ? birthDate : null }, + }); + + people = people.map((person: PersonResponseDto) => { + if (person.id === updatedPerson.id) { + return updatedPerson; + } + return person; + }); + + notificationController.show({ message: 'Date of birth saved successfully', type: NotificationType.Info }); + } catch (error) { + handleError(error, 'Unable to save date of birth'); + } + }; {#if viewMode === ViewMode.SUGGEST_MERGE} @@ -185,6 +210,14 @@ /> {/if} +{#if viewMode === ViewMode.BIRTH_DATE} + (viewMode = ViewMode.VIEW_ASSETS)} + on:updated={(event) => handleSetBirthDate(event.detail)} + /> +{/if} + {#if viewMode === ViewMode.MERGE_FACES} (viewMode = ViewMode.VIEW_ASSETS)} /> {/if} @@ -206,11 +239,12 @@ {:else} - {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE} + {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE} goto(previousRoute)}> (viewMode = ViewMode.SELECT_FACE)} /> + (viewMode = ViewMode.BIRTH_DATE)} /> (viewMode = ViewMode.MERGE_FACES)} /> @@ -233,7 +267,7 @@ singleSelect={viewMode === ViewMode.SELECT_FACE} on:select={({ detail: asset }) => handleSelectFeaturePhoto(asset)} > - {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE} + {#if viewMode === ViewMode.VIEW_ASSETS || viewMode === ViewMode.SUGGEST_MERGE || viewMode === ViewMode.BIRTH_DATE}
{#if isEditingName}