diff --git a/mobile/openapi/lib/api/people_api.dart b/mobile/openapi/lib/api/people_api.dart index 7df0d66c79..2415197f42 100644 --- a/mobile/openapi/lib/api/people_api.dart +++ b/mobile/openapi/lib/api/people_api.dart @@ -184,7 +184,9 @@ class PeopleApi { /// Parameters: /// /// * [String] id (required): - Future getPersonStatisticsWithHttpInfo(String id,) async { + /// + /// * [bool] withArchived: + Future getPersonStatisticsWithHttpInfo(String id, { bool? withArchived, }) async { // ignore: prefer_const_declarations final path = r'/people/{id}/statistics' .replaceAll('{id}', id); @@ -196,6 +198,10 @@ class PeopleApi { final headerParams = {}; final formParams = {}; + if (withArchived != null) { + queryParams.addAll(_queryParams('', 'withArchived', withArchived)); + } + const contentTypes = []; @@ -213,8 +219,10 @@ class PeopleApi { /// Parameters: /// /// * [String] id (required): - Future getPersonStatistics(String id,) async { - final response = await getPersonStatisticsWithHttpInfo(id,); + /// + /// * [bool] withArchived: + Future getPersonStatistics(String id, { bool? withArchived, }) async { + final response = await getPersonStatisticsWithHttpInfo(id, withArchived: withArchived, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/model/people_update_item.dart b/mobile/openapi/lib/model/people_update_item.dart index 042e4fa36f..28bc7ef71b 100644 --- a/mobile/openapi/lib/model/people_update_item.dart +++ b/mobile/openapi/lib/model/people_update_item.dart @@ -18,6 +18,7 @@ class PeopleUpdateItem { required this.id, this.isHidden, this.name, + this.withArchived, }); /// Person date of birth. Note: the mobile app cannot currently set the birth date to null. @@ -53,13 +54,23 @@ class PeopleUpdateItem { /// String? name; + /// This property was added in v1.118.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? withArchived; + @override bool operator ==(Object other) => identical(this, other) || other is PeopleUpdateItem && other.birthDate == birthDate && other.featureFaceAssetId == featureFaceAssetId && other.id == id && other.isHidden == isHidden && - other.name == name; + other.name == name && + other.withArchived == withArchived; @override int get hashCode => @@ -68,10 +79,11 @@ class PeopleUpdateItem { (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + (id.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + - (name == null ? 0 : name!.hashCode); + (name == null ? 0 : name!.hashCode) + + (withArchived == null ? 0 : withArchived!.hashCode); @override - String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isHidden=$isHidden, name=$name]'; + String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isHidden=$isHidden, name=$name, withArchived=$withArchived]'; Map toJson() { final json = {}; @@ -96,6 +108,11 @@ class PeopleUpdateItem { } else { // json[r'name'] = null; } + if (this.withArchived != null) { + json[r'withArchived'] = this.withArchived; + } else { + // json[r'withArchived'] = null; + } return json; } @@ -113,6 +130,7 @@ class PeopleUpdateItem { id: mapValueOfType(json, r'id')!, isHidden: mapValueOfType(json, r'isHidden'), name: mapValueOfType(json, r'name'), + withArchived: mapValueOfType(json, r'withArchived'), ); } return null; diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index 0b36fcde3b..1d0072ae17 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -19,6 +19,7 @@ class PersonResponseDto { required this.name, required this.thumbnailPath, this.updatedAt, + this.withArchived, }); DateTime? birthDate; @@ -40,6 +41,15 @@ class PersonResponseDto { /// DateTime? updatedAt; + /// This property was added in v1.118.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? withArchived; + @override bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto && other.birthDate == birthDate && @@ -47,7 +57,8 @@ class PersonResponseDto { other.isHidden == isHidden && other.name == name && other.thumbnailPath == thumbnailPath && - other.updatedAt == updatedAt; + other.updatedAt == updatedAt && + other.withArchived == withArchived; @override int get hashCode => @@ -57,10 +68,11 @@ class PersonResponseDto { (isHidden.hashCode) + (name.hashCode) + (thumbnailPath.hashCode) + - (updatedAt == null ? 0 : updatedAt!.hashCode); + (updatedAt == null ? 0 : updatedAt!.hashCode) + + (withArchived == null ? 0 : withArchived!.hashCode); @override - String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; + String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt, withArchived=$withArchived]'; Map toJson() { final json = {}; @@ -78,6 +90,11 @@ class PersonResponseDto { } else { // json[r'updatedAt'] = null; } + if (this.withArchived != null) { + json[r'withArchived'] = this.withArchived; + } else { + // json[r'withArchived'] = null; + } return json; } @@ -96,6 +113,7 @@ class PersonResponseDto { name: mapValueOfType(json, r'name')!, thumbnailPath: mapValueOfType(json, r'thumbnailPath')!, updatedAt: mapDateTime(json, r'updatedAt', r''), + withArchived: mapValueOfType(json, r'withArchived'), ); } return null; diff --git a/mobile/openapi/lib/model/person_update_dto.dart b/mobile/openapi/lib/model/person_update_dto.dart index 51a7ea25d0..3772be770d 100644 --- a/mobile/openapi/lib/model/person_update_dto.dart +++ b/mobile/openapi/lib/model/person_update_dto.dart @@ -17,6 +17,7 @@ class PersonUpdateDto { this.featureFaceAssetId, this.isHidden, this.name, + this.withArchived, }); /// Person date of birth. Note: the mobile app cannot currently set the birth date to null. @@ -49,12 +50,22 @@ class PersonUpdateDto { /// String? name; + /// This property was added in v1.118.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? withArchived; + @override bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto && other.birthDate == birthDate && other.featureFaceAssetId == featureFaceAssetId && other.isHidden == isHidden && - other.name == name; + other.name == name && + other.withArchived == withArchived; @override int get hashCode => @@ -62,10 +73,11 @@ class PersonUpdateDto { (birthDate == null ? 0 : birthDate!.hashCode) + (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + (isHidden == null ? 0 : isHidden!.hashCode) + - (name == null ? 0 : name!.hashCode); + (name == null ? 0 : name!.hashCode) + + (withArchived == null ? 0 : withArchived!.hashCode); @override - String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden, name=$name]'; + String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden, name=$name, withArchived=$withArchived]'; Map toJson() { final json = {}; @@ -89,6 +101,11 @@ class PersonUpdateDto { } else { // json[r'name'] = null; } + if (this.withArchived != null) { + json[r'withArchived'] = this.withArchived; + } else { + // json[r'withArchived'] = null; + } return json; } @@ -105,6 +122,7 @@ class PersonUpdateDto { featureFaceAssetId: mapValueOfType(json, r'featureFaceAssetId'), isHidden: mapValueOfType(json, r'isHidden'), name: mapValueOfType(json, r'name'), + withArchived: mapValueOfType(json, r'withArchived'), ); } return null; 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..17e92bb112 100644 --- a/mobile/openapi/lib/model/person_with_faces_response_dto.dart +++ b/mobile/openapi/lib/model/person_with_faces_response_dto.dart @@ -20,6 +20,7 @@ class PersonWithFacesResponseDto { required this.name, required this.thumbnailPath, this.updatedAt, + this.withArchived, }); DateTime? birthDate; @@ -43,6 +44,15 @@ class PersonWithFacesResponseDto { /// DateTime? updatedAt; + /// This property was added in v1.118.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? withArchived; + @override bool operator ==(Object other) => identical(this, other) || other is PersonWithFacesResponseDto && other.birthDate == birthDate && @@ -51,7 +61,8 @@ class PersonWithFacesResponseDto { other.isHidden == isHidden && other.name == name && other.thumbnailPath == thumbnailPath && - other.updatedAt == updatedAt; + other.updatedAt == updatedAt && + other.withArchived == withArchived; @override int get hashCode => @@ -62,10 +73,11 @@ class PersonWithFacesResponseDto { (isHidden.hashCode) + (name.hashCode) + (thumbnailPath.hashCode) + - (updatedAt == null ? 0 : updatedAt!.hashCode); + (updatedAt == null ? 0 : updatedAt!.hashCode) + + (withArchived == null ? 0 : withArchived!.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, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt, withArchived=$withArchived]'; Map toJson() { final json = {}; @@ -84,6 +96,11 @@ class PersonWithFacesResponseDto { } else { // json[r'updatedAt'] = null; } + if (this.withArchived != null) { + json[r'withArchived'] = this.withArchived; + } else { + // json[r'withArchived'] = null; + } return json; } @@ -103,6 +120,7 @@ class PersonWithFacesResponseDto { name: mapValueOfType(json, r'name')!, thumbnailPath: mapValueOfType(json, r'thumbnailPath')!, updatedAt: mapDateTime(json, r'updatedAt', r''), + withArchived: mapValueOfType(json, r'withArchived'), ); } return null; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 415cc663f4..c4f71c4c60 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4152,6 +4152,14 @@ "format": "uuid", "type": "string" } + }, + { + "name": "withArchived", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -10097,6 +10105,10 @@ "name": { "description": "Person name.", "type": "string" + }, + "withArchived": { + "description": "This property was added in v1.118.0", + "type": "boolean" } }, "required": [ @@ -10229,6 +10241,10 @@ "description": "This property was added in v1.107.0", "format": "date-time", "type": "string" + }, + "withArchived": { + "description": "This property was added in v1.118.0", + "type": "boolean" } }, "required": [ @@ -10270,6 +10286,10 @@ "name": { "description": "Person name.", "type": "string" + }, + "withArchived": { + "description": "This property was added in v1.118.0", + "type": "boolean" } }, "type": "object" @@ -10303,6 +10323,10 @@ "description": "This property was added in v1.107.0", "format": "date-time", "type": "string" + }, + "withArchived": { + "description": "This property was added in v1.118.0", + "type": "boolean" } }, "required": [ diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 2077943bf8..28602dbb5f 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -220,6 +220,8 @@ export type PersonWithFacesResponseDto = { thumbnailPath: string; /** This property was added in v1.107.0 */ updatedAt?: string; + /** This property was added in v1.118.0 */ + withArchived?: boolean; }; export type SmartInfoResponseDto = { objects?: string[] | null; @@ -502,6 +504,8 @@ export type PersonResponseDto = { thumbnailPath: string; /** This property was added in v1.107.0 */ updatedAt?: string; + /** This property was added in v1.118.0 */ + withArchived?: boolean; }; export type AssetFaceResponseDto = { boundingBoxX1: number; @@ -703,6 +707,8 @@ export type PeopleUpdateItem = { isHidden?: boolean; /** Person name. */ name?: string; + /** This property was added in v1.118.0 */ + withArchived?: boolean; }; export type PeopleUpdateDto = { people: PeopleUpdateItem[]; @@ -717,6 +723,8 @@ export type PersonUpdateDto = { isHidden?: boolean; /** Person name. */ name?: string; + /** This property was added in v1.118.0 */ + withArchived?: boolean; }; export type MergePersonDto = { ids: string[]; @@ -2410,13 +2418,16 @@ export function reassignFaces({ id, assetFaceUpdateDto }: { body: assetFaceUpdateDto }))); } -export function getPersonStatistics({ id }: { +export function getPersonStatistics({ id, withArchived }: { id: string; + withArchived?: boolean; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: PersonStatisticsResponseDto; - }>(`/people/${encodeURIComponent(id)}/statistics`, { + }>(`/people/${encodeURIComponent(id)}/statistics${QS.query(QS.explode({ + withArchived + }))}`, { ...opts })); } diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index ba9a181c41..5cd944b55e 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -12,6 +12,7 @@ import { PersonResponseDto, PersonSearchDto, PersonStatisticsResponseDto, + PersonStatsDto, PersonUpdateDto, } from 'src/dtos/person.dto'; import { Permission } from 'src/enum'; @@ -65,8 +66,12 @@ export class PersonController { @Get(':id/statistics') @Authenticated({ permission: Permission.PERSON_STATISTICS }) - getPersonStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.getStatistics(auth, id); + getPersonStatistics( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Query() dto: PersonStatsDto, + ): Promise { + return this.service.getStatistics(auth, id, dto); } @Get(':id/thumbnail') diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 94ee52d916..ac1eab5423 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsArray, IsInt, IsNotEmpty, IsString, Max, Min, ValidateNested } from 'class-validator'; +import { IsArray, IsBoolean, IsInt, IsNotEmpty, IsString, Max, Min, ValidateNested } from 'class-validator'; import { DateTime } from 'luxon'; import { PropertyLifecycle } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -41,6 +41,11 @@ export class PersonUpdateDto extends PersonCreateDto { @Optional() @IsString() featureFaceAssetId?: string; + + @Optional() + @IsBoolean() + @PropertyLifecycle({ addedAt: 'v1.118.0' }) + withArchived?: boolean; } export class PeopleUpdateDto { @@ -93,6 +98,8 @@ export class PersonResponseDto { isHidden!: boolean; @PropertyLifecycle({ addedAt: 'v1.107.0' }) updatedAt?: Date; + @PropertyLifecycle({ addedAt: 'v1.118.0' }) + withArchived?: boolean; } export class PersonWithFacesResponseDto extends PersonResponseDto { @@ -147,6 +154,11 @@ export class PersonStatisticsResponseDto { assets!: number; } +export class PersonStatsDto { + @ValidateBoolean({ optional: true }) + withArchived?: boolean; +} + export class PeopleResponseDto { @ApiProperty({ type: 'integer' }) total!: number; @@ -167,6 +179,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto { thumbnailPath: person.thumbnailPath, isHidden: person.isHidden, updatedAt: person.updatedAt, + withArchived: person.withArchived, }; } diff --git a/server/src/entities/person.entity.ts b/server/src/entities/person.entity.ts index 5efbcbfa0b..9599cbb54c 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 }) + withArchived!: boolean; } diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts index b3e2c0990e..ce06cb4d6e 100644 --- a/server/src/interfaces/person.interface.ts +++ b/server/src/interfaces/person.interface.ts @@ -45,6 +45,10 @@ export interface DeleteFacesOptions { sourceType: SourceType; } +export interface PersonStatsOptions { + withArchived?: boolean; +} + export type UnassignFacesOptions = DeleteFacesOptions; export interface IPersonRepository { @@ -74,7 +78,7 @@ export interface IPersonRepository { getFaces(assetId: string): Promise; getFacesByIds(ids: AssetFaceId[]): Promise; getRandomFace(personId: string): Promise; - getStatistics(personId: string): Promise; + getStatistics(personId: string, options: PersonStatsOptions): Promise; reassignFace(assetFaceId: string, newPersonId: string): Promise; getNumberOfPeople(userId: string): Promise; reassignFaces(data: UpdateFacesData): Promise; diff --git a/server/src/migrations/1728944141526-AddWithArchivedToPerson.ts b/server/src/migrations/1728944141526-AddWithArchivedToPerson.ts new file mode 100644 index 0000000000..0f43a8073c --- /dev/null +++ b/server/src/migrations/1728944141526-AddWithArchivedToPerson.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddWithArchivedToPerson1728944141526 implements MigrationInterface { + name = 'AddWithArchivedToPerson1728944141526' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" ADD "withArchived" boolean NOT NULL DEFAULT false`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "withArchived"`); + } + +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index eda91482bb..3d310b9e01 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -212,6 +212,7 @@ SELECT "8258e303a73a72cf6abb13d73fb592dde0d68280"."thumbnailPath" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_thumbnailPath", "8258e303a73a72cf6abb13d73fb592dde0d68280"."faceAssetId" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_faceAssetId", "8258e303a73a72cf6abb13d73fb592dde0d68280"."isHidden" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_isHidden", + "8258e303a73a72cf6abb13d73fb592dde0d68280"."withArchived" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_withArchived", "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 5616559d7d..47ca2f4d43 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"."withArchived" AS "person_withArchived" FROM "person" "person" LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" @@ -54,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"."withArchived" AS "person_withArchived" FROM "person" "person" LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" @@ -83,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"."withArchived" AS "AssetFaceEntity__AssetFaceEntity_person_withArchived" FROM "asset_faces" "AssetFaceEntity" LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" @@ -116,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"."withArchived" AS "AssetFaceEntity__AssetFaceEntity_person_withArchived" FROM "asset_faces" "AssetFaceEntity" LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId" @@ -153,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"."withArchived" AS "AssetFaceEntity__AssetFaceEntity_person_withArchived", "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", @@ -213,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"."withArchived" AS "person_withArchived" FROM "person" "person" WHERE @@ -242,11 +248,18 @@ FROM "asset_faces" "face" LEFT JOIN "assets" "asset" ON "asset"."id" = "face"."assetId" AND ("asset"."deletedAt" IS NULL) + LEFT JOIN "person" "person" ON "person"."id" = "face"."personId" WHERE "face"."personId" = $1 - AND "asset"."isArchived" = false AND "asset"."deletedAt" IS NULL AND "asset"."livePhotoVideoId" IS NULL + AND ( + ( + "person"."withArchived" = false + AND "asset"."isArchived" = false + ) + OR "person"."withArchived" = true + ) -- PersonRepository.getNumberOfPeople SELECT diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index c62c4b8739..54408c73d1 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -17,6 +17,7 @@ import { PersonNameSearchOptions, PersonSearchOptions, PersonStatistics, + PersonStatsOptions, UnassignFacesOptions, UpdateFacesData, } from 'src/interfaces/person.interface'; @@ -214,17 +215,33 @@ export class PersonRepository implements IPersonRepository { return queryBuilder.getMany(); } - @GenerateSql({ params: [DummyValue.UUID] }) - async getStatistics(personId: string): Promise { - const items = await this.assetFaceRepository + @GenerateSql({ params: [DummyValue.UUID, { withArchived: undefined }] }) + async getStatistics(personId: string, options: PersonStatsOptions): Promise { + /* + * withArchived: true -> Return the count of all assets for a given person + * withArchived: false -> Return the count of all unarchived assets for a given person + * withArchived: undefiend -> + * - If person.withArchived = true -> Return the count of all assets for a given person + * - If person.withArchived = false -> Return the count of all unarchived assets for a given person + */ + + let queryBuilder = this.assetFaceRepository .createQueryBuilder('face') .leftJoin('face.asset', 'asset') .where('face.personId = :personId', { personId }) - .andWhere('asset.isArchived = false') .andWhere('asset.deletedAt IS NULL') .andWhere('asset.livePhotoVideoId IS NULL') - .select('COUNT(DISTINCT(asset.id))', 'count') - .getRawOne(); + .select('COUNT(DISTINCT(asset.id))', 'count'); + + if (options.withArchived === false) { + queryBuilder = queryBuilder.andWhere('asset.isArchived = false'); + } else if (options.withArchived === undefined) { + queryBuilder = queryBuilder + .leftJoin('face.person', 'person') + .andWhere('((person.withArchived = false AND asset.isArchived = false) OR person.withArchived = true)'); + } + + const items = await queryBuilder.getRawOne(); return { assets: items.count ?? 0, }; diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index da4656be02..2e3ba97663 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), + withArchived: false, }; const statistics = { assets: 3 }; @@ -118,6 +119,7 @@ describe(PersonService.name, () => { thumbnailPath: '/path/to/thumbnail.jpg', isHidden: true, updatedAt: expect.any(Date), + withArchived: false, }, ], }); @@ -218,6 +220,16 @@ describe(PersonService.name, () => { expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); + it("should update a person's withArchived", async () => { + personMock.update.mockResolvedValue(personStub.withName); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); + + await expect(sut.update(authStub.admin, 'person-1', { withArchived: true })).resolves.toEqual(responseDto); + + expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', withArchived: true }); + expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); + }); + it("should update a person's date of birth", async () => { personMock.update.mockResolvedValue(personStub.withBirthDate); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); @@ -229,6 +241,7 @@ describe(PersonService.name, () => { thumbnailPath: '/path/to/thumbnail.jpg', isHidden: false, updatedAt: expect.any(Date), + withArchived: false, }); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' }); expect(jobMock.queue).not.toHaveBeenCalled(); @@ -381,6 +394,7 @@ describe(PersonService.name, () => { name: personStub.noName.name, thumbnailPath: personStub.noName.thumbnailPath, updatedAt: expect.any(Date), + withArchived: personStub.noName.withArchived, }); expect(jobMock.queue).not.toHaveBeenCalledWith(); @@ -1171,13 +1185,13 @@ describe(PersonService.name, () => { personMock.getById.mockResolvedValue(personStub.primaryPerson); personMock.getStatistics.mockResolvedValue(statistics); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 }); + await expect(sut.getStatistics(authStub.admin, 'person-1', {})).resolves.toEqual({ assets: 3 }); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); it('should require person.read permission', async () => { personMock.getById.mockResolvedValue(personStub.primaryPerson); - await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.getStatistics(authStub.admin, 'person-1', {})).rejects.toBeInstanceOf(BadRequestException); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index e5f016d8ef..9197add85e 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -14,6 +14,7 @@ import { PersonResponseDto, PersonSearchDto, PersonStatisticsResponseDto, + PersonStatsDto, PersonUpdateDto, mapFaces, mapPerson, @@ -153,9 +154,9 @@ export class PersonService extends BaseService { return this.findOrFail(id).then(mapPerson); } - async getStatistics(auth: AuthDto, id: string): Promise { + async getStatistics(auth: AuthDto, id: string, dto: PersonStatsDto): Promise { await this.requireAccess({ auth, permission: Permission.PERSON_READ, ids: [id] }); - return this.personRepository.getStatistics(id); + return this.personRepository.getStatistics(id, dto); } async getThumbnail(auth: AuthDto, id: string): Promise { @@ -184,7 +185,7 @@ export class PersonService extends BaseService { 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, withArchived } = dto; // TODO: set by faceId directly let faceId: string | undefined = undefined; if (assetId) { @@ -197,7 +198,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, + withArchived, + }); if (assetId) { await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } }); diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts index 544894b31e..2f10623f36 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, + withArchived: false, }), hidden: Object.freeze({ id: 'person-1', @@ -29,6 +30,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: true, + withArchived: false, }), withName: Object.freeze({ id: 'person-1', @@ -43,6 +45,7 @@ export const personStub = { faceAssetId: 'assetFaceId', faceAsset: null, isHidden: false, + withArchived: false, }), withBirthDate: Object.freeze({ id: 'person-1', @@ -57,6 +60,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + withArchived: false, }), noThumbnail: Object.freeze({ id: 'person-1', @@ -71,6 +75,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + withArchived: false, }), newThumbnail: Object.freeze({ id: 'person-1', @@ -85,6 +90,7 @@ export const personStub = { faceAssetId: 'asset-id', faceAsset: null, isHidden: false, + withArchived: false, }), primaryPerson: Object.freeze({ id: 'person-1', @@ -99,6 +105,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + withArchived: false, }), mergePerson: Object.freeze({ id: 'person-2', @@ -113,6 +120,7 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + withArchived: false, }), randomPerson: Object.freeze({ id: 'person-3', @@ -127,5 +135,6 @@ export const personStub = { faceAssetId: null, faceAsset: null, isHidden: false, + withArchived: false, }), }; diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 155be38bcf..435fc6e034 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1140,6 +1140,7 @@ "show_albums": "Show albums", "show_all_people": "Show all people", "show_and_hide_people": "Show & hide people", + "show_archived_or_unarchived_assets": "{with, select, true {With} other {Without}} archived assets", "show_file_location": "Show file location", "show_gallery": "Show gallery", "show_hidden_people": "Show hidden people", 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 037feaf35f..d4146ab663 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 @@ -45,6 +45,8 @@ import { mdiAccountBoxOutline, mdiAccountMultipleCheckOutline, + mdiArchiveArrowDown, + mdiArchiveArrowDownOutline, mdiArrowLeft, mdiCalendarEditOutline, mdiDotsVertical, @@ -72,8 +74,10 @@ UNASSIGN_ASSETS = 'unassign-faces', } + $: isArchived = person.withArchived ? undefined : person.withArchived; + let assetStore = new AssetStore({ - isArchived: false, + isArchived, personId: data.person.id, }); @@ -81,7 +85,7 @@ $: thumbnailData = getPeopleThumbnailUrl(person); $: if (person) { handlePromiseError(updateAssetCount()); - handlePromiseError(assetStore.updateOptions({ personId: person.id })); + handlePromiseError(assetStore.updateOptions({ personId: person.id, isArchived })); } const assetInteractionStore = createAssetInteractionStore(); @@ -338,6 +342,27 @@ } }; + const handleToggleWithArhived = async () => { + const withArchived = !person.withArchived; + + try { + await updatePerson({ + id: person.id, + personUpdateDto: { withArchived }, + }); + + data.person.withArchived = withArchived; + refreshAssetGrid = !refreshAssetGrid; + } catch (error) { + handleError(error, $t('errors.unable_to_archive_unarchive', { values: { archived: withArchived } })); + } + }; + + const handleDeleteAssets = async (assetIds: string[]) => { + $assetStore.removeAssets(assetIds); + await updateAssetCount(); + }; + onDestroy(() => { assetStore.destroy(); }); @@ -395,7 +420,7 @@ $assetStore.removeAssets(assetIds)} /> - $assetStore.removeAssets(assetIds)} /> + {:else} @@ -423,6 +448,11 @@ icon={mdiAccountMultipleCheckOutline} onClick={() => (viewMode = ViewMode.MERGE_PEOPLE)} /> +