feat: show archived assets for a person

This commit is contained in:
martabal 2024-10-15 11:25:28 +02:00
parent e8015dc7d7
commit 882d9bee04
No known key found for this signature in database
GPG Key ID: C00196E3148A52BD
20 changed files with 289 additions and 42 deletions

View File

@ -184,7 +184,9 @@ class PeopleApi {
/// Parameters:
///
/// * [String] id (required):
Future<Response> getPersonStatisticsWithHttpInfo(String id,) async {
///
/// * [bool] withArchived:
Future<Response> 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 = <String, String>{};
final formParams = <String, String>{};
if (withArchived != null) {
queryParams.addAll(_queryParams('', 'withArchived', withArchived));
}
const contentTypes = <String>[];
@ -213,8 +219,10 @@ class PeopleApi {
/// Parameters:
///
/// * [String] id (required):
Future<PersonStatisticsResponseDto?> getPersonStatistics(String id,) async {
final response = await getPersonStatisticsWithHttpInfo(id,);
///
/// * [bool] withArchived:
Future<PersonStatisticsResponseDto?> 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));
}

View File

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -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<String>(json, r'id')!,
isHidden: mapValueOfType<bool>(json, r'isHidden'),
name: mapValueOfType<String>(json, r'name'),
withArchived: mapValueOfType<bool>(json, r'withArchived'),
);
}
return null;

View File

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -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<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
updatedAt: mapDateTime(json, r'updatedAt', r''),
withArchived: mapValueOfType<bool>(json, r'withArchived'),
);
}
return null;

View File

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -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<String>(json, r'featureFaceAssetId'),
isHidden: mapValueOfType<bool>(json, r'isHidden'),
name: mapValueOfType<String>(json, r'name'),
withArchived: mapValueOfType<bool>(json, r'withArchived'),
);
}
return null;

View File

@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -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<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
updatedAt: mapDateTime(json, r'updatedAt', r''),
withArchived: mapValueOfType<bool>(json, r'withArchived'),
);
}
return null;

View File

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

View File

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

View File

@ -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<PersonStatisticsResponseDto> {
return this.service.getStatistics(auth, id);
getPersonStatistics(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Query() dto: PersonStatsDto,
): Promise<PersonStatisticsResponseDto> {
return this.service.getStatistics(auth, id, dto);
}
@Get(':id/thumbnail')

View File

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

View File

@ -49,4 +49,7 @@ export class PersonEntity {
@Column({ default: false })
isHidden!: boolean;
@Column({ default: false })
withArchived!: boolean;
}

View File

@ -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<AssetFaceEntity[]>;
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
getRandomFace(personId: string): Promise<AssetFaceEntity | null>;
getStatistics(personId: string): Promise<PersonStatistics>;
getStatistics(personId: string, options: PersonStatsOptions): Promise<PersonStatistics>;
reassignFace(assetFaceId: string, newPersonId: string): Promise<number>;
getNumberOfPeople(userId: string): Promise<PeopleStatistics>;
reassignFaces(data: UpdateFacesData): Promise<number>;

View File

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddWithArchivedToPerson1728944141526 implements MigrationInterface {
name = 'AddWithArchivedToPerson1728944141526'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" ADD "withArchived" boolean NOT NULL DEFAULT false`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "withArchived"`);
}
}

View File

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

View File

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

View File

@ -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<PersonStatistics> {
const items = await this.assetFaceRepository
@GenerateSql({ params: [DummyValue.UUID, { withArchived: undefined }] })
async getStatistics(personId: string, options: PersonStatsOptions): Promise<PersonStatistics> {
/*
* 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,
};

View File

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

View File

@ -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<PersonStatisticsResponseDto> {
async getStatistics(auth: AuthDto, id: string, dto: PersonStatsDto): Promise<PersonStatisticsResponseDto> {
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<ImmichFileResponse> {
@ -184,7 +185,7 @@ export class PersonService extends BaseService {
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, 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 } });

View File

@ -15,6 +15,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
withArchived: false,
}),
hidden: Object.freeze<PersonEntity>({
id: 'person-1',
@ -29,6 +30,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: true,
withArchived: false,
}),
withName: Object.freeze<PersonEntity>({
id: 'person-1',
@ -43,6 +45,7 @@ export const personStub = {
faceAssetId: 'assetFaceId',
faceAsset: null,
isHidden: false,
withArchived: false,
}),
withBirthDate: Object.freeze<PersonEntity>({
id: 'person-1',
@ -57,6 +60,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
withArchived: false,
}),
noThumbnail: Object.freeze<PersonEntity>({
id: 'person-1',
@ -71,6 +75,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
withArchived: false,
}),
newThumbnail: Object.freeze<PersonEntity>({
id: 'person-1',
@ -85,6 +90,7 @@ export const personStub = {
faceAssetId: 'asset-id',
faceAsset: null,
isHidden: false,
withArchived: false,
}),
primaryPerson: Object.freeze<PersonEntity>({
id: 'person-1',
@ -99,6 +105,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
withArchived: false,
}),
mergePerson: Object.freeze<PersonEntity>({
id: 'person-2',
@ -113,6 +120,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
withArchived: false,
}),
randomPerson: Object.freeze<PersonEntity>({
id: 'person-3',
@ -127,5 +135,6 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
withArchived: false,
}),
};

View File

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

View File

@ -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 @@
<ChangeDate menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(assetIds) => $assetStore.removeAssets(assetIds)} />
<DeleteAssets menuItem onAssetDelete={(assetIds) => $assetStore.removeAssets(assetIds)} />
<DeleteAssets menuItem onAssetDelete={handleDeleteAssets} />
</ButtonContextMenu>
</AssetSelectControlBar>
{:else}
@ -423,6 +448,11 @@
icon={mdiAccountMultipleCheckOutline}
onClick={() => (viewMode = ViewMode.MERGE_PEOPLE)}
/>
<MenuOption
text={$t('show_archived_or_unarchived_assets', { values: { with: !person.withArchived } })}
icon={person.withArchived ? mdiArchiveArrowDown : mdiArchiveArrowDownOutline}
onClick={handleToggleWithArhived}
/>
</ButtonContextMenu>
</svelte:fragment>
</ControlAppBar>