feat: tags (#11980)

* feat: tags

* fix: folder tree icons

* navigate to tag from detail panel

* delete tag

* Tag position and add tag button

* Tag asset in detail panel

* refactor form

* feat: navigate to tag page from clicking on a tag

* feat: delete tags from the tag page

* refactor: moving tag section in detail panel and add + tag button

* feat: tag asset action in detail panel

* refactor add tag form

* fdisable add tag button when there is no selection

* feat: tag bulk endpoint

* feat: tag colors

* chore: clean up

* chore: unit tests

* feat: write tags to sidecar

* Remove tag and auto focus on tag creation form opened

* chore: regenerate migration

* chore: linting

* add color picker to tag edit form

* fix: force render tags timeline on navigating back from asset viewer

* feat: read tags from keywords

* chore: clean up

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen
2024-08-29 12:14:03 -04:00
committed by GitHub
parent 682adaa334
commit d08a20bd57
68 changed files with 3032 additions and 814 deletions
@@ -18,11 +18,13 @@ import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { MetadataService, Orientation } from 'src/services/metadata.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { probeStub } from 'test/fixtures/media.stub';
import { tagStub } from 'test/fixtures/tag.stub';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
@@ -37,6 +39,7 @@ import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest';
@@ -56,6 +59,7 @@ describe(MetadataService.name, () => {
let databaseMock: Mocked<IDatabaseRepository>;
let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let tagMock: Mocked<ITagRepository>;
let sut: MetadataService;
beforeEach(() => {
@@ -74,6 +78,7 @@ describe(MetadataService.name, () => {
databaseMock = newDatabaseRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
tagMock = newTagRepositoryMock();
sut = new MetadataService(
albumMock,
@@ -89,6 +94,7 @@ describe(MetadataService.name, () => {
personMock,
storageMock,
systemMock,
tagMock,
userMock,
loggerMock,
);
@@ -356,6 +362,72 @@ describe(MetadataService.name, () => {
expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null }));
});
it('should extract tags from TagsList', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ TagsList: ['Parent'] });
tagMock.getByValue.mockResolvedValue(null);
tagMock.create.mockResolvedValue(tagStub.parent);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
});
it('should extract hierarchy from TagsList', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ TagsList: ['Parent/Child'] });
tagMock.getByValue.mockResolvedValue(null);
tagMock.create.mockResolvedValueOnce(tagStub.parent);
tagMock.create.mockResolvedValueOnce(tagStub.child);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(tagMock.create).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined });
expect(tagMock.create).toHaveBeenNthCalledWith(2, {
userId: 'user-id',
value: 'Parent/Child',
parent: tagStub.parent,
});
});
it('should extract tags from Keywords as a string', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ Keywords: 'Parent' });
tagMock.getByValue.mockResolvedValue(null);
tagMock.create.mockResolvedValue(tagStub.parent);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
});
it('should extract tags from Keywords as a list', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent'] });
tagMock.getByValue.mockResolvedValue(null);
tagMock.create.mockResolvedValue(tagStub.parent);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
});
it('should extract hierarchal tags from Keywords', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ Keywords: 'Parent/Child' });
tagMock.getByValue.mockResolvedValue(null);
tagMock.create.mockResolvedValue(tagStub.parent);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(tagMock.create).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined });
expect(tagMock.create).toHaveBeenNthCalledWith(2, {
userId: 'user-id',
value: 'Parent/Child',
parent: tagStub.parent,
});
});
it('should not apply motion photos if asset is video', async () => {
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
+77 -36
View File
@@ -22,8 +22,8 @@ import {
IEntityJob,
IJobRepository,
ISidecarWriteJob,
JOBS_ASSET_PAGINATION_SIZE,
JobName,
JOBS_ASSET_PAGINATION_SIZE,
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
@@ -35,8 +35,10 @@ import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { usePagination } from 'src/utils/pagination';
import { upsertTags } from 'src/utils/tag';
/** look for a date from these tags (in order) */
const EXIF_DATE_TAGS: Array<keyof Tags> = [
@@ -105,6 +107,7 @@ export class MetadataService {
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ITagRepository) private tagRepository: ITagRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
@@ -217,24 +220,27 @@ export class MetadataService {
return JobStatus.FAILED;
}
const { exifData, tags } = await this.exifData(asset);
const { exifData, exifTags } = await this.exifData(asset);
if (asset.type === AssetType.VIDEO) {
await this.applyVideoMetadata(asset, exifData);
}
await this.applyMotionPhotos(asset, tags);
await this.applyMotionPhotos(asset, exifTags);
await this.applyReverseGeocoding(asset, exifData);
await this.applyTagList(asset, exifTags);
await this.assetRepository.upsertExif(exifData);
const dateTimeOriginal = exifData.dateTimeOriginal;
let localDateTime = dateTimeOriginal ?? undefined;
const timeZoneOffset = tzOffset(firstDateTime(tags as Tags)) ?? 0;
const timeZoneOffset = tzOffset(firstDateTime(exifTags as Tags)) ?? 0;
if (dateTimeOriginal && timeZoneOffset) {
localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000);
}
await this.assetRepository.update({
id: asset.id,
duration: asset.duration,
@@ -278,22 +284,35 @@ export class MetadataService {
return this.processSidecar(id, false);
}
@OnEmit({ event: 'asset.tag' })
async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) {
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
}
@OnEmit({ event: 'asset.untag' })
async handleUntagAsset({ assetId }: ArgOf<'asset.untag'>) {
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
}
async handleSidecarWrite(job: ISidecarWriteJob): Promise<JobStatus> {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = job;
const [asset] = await this.assetRepository.getByIds([id]);
const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job;
const [asset] = await this.assetRepository.getByIds([id], { tags: true });
if (!asset) {
return JobStatus.FAILED;
}
const tagsList = (asset.tags || []).map((tag) => tag.value);
const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
const exif = _.omitBy<Tags>(
{
const exif = _.omitBy(
<Tags>{
Description: description,
ImageDescription: description,
DateTimeOriginal: dateTimeOriginal,
GPSLatitude: latitude,
GPSLongitude: longitude,
Rating: rating,
TagsList: tags ? tagsList : undefined,
},
_.isUndefined,
);
@@ -332,6 +351,28 @@ export class MetadataService {
}
}
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
const tags: string[] = [];
if (exifTags.TagsList) {
tags.push(...exifTags.TagsList);
}
if (exifTags.Keywords) {
let keywords = exifTags.Keywords;
if (typeof keywords === 'string') {
keywords = [keywords];
}
tags.push(...keywords);
}
if (tags.length > 0) {
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags });
const tagIds = results.map((tag) => tag.id);
await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds });
}
}
private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) {
if (asset.type !== AssetType.IMAGE) {
return;
@@ -466,7 +507,7 @@ export class MetadataService {
private async exifData(
asset: AssetEntity,
): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; tags: ImmichTags }> {
): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> {
const stats = await this.storageRepository.stat(asset.originalPath);
const mediaTags = await this.repository.readTags(asset.originalPath);
const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null;
@@ -479,38 +520,38 @@ export class MetadataService {
}
}
const tags = { ...mediaTags, ...sidecarTags };
const exifTags = { ...mediaTags, ...sidecarTags };
this.logger.verbose('Exif Tags', tags);
this.logger.verbose('Exif Tags', exifTags);
const exifData = {
// altitude: tags.GPSAltitude ?? null,
assetId: asset.id,
bitsPerSample: this.getBitsPerSample(tags),
colorspace: tags.ColorSpace ?? null,
dateTimeOriginal: this.getDateTimeOriginal(tags) ?? asset.fileCreatedAt,
description: String(tags.ImageDescription || tags.Description || '').trim(),
exifImageHeight: validate(tags.ImageHeight),
exifImageWidth: validate(tags.ImageWidth),
exposureTime: tags.ExposureTime ?? null,
bitsPerSample: this.getBitsPerSample(exifTags),
colorspace: exifTags.ColorSpace ?? null,
dateTimeOriginal: this.getDateTimeOriginal(exifTags) ?? asset.fileCreatedAt,
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
exifImageHeight: validate(exifTags.ImageHeight),
exifImageWidth: validate(exifTags.ImageWidth),
exposureTime: exifTags.ExposureTime ?? null,
fileSizeInByte: stats.size,
fNumber: validate(tags.FNumber),
focalLength: validate(tags.FocalLength),
fps: validate(Number.parseFloat(tags.VideoFrameRate!)),
iso: validate(tags.ISO),
latitude: validate(tags.GPSLatitude),
lensModel: tags.LensModel ?? null,
livePhotoCID: (tags.ContentIdentifier || tags.MediaGroupUUID) ?? null,
autoStackId: this.getAutoStackId(tags),
longitude: validate(tags.GPSLongitude),
make: tags.Make ?? null,
model: tags.Model ?? null,
modifyDate: exifDate(tags.ModifyDate) ?? asset.fileModifiedAt,
orientation: validate(tags.Orientation)?.toString() ?? null,
profileDescription: tags.ProfileDescription || null,
projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null,
timeZone: tags.tz ?? null,
rating: tags.Rating ?? null,
fNumber: validate(exifTags.FNumber),
focalLength: validate(exifTags.FocalLength),
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
iso: validate(exifTags.ISO),
latitude: validate(exifTags.GPSLatitude),
lensModel: exifTags.LensModel ?? null,
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
autoStackId: this.getAutoStackId(exifTags),
longitude: validate(exifTags.GPSLongitude),
make: exifTags.Make ?? null,
model: exifTags.Model ?? null,
modifyDate: exifDate(exifTags.ModifyDate) ?? asset.fileModifiedAt,
orientation: validate(exifTags.Orientation)?.toString() ?? null,
profileDescription: exifTags.ProfileDescription || null,
projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
timeZone: exifTags.tz ?? null,
rating: exifTags.Rating ?? null,
};
if (exifData.latitude === 0 && exifData.longitude === 0) {
@@ -519,7 +560,7 @@ export class MetadataService {
exifData.longitude = null;
}
return { exifData, tags };
return { exifData, exifTags };
}
private getAutoStackId(tags: ImmichTags | null): string | null {
+154 -79
View File
@@ -1,21 +1,28 @@
import { BadRequestException } from '@nestjs/common';
import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { TagType } from 'src/entities/tag.entity';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { TagService } from 'src/services/tag.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock';
import { Mocked } from 'vitest';
describe(TagService.name, () => {
let sut: TagService;
let accessMock: IAccessRepositoryMock;
let eventMock: Mocked<IEventRepository>;
let tagMock: Mocked<ITagRepository>;
beforeEach(() => {
accessMock = newAccessRepositoryMock();
eventMock = newEventRepositoryMock();
tagMock = newTagRepositoryMock();
sut = new TagService(tagMock);
sut = new TagService(accessMock, eventMock, tagMock);
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1']));
});
it('should work', () => {
@@ -30,148 +37,216 @@ describe(TagService.name, () => {
});
});
describe('getById', () => {
describe('get', () => {
it('should throw an error for an invalid id', async () => {
tagMock.getById.mockResolvedValue(null);
await expect(sut.getById(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
tagMock.get.mockResolvedValue(null);
await expect(sut.get(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
expect(tagMock.get).toHaveBeenCalledWith('tag-1');
});
it('should return a tag for a user', async () => {
tagMock.getById.mockResolvedValue(tagStub.tag1);
await expect(sut.getById(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
tagMock.get.mockResolvedValue(tagStub.tag1);
await expect(sut.get(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1);
expect(tagMock.get).toHaveBeenCalledWith('tag-1');
});
});
describe('create', () => {
it('should throw an error for no parent tag access', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.create(authStub.admin, { name: 'tag', parentId: 'tag-parent' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tagMock.create).not.toHaveBeenCalled();
});
it('should create a tag with a parent', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent']));
tagMock.create.mockResolvedValue(tagStub.tag1);
tagMock.get.mockResolvedValueOnce(tagStub.parent);
tagMock.get.mockResolvedValueOnce(tagStub.child);
await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).resolves.toBeDefined();
expect(tagMock.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' }));
});
it('should handle invalid parent ids', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent']));
await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tagMock.create).not.toHaveBeenCalled();
});
});
describe('create', () => {
it('should throw an error for a duplicate tag', async () => {
tagMock.hasName.mockResolvedValue(true);
await expect(sut.create(authStub.admin, { name: 'tag-1', type: TagType.CUSTOM })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tagMock.hasName).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
tagMock.getByValue.mockResolvedValue(tagStub.tag1);
await expect(sut.create(authStub.admin, { name: 'tag-1' })).rejects.toBeInstanceOf(BadRequestException);
expect(tagMock.getByValue).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.create).not.toHaveBeenCalled();
});
it('should create a new tag', async () => {
tagMock.create.mockResolvedValue(tagStub.tag1);
await expect(sut.create(authStub.admin, { name: 'tag-1', type: TagType.CUSTOM })).resolves.toEqual(
tagResponseStub.tag1,
);
await expect(sut.create(authStub.admin, { name: 'tag-1' })).resolves.toEqual(tagResponseStub.tag1);
expect(tagMock.create).toHaveBeenCalledWith({
userId: authStub.admin.user.id,
name: 'tag-1',
type: TagType.CUSTOM,
value: 'tag-1',
});
});
});
describe('update', () => {
it('should throw an error for an invalid id', async () => {
tagMock.getById.mockResolvedValue(null);
await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag-2' })).rejects.toBeInstanceOf(BadRequestException);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.remove).not.toHaveBeenCalled();
it('should throw an error for no update permission', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tagMock.update).not.toHaveBeenCalled();
});
it('should update a tag', async () => {
tagMock.getById.mockResolvedValue(tagStub.tag1);
tagMock.update.mockResolvedValue(tagStub.tag1);
await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag-2' })).resolves.toEqual(tagResponseStub.tag1);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', name: 'tag-2' });
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1']));
tagMock.update.mockResolvedValue(tagStub.color1);
await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).resolves.toEqual(tagResponseStub.color1);
expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', color: '#000000' });
});
});
describe('upsert', () => {
it('should upsert a new tag', async () => {
tagMock.create.mockResolvedValue(tagStub.parent);
await expect(sut.upsert(authStub.admin, { tags: ['Parent'] })).resolves.toBeDefined();
expect(tagMock.create).toHaveBeenCalledWith({
value: 'Parent',
userId: 'admin_id',
parentId: undefined,
});
});
it('should upsert a nested tag', async () => {
tagMock.getByValue.mockResolvedValueOnce(null);
tagMock.create.mockResolvedValueOnce(tagStub.parent);
tagMock.create.mockResolvedValueOnce(tagStub.child);
await expect(sut.upsert(authStub.admin, { tags: ['Parent/Child'] })).resolves.toBeDefined();
expect(tagMock.create).toHaveBeenNthCalledWith(1, {
value: 'Parent',
userId: 'admin_id',
parentId: undefined,
});
expect(tagMock.create).toHaveBeenNthCalledWith(2, {
value: 'Parent/Child',
userId: 'admin_id',
parent: expect.objectContaining({ id: 'tag-parent' }),
});
});
});
describe('remove', () => {
it('should throw an error for an invalid id', async () => {
tagMock.getById.mockResolvedValue(null);
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.remove).not.toHaveBeenCalled();
expect(tagMock.delete).not.toHaveBeenCalled();
});
it('should remove a tag', async () => {
tagMock.getById.mockResolvedValue(tagStub.tag1);
tagMock.get.mockResolvedValue(tagStub.tag1);
await sut.remove(authStub.admin, 'tag-1');
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.remove).toHaveBeenCalledWith(tagStub.tag1);
expect(tagMock.delete).toHaveBeenCalledWith('tag-1');
});
});
describe('getAssets', () => {
it('should throw an error for an invalid id', async () => {
tagMock.getById.mockResolvedValue(null);
await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.remove).not.toHaveBeenCalled();
describe('bulkTagAssets', () => {
it('should handle invalid requests', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
tagMock.upsertAssetIds.mockResolvedValue([]);
await expect(sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1'], assetIds: ['asset-1'] })).resolves.toEqual({
count: 0,
});
expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([]);
});
it('should get the assets for a tag', async () => {
tagMock.getById.mockResolvedValue(tagStub.tag1);
tagMock.getAssets.mockResolvedValue([assetStub.image]);
await sut.getAssets(authStub.admin, 'tag-1');
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.getAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
it('should upsert records', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
tagMock.upsertAssetIds.mockResolvedValue([
{ tagId: 'tag-1', assetId: 'asset-1' },
{ tagId: 'tag-1', assetId: 'asset-2' },
{ tagId: 'tag-1', assetId: 'asset-3' },
{ tagId: 'tag-2', assetId: 'asset-1' },
{ tagId: 'tag-2', assetId: 'asset-2' },
{ tagId: 'tag-2', assetId: 'asset-3' },
]);
await expect(
sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1', 'tag-2'], assetIds: ['asset-1', 'asset-2', 'asset-3'] }),
).resolves.toEqual({
count: 6,
});
expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([
{ tagId: 'tag-1', assetId: 'asset-1' },
{ tagId: 'tag-1', assetId: 'asset-2' },
{ tagId: 'tag-1', assetId: 'asset-3' },
{ tagId: 'tag-2', assetId: 'asset-1' },
{ tagId: 'tag-2', assetId: 'asset-2' },
{ tagId: 'tag-2', assetId: 'asset-3' },
]);
});
});
describe('addAssets', () => {
it('should throw an error for an invalid id', async () => {
tagMock.getById.mockResolvedValue(null);
await expect(sut.addAssets(authStub.admin, 'tag-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.addAssets).not.toHaveBeenCalled();
it('should handle invalid ids', async () => {
tagMock.get.mockResolvedValue(null);
tagMock.getAssetIds.mockResolvedValue(new Set([]));
await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
{ id: 'asset-1', success: false, error: 'no_permission' },
]);
expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']);
expect(tagMock.addAssetIds).not.toHaveBeenCalled();
});
it('should reject duplicate asset ids and accept new ones', async () => {
tagMock.getById.mockResolvedValue(tagStub.tag1);
tagMock.hasAsset.mockImplementation((userId, tagId, assetId) => Promise.resolve(assetId === 'asset-1'));
it('should accept accept ids that are new and reject the rest', async () => {
tagMock.get.mockResolvedValue(tagStub.tag1);
tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1']));
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
await expect(
sut.addAssets(authStub.admin, 'tag-1', {
assetIds: ['asset-1', 'asset-2'],
ids: ['asset-1', 'asset-2'],
}),
).resolves.toEqual([
{ assetId: 'asset-1', success: false, error: AssetIdErrorReason.DUPLICATE },
{ assetId: 'asset-2', success: true },
{ id: 'asset-1', success: false, error: BulkIdErrorReason.DUPLICATE },
{ id: 'asset-2', success: true },
]);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.hasAsset).toHaveBeenCalledTimes(2);
expect(tagMock.addAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1', ['asset-2']);
expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
expect(tagMock.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']);
});
});
describe('removeAssets', () => {
it('should throw an error for an invalid id', async () => {
tagMock.getById.mockResolvedValue(null);
await expect(sut.removeAssets(authStub.admin, 'tag-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.removeAssets).not.toHaveBeenCalled();
tagMock.get.mockResolvedValue(null);
tagMock.getAssetIds.mockResolvedValue(new Set());
await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
{ id: 'asset-1', success: false, error: 'not_found' },
]);
});
it('should accept accept ids that are tagged and reject the rest', async () => {
tagMock.getById.mockResolvedValue(tagStub.tag1);
tagMock.hasAsset.mockImplementation((userId, tagId, assetId) => Promise.resolve(assetId === 'asset-1'));
tagMock.get.mockResolvedValue(tagStub.tag1);
tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1']));
await expect(
sut.removeAssets(authStub.admin, 'tag-1', {
assetIds: ['asset-1', 'asset-2'],
ids: ['asset-1', 'asset-2'],
}),
).resolves.toEqual([
{ assetId: 'asset-1', success: true },
{ assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND },
{ id: 'asset-1', success: true },
{ id: 'asset-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
]);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.hasAsset).toHaveBeenCalledTimes(2);
expect(tagMock.removeAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1', ['asset-1']);
expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
expect(tagMock.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']);
});
});
});
+101 -58
View File
@@ -1,102 +1,145 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { CreateTagDto, TagResponseDto, UpdateTagDto, mapTag } from 'src/dtos/tag.dto';
import { ITagRepository } from 'src/interfaces/tag.interface';
import {
TagBulkAssetsDto,
TagBulkAssetsResponseDto,
TagCreateDto,
TagResponseDto,
TagUpdateDto,
TagUpsertDto,
mapTag,
} from 'src/dtos/tag.dto';
import { TagEntity } from 'src/entities/tag.entity';
import { Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
import { checkAccess, requireAccess } from 'src/utils/access';
import { addAssets, removeAssets } from 'src/utils/asset.util';
import { upsertTags } from 'src/utils/tag';
@Injectable()
export class TagService {
constructor(@Inject(ITagRepository) private repository: ITagRepository) {}
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ITagRepository) private repository: ITagRepository,
) {}
getAll(auth: AuthDto) {
return this.repository.getAll(auth.user.id).then((tags) => tags.map((tag) => mapTag(tag)));
async getAll(auth: AuthDto) {
const tags = await this.repository.getAll(auth.user.id);
return tags.map((tag) => mapTag(tag));
}
async getById(auth: AuthDto, id: string): Promise<TagResponseDto> {
const tag = await this.findOrFail(auth, id);
async get(auth: AuthDto, id: string): Promise<TagResponseDto> {
await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [id] });
const tag = await this.findOrFail(id);
return mapTag(tag);
}
async create(auth: AuthDto, dto: CreateTagDto) {
const duplicate = await this.repository.hasName(auth.user.id, dto.name);
async create(auth: AuthDto, dto: TagCreateDto) {
let parent: TagEntity | undefined;
if (dto.parentId) {
await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.parentId] });
parent = (await this.repository.get(dto.parentId)) || undefined;
if (!parent) {
throw new BadRequestException('Tag not found');
}
}
const userId = auth.user.id;
const value = parent ? `${parent.value}/${dto.name}` : dto.name;
const duplicate = await this.repository.getByValue(userId, value);
if (duplicate) {
throw new BadRequestException(`A tag with that name already exists`);
}
const tag = await this.repository.create({
userId: auth.user.id,
name: dto.name,
type: dto.type,
});
const tag = await this.repository.create({ userId, value, parent });
return mapTag(tag);
}
async update(auth: AuthDto, id: string, dto: UpdateTagDto): Promise<TagResponseDto> {
await this.findOrFail(auth, id);
const tag = await this.repository.update({ id, name: dto.name });
async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise<TagResponseDto> {
await requireAccess(this.access, { auth, permission: Permission.TAG_UPDATE, ids: [id] });
const { color } = dto;
const tag = await this.repository.update({ id, color });
return mapTag(tag);
}
async upsert(auth: AuthDto, dto: TagUpsertDto) {
const tags = await upsertTags(this.repository, { userId: auth.user.id, tags: dto.tags });
return tags.map((tag) => mapTag(tag));
}
async remove(auth: AuthDto, id: string): Promise<void> {
const tag = await this.findOrFail(auth, id);
await this.repository.remove(tag);
await requireAccess(this.access, { auth, permission: Permission.TAG_DELETE, ids: [id] });
// TODO sync tag changes for affected assets
await this.repository.delete(id);
}
async getAssets(auth: AuthDto, id: string): Promise<AssetResponseDto[]> {
await this.findOrFail(auth, id);
const assets = await this.repository.getAssets(auth.user.id, id);
return assets.map((asset) => mapAsset(asset));
}
async bulkTagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise<TagBulkAssetsResponseDto> {
const [tagIds, assetIds] = await Promise.all([
checkAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }),
checkAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }),
]);
async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
await this.findOrFail(auth, id);
const results: AssetIdsResponseDto[] = [];
for (const assetId of dto.assetIds) {
const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId);
if (hasAsset) {
results.push({ assetId, success: false, error: AssetIdErrorReason.DUPLICATE });
} else {
results.push({ assetId, success: true });
const items: AssetTagItem[] = [];
for (const tagId of tagIds) {
for (const assetId of assetIds) {
items.push({ tagId, assetId });
}
}
await this.repository.addAssets(
auth.user.id,
id,
results.filter((result) => result.success).map((result) => result.assetId),
const results = await this.repository.upsertAssetIds(items);
for (const assetId of new Set(results.map((item) => item.assetId))) {
await this.eventRepository.emit('asset.tag', { assetId });
}
return { count: results.length };
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] });
const results = await addAssets(
auth,
{ access: this.access, bulk: this.repository },
{ parentId: id, assetIds: dto.ids },
);
for (const { id: assetId, success } of results) {
if (success) {
await this.eventRepository.emit('asset.tag', { assetId });
}
}
return results;
}
async removeAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
await this.findOrFail(auth, id);
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] });
const results: AssetIdsResponseDto[] = [];
for (const assetId of dto.assetIds) {
const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId);
if (hasAsset) {
results.push({ assetId, success: true });
} else {
results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND });
const results = await removeAssets(
auth,
{ access: this.access, bulk: this.repository },
{ parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.TAG_DELETE },
);
for (const { id: assetId, success } of results) {
if (success) {
await this.eventRepository.emit('asset.untag', { assetId });
}
}
await this.repository.removeAssets(
auth.user.id,
id,
results.filter((result) => result.success).map((result) => result.assetId),
);
return results;
}
private async findOrFail(auth: AuthDto, id: string) {
const tag = await this.repository.getById(auth.user.id, id);
private async findOrFail(id: string) {
const tag = await this.repository.get(id);
if (!tag) {
throw new BadRequestException('Tag not found');
}
+4
View File
@@ -68,6 +68,10 @@ export class TimelineService {
}
}
if (dto.tagId) {
await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.tagId] });
}
if (dto.withPartners) {
const requestedArchived = dto.isArchived === true || dto.isArchived === undefined;
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;