7702560b12
* feat: unassign person faces * multiple improvements * chore: regenerate api * feat: improve face interactions in photos * fix: tests * fix: tests * optimize * fix: wrong assignment on complex-multiple re-assignments * fix: thumbnails with large photos * fix: complex reassign * fix: don't send people with faces * fix: person thumbnail generation * chore: regenerate api * add tess * feat: face box even when zoomed * fix: change feature photo * feat: make the blue icon hoverable * chore: regenerate api * feat: use websocket * fix: loading spinner when clicking on the done button * fix: use the svelte way * fix: tests * simplify * fix: unused vars * fix: remove unused code * fix: add migration * chore: regenerate api * ci: add unit tests * chore: regenerate api * feat: if a new person is created for a face and the server takes more than 15 seconds to generate the person thumbnail, don't wait for it * reorganize * chore: regenerate api * feat: global edit * pr feedback * pr feedback * simplify * revert test * fix: face generation * fix: tests * fix: face generation * fix merge * feat: search names in unmerge face selector modal * fix: merge face selector * simplify feature photo generation * fix: change endpoint * pr feedback * chore: fix merge * chore: fix merge * fix: tests * fix: edit & hide buttons * fix: tests * feat: show if person is hidden * feat: rename face to person * feat: split in new panel * copy-paste-error * pr feedback * fix: feature photo * do not leak faces * fix: unmerge modal * fix: merge modal event * feat(server): remove duplicates * fix: title for image thumbnails * fix: disable side panel when there's no face until next PR --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
237 lines
7.5 KiB
TypeScript
237 lines
7.5 KiB
TypeScript
import {
|
|
AssetFaceId,
|
|
IPersonRepository,
|
|
PersonNameSearchOptions,
|
|
PersonSearchOptions,
|
|
PersonStatistics,
|
|
UpdateFacesData,
|
|
} from '@app/domain';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { In, Repository } from 'typeorm';
|
|
import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities';
|
|
import { DummyValue, GenerateSql } from '../infra.util';
|
|
|
|
export class PersonRepository implements IPersonRepository {
|
|
constructor(
|
|
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
|
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
|
|
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
|
|
) {}
|
|
|
|
/**
|
|
* Before reassigning faces, delete potential key violations
|
|
*/
|
|
async prepareReassignFaces({ oldPersonId, newPersonId }: UpdateFacesData): Promise<string[]> {
|
|
const results = await this.assetFaceRepository
|
|
.createQueryBuilder('face')
|
|
.select('face."assetId"')
|
|
.where(`face."personId" IN (:...ids)`, { ids: [oldPersonId, newPersonId] })
|
|
.groupBy('face."assetId"')
|
|
.having('COUNT(face."personId") > 1')
|
|
.getRawMany();
|
|
|
|
const assetIds = results.map(({ assetId }) => assetId);
|
|
|
|
await this.assetFaceRepository.delete({ personId: oldPersonId, assetId: In(assetIds) });
|
|
|
|
return assetIds;
|
|
}
|
|
|
|
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
|
|
async reassignFaces({ oldPersonId, newPersonId }: UpdateFacesData): Promise<number> {
|
|
const result = await this.assetFaceRepository
|
|
.createQueryBuilder()
|
|
.update()
|
|
.set({ personId: newPersonId })
|
|
.where({ personId: oldPersonId })
|
|
.execute();
|
|
|
|
return result.affected ?? 0;
|
|
}
|
|
|
|
delete(entity: PersonEntity): Promise<PersonEntity | null> {
|
|
return this.personRepository.remove(entity);
|
|
}
|
|
|
|
async deleteAll(): Promise<number> {
|
|
const people = await this.personRepository.find();
|
|
await this.personRepository.remove(people);
|
|
return people.length;
|
|
}
|
|
|
|
@GenerateSql()
|
|
getAllFaces(): Promise<AssetFaceEntity[]> {
|
|
return this.assetFaceRepository.find({ relations: { asset: true }, withDeleted: true });
|
|
}
|
|
|
|
@GenerateSql()
|
|
getAll(): Promise<PersonEntity[]> {
|
|
return this.personRepository.find();
|
|
}
|
|
|
|
@GenerateSql()
|
|
getAllWithoutThumbnail(): Promise<PersonEntity[]> {
|
|
return this.personRepository.findBy({ thumbnailPath: '' });
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
getAllForUser(userId: string, options?: PersonSearchOptions): Promise<PersonEntity[]> {
|
|
const queryBuilder = this.personRepository
|
|
.createQueryBuilder('person')
|
|
.leftJoin('person.faces', 'face')
|
|
.where('person.ownerId = :userId', { userId })
|
|
.innerJoin('face.asset', 'asset')
|
|
.orderBy('person.isHidden', 'ASC')
|
|
.addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC')
|
|
.addOrderBy('COUNT(face.assetId)', 'DESC')
|
|
.addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST')
|
|
.having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 })
|
|
.groupBy('person.id')
|
|
.limit(500);
|
|
if (!options?.withHidden) {
|
|
queryBuilder.andWhere('person.isHidden = false');
|
|
}
|
|
|
|
return queryBuilder.getMany();
|
|
}
|
|
|
|
@GenerateSql()
|
|
getAllWithoutFaces(): Promise<PersonEntity[]> {
|
|
return this.personRepository
|
|
.createQueryBuilder('person')
|
|
.leftJoin('person.faces', 'face')
|
|
.having('COUNT(face.assetId) = 0')
|
|
.groupBy('person.id')
|
|
.withDeleted()
|
|
.getMany();
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
getFaces(assetId: string): Promise<AssetFaceEntity[]> {
|
|
return this.assetFaceRepository.find({
|
|
where: { assetId },
|
|
relations: {
|
|
person: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
getFaceById(id: string): Promise<AssetFaceEntity> {
|
|
return this.assetFaceRepository.findOneOrFail({
|
|
where: { id },
|
|
relations: {
|
|
person: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
getFaceByIdWithAssets(id: string): Promise<AssetFaceEntity | null> {
|
|
return this.assetFaceRepository.findOne({
|
|
where: { id },
|
|
relations: {
|
|
person: true,
|
|
asset: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
|
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
|
|
const result = await this.assetFaceRepository
|
|
.createQueryBuilder()
|
|
.update()
|
|
.set({ personId: newPersonId })
|
|
.where({ id: assetFaceId })
|
|
.execute();
|
|
|
|
return result.affected ?? 0;
|
|
}
|
|
|
|
getById(personId: string): Promise<PersonEntity | null> {
|
|
return this.personRepository.findOne({ where: { id: personId } });
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
|
|
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise<PersonEntity[]> {
|
|
const queryBuilder = this.personRepository
|
|
.createQueryBuilder('person')
|
|
.leftJoin('person.faces', 'face')
|
|
.where(
|
|
'person.ownerId = :userId AND (LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere)',
|
|
{ userId, nameStart: `${personName.toLowerCase()}%`, nameAnywhere: `% ${personName.toLowerCase()}%` },
|
|
)
|
|
.groupBy('person.id')
|
|
.orderBy('COUNT(face.assetId)', 'DESC')
|
|
.limit(20);
|
|
|
|
if (!withHidden) {
|
|
queryBuilder.andWhere('person.isHidden = false');
|
|
}
|
|
return queryBuilder.getMany();
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
async getStatistics(personId: string): Promise<PersonStatistics> {
|
|
return {
|
|
assets: await 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')
|
|
.distinct(true)
|
|
.getCount(),
|
|
};
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
getAssets(personId: string): Promise<AssetEntity[]> {
|
|
return this.assetRepository.find({
|
|
where: {
|
|
faces: {
|
|
personId,
|
|
},
|
|
isVisible: true,
|
|
isArchived: false,
|
|
},
|
|
relations: {
|
|
faces: {
|
|
person: true,
|
|
},
|
|
exifInfo: true,
|
|
},
|
|
order: {
|
|
fileCreatedAt: 'desc',
|
|
},
|
|
// TODO: remove after either (1) pagination or (2) time bucket is implemented for this query
|
|
take: 1000,
|
|
});
|
|
}
|
|
|
|
create(entity: Partial<PersonEntity>): Promise<PersonEntity> {
|
|
return this.personRepository.save(entity);
|
|
}
|
|
|
|
createFace(entity: Partial<AssetFaceEntity>): Promise<AssetFaceEntity> {
|
|
return this.assetFaceRepository.save(entity);
|
|
}
|
|
|
|
async update(entity: Partial<PersonEntity>): Promise<PersonEntity> {
|
|
const { id } = await this.personRepository.save(entity);
|
|
return this.personRepository.findOneByOrFail({ id });
|
|
}
|
|
|
|
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] })
|
|
async getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]> {
|
|
return this.assetFaceRepository.find({ where: ids, relations: { asset: true }, withDeleted: true });
|
|
}
|
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
|
async getRandomFace(personId: string): Promise<AssetFaceEntity | null> {
|
|
return this.assetFaceRepository.findOneBy({ personId });
|
|
}
|
|
}
|