diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts index bb838bbae3..f3e0530696 100644 --- a/e2e/src/api/specs/person.e2e-spec.ts +++ b/e2e/src/api/specs/person.e2e-spec.ts @@ -1,7 +1,7 @@ -import { LoginResponseDto, PersonResponseDto } from '@immich/sdk'; +import { getPerson, LoginResponseDto, PersonResponseDto } from '@immich/sdk'; import { uuidDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; -import { app, utils } from 'src/utils'; +import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; @@ -203,6 +203,22 @@ describe('/people', () => { birthDate: '1990-01-01T00:00:00.000Z', }); }); + + it('should create a favorite person', async () => { + const { status, body } = await request(app) + .post(`/people`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ + name: 'New Favorite Person', + isFavorite: true, + }); + expect(status).toBe(201); + expect(body).toMatchObject({ + id: expect.any(String), + name: 'New Favorite Person', + isFavorite: true, + }); + }); }); describe('PUT /people/:id', () => { @@ -216,6 +232,7 @@ describe('/people', () => { { key: 'name', type: 'string' }, { key: 'featureFaceAssetId', type: 'string' }, { key: 'isHidden', type: 'boolean value' }, + { key: 'isFavorite', type: 'boolean value' }, ]) { it(`should not allow null ${key}`, async () => { const { status, body } = await request(app) @@ -255,6 +272,24 @@ describe('/people', () => { expect(status).toBe(200); expect(body).toMatchObject({ birthDate: null }); }); + + it('should mark a person as favorite', async () => { + const person = await utils.createPerson(admin.accessToken, { + name: 'visible_person', + }); + + expect(person.isFavorite).toBe(false); + + const { status, body } = await request(app) + .put(`/people/${person.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ isFavorite: true }); + expect(status).toBe(200); + expect(body).toMatchObject({ isFavorite: true }); + + const person2 = await getPerson({ id: person.id }, { headers: asBearerAuth(admin.accessToken) }); + expect(person2).toMatchObject({ id: person.id, isFavorite: true }); + }); }); describe('POST /people/:id/merge', () => { diff --git a/mobile/openapi/lib/model/people_update_item.dart b/mobile/openapi/lib/model/people_update_item.dart index 042e4fa36f..6f8e312959 100644 --- a/mobile/openapi/lib/model/people_update_item.dart +++ b/mobile/openapi/lib/model/people_update_item.dart @@ -16,6 +16,7 @@ class PeopleUpdateItem { this.birthDate, this.featureFaceAssetId, required this.id, + this.isFavorite, this.isHidden, this.name, }); @@ -35,6 +36,14 @@ class PeopleUpdateItem { /// Person id. String id; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isFavorite; + /// Person visibility /// /// Please note: This property should have been non-nullable! Since the specification file @@ -58,6 +67,7 @@ class PeopleUpdateItem { other.birthDate == birthDate && other.featureFaceAssetId == featureFaceAssetId && other.id == id && + other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name; @@ -67,11 +77,12 @@ class PeopleUpdateItem { (birthDate == null ? 0 : birthDate!.hashCode) + (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + (id.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isHidden=$isHidden, name=$name]'; + String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; Map<String, dynamic> toJson() { final json = <String, dynamic>{}; @@ -86,6 +97,11 @@ class PeopleUpdateItem { // json[r'featureFaceAssetId'] = null; } json[r'id'] = this.id; + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } if (this.isHidden != null) { json[r'isHidden'] = this.isHidden; } else { @@ -111,6 +127,7 @@ class PeopleUpdateItem { birthDate: mapDateTime(json, r'birthDate', r''), featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'), id: mapValueOfType<String>(json, r'id')!, + isFavorite: mapValueOfType<bool>(json, r'isFavorite'), isHidden: mapValueOfType<bool>(json, r'isHidden'), name: mapValueOfType<String>(json, r'name'), ); diff --git a/mobile/openapi/lib/model/person_create_dto.dart b/mobile/openapi/lib/model/person_create_dto.dart index 36bd6dfee9..bc1d67c240 100644 --- a/mobile/openapi/lib/model/person_create_dto.dart +++ b/mobile/openapi/lib/model/person_create_dto.dart @@ -14,6 +14,7 @@ class PersonCreateDto { /// Returns a new [PersonCreateDto] instance. PersonCreateDto({ this.birthDate, + this.isFavorite, this.isHidden, this.name, }); @@ -21,6 +22,14 @@ class PersonCreateDto { /// Person date of birth. Note: the mobile app cannot currently set the birth date to null. DateTime? birthDate; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isFavorite; + /// Person visibility /// /// Please note: This property should have been non-nullable! Since the specification file @@ -42,6 +51,7 @@ class PersonCreateDto { @override bool operator ==(Object other) => identical(this, other) || other is PersonCreateDto && other.birthDate == birthDate && + other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name; @@ -49,11 +59,12 @@ class PersonCreateDto { int get hashCode => // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'PersonCreateDto[birthDate=$birthDate, isHidden=$isHidden, name=$name]'; + String toString() => 'PersonCreateDto[birthDate=$birthDate, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; Map<String, dynamic> toJson() { final json = <String, dynamic>{}; @@ -62,6 +73,11 @@ class PersonCreateDto { } else { // json[r'birthDate'] = null; } + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } if (this.isHidden != null) { json[r'isHidden'] = this.isHidden; } else { @@ -85,6 +101,7 @@ class PersonCreateDto { return PersonCreateDto( birthDate: mapDateTime(json, r'birthDate', r''), + isFavorite: mapValueOfType<bool>(json, r'isFavorite'), isHidden: mapValueOfType<bool>(json, r'isHidden'), name: mapValueOfType<String>(json, r'name'), ); diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index 0b36fcde3b..1884459928 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -15,6 +15,7 @@ class PersonResponseDto { PersonResponseDto({ required this.birthDate, required this.id, + this.isFavorite, required this.isHidden, required this.name, required this.thumbnailPath, @@ -25,6 +26,15 @@ class PersonResponseDto { String id; + /// This property was added in v1.126.0 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isFavorite; + bool isHidden; String name; @@ -44,6 +54,7 @@ class PersonResponseDto { bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto && other.birthDate == birthDate && other.id == id && + other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name && other.thumbnailPath == thumbnailPath && @@ -54,13 +65,14 @@ class PersonResponseDto { // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + (id.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden.hashCode) + (name.hashCode) + (thumbnailPath.hashCode) + (updatedAt == null ? 0 : updatedAt!.hashCode); @override - String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; + String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; Map<String, dynamic> toJson() { final json = <String, dynamic>{}; @@ -70,6 +82,11 @@ class PersonResponseDto { // json[r'birthDate'] = null; } json[r'id'] = this.id; + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } json[r'isHidden'] = this.isHidden; json[r'name'] = this.name; json[r'thumbnailPath'] = this.thumbnailPath; @@ -92,6 +109,7 @@ class PersonResponseDto { return PersonResponseDto( birthDate: mapDateTime(json, r'birthDate', r''), id: mapValueOfType<String>(json, r'id')!, + isFavorite: mapValueOfType<bool>(json, r'isFavorite'), isHidden: mapValueOfType<bool>(json, r'isHidden')!, name: mapValueOfType<String>(json, r'name')!, thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!, diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index 51a7ea25d0..cf0688a27f 100644 --- a/mobile/openapi/lib/model/person_update_dto.dart +++ b/mobile/openapi/lib/model/person_update_dto.dart @@ -15,6 +15,7 @@ class PersonUpdateDto { PersonUpdateDto({ this.birthDate, this.featureFaceAssetId, + this.isFavorite, this.isHidden, this.name, }); @@ -31,6 +32,14 @@ class PersonUpdateDto { /// String? featureFaceAssetId; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isFavorite; + /// Person visibility /// /// Please note: This property should have been non-nullable! Since the specification file @@ -53,6 +62,7 @@ class PersonUpdateDto { bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto && other.birthDate == birthDate && other.featureFaceAssetId == featureFaceAssetId && + other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name; @@ -61,11 +71,12 @@ class PersonUpdateDto { // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + (name == null ? 0 : name!.hashCode); @override - String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden, name=$name]'; + String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]'; Map<String, dynamic> toJson() { final json = <String, dynamic>{}; @@ -79,6 +90,11 @@ class PersonUpdateDto { } else { // json[r'featureFaceAssetId'] = null; } + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } if (this.isHidden != null) { json[r'isHidden'] = this.isHidden; } else { @@ -103,6 +119,7 @@ class PersonUpdateDto { return PersonUpdateDto( birthDate: mapDateTime(json, r'birthDate', r''), featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'), + isFavorite: mapValueOfType<bool>(json, r'isFavorite'), isHidden: mapValueOfType<bool>(json, r'isHidden'), name: mapValueOfType<String>(json, r'name'), ); diff --git a/mobile/openapi/lib/model/person_with_faces_response_dto.dart b/mobile/openapi/lib/model/person_with_faces_response_dto.dart index b14bad7895..7d61db11f3 100644 --- a/mobile/openapi/lib/model/person_with_faces_response_dto.dart +++ b/mobile/openapi/lib/model/person_with_faces_response_dto.dart @@ -16,6 +16,7 @@ class PersonWithFacesResponseDto { required this.birthDate, this.faces = const [], required this.id, + this.isFavorite, required this.isHidden, required this.name, required this.thumbnailPath, @@ -28,6 +29,15 @@ class PersonWithFacesResponseDto { String id; + /// This property was added in v1.126.0 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? isFavorite; + bool isHidden; String name; @@ -48,6 +58,7 @@ class PersonWithFacesResponseDto { other.birthDate == birthDate && _deepEquality.equals(other.faces, faces) && other.id == id && + other.isFavorite == isFavorite && other.isHidden == isHidden && other.name == name && other.thumbnailPath == thumbnailPath && @@ -59,13 +70,14 @@ class PersonWithFacesResponseDto { (birthDate == null ? 0 : birthDate!.hashCode) + (faces.hashCode) + (id.hashCode) + + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden.hashCode) + (name.hashCode) + (thumbnailPath.hashCode) + (updatedAt == null ? 0 : updatedAt!.hashCode); @override - String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; + String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; Map<String, dynamic> toJson() { final json = <String, dynamic>{}; @@ -76,6 +88,11 @@ class PersonWithFacesResponseDto { } json[r'faces'] = this.faces; json[r'id'] = this.id; + if (this.isFavorite != null) { + json[r'isFavorite'] = this.isFavorite; + } else { + // json[r'isFavorite'] = null; + } json[r'isHidden'] = this.isHidden; json[r'name'] = this.name; json[r'thumbnailPath'] = this.thumbnailPath; @@ -99,6 +116,7 @@ class PersonWithFacesResponseDto { birthDate: mapDateTime(json, r'birthDate', r''), faces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'faces']), id: mapValueOfType<String>(json, r'id')!, + isFavorite: mapValueOfType<bool>(json, r'isFavorite'), isHidden: mapValueOfType<bool>(json, r'isHidden')!, name: mapValueOfType<String>(json, r'name')!, thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!, diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 85dc55aed8..f1ef466df4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10294,6 +10294,9 @@ "description": "Person id.", "type": "string" }, + "isFavorite": { + "type": "boolean" + }, "isHidden": { "description": "Person visibility", "type": "boolean" @@ -10399,6 +10402,9 @@ "nullable": true, "type": "string" }, + "isFavorite": { + "type": "boolean" + }, "isHidden": { "description": "Person visibility", "type": "boolean" @@ -10420,6 +10426,10 @@ "id": { "type": "string" }, + "isFavorite": { + "description": "This property was added in v1.126.0", + "type": "boolean" + }, "isHidden": { "type": "boolean" }, @@ -10467,6 +10477,9 @@ "description": "Asset is used to get the feature face thumbnail.", "type": "string" }, + "isFavorite": { + "type": "boolean" + }, "isHidden": { "description": "Person visibility", "type": "boolean" @@ -10494,6 +10507,10 @@ "id": { "type": "string" }, + "isFavorite": { + "description": "This property was added in v1.126.0", + "type": "boolean" + }, "isHidden": { "type": "boolean" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 32cca2dbce..6704a83cc7 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -215,6 +215,8 @@ export type PersonWithFacesResponseDto = { birthDate: string | null; faces: AssetFaceWithoutPersonResponseDto[]; id: string; + /** This property was added in v1.126.0 */ + isFavorite?: boolean; isHidden: boolean; name: string; thumbnailPath: string; @@ -492,6 +494,8 @@ export type DuplicateResponseDto = { export type PersonResponseDto = { birthDate: string | null; id: string; + /** This property was added in v1.126.0 */ + isFavorite?: boolean; isHidden: boolean; name: string; thumbnailPath: string; @@ -689,6 +693,7 @@ export type PersonCreateDto = { /** Person date of birth. Note: the mobile app cannot currently set the birth date to null. */ birthDate?: string | null; + isFavorite?: boolean; /** Person visibility */ isHidden?: boolean; /** Person name. */ @@ -702,6 +707,7 @@ export type PeopleUpdateItem = { featureFaceAssetId?: string; /** Person id. */ id: string; + isFavorite?: boolean; /** Person visibility */ isHidden?: boolean; /** Person name. */ @@ -716,6 +722,7 @@ export type PersonUpdateDto = { birthDate?: string | null; /** Asset is used to get the feature face thumbnail. */ featureFaceAssetId?: string; + isFavorite?: boolean; /** Person visibility */ isHidden?: boolean; /** Person name. */ diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 6242914bee..16f73c53e7 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -279,6 +279,7 @@ export interface Person { createdAt: Generated<Timestamp>; faceAssetId: string | null; id: Generated<string>; + isFavorite: Generated<boolean>; isHidden: Generated<boolean>; name: Generated<string>; ownerId: string; @@ -327,11 +328,6 @@ export interface SocketIoAttachments { payload: Buffer | null; } -export interface SystemConfig { - key: string; - value: string | null; -} - export interface SystemMetadata { key: string; value: Json; @@ -357,6 +353,15 @@ export interface TagsClosure { id_descendant: string; } +export interface TypeormMetadata { + database: string | null; + name: string | null; + schema: string | null; + table: string | null; + type: string; + value: string | null; +} + export interface UserMetadata { key: string; userId: string; @@ -431,11 +436,11 @@ export interface DB { shared_links: SharedLinks; smart_search: SmartSearch; socket_io_attachments: SocketIoAttachments; - system_config: SystemConfig; system_metadata: SystemMetadata; tag_asset: TagAsset; tags: Tags; tags_closure: TagsClosure; + typeorm_metadata: TypeormMetadata; user_metadata: UserMetadata; users: Users; "vectors.pg_vector_index_stat": VectorsPgVectorIndexStat; diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 047ef600b8..8bf041be37 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -32,6 +32,9 @@ export class PersonCreateDto { */ @ValidateBoolean({ optional: true }) isHidden?: boolean; + + @ValidateBoolean({ optional: true }) + isFavorite?: boolean; } export class PersonUpdateDto extends PersonCreateDto { @@ -97,6 +100,8 @@ export class PersonResponseDto { isHidden!: boolean; @PropertyLifecycle({ addedAt: 'v1.107.0' }) updatedAt?: Date; + @PropertyLifecycle({ addedAt: 'v1.126.0' }) + isFavorite?: boolean; } export class PersonWithFacesResponseDto extends PersonResponseDto { @@ -170,6 +175,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto { birthDate: person.birthDate, thumbnailPath: person.thumbnailPath, isHidden: person.isHidden, + isFavorite: person.isFavorite, updatedAt: person.updatedAt, }; } diff --git a/server/src/entities/person.entity.ts b/server/src/entities/person.entity.ts index 5efbcbfa0b..8cf416b766 100644 --- a/server/src/entities/person.entity.ts +++ b/server/src/entities/person.entity.ts @@ -49,4 +49,7 @@ export class PersonEntity { @Column({ default: false }) isHidden!: boolean; + + @Column({ default: false }) + isFavorite!: boolean; } diff --git a/server/src/migrations/1734879118272-AddIsFavoritePerson.ts b/server/src/migrations/1734879118272-AddIsFavoritePerson.ts new file mode 100644 index 0000000000..6f7640f96f --- /dev/null +++ b/server/src/migrations/1734879118272-AddIsFavoritePerson.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddIsFavoritePerson1734879118272 implements MigrationInterface { + name = 'AddIsFavoritePerson1734879118272' + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "person" ADD "isFavorite" boolean NOT NULL DEFAULT false`); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "isFavorite"`); + } + +} diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 7c2512aa26..73fb8313d2 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -132,6 +132,7 @@ export class PersonRepository implements IPersonRepository { ) .where('person.ownerId', '=', userId) .orderBy('person.isHidden', 'asc') + .orderBy('person.isFavorite', 'desc') .having((eb) => eb.or([ eb('person.name', '!=', ''), diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index dc9d7a9329..5407821fab 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -30,6 +30,7 @@ const responseDto: PersonResponseDto = { thumbnailPath: '/path/to/thumbnail.jpg', isHidden: false, updatedAt: expect.any(Date), + isFavorite: false, }; const statistics = { assets: 3 }; @@ -116,6 +117,7 @@ describe(PersonService.name, () => { birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', isHidden: true, + isFavorite: false, updatedAt: expect.any(Date), }, ], @@ -125,6 +127,35 @@ describe(PersonService.name, () => { withHidden: true, }); }); + + it('should get all visible people and favorites should be first in the array', async () => { + personMock.getAllForUser.mockResolvedValue({ + items: [personStub.isFavorite, personStub.withName], + hasNextPage: false, + }); + personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); + await expect(sut.getAll(authStub.admin, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({ + hasNextPage: false, + total: 2, + hidden: 1, + people: [ + { + id: 'person-4', + name: personStub.isFavorite.name, + birthDate: null, + thumbnailPath: '/path/to/thumbnail.jpg', + isHidden: false, + isFavorite: true, + updatedAt: expect.any(Date), + }, + responseDto, + ], + }); + expect(personMock.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, { + minimumFaceCount: 3, + withHidden: false, + }); + }); }); describe('getById', () => { @@ -227,6 +258,7 @@ describe(PersonService.name, () => { birthDate: '1976-06-30', thumbnailPath: '/path/to/thumbnail.jpg', isHidden: false, + isFavorite: false, updatedAt: expect.any(Date), }); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' }); @@ -245,6 +277,16 @@ describe(PersonService.name, () => { expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); + it('should update a person favorite status', async () => { + personMock.update.mockResolvedValue(personStub.withName); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + + await expect(sut.update(authStub.admin, 'person-1', { isFavorite: true })).resolves.toEqual(responseDto); + + expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true }); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + }); + it("should update a person's thumbnailPath", async () => { personMock.update.mockResolvedValue(personStub.withName); personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); @@ -375,6 +417,7 @@ describe(PersonService.name, () => { ).resolves.toEqual({ birthDate: personStub.noName.birthDate, isHidden: personStub.noName.isHidden, + isFavorite: personStub.noName.isFavorite, id: personStub.noName.id, name: personStub.noName.name, thumbnailPath: personStub.noName.thumbnailPath, diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index bcc65cfad3..2f4a6bb0d1 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -184,13 +184,14 @@ export class PersonService extends BaseService { name: dto.name, birthDate: dto.birthDate, isHidden: dto.isHidden, + isFavorite: dto.isFavorite, }); } async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> { await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] }); - const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto; + const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite } = dto; // TODO: set by faceId directly let faceId: string | undefined = undefined; if (assetId) { @@ -203,7 +204,14 @@ export class PersonService extends BaseService { faceId = face.id; } - const person = await this.personRepository.update({ id, faceAssetId: faceId, name, birthDate, isHidden }); + const person = await this.personRepository.update({ + id, + faceAssetId: faceId, + name, + birthDate, + isHidden, + isFavorite, + }); if (assetId) { await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }); @@ -221,6 +229,7 @@ export class PersonService extends BaseService { name: person.name, birthDate: person.birthDate, featureFaceAssetId: person.featureFaceAssetId, + isFavorite: person.isFavorite, }); results.push({ id: person.id, success: true }); } catch (error: Error | any) { diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts index 544894b31e..ecd5b0dbea 100644 --- a/server/test/fixtures/person.stub.ts +++ b/server/test/fixtures/person.stub.ts @@ -15,6 +15,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), hidden: Object.freeze<PersonEntity>({ id: 'person-1', @@ -29,6 +30,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: true, + isFavorite: false, }), withName: Object.freeze<PersonEntity>({ id: 'person-1', @@ -43,6 +45,7 @@ export const personStub = { faceAssetId: 'assetFaceId', faceAsset: null, isHidden: false, + isFavorite: false, }), withBirthDate: Object.freeze<PersonEntity>({ id: 'person-1', @@ -57,6 +60,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), noThumbnail: Object.freeze<PersonEntity>({ id: 'person-1', @@ -71,6 +75,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), newThumbnail: Object.freeze<PersonEntity>({ id: 'person-1', @@ -85,6 +90,7 @@ export const personStub = { faceAssetId: 'asset-id', faceAsset: null, isHidden: false, + isFavorite: false, }), primaryPerson: Object.freeze<PersonEntity>({ id: 'person-1', @@ -99,6 +105,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), mergePerson: Object.freeze<PersonEntity>({ id: 'person-2', @@ -113,6 +120,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), randomPerson: Object.freeze<PersonEntity>({ id: 'person-3', @@ -127,5 +135,21 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, + }), + isFavorite: Object.freeze<PersonEntity>({ + id: 'person-4', + 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: [], + faceAssetId: 'assetFaceId', + faceAsset: null, + isHidden: false, + isFavorite: true, }), }; diff --git a/web/src/lib/components/faces-page/people-card.svelte b/web/src/lib/components/faces-page/people-card.svelte index a83d1180f9..494dd94666 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -1,4 +1,7 @@ <script lang="ts"> + import { focusOutside } from '$lib/actions/focus-outside'; + import Icon from '$lib/components/elements/icon.svelte'; + import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import { AppRoute, QueryParameter } from '$lib/constants'; import { getPeopleThumbnailUrl } from '$lib/utils'; import { type PersonResponseDto } from '@immich/sdk'; @@ -8,12 +11,13 @@ mdiCalendarEditOutline, mdiDotsVertical, mdiEyeOffOutline, + mdiHeart, + mdiHeartMinusOutline, + mdiHeartOutline, } from '@mdi/js'; + import { t } from 'svelte-i18n'; import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; import MenuOption from '../shared-components/context-menu/menu-option.svelte'; - import { t } from 'svelte-i18n'; - import { focusOutside } from '$lib/actions/focus-outside'; - import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; interface Props { person: PersonResponseDto; @@ -22,9 +26,18 @@ onSetBirthDate: () => void; onMergePeople: () => void; onHidePerson: () => void; + onToggleFavorite: () => void; } - let { person, preload = false, onChangeName, onSetBirthDate, onMergePeople, onHidePerson }: Props = $props(); + let { + person, + preload = false, + onChangeName, + onSetBirthDate, + onMergePeople, + onHidePerson, + onToggleFavorite, + }: Props = $props(); let showVerticalDots = $state(false); </script> @@ -51,6 +64,11 @@ title={person.name} widthStyle="100%" /> + {#if person.isFavorite} + <div class="absolute bottom-2 left-2 z-10"> + <Icon path={mdiHeart} size="24" class="text-white" /> + </div> + {/if} </div> {#if person.name} <span @@ -76,6 +94,11 @@ <MenuOption onClick={onChangeName} icon={mdiAccountEditOutline} text={$t('change_name')} /> <MenuOption onClick={onSetBirthDate} icon={mdiCalendarEditOutline} text={$t('set_date_of_birth')} /> <MenuOption onClick={onMergePeople} icon={mdiAccountMultipleCheckOutline} text={$t('merge_people')} /> + <MenuOption + onClick={onToggleFavorite} + icon={person.isFavorite ? mdiHeartMinusOutline : mdiHeartOutline} + text={person.isFavorite ? $t('unfavorite') : $t('to_favorite')} + /> </ButtonContextMenu> </div> {/if} diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index fef6a29b85..40a02f7425 100644 --- a/web/src/routes/(user)/explore/+page.svelte +++ b/web/src/routes/(user)/explore/+page.svelte @@ -11,6 +11,8 @@ import { onMount } from 'svelte'; import { websocketEvents } from '$lib/stores/websocket'; import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte'; + import Icon from '$lib/components/elements/icon.svelte'; + import { mdiHeart } from '@mdi/js'; interface Props { data: PageData; @@ -53,7 +55,7 @@ <SingleGridRow class="grid md:grid-auto-fill-28 grid-auto-fill-20 gap-x-4"> {#snippet children({ itemCount })} {#each people.slice(0, itemCount) as person (person.id)} - <a href="{AppRoute.PEOPLE}/{person.id}" class="text-center"> + <a href="{AppRoute.PEOPLE}/{person.id}" class="text-center relative"> <ImageThumbnail circle shadow @@ -61,6 +63,11 @@ altText={person.name} widthStyle="100%" /> + {#if person.isFavorite} + <div class="absolute bottom-2 left-2 z-10"> + <Icon path={mdiHeart} size="24" class="text-white" /> + </div> + {/if} <p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p> </a> {/each} diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index cdfd0045c6..f3ea5d8638 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -222,6 +222,25 @@ } }; + const handleToggleFavorite = async (detail: PersonResponseDto) => { + try { + const updatedPerson = await updatePerson({ + id: detail.id, + personUpdateDto: { isFavorite: !detail.isFavorite }, + }); + + const index = people.findIndex((person) => person.id === detail.id); + people[index] = updatedPerson; + + notificationController.show({ + message: updatedPerson.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'), + type: NotificationType.Info, + }); + } catch (error) { + handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: detail.isFavorite } })); + } + }; + const handleMergePeople = async (detail: PersonResponseDto) => { await goto( `${AppRoute.PEOPLE}/${detail.id}?${QueryParameter.ACTION}=${ActionQueryParameterValue.MERGE}&${QueryParameter.PREVIOUS_ROUTE}=${AppRoute.PEOPLE}`, @@ -413,6 +432,7 @@ onSetBirthDate={() => handleSetBirthDate(person)} onMergePeople={() => handleMergePeople(person)} onHidePerson={() => handleHidePerson(person)} + onToggleFavorite={() => handleToggleFavorite(person)} /> {/snippet} </PeopleInfiniteScroll> diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 00a5284452..edaf33487c 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,6 +1,8 @@ <script lang="ts"> - import { afterNavigate, goto } from '$app/navigation'; + import { afterNavigate, goto, invalidateAll } from '$app/navigation'; import { page } from '$app/stores'; + import { clickOutside } from '$lib/actions/click-outside'; + import { listNavigation } from '$lib/actions/list-navigation'; import { scrollMemoryClearer } from '$lib/actions/scroll-memory'; import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte'; import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte'; @@ -17,8 +19,10 @@ import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; + import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; + import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; @@ -27,11 +31,12 @@ notificationController, } from '$lib/components/shared-components/notification/notification'; import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { AssetStore } from '$lib/stores/assets.store'; + import { preferences } from '$lib/stores/user.store'; import { websocketEvents } from '$lib/stores/websocket'; import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; - import { clickOutside } from '$lib/actions/click-outside'; import { handleError } from '$lib/utils/handle-error'; import { isExternalUrl } from '$lib/utils/navigation'; import { @@ -50,16 +55,13 @@ mdiDotsVertical, mdiEyeOffOutline, mdiEyeOutline, + mdiHeartMinusOutline, + mdiHeartOutline, mdiPlus, } from '@mdi/js'; import { onDestroy, onMount } from 'svelte'; - import type { PageData } from './$types'; - import { listNavigation } from '$lib/actions/list-navigation'; import { t } from 'svelte-i18n'; - import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; - import { preferences } from '$lib/stores/user.store'; - import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; - import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; + import type { PageData } from './$types'; interface Props { data: PageData; @@ -181,6 +183,25 @@ } }; + const handleToggleFavorite = async () => { + try { + const updatedPerson = await updatePerson({ + id: person.id, + personUpdateDto: { isFavorite: !person.isFavorite }, + }); + + // Invalidate to reload the page data and have the favorite status updated + await invalidateAll(); + + notificationController.show({ + message: updatedPerson.isFavorite ? $t('added_to_favorites') : $t('removed_from_favorites'), + type: NotificationType.Info, + }); + } catch (error) { + handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: person.isFavorite } })); + } + }; + const handleMerge = async (person: PersonResponseDto) => { await updateAssetCount(); await handleGoBack(); @@ -445,6 +466,11 @@ icon={mdiAccountMultipleCheckOutline} onClick={() => (viewMode = PersonPageViewMode.MERGE_PEOPLE)} /> + <MenuOption + icon={person.isFavorite ? mdiHeartMinusOutline : mdiHeartOutline} + text={person.isFavorite ? $t('unfavorite') : $t('to_favorite')} + onClick={handleToggleFavorite} + /> </ButtonContextMenu> {/snippet} </ControlAppBar>