diff --git a/mobile/openapi/lib/model/people_update_item.dart b/mobile/openapi/lib/model/people_update_item.dart index 042e4fa36f969..6f8e312959ac3 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 toJson() { final json = {}; @@ -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(json, r'featureFaceAssetId'), id: mapValueOfType(json, r'id')!, + isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden'), name: mapValueOfType(json, r'name'), ); diff --git a/mobile/openapi/lib/model/person_create_dto.dart b/mobile/openapi/lib/model/person_create_dto.dart index 36bd6dfee9072..bc1d67c2407f4 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 toJson() { final json = {}; @@ -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(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden'), name: mapValueOfType(json, r'name'), ); diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index 0b36fcde3b271..848018aa6b169 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.124.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 toJson() { final json = {}; @@ -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(json, r'id')!, + isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden')!, name: mapValueOfType(json, r'name')!, thumbnailPath: mapValueOfType(json, r'thumbnailPath')!, diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index 51a7ea25d07b3..cf0688a27f463 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 toJson() { final json = {}; @@ -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(json, r'featureFaceAssetId'), + isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden'), name: mapValueOfType(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 b14bad789505b..556b88975a24f 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.124.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 toJson() { final json = {}; @@ -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(json, r'id')!, + isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden')!, name: mapValueOfType(json, r'name')!, thumbnailPath: mapValueOfType(json, r'thumbnailPath')!, diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2686d4f96d69f..7e389d8c5c30b 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10279,6 +10279,9 @@ "description": "Person id.", "type": "string" }, + "isFavorite": { + "type": "boolean" + }, "isHidden": { "description": "Person visibility", "type": "boolean" @@ -10384,6 +10387,9 @@ "nullable": true, "type": "string" }, + "isFavorite": { + "type": "boolean" + }, "isHidden": { "description": "Person visibility", "type": "boolean" @@ -10405,6 +10411,10 @@ "id": { "type": "string" }, + "isFavorite": { + "description": "This property was added in v1.124.0", + "type": "boolean" + }, "isHidden": { "type": "boolean" }, @@ -10452,6 +10462,9 @@ "description": "Asset is used to get the feature face thumbnail.", "type": "string" }, + "isFavorite": { + "type": "boolean" + }, "isHidden": { "description": "Person visibility", "type": "boolean" @@ -10479,6 +10492,10 @@ "id": { "type": "string" }, + "isFavorite": { + "description": "This property was added in v1.124.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 c31e71d05e961..c127e51cf9e6b 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.124.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.124.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/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 047ef600b821d..f836adde00d97 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -32,6 +32,10 @@ export class PersonCreateDto { */ @ValidateBoolean({ optional: true }) isHidden?: boolean; + + @ApiProperty() + @ValidateBoolean({ optional: true }) + isFavorite?: boolean; } export class PersonUpdateDto extends PersonCreateDto { @@ -97,6 +101,9 @@ export class PersonResponseDto { isHidden!: boolean; @PropertyLifecycle({ addedAt: 'v1.107.0' }) updatedAt?: Date; + @ApiProperty() + @PropertyLifecycle({ addedAt: 'v1.124.0' }) + isFavorite?: boolean; } export class PersonWithFacesResponseDto extends PersonResponseDto { @@ -170,6 +177,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 5efbcbfa0bf99..8cf416b766069 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 0000000000000..6f7640f96f3b7 --- /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 { + await queryRunner.query(`ALTER TABLE "person" ADD "isFavorite" boolean NOT NULL DEFAULT false`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "isFavorite"`); + } + +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 4694cd20fc532..890d9218dd334 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -219,6 +219,7 @@ SELECT "8258e303a73a72cf6abb13d73fb592dde0d68280"."thumbnailPath" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_thumbnailPath", "8258e303a73a72cf6abb13d73fb592dde0d68280"."faceAssetId" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_faceAssetId", "8258e303a73a72cf6abb13d73fb592dde0d68280"."isHidden" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_isHidden", + "8258e303a73a72cf6abb13d73fb592dde0d68280"."isFavorite" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_isFavorite", "AssetEntity__AssetEntity_stack"."id" AS "AssetEntity__AssetEntity_stack_id", "AssetEntity__AssetEntity_stack"."ownerId" AS "AssetEntity__AssetEntity_stack_ownerId", "AssetEntity__AssetEntity_stack"."primaryAssetId" AS "AssetEntity__AssetEntity_stack_primaryAssetId", diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index a7e683fca1e72..6be269597233f 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -17,7 +17,8 @@ SELECT "person"."birthDate" AS "person_birthDate", "person"."thumbnailPath" AS "person_thumbnailPath", "person"."faceAssetId" AS "person_faceAssetId", - "person"."isHidden" AS "person_isHidden" + "person"."isHidden" AS "person_isHidden", + "person"."isFavorite" AS "person_isFavorite" FROM "person" "person" INNER JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" @@ -34,6 +35,7 @@ HAVING OR COUNT("face"."assetId") >= $2 ORDER BY "person"."isHidden" ASC, + "person"."isFavorite" DESC, NULLIF("person"."name", '') IS NULL ASC, COUNT("face"."assetId") DESC, NULLIF("person"."name", '') ASC NULLS LAST, @@ -53,7 +55,8 @@ SELECT "person"."birthDate" AS "person_birthDate", "person"."thumbnailPath" AS "person_thumbnailPath", "person"."faceAssetId" AS "person_faceAssetId", - "person"."isHidden" AS "person_isHidden" + "person"."isHidden" AS "person_isHidden", + "person"."isFavorite" AS "person_isFavorite" FROM "person" "person" LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" @@ -82,7 +85,8 @@ SELECT "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_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden", + "AssetFaceEntity__AssetFaceEntity_person"."isFavorite" AS "AssetFaceEntity__AssetFaceEntity_person_isFavorite" FROM "asset_faces" "AssetFaceEntity" LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" @@ -115,7 +119,8 @@ FROM "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_person"."isHidden" AS "AssetFaceEntity__AssetFaceEntity_person_isHidden", + "AssetFaceEntity__AssetFaceEntity_person"."isFavorite" AS "AssetFaceEntity__AssetFaceEntity_person_isFavorite" FROM "asset_faces" "AssetFaceEntity" LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" @@ -152,6 +157,7 @@ FROM "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_person"."isFavorite" AS "AssetFaceEntity__AssetFaceEntity_person_isFavorite", "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", @@ -212,7 +218,8 @@ SELECT "person"."birthDate" AS "person_birthDate", "person"."thumbnailPath" AS "person_thumbnailPath", "person"."faceAssetId" AS "person_faceAssetId", - "person"."isHidden" AS "person_isHidden" + "person"."isHidden" AS "person_isHidden", + "person"."isFavorite" AS "person_isFavorite" FROM "person" "person" WHERE diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 4229286706619..8a91774661f11 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -95,6 +95,7 @@ export class PersonRepository implements IPersonRepository { .innerJoin('face.asset', 'asset') .andWhere('asset.isArchived = false') .orderBy('person.isHidden', 'ASC') + .addOrderBy('person.isFavorite', 'DESC') .addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC') .addOrderBy('COUNT(face.assetId)', 'DESC') .addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST') diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 3b749c0ab65cc..c3829e754f758 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -31,6 +31,7 @@ const responseDto: PersonResponseDto = { thumbnailPath: '/path/to/thumbnail.jpg', isHidden: false, updatedAt: expect.any(Date), + isFavorite: false, }; const statistics = { assets: 3 }; @@ -117,6 +118,7 @@ describe(PersonService.name, () => { birthDate: null, thumbnailPath: '/path/to/thumbnail.jpg', isHidden: true, + isFavorite: false, updatedAt: expect.any(Date), }, ], @@ -126,6 +128,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', () => { @@ -228,6 +259,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' }); @@ -246,6 +278,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]); @@ -377,6 +419,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 bdec6f88e8e7b..b026dafe4911c 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -185,13 +185,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 { 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) { @@ -204,7 +205,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 } }); @@ -222,6 +230,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 544894b31e1f4..ecd5b0dbea08c 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({ id: 'person-1', @@ -29,6 +30,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: true, + isFavorite: false, }), withName: Object.freeze({ id: 'person-1', @@ -43,6 +45,7 @@ export const personStub = { faceAssetId: 'assetFaceId', faceAsset: null, isHidden: false, + isFavorite: false, }), withBirthDate: Object.freeze({ id: 'person-1', @@ -57,6 +60,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), noThumbnail: Object.freeze({ id: 'person-1', @@ -71,6 +75,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), newThumbnail: Object.freeze({ id: 'person-1', @@ -85,6 +90,7 @@ export const personStub = { faceAssetId: 'asset-id', faceAsset: null, isHidden: false, + isFavorite: false, }), primaryPerson: Object.freeze({ id: 'person-1', @@ -99,6 +105,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), mergePerson: Object.freeze({ id: 'person-2', @@ -113,6 +120,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, }), randomPerson: Object.freeze({ id: 'person-3', @@ -127,5 +135,21 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + isFavorite: false, + }), + isFavorite: Object.freeze({ + 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 a83d1180f954e..167f3588d63ba 100644 --- a/web/src/lib/components/faces-page/people-card.svelte +++ b/web/src/lib/components/faces-page/people-card.svelte @@ -8,12 +8,15 @@ mdiCalendarEditOutline, mdiDotsVertical, mdiEyeOffOutline, + mdiHeart, + mdiHeartOutline, } from '@mdi/js'; 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'; + import Icon from '$lib/components/elements/icon.svelte'; interface Props { person: PersonResponseDto; @@ -22,9 +25,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); @@ -51,6 +63,11 @@ title={person.name} widthStyle="100%" /> + {#if person.isFavorite} +
+ +
+ {/if} {#if person.name} + {/if} diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index fef6a29b85db6..40a02f7425723 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 @@ {#snippet children({ itemCount })} {#each people.slice(0, itemCount) as person (person.id)} - + + {#if person.isFavorite} +
+ +
+ {/if}

{person.name}

{/each} diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 0b51a7e240a73..8b6d77db88bad 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -223,6 +223,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}