mirror of
https://github.com/immich-app/immich.git
synced 2026-03-04 09:57:33 +03:00
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:
@@ -12,6 +12,7 @@ import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { SessionEntity } from 'src/entities/session.entity';
|
||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
|
||||
import { StackEntity } from 'src/entities/stack.entity';
|
||||
import { TagEntity } from 'src/entities/tag.entity';
|
||||
import { AlbumUserRole } from 'src/enum';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
@@ -25,6 +26,7 @@ type IMemoryAccess = IAccessRepository['memory'];
|
||||
type IPersonAccess = IAccessRepository['person'];
|
||||
type IPartnerAccess = IAccessRepository['partner'];
|
||||
type IStackAccess = IAccessRepository['stack'];
|
||||
type ITagAccess = IAccessRepository['tag'];
|
||||
type ITimelineAccess = IAccessRepository['timeline'];
|
||||
|
||||
@Instrumentation()
|
||||
@@ -444,6 +446,28 @@ class PartnerAccess implements IPartnerAccess {
|
||||
}
|
||||
}
|
||||
|
||||
class TagAccess implements ITagAccess {
|
||||
constructor(private tagRepository: Repository<TagEntity>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||
@ChunkedSet({ paramIndex: 1 })
|
||||
async checkOwnerAccess(userId: string, tagIds: Set<string>): Promise<Set<string>> {
|
||||
if (tagIds.size === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return this.tagRepository
|
||||
.find({
|
||||
select: { id: true },
|
||||
where: {
|
||||
id: In([...tagIds]),
|
||||
userId,
|
||||
},
|
||||
})
|
||||
.then((tags) => new Set(tags.map((tag) => tag.id)));
|
||||
}
|
||||
}
|
||||
|
||||
export class AccessRepository implements IAccessRepository {
|
||||
activity: IActivityAccess;
|
||||
album: IAlbumAccess;
|
||||
@@ -453,6 +477,7 @@ export class AccessRepository implements IAccessRepository {
|
||||
person: IPersonAccess;
|
||||
partner: IPartnerAccess;
|
||||
stack: IStackAccess;
|
||||
tag: ITagAccess;
|
||||
timeline: ITimelineAccess;
|
||||
|
||||
constructor(
|
||||
@@ -467,6 +492,7 @@ export class AccessRepository implements IAccessRepository {
|
||||
@InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository<SharedLinkEntity>,
|
||||
@InjectRepository(SessionEntity) sessionRepository: Repository<SessionEntity>,
|
||||
@InjectRepository(StackEntity) stackRepository: Repository<StackEntity>,
|
||||
@InjectRepository(TagEntity) tagRepository: Repository<TagEntity>,
|
||||
) {
|
||||
this.activity = new ActivityAccess(activityRepository, albumRepository);
|
||||
this.album = new AlbumAccess(albumRepository, sharedLinkRepository);
|
||||
@@ -476,6 +502,7 @@ export class AccessRepository implements IAccessRepository {
|
||||
this.person = new PersonAccess(assetFaceRepository, personRepository);
|
||||
this.partner = new PartnerAccess(partnerRepository);
|
||||
this.stack = new StackAccess(stackRepository);
|
||||
this.tag = new TagAccess(tagRepository);
|
||||
this.timeline = new TimelineAccess(partnerRepository);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -723,6 +723,15 @@ export class AssetRepository implements IAssetRepository {
|
||||
builder.andWhere('asset.type = :assetType', { assetType: options.assetType });
|
||||
}
|
||||
|
||||
if (options.tagId) {
|
||||
builder.innerJoin(
|
||||
'asset.tags',
|
||||
'asset_tags',
|
||||
'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)',
|
||||
{ tagId: options.tagId },
|
||||
);
|
||||
}
|
||||
|
||||
let stackJoined = false;
|
||||
|
||||
if (options.exifInfo !== false) {
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { TagEntity } from 'src/entities/tag.entity';
|
||||
import { ITagRepository } from 'src/interfaces/tag.interface';
|
||||
import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, In, Repository } from 'typeorm';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class TagRepository implements ITagRepository {
|
||||
constructor(
|
||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||
@InjectDataSource() private dataSource: DataSource,
|
||||
@InjectRepository(TagEntity) private repository: Repository<TagEntity>,
|
||||
) {}
|
||||
|
||||
getById(userId: string, id: string): Promise<TagEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
id,
|
||||
userId,
|
||||
},
|
||||
relations: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
get(id: string): Promise<TagEntity | null> {
|
||||
return this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
getAll(userId: string): Promise<TagEntity[]> {
|
||||
return this.repository.find({ where: { userId } });
|
||||
getByValue(userId: string, value: string): Promise<TagEntity | null> {
|
||||
return this.repository.findOne({ where: { userId, value } });
|
||||
}
|
||||
|
||||
async getAll(userId: string): Promise<TagEntity[]> {
|
||||
const tags = await this.repository.find({
|
||||
where: { userId },
|
||||
order: {
|
||||
value: 'ASC',
|
||||
},
|
||||
});
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
create(tag: Partial<TagEntity>): Promise<TagEntity> {
|
||||
@@ -38,89 +41,99 @@ export class TagRepository implements ITagRepository {
|
||||
return this.save(tag);
|
||||
}
|
||||
|
||||
async remove(tag: TagEntity): Promise<void> {
|
||||
await this.repository.remove(tag);
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.repository.delete(id);
|
||||
}
|
||||
|
||||
async getAssets(userId: string, tagId: string): Promise<AssetEntity[]> {
|
||||
return this.assetRepository.find({
|
||||
where: {
|
||||
tags: {
|
||||
userId,
|
||||
id: tagId,
|
||||
},
|
||||
},
|
||||
relations: {
|
||||
exifInfo: true,
|
||||
tags: true,
|
||||
faces: {
|
||||
person: true,
|
||||
},
|
||||
},
|
||||
order: {
|
||||
createdAt: 'ASC',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async addAssets(userId: string, id: string, assetIds: string[]): Promise<void> {
|
||||
for (const assetId of assetIds) {
|
||||
const asset = await this.assetRepository.findOneOrFail({
|
||||
where: {
|
||||
ownerId: userId,
|
||||
id: assetId,
|
||||
},
|
||||
relations: {
|
||||
tags: true,
|
||||
},
|
||||
});
|
||||
asset.tags.push({ id } as TagEntity);
|
||||
await this.assetRepository.save(asset);
|
||||
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
|
||||
@ChunkedSet({ paramIndex: 1 })
|
||||
async getAssetIds(tagId: string, assetIds: string[]): Promise<Set<string>> {
|
||||
if (assetIds.length === 0) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const results = await this.dataSource
|
||||
.createQueryBuilder()
|
||||
.select('tag_asset.assetsId', 'assetId')
|
||||
.from('tag_asset', 'tag_asset')
|
||||
.where('"tag_asset"."tagsId" = :tagId', { tagId })
|
||||
.andWhere('"tag_asset"."assetsId" IN (:...assetIds)', { assetIds })
|
||||
.getRawMany<{ assetId: string }>();
|
||||
|
||||
return new Set(results.map(({ assetId }) => assetId));
|
||||
}
|
||||
|
||||
async removeAssets(userId: string, id: string, assetIds: string[]): Promise<void> {
|
||||
for (const assetId of assetIds) {
|
||||
const asset = await this.assetRepository.findOneOrFail({
|
||||
where: {
|
||||
ownerId: userId,
|
||||
id: assetId,
|
||||
},
|
||||
relations: {
|
||||
tags: true,
|
||||
},
|
||||
});
|
||||
asset.tags = asset.tags.filter((tag) => tag.id !== id);
|
||||
await this.assetRepository.save(asset);
|
||||
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
|
||||
async addAssetIds(tagId: string, assetIds: string[]): Promise<void> {
|
||||
if (assetIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dataSource.manager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into('tag_asset', ['tagsId', 'assetsId'])
|
||||
.values(assetIds.map((assetId) => ({ tagsId: tagId, assetsId: assetId })))
|
||||
.execute();
|
||||
}
|
||||
|
||||
hasAsset(userId: string, tagId: string, assetId: string): Promise<boolean> {
|
||||
return this.repository.exists({
|
||||
where: {
|
||||
id: tagId,
|
||||
userId,
|
||||
assets: {
|
||||
id: assetId,
|
||||
},
|
||||
},
|
||||
relations: {
|
||||
assets: true,
|
||||
},
|
||||
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
|
||||
@Chunked({ paramIndex: 1 })
|
||||
async removeAssetIds(tagId: string, assetIds: string[]): Promise<void> {
|
||||
if (assetIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dataSource
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.from('tag_asset')
|
||||
.where({
|
||||
tagsId: tagId,
|
||||
assetsId: In(assetIds),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagId: DummyValue.UUID }]] })
|
||||
@Chunked()
|
||||
async upsertAssetIds(items: AssetTagItem[]): Promise<AssetTagItem[]> {
|
||||
if (items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { identifiers } = await this.dataSource
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into('tag_asset', ['assetsId', 'tagsId'])
|
||||
.values(items.map(({ assetId, tagId }) => ({ assetsId: assetId, tagsId: tagId })))
|
||||
.execute();
|
||||
|
||||
return (identifiers as Array<{ assetsId: string; tagsId: string }>).map(({ assetsId, tagsId }) => ({
|
||||
assetId: assetsId,
|
||||
tagId: tagsId,
|
||||
}));
|
||||
}
|
||||
|
||||
async upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.createQueryBuilder().delete().from('tag_asset').where({ assetsId: assetId }).execute();
|
||||
|
||||
if (tagIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await manager
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into('tag_asset', ['tagsId', 'assetsId'])
|
||||
.values(tagIds.map((tagId) => ({ tagsId: tagId, assetsId: assetId })))
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
hasName(userId: string, name: string): Promise<boolean> {
|
||||
return this.repository.exists({
|
||||
where: {
|
||||
name,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async save(tag: Partial<TagEntity>): Promise<TagEntity> {
|
||||
const { id } = await this.repository.save(tag);
|
||||
return this.repository.findOneOrFail({ where: { id }, relations: { user: true } });
|
||||
private async save(partial: Partial<TagEntity>): Promise<TagEntity> {
|
||||
const { id } = await this.repository.save(partial);
|
||||
return this.repository.findOneOrFail({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user