diff --git a/e2e/src/api/specs/tag.e2e-spec.ts b/e2e/src/api/specs/tag.e2e-spec.ts new file mode 100644 index 0000000000..0a26ccef0e --- /dev/null +++ b/e2e/src/api/specs/tag.e2e-spec.ts @@ -0,0 +1,559 @@ +import { + AssetMediaResponseDto, + LoginResponseDto, + Permission, + TagCreateDto, + createTag, + getAllTags, + tagAssets, + upsertTags, +} from '@immich/sdk'; +import { createUserDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, asBearerAuth, utils } from 'src/utils'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const create = (accessToken: string, dto: TagCreateDto) => + createTag({ tagCreateDto: dto }, { headers: asBearerAuth(accessToken) }); + +const upsert = (accessToken: string, tags: string[]) => + upsertTags({ tagUpsertDto: { tags } }, { headers: asBearerAuth(accessToken) }); + +describe('/tags', () => { + let admin: LoginResponseDto; + let user: LoginResponseDto; + let userAsset: AssetMediaResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + + admin = await utils.adminSetup(); + user = await utils.userSetup(admin.accessToken, createUserDto.user1); + userAsset = await utils.createAsset(user.accessToken); + }); + + beforeEach(async () => { + // tagging assets eventually triggers metadata extraction which can impact other tests + await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); + await utils.resetDatabase(['tags']); + }); + + describe('POST /tags', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post('/tags').send({ name: 'TagA' }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.create')); + }); + + it('should work with tag.create', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.TagCreate]); + const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' }); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagA', + value: 'TagA', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(status).toBe(201); + }); + + it('should create a tag', async () => { + const { status, body } = await request(app) + .post('/tags') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ name: 'TagA' }); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagA', + value: 'TagA', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(status).toBe(201); + }); + + it('should create a nested tag', async () => { + const parent = await create(admin.accessToken, { name: 'TagA' }); + + const { status, body } = await request(app) + .post('/tags') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ name: 'TagB', parentId: parent.id }); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagB', + value: 'TagA/TagB', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(status).toBe(201); + }); + }); + + describe('GET /tags', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/tags'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app).get('/tags').set('x-api-key', secret); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.read')); + }); + + it('should start off empty', async () => { + const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toEqual([]); + expect(status).toEqual(200); + }); + + it('should return a list of tags', async () => { + const [tagA, tagB, tagC] = await Promise.all([ + create(admin.accessToken, { name: 'TagA' }), + create(admin.accessToken, { name: 'TagB' }), + create(admin.accessToken, { name: 'TagC' }), + ]); + const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toHaveLength(3); + expect(body).toEqual([tagA, tagB, tagC]); + expect(status).toEqual(200); + }); + + it('should return a nested tags', async () => { + await upsert(admin.accessToken, ['TagA/TagB/TagC', 'TagD']); + const { status, body } = await request(app).get('/tags').set('Authorization', `Bearer ${admin.accessToken}`); + expect(body).toHaveLength(4); + expect(body).toEqual([ + expect.objectContaining({ name: 'TagA', value: 'TagA' }), + expect.objectContaining({ name: 'TagB', value: 'TagA/TagB' }), + expect.objectContaining({ name: 'TagC', value: 'TagA/TagB/TagC' }), + expect.objectContaining({ name: 'TagD', value: 'TagD' }), + ]); + expect(status).toEqual(200); + }); + }); + + describe('PUT /tags', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put(`/tags`).send({ name: 'TagA/TagB' }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app).put('/tags').set('x-api-key', secret).send({ name: 'TagA' }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.create')); + }); + + it('should upsert tags', async () => { + const { status, body } = await request(app) + .put(`/tags`) + .send({ tags: ['TagA/TagB/TagC/TagD'] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ name: 'TagD', value: 'TagA/TagB/TagC/TagD' })]); + }); + }); + + describe('PUT /tags/assets', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put(`/tags/assets`).send({ tagIds: [], assetIds: [] }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .put('/tags/assets') + .set('x-api-key', secret) + .send({ assetIds: [], tagIds: [] }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.asset')); + }); + + it('should skip assets that are not owned by the user', async () => { + const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([ + create(user.accessToken, { name: 'TagA' }), + create(user.accessToken, { name: 'TagB' }), + create(user.accessToken, { name: 'TagC' }), + utils.createAsset(user.accessToken), + utils.createAsset(admin.accessToken), + ]); + const { status, body } = await request(app) + .put(`/tags/assets`) + .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 3 }); + }); + + it('should skip tags that are not owned by the user', async () => { + const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([ + create(user.accessToken, { name: 'TagA' }), + create(user.accessToken, { name: 'TagB' }), + create(admin.accessToken, { name: 'TagC' }), + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken), + ]); + const { status, body } = await request(app) + .put(`/tags/assets`) + .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 4 }); + }); + + it('should bulk tag assets', async () => { + const [tagA, tagB, tagC, assetA, assetB] = await Promise.all([ + create(user.accessToken, { name: 'TagA' }), + create(user.accessToken, { name: 'TagB' }), + create(user.accessToken, { name: 'TagC' }), + utils.createAsset(user.accessToken), + utils.createAsset(user.accessToken), + ]); + const { status, body } = await request(app) + .put(`/tags/assets`) + .send({ tagIds: [tagA.id, tagB.id, tagC.id], assetIds: [assetA.id, assetB.id] }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ count: 6 }); + }); + }); + + describe('GET /tags/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get(`/tags/${uuidDto.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .get(`/tags/${tag.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .get(`/tags/${uuidDto.notFound}`) + .set('x-api-key', secret) + .send({ assetIds: [], tagIds: [] }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.read')); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(app) + .get(`/tags/${uuidDto.invalid}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should get tag details', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .get(`/tags/${tag.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagA', + value: 'TagA', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + + it('should get nested tag details', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const tagB = await create(user.accessToken, { name: 'TagB', parentId: tagA.id }); + const tagC = await create(user.accessToken, { name: 'TagC', parentId: tagB.id }); + const tagD = await create(user.accessToken, { name: 'TagD', parentId: tagC.id }); + + const { status, body } = await request(app) + .get(`/tags/${tagD.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + id: expect.any(String), + name: 'TagD', + value: 'TagA/TagB/TagC/TagD', + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + }); + + describe('PUT /tags/:id', () => { + it('should require authentication', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app).put(`/tags/${tag.id}`).send({ color: '#000000' }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tag = await create(admin.accessToken, { name: 'tagA' }); + const { status, body } = await request(app) + .put(`/tags/${tag.id}`) + .send({ color: '#000000' }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .put(`/tags/${tag.id}`) + .set('x-api-key', secret) + .send({ color: '#000000' }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.update')); + }); + + it('should update a tag', async () => { + const tag = await create(user.accessToken, { name: 'tagA' }); + const { status, body } = await request(app) + .put(`/tags/${tag.id}`) + .send({ color: '#000000' }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual(expect.objectContaining({ color: `#000000` })); + }); + + it('should update a tag color without a # prefix', async () => { + const tag = await create(user.accessToken, { name: 'tagA' }); + const { status, body } = await request(app) + .put(`/tags/${tag.id}`) + .send({ color: '000000' }) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual(expect.objectContaining({ color: `#000000` })); + }); + }); + + describe('DELETE /tags/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).delete(`/tags/${uuidDto.notFound}`); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .delete(`/tags/${tag.id}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app).delete(`/tags/${tag.id}`).set('x-api-key', secret); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.delete')); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(app) + .delete(`/tags/${uuidDto.invalid}`) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['id must be a UUID'])); + }); + + it('should delete a tag', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status } = await request(app) + .delete(`/tags/${tag.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(204); + }); + + it('should delete a nested tag (root)', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + await create(user.accessToken, { name: 'TagB', parentId: tagA.id }); + const { status } = await request(app) + .delete(`/tags/${tagA.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(204); + const tags = await getAllTags({ headers: asBearerAuth(user.accessToken) }); + expect(tags.length).toBe(0); + }); + + it('should delete a nested tag (leaf)', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const tagB = await create(user.accessToken, { name: 'TagB', parentId: tagA.id }); + const { status } = await request(app) + .delete(`/tags/${tagB.id}`) + .set('Authorization', `Bearer ${user.accessToken}`); + expect(status).toBe(204); + const tags = await getAllTags({ headers: asBearerAuth(user.accessToken) }); + expect(tags.length).toBe(1); + expect(tags[0]).toEqual(tagA); + }); + }); + + describe('PUT /tags/:id/assets', () => { + it('should require authentication', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tagA.id}/assets`) + .send({ ids: [userAsset.id] }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tag.id}/assets`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ids: [userAsset.id] }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .put(`/tags/${tag.id}/assets`) + .set('x-api-key', secret) + .send({ ids: [userAsset.id] }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.asset')); + }); + + it('should be able to tag own asset', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]); + }); + + it("should not be able to add assets to another user's tag", async () => { + const tagA = await create(admin.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Not found or no tag.asset access')); + }); + + it('should add duplicate assets only once', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .put(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id, userAsset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([ + expect.objectContaining({ id: userAsset.id, success: true }), + expect.objectContaining({ id: userAsset.id, success: false, error: 'duplicate' }), + ]); + }); + }); + + describe('DELETE /tags/:id/assets', () => { + it('should require authentication', async () => { + const tagA = await create(admin.accessToken, { name: 'TagA' }); + const { status, body } = await request(app) + .delete(`/tags/${tagA}/assets`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require authorization', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + await tagAssets( + { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, + { headers: asBearerAuth(user.accessToken) }, + ); + const { status, body } = await request(app) + .delete(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should require authorization (api key)', async () => { + const tag = await create(user.accessToken, { name: 'TagA' }); + const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]); + const { status, body } = await request(app) + .delete(`/tags/${tag.id}/assets`) + .set('x-api-key', secret) + .send({ ids: [userAsset.id] }); + expect(status).toBe(403); + expect(body).toEqual(errorDto.missingPermission('tag.asset')); + }); + + it('should be able to remove own asset from own tag', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + await tagAssets( + { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, + { headers: asBearerAuth(user.accessToken) }, + ); + const { status, body } = await request(app) + .delete(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([expect.objectContaining({ id: userAsset.id, success: true })]); + }); + + it('should remove duplicate assets only once', async () => { + const tagA = await create(user.accessToken, { name: 'TagA' }); + await tagAssets( + { id: tagA.id, bulkIdsDto: { ids: [userAsset.id] } }, + { headers: asBearerAuth(user.accessToken) }, + ); + const { status, body } = await request(app) + .delete(`/tags/${tagA.id}/assets`) + .set('Authorization', `Bearer ${user.accessToken}`) + .send({ ids: [userAsset.id, userAsset.id] }); + + expect(status).toBe(200); + expect(body).toEqual([ + expect.objectContaining({ id: userAsset.id, success: true }), + expect.objectContaining({ id: userAsset.id, success: false, error: 'not_found' }), + ]); + }); + }); +}); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 30e2497b51..a53a3ddd25 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -148,6 +148,7 @@ export const utils = { 'sessions', 'users', 'system_metadata', + 'tags', ]; const sql: string[] = []; diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1da4463a12..1f8958dd95 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 05a43c8af7..532d7e22cd 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/tags_api.dart b/mobile/openapi/lib/api/tags_api.dart index e5d1e9c650..87c9001a3c 100644 Binary files a/mobile/openapi/lib/api/tags_api.dart and b/mobile/openapi/lib/api/tags_api.dart differ diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 4acb98bdf2..8c94e09bf5 100644 Binary files a/mobile/openapi/lib/api/timeline_api.dart and b/mobile/openapi/lib/api/timeline_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index c9ed2a508d..54873a5955 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 7f46e145b1..a486551cc5 100644 Binary files a/mobile/openapi/lib/api_helper.dart and b/mobile/openapi/lib/api_helper.dart differ diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 3f89c9826d..1244a434b6 100644 Binary files a/mobile/openapi/lib/model/permission.dart and b/mobile/openapi/lib/model/permission.dart differ diff --git a/mobile/openapi/lib/model/tag_bulk_assets_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart new file mode 100644 index 0000000000..c11cb66ce0 Binary files /dev/null and b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart differ diff --git a/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart new file mode 100644 index 0000000000..d4dcb91d8c Binary files /dev/null and b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart differ diff --git a/mobile/openapi/lib/model/update_tag_dto.dart b/mobile/openapi/lib/model/tag_create_dto.dart similarity index 56% rename from mobile/openapi/lib/model/update_tag_dto.dart rename to mobile/openapi/lib/model/tag_create_dto.dart index dfa9b8cfc0..dd7e537a0a 100644 Binary files a/mobile/openapi/lib/model/update_tag_dto.dart and b/mobile/openapi/lib/model/tag_create_dto.dart differ diff --git a/mobile/openapi/lib/model/tag_response_dto.dart b/mobile/openapi/lib/model/tag_response_dto.dart index d371bd1c04..4f0a62a8b9 100644 Binary files a/mobile/openapi/lib/model/tag_response_dto.dart and b/mobile/openapi/lib/model/tag_response_dto.dart differ diff --git a/mobile/openapi/lib/model/tag_type_enum.dart b/mobile/openapi/lib/model/tag_type_enum.dart deleted file mode 100644 index 3f2e723796..0000000000 Binary files a/mobile/openapi/lib/model/tag_type_enum.dart and /dev/null differ diff --git a/mobile/openapi/lib/model/create_tag_dto.dart b/mobile/openapi/lib/model/tag_update_dto.dart similarity index 57% rename from mobile/openapi/lib/model/create_tag_dto.dart rename to mobile/openapi/lib/model/tag_update_dto.dart index 31b194993d..661f65896e 100644 Binary files a/mobile/openapi/lib/model/create_tag_dto.dart and b/mobile/openapi/lib/model/tag_update_dto.dart differ diff --git a/mobile/openapi/lib/model/tag_upsert_dto.dart b/mobile/openapi/lib/model/tag_upsert_dto.dart new file mode 100644 index 0000000000..941d25b6ae Binary files /dev/null and b/mobile/openapi/lib/model/tag_upsert_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2137bf7b11..4d80353177 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6169,7 +6169,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateTagDto" + "$ref": "#/components/schemas/TagCreateDto" } } }, @@ -6201,6 +6201,91 @@ "tags": [ "Tags" ] + }, + "put": { + "operationId": "upsertTags", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagUpsertDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/TagResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Tags" + ] + } + }, + "/tags/assets": { + "put": { + "operationId": "bulkTagAssets", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagBulkAssetsDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TagBulkAssetsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Tags" + ] } }, "/tags/{id}": { @@ -6218,7 +6303,7 @@ } ], "responses": { - "200": { + "204": { "description": "" } }, @@ -6277,7 +6362,7 @@ "Tags" ] }, - "patch": { + "put": { "operationId": "updateTag", "parameters": [ { @@ -6294,7 +6379,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateTagDto" + "$ref": "#/components/schemas/TagUpdateDto" } } }, @@ -6346,7 +6431,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetIdsDto" + "$ref": "#/components/schemas/BulkIdsDto" } } }, @@ -6358,50 +6443,7 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/AssetIdsResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Tags" - ] - }, - "get": { - "operationId": "getTagAssets", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/AssetResponseDto" + "$ref": "#/components/schemas/BulkIdResponseDto" }, "type": "array" } @@ -6442,7 +6484,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AssetIdsDto" + "$ref": "#/components/schemas/BulkIdsDto" } } }, @@ -6454,7 +6496,7 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/AssetIdsResponseDto" + "$ref": "#/components/schemas/BulkIdResponseDto" }, "type": "array" } @@ -6549,6 +6591,15 @@ "$ref": "#/components/schemas/TimeBucketSize" } }, + { + "name": "tagId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "name": "timeBucket", "required": true, @@ -6684,6 +6735,15 @@ "$ref": "#/components/schemas/TimeBucketSize" } }, + { + "name": "tagId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "name": "userId", "required": false, @@ -8685,21 +8745,6 @@ ], "type": "object" }, - "CreateTagDto": { - "properties": { - "name": { - "type": "string" - }, - "type": { - "$ref": "#/components/schemas/TagTypeEnum" - } - }, - "required": [ - "name", - "type" - ], - "type": "object" - }, "DownloadArchiveInfo": { "properties": { "assetIds": { @@ -10053,6 +10098,7 @@ "tag.read", "tag.update", "tag.delete", + "tag.asset", "admin.user.create", "admin.user.read", "admin.user.update", @@ -11848,36 +11894,113 @@ ], "type": "object" }, + "TagBulkAssetsDto": { + "properties": { + "assetIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + }, + "tagIds": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "assetIds", + "tagIds" + ], + "type": "object" + }, + "TagBulkAssetsResponseDto": { + "properties": { + "count": { + "type": "integer" + } + }, + "required": [ + "count" + ], + "type": "object" + }, + "TagCreateDto": { + "properties": { + "color": { + "type": "string" + }, + "name": { + "type": "string" + }, + "parentId": { + "format": "uuid", + "nullable": true, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, "TagResponseDto": { "properties": { + "color": { + "type": "string" + }, + "createdAt": { + "format": "date-time", + "type": "string" + }, "id": { "type": "string" }, "name": { "type": "string" }, - "type": { - "$ref": "#/components/schemas/TagTypeEnum" + "updatedAt": { + "format": "date-time", + "type": "string" }, - "userId": { + "value": { "type": "string" } }, "required": [ + "createdAt", "id", "name", - "type", - "userId" + "updatedAt", + "value" ], "type": "object" }, - "TagTypeEnum": { - "enum": [ - "OBJECT", - "FACE", - "CUSTOM" + "TagUpdateDto": { + "properties": { + "color": { + "nullable": true, + "type": "string" + } + }, + "type": "object" + }, + "TagUpsertDto": { + "properties": { + "tags": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "tags" ], - "type": "string" + "type": "object" }, "TimeBucketResponseDto": { "properties": { @@ -12021,14 +12144,6 @@ ], "type": "object" }, - "UpdateTagDto": { - "properties": { - "name": { - "type": "string" - } - }, - "type": "object" - }, "UsageByUserDto": { "properties": { "photos": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index bf0c63c2b8..3fdcf33757 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -198,10 +198,12 @@ export type AssetStackResponseDto = { primaryAssetId: string; }; export type TagResponseDto = { + color?: string; + createdAt: string; id: string; name: string; - "type": TagTypeEnum; - userId: string; + updatedAt: string; + value: string; }; export type AssetResponseDto = { /** base64 encoded sha1 hash */ @@ -1171,12 +1173,23 @@ export type ReverseGeocodingStateResponseDto = { lastImportFileName: string | null; lastUpdate: string | null; }; -export type CreateTagDto = { +export type TagCreateDto = { + color?: string; name: string; - "type": TagTypeEnum; + parentId?: string | null; }; -export type UpdateTagDto = { - name?: string; +export type TagUpsertDto = { + tags: string[]; +}; +export type TagBulkAssetsDto = { + assetIds: string[]; + tagIds: string[]; +}; +export type TagBulkAssetsResponseDto = { + count: number; +}; +export type TagUpdateDto = { + color?: string | null; }; export type TimeBucketResponseDto = { count: number; @@ -2835,8 +2848,8 @@ export function getAllTags(opts?: Oazapfts.RequestOpts) { ...opts })); } -export function createTag({ createTagDto }: { - createTagDto: CreateTagDto; +export function createTag({ tagCreateDto }: { + tagCreateDto: TagCreateDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; @@ -2844,7 +2857,31 @@ export function createTag({ createTagDto }: { }>("/tags", oazapfts.json({ ...opts, method: "POST", - body: createTagDto + body: tagCreateDto + }))); +} +export function upsertTags({ tagUpsertDto }: { + tagUpsertDto: TagUpsertDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TagResponseDto[]; + }>("/tags", oazapfts.json({ + ...opts, + method: "PUT", + body: tagUpsertDto + }))); +} +export function bulkTagAssets({ tagBulkAssetsDto }: { + tagBulkAssetsDto: TagBulkAssetsDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: TagBulkAssetsResponseDto; + }>("/tags/assets", oazapfts.json({ + ...opts, + method: "PUT", + body: tagBulkAssetsDto }))); } export function deleteTag({ id }: { @@ -2865,56 +2902,46 @@ export function getTagById({ id }: { ...opts })); } -export function updateTag({ id, updateTagDto }: { +export function updateTag({ id, tagUpdateDto }: { id: string; - updateTagDto: UpdateTagDto; + tagUpdateDto: TagUpdateDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: TagResponseDto; }>(`/tags/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, - method: "PATCH", - body: updateTagDto + method: "PUT", + body: tagUpdateDto }))); } -export function untagAssets({ id, assetIdsDto }: { +export function untagAssets({ id, bulkIdsDto }: { id: string; - assetIdsDto: AssetIdsDto; + bulkIdsDto: BulkIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AssetIdsResponseDto[]; + data: BulkIdResponseDto[]; }>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({ ...opts, method: "DELETE", - body: assetIdsDto + body: bulkIdsDto }))); } -export function getTagAssets({ id }: { +export function tagAssets({ id, bulkIdsDto }: { id: string; + bulkIdsDto: BulkIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AssetResponseDto[]; - }>(`/tags/${encodeURIComponent(id)}/assets`, { - ...opts - })); -} -export function tagAssets({ id, assetIdsDto }: { - id: string; - assetIdsDto: AssetIdsDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: AssetIdsResponseDto[]; + data: BulkIdResponseDto[]; }>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({ ...opts, method: "PUT", - body: assetIdsDto + body: bulkIdsDto }))); } -export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: { +export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, withPartners, withStacked }: { albumId?: string; isArchived?: boolean; isFavorite?: boolean; @@ -2923,6 +2950,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order?: AssetOrder; personId?: string; size: TimeBucketSize; + tagId?: string; timeBucket: string; userId?: string; withPartners?: boolean; @@ -2940,6 +2968,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, + tagId, timeBucket, userId, withPartners, @@ -2948,7 +2977,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, ...opts })); } -export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, userId, withPartners, withStacked }: { albumId?: string; isArchived?: boolean; isFavorite?: boolean; @@ -2957,6 +2986,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key order?: AssetOrder; personId?: string; size: TimeBucketSize; + tagId?: string; userId?: string; withPartners?: boolean; withStacked?: boolean; @@ -2973,6 +3003,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key order, personId, size, + tagId, userId, withPartners, withStacked @@ -3162,11 +3193,6 @@ export enum AlbumUserRole { Editor = "editor", Viewer = "viewer" } -export enum TagTypeEnum { - Object = "OBJECT", - Face = "FACE", - Custom = "CUSTOM" -} export enum AssetTypeEnum { Image = "IMAGE", Video = "VIDEO", @@ -3257,6 +3283,7 @@ export enum Permission { TagRead = "tag.read", TagUpdate = "tag.update", TagDelete = "tag.delete", + TagAsset = "tag.asset", AdminUserCreate = "admin.user.create", AdminUserRead = "admin.user.read", AdminUserUpdate = "admin.user.update", diff --git a/server/src/controllers/tag.controller.ts b/server/src/controllers/tag.controller.ts index 8b646400cc..cf6b8ac695 100644 --- a/server/src/controllers/tag.controller.ts +++ b/server/src/controllers/tag.controller.ts @@ -1,10 +1,15 @@ -import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; -import { AssetResponseDto } 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 } from 'src/dtos/tag.dto'; +import { + TagBulkAssetsDto, + TagBulkAssetsResponseDto, + TagCreateDto, + TagResponseDto, + TagUpdateDto, + TagUpsertDto, +} from 'src/dtos/tag.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { TagService } from 'src/services/tag.service'; @@ -17,7 +22,7 @@ export class TagController { @Post() @Authenticated({ permission: Permission.TAG_CREATE }) - createTag(@Auth() auth: AuthDto, @Body() dto: CreateTagDto): Promise { + createTag(@Auth() auth: AuthDto, @Body() dto: TagCreateDto): Promise { return this.service.create(auth, dto); } @@ -27,47 +32,54 @@ export class TagController { return this.service.getAll(auth); } + @Put() + @Authenticated({ permission: Permission.TAG_CREATE }) + upsertTags(@Auth() auth: AuthDto, @Body() dto: TagUpsertDto): Promise { + return this.service.upsert(auth, dto); + } + + @Put('assets') + @Authenticated({ permission: Permission.TAG_ASSET }) + bulkTagAssets(@Auth() auth: AuthDto, @Body() dto: TagBulkAssetsDto): Promise { + return this.service.bulkTagAssets(auth, dto); + } + @Get(':id') @Authenticated({ permission: Permission.TAG_READ }) getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.getById(auth, id); + return this.service.get(auth, id); } - @Patch(':id') + @Put(':id') @Authenticated({ permission: Permission.TAG_UPDATE }) - updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateTagDto): Promise { + updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: TagUpdateDto): Promise { return this.service.update(auth, id, dto); } @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) @Authenticated({ permission: Permission.TAG_DELETE }) deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.remove(auth, id); } - @Get(':id/assets') - @Authenticated() - getTagAssets(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.getAssets(auth, id); - } - @Put(':id/assets') - @Authenticated() + @Authenticated({ permission: Permission.TAG_ASSET }) tagAssets( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, - @Body() dto: AssetIdsDto, - ): Promise { + @Body() dto: BulkIdsDto, + ): Promise { return this.service.addAssets(auth, id, dto); } @Delete(':id/assets') - @Authenticated() + @Authenticated({ permission: Permission.TAG_ASSET }) untagAssets( @Auth() auth: AuthDto, - @Body() dto: AssetIdsDto, + @Body() dto: BulkIdsDto, @Param() { id }: UUIDParamDto, - ): Promise { + ): Promise { return this.service.removeAssets(auth, id, dto); } } diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index caeae2971a..463ab119a6 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -140,7 +140,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined, smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, - tags: entity.tags?.map(mapTag), + tags: entity.tags?.map((tag) => mapTag(tag)), people: peopleWithFaces(entity.faces), unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)), checksum: entity.checksum.toString('base64'), diff --git a/server/src/dtos/tag.dto.ts b/server/src/dtos/tag.dto.ts index 1094d70df3..40c5b176ff 100644 --- a/server/src/dtos/tag.dto.ts +++ b/server/src/dtos/tag.dto.ts @@ -1,38 +1,64 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; -import { TagEntity, TagType } from 'src/entities/tag.entity'; -import { Optional } from 'src/validation'; +import { Transform } from 'class-transformer'; +import { IsHexColor, IsNotEmpty, IsString } from 'class-validator'; +import { TagEntity } from 'src/entities/tag.entity'; +import { Optional, ValidateUUID } from 'src/validation'; -export class CreateTagDto { +export class TagCreateDto { @IsString() @IsNotEmpty() name!: string; - @IsEnum(TagType) - @IsNotEmpty() - @ApiProperty({ enumName: 'TagTypeEnum', enum: TagType }) - type!: TagType; + @ValidateUUID({ optional: true, nullable: true }) + parentId?: string | null; + + @IsHexColor() + @Optional({ nullable: true, emptyToNull: true }) + color?: string; } -export class UpdateTagDto { - @IsString() - @Optional() - name?: string; +export class TagUpdateDto { + @Optional({ nullable: true, emptyToNull: true }) + @IsHexColor() + @Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value)) + color?: string | null; +} + +export class TagUpsertDto { + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + tags!: string[]; +} + +export class TagBulkAssetsDto { + @ValidateUUID({ each: true }) + tagIds!: string[]; + + @ValidateUUID({ each: true }) + assetIds!: string[]; +} + +export class TagBulkAssetsResponseDto { + @ApiProperty({ type: 'integer' }) + count!: number; } export class TagResponseDto { id!: string; - @ApiProperty({ enumName: 'TagTypeEnum', enum: TagType }) - type!: string; name!: string; - userId!: string; + value!: string; + createdAt!: Date; + updatedAt!: Date; + color?: string; } export function mapTag(entity: TagEntity): TagResponseDto { return { id: entity.id, - type: entity.type, - name: entity.name, - userId: entity.userId, + name: entity.value.split('/').at(-1) as string, + value: entity.value, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + color: entity.color ?? undefined, }; } diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index 8803f24fc4..dd7a01df35 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -19,6 +19,9 @@ export class TimeBucketDto { @ValidateUUID({ optional: true }) personId?: string; + @ValidateUUID({ optional: true }) + tagId?: string; + @ValidateBoolean({ optional: true }) isArchived?: boolean; diff --git a/server/src/entities/tag.entity.ts b/server/src/entities/tag.entity.ts index 93edcb0555..940b446aea 100644 --- a/server/src/entities/tag.entity.ts +++ b/server/src/entities/tag.entity.ts @@ -1,45 +1,48 @@ import { AssetEntity } from 'src/entities/asset.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { Column, Entity, ManyToMany, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + ManyToMany, + ManyToOne, + PrimaryGeneratedColumn, + Tree, + TreeChildren, + TreeParent, + UpdateDateColumn, +} from 'typeorm'; @Entity('tags') -@Unique('UQ_tag_name_userId', ['name', 'userId']) +@Tree('closure-table') export class TagEntity { @PrimaryGeneratedColumn('uuid') id!: string; - @Column() - type!: TagType; + @Column({ unique: true }) + value!: string; - @Column() - name!: string; + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; - @ManyToOne(() => UserEntity, (user) => user.tags) - user!: UserEntity; + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; + + @Column({ type: 'varchar', nullable: true, default: null }) + color!: string | null; + + @TreeParent({ onDelete: 'CASCADE' }) + parent?: TagEntity; + + @TreeChildren() + children?: TagEntity[]; + + @ManyToOne(() => UserEntity, (user) => user.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + user?: UserEntity; @Column() userId!: string; - @Column({ type: 'uuid', comment: 'The new renamed tagId', nullable: true }) - renameTagId!: string | null; - - @ManyToMany(() => AssetEntity, (asset) => asset.tags) - assets!: AssetEntity[]; -} - -export enum TagType { - /** - * Tag that is detected by the ML model for object detection will use this type - */ - OBJECT = 'OBJECT', - - /** - * Face that is detected by the ML model for facial detection (TBD/NOT YET IMPLEMENTED) will use this type - */ - FACE = 'FACE', - - /** - * Tag that is created by the user will use this type - */ - CUSTOM = 'CUSTOM', + @ManyToMany(() => AssetEntity, (asset) => asset.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' }) + assets?: AssetEntity[]; } diff --git a/server/src/enum.ts b/server/src/enum.ts index 25ccbf961e..9cd5c189e8 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -130,6 +130,7 @@ export enum Permission { TAG_READ = 'tag.read', TAG_UPDATE = 'tag.update', TAG_DELETE = 'tag.delete', + TAG_ASSET = 'tag.asset', ADMIN_USER_CREATE = 'admin.user.create', ADMIN_USER_READ = 'admin.user.read', diff --git a/server/src/interfaces/access.interface.ts b/server/src/interfaces/access.interface.ts index 2dcf9d6b94..d8d7b4e807 100644 --- a/server/src/interfaces/access.interface.ts +++ b/server/src/interfaces/access.interface.ts @@ -46,4 +46,8 @@ export interface IAccessRepository { stack: { checkOwnerAccess(userId: string, stackIds: Set): Promise>; }; + + tag: { + checkOwnerAccess(userId: string, tagIds: Set): Promise>; + }; } diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 9f9218a3e3..e323d98640 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -51,6 +51,7 @@ export interface AssetBuilderOptions { isTrashed?: boolean; isDuplicate?: boolean; albumId?: string; + tagId?: string; personId?: string; userIds?: string[]; withStacked?: boolean; diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index 609f42cc32..bb2b0d9ab4 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -17,6 +17,10 @@ type EmitEventMap = { 'album.update': [{ id: string; updatedBy: string }]; 'album.invite': [{ id: string; userId: string }]; + // tag events + 'asset.tag': [{ assetId: string }]; + 'asset.untag': [{ assetId: string }]; + // user events 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; }; diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index b2ac5ec6f1..bc780398ea 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -155,6 +155,7 @@ export interface ISidecarWriteJob extends IEntityJob { latitude?: number; longitude?: number; rating?: number; + tags?: true; } export interface IDeferrableJob extends IEntityJob { diff --git a/server/src/interfaces/tag.interface.ts b/server/src/interfaces/tag.interface.ts index 8071461dfc..f9f3784f06 100644 --- a/server/src/interfaces/tag.interface.ts +++ b/server/src/interfaces/tag.interface.ts @@ -1,17 +1,19 @@ -import { AssetEntity } from 'src/entities/asset.entity'; import { TagEntity } from 'src/entities/tag.entity'; +import { IBulkAsset } from 'src/utils/asset.util'; export const ITagRepository = 'ITagRepository'; -export interface ITagRepository { - getById(userId: string, tagId: string): Promise; +export type AssetTagItem = { assetId: string; tagId: string }; + +export interface ITagRepository extends IBulkAsset { getAll(userId: string): Promise; + getByValue(userId: string, value: string): Promise; + create(tag: Partial): Promise; - update(tag: Partial): Promise; - remove(tag: TagEntity): Promise; - hasName(userId: string, name: string): Promise; - hasAsset(userId: string, tagId: string, assetId: string): Promise; - getAssets(userId: string, tagId: string): Promise; - addAssets(userId: string, tagId: string, assetIds: string[]): Promise; - removeAssets(userId: string, tagId: string, assetIds: string[]): Promise; + get(id: string): Promise; + update(tag: { id: string } & Partial): Promise; + delete(id: string): Promise; + + upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise; + upsertAssetIds(items: AssetTagItem[]): Promise; } diff --git a/server/src/migrations/1724790460210-NestedTagTable.ts b/server/src/migrations/1724790460210-NestedTagTable.ts new file mode 100644 index 0000000000..dfda9a6d7a --- /dev/null +++ b/server/src/migrations/1724790460210-NestedTagTable.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class NestedTagTable1724790460210 implements MigrationInterface { + name = 'NestedTagTable1724790460210' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('TRUNCATE TABLE "tags" CASCADE'); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_tag_name_userId"`); + await queryRunner.query(`CREATE TABLE "tags_closure" ("id_ancestor" uuid NOT NULL, "id_descendant" uuid NOT NULL, CONSTRAINT "PK_eab38eb12a3ec6df8376c95477c" PRIMARY KEY ("id_ancestor", "id_descendant"))`); + await queryRunner.query(`CREATE INDEX "IDX_15fbcbc67663c6bfc07b354c22" ON "tags_closure" ("id_ancestor") `); + await queryRunner.query(`CREATE INDEX "IDX_b1a2a7ed45c29179b5ad51548a" ON "tags_closure" ("id_descendant") `); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "renameTagId"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "type"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "name"`); + await queryRunner.query(`ALTER TABLE "tags" ADD "value" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451" UNIQUE ("value")`); + await queryRunner.query(`ALTER TABLE "tags" ADD "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`); + await queryRunner.query(`ALTER TABLE "tags" ADD "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`); + await queryRunner.query(`ALTER TABLE "tags" ADD "color" character varying`); + await queryRunner.query(`ALTER TABLE "tags" ADD "parentId" uuid`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_9f9590cc11561f1f48ff034ef99" FOREIGN KEY ("parentId") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "tags_closure" ADD CONSTRAINT "FK_15fbcbc67663c6bfc07b354c22c" FOREIGN KEY ("id_ancestor") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tags_closure" ADD CONSTRAINT "FK_b1a2a7ed45c29179b5ad51548a1" FOREIGN KEY ("id_descendant") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42"`); + await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9"`); + await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42" FOREIGN KEY ("tagsId") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42"`); + await queryRunner.query(`ALTER TABLE "tag_asset" DROP CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9"`); + await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_f8e8a9e893cb5c54907f1b798e9" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "tag_asset" ADD CONSTRAINT "FK_e99f31ea4cdf3a2c35c7287eb42" FOREIGN KEY ("tagsId") REFERENCES "tags"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "tags_closure" DROP CONSTRAINT "FK_b1a2a7ed45c29179b5ad51548a1"`); + await queryRunner.query(`ALTER TABLE "tags_closure" DROP CONSTRAINT "FK_15fbcbc67663c6bfc07b354c22c"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_92e67dc508c705dd66c94615576"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "FK_9f9590cc11561f1f48ff034ef99"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "parentId"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "color"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "updatedAt"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "createdAt"`); + await queryRunner.query(`ALTER TABLE "tags" DROP CONSTRAINT "UQ_d090e09fe86ebe2ec0aec27b451"`); + await queryRunner.query(`ALTER TABLE "tags" DROP COLUMN "value"`); + await queryRunner.query(`ALTER TABLE "tags" ADD "name" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "tags" ADD "type" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "tags" ADD "renameTagId" uuid`); + await queryRunner.query(`DROP INDEX "public"."IDX_b1a2a7ed45c29179b5ad51548a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_15fbcbc67663c6bfc07b354c22"`); + await queryRunner.query(`DROP TABLE "tags_closure"`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "UQ_tag_name_userId" UNIQUE ("name", "userId")`); + await queryRunner.query(`ALTER TABLE "tags" ADD CONSTRAINT "FK_92e67dc508c705dd66c94615576" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + +} diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 48a93f546b..ad57eac0ad 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -259,6 +259,17 @@ WHERE AND ("StackEntity"."ownerId" = $2) ) +-- AccessRepository.tag.checkOwnerAccess +SELECT + "TagEntity"."id" AS "TagEntity_id" +FROM + "tags" "TagEntity" +WHERE + ( + ("TagEntity"."id" IN ($1)) + AND ("TagEntity"."userId" = $2) + ) + -- AccessRepository.timeline.checkPartnerAccess SELECT "partner"."sharedById" AS "partner_sharedById", diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index b08130b183..ba52f7d148 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -184,10 +184,12 @@ SELECT "AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags", "AssetEntity__AssetEntity_smartInfo"."objects" AS "AssetEntity__AssetEntity_smartInfo_objects", "AssetEntity__AssetEntity_tags"."id" AS "AssetEntity__AssetEntity_tags_id", - "AssetEntity__AssetEntity_tags"."type" AS "AssetEntity__AssetEntity_tags_type", - "AssetEntity__AssetEntity_tags"."name" AS "AssetEntity__AssetEntity_tags_name", + "AssetEntity__AssetEntity_tags"."value" AS "AssetEntity__AssetEntity_tags_value", + "AssetEntity__AssetEntity_tags"."createdAt" AS "AssetEntity__AssetEntity_tags_createdAt", + "AssetEntity__AssetEntity_tags"."updatedAt" AS "AssetEntity__AssetEntity_tags_updatedAt", + "AssetEntity__AssetEntity_tags"."color" AS "AssetEntity__AssetEntity_tags_color", "AssetEntity__AssetEntity_tags"."userId" AS "AssetEntity__AssetEntity_tags_userId", - "AssetEntity__AssetEntity_tags"."renameTagId" AS "AssetEntity__AssetEntity_tags_renameTagId", + "AssetEntity__AssetEntity_tags"."parentId" AS "AssetEntity__AssetEntity_tags_parentId", "AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id", "AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId", "AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId", diff --git a/server/src/queries/tag.repository.sql b/server/src/queries/tag.repository.sql new file mode 100644 index 0000000000..ba1aac82b3 --- /dev/null +++ b/server/src/queries/tag.repository.sql @@ -0,0 +1,30 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- TagRepository.getAssetIds +SELECT + "tag_asset"."assetsId" AS "assetId" +FROM + "tag_asset" "tag_asset" +WHERE + "tag_asset"."tagsId" = $1 + AND "tag_asset"."assetsId" IN ($2) + +-- TagRepository.addAssetIds +INSERT INTO + "tag_asset" ("assetsId", "tagsId") +VALUES + ($1, $2) + +-- TagRepository.removeAssetIds +DELETE FROM "tag_asset" +WHERE + ( + "tagsId" = $1 + AND "assetsId" IN ($2) + ) + +-- TagRepository.upsertAssetIds +INSERT INTO + "tag_asset" ("assetsId", "tagsId") +VALUES + ($1, $2) diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 6dd6d47a46..f6921ffe27 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -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) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, tagIds: Set): Promise> { + 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, @InjectRepository(SessionEntity) sessionRepository: Repository, @InjectRepository(StackEntity) stackRepository: Repository, + @InjectRepository(TagEntity) tagRepository: Repository, ) { 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); } } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 1a2a0474a1..dd526dd664 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -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) { diff --git a/server/src/repositories/tag.repository.ts b/server/src/repositories/tag.repository.ts index 788b976357..7699d5897a 100644 --- a/server/src/repositories/tag.repository.ts +++ b/server/src/repositories/tag.repository.ts @@ -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, + @InjectDataSource() private dataSource: DataSource, @InjectRepository(TagEntity) private repository: Repository, ) {} - getById(userId: string, id: string): Promise { - return this.repository.findOne({ - where: { - id, - userId, - }, - relations: { - user: true, - }, - }); + get(id: string): Promise { + return this.repository.findOne({ where: { id } }); } - getAll(userId: string): Promise { - return this.repository.find({ where: { userId } }); + getByValue(userId: string, value: string): Promise { + return this.repository.findOne({ where: { userId, value } }); + } + + async getAll(userId: string): Promise { + const tags = await this.repository.find({ + where: { userId }, + order: { + value: 'ASC', + }, + }); + + return tags; } create(tag: Partial): Promise { @@ -38,89 +41,99 @@ export class TagRepository implements ITagRepository { return this.save(tag); } - async remove(tag: TagEntity): Promise { - await this.repository.remove(tag); + async delete(id: string): Promise { + await this.repository.delete(id); } - async getAssets(userId: string, tagId: string): Promise { - 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 { - 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> { + 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 { - 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 { + 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 { - 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 { + 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 { + 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 { - return this.repository.exists({ - where: { - name, - userId, - }, - }); - } - - private async save(tag: Partial): Promise { - const { id } = await this.repository.save(tag); - return this.repository.findOneOrFail({ where: { id }, relations: { user: true } }); + private async save(partial: Partial): Promise { + const { id } = await this.repository.save(partial); + return this.repository.findOneOrFail({ where: { id } }); } } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 6585b8c2ee..cb89de184a 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -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; let userMock: Mocked; let loggerMock: Mocked; + let tagMock: Mocked; 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); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 3c938a4e59..875414d84d 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -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 = [ @@ -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 { - 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( - { + const exif = _.omitBy( + { 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 { diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index 4323c061e1..ffa7895cb4 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -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; let tagMock: Mocked; 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']); }); }); }); diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts index c04f9b14c4..97b0ef1be6 100644 --- a/server/src/services/tag.service.ts +++ b/server/src/services/tag.service.ts @@ -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 { - const tag = await this.findOrFail(auth, id); + async get(auth: AuthDto, id: string): Promise { + 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 { - await this.findOrFail(auth, id); - const tag = await this.repository.update({ id, name: dto.name }); + async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise { + 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 { - 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 { - 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 { + 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 { - 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 { + 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 { - await this.findOrFail(auth, id); + async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise { + 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'); } diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index 052565fca9..bc08505b94 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -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; diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 45badeec73..d3219a1a6c 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -41,7 +41,10 @@ export const requireAccess = async (access: IAccessRepository, request: AccessRe } }; -export const checkAccess = async (access: IAccessRepository, { ids, auth, permission }: AccessRequest) => { +export const checkAccess = async ( + access: IAccessRepository, + { ids, auth, permission }: AccessRequest, +): Promise> => { const idSet = Array.isArray(ids) ? new Set(ids) : ids; if (idSet.size === 0) { return new Set(); @@ -52,7 +55,10 @@ export const checkAccess = async (access: IAccessRepository, { ids, auth, permis : checkOtherAccess(access, { auth, permission, ids: idSet }); }; -const checkSharedLinkAccess = async (access: IAccessRepository, request: SharedLinkAccessRequest) => { +const checkSharedLinkAccess = async ( + access: IAccessRepository, + request: SharedLinkAccessRequest, +): Promise> => { const { sharedLink, permission, ids } = request; const sharedLinkId = sharedLink.id; @@ -96,7 +102,7 @@ const checkSharedLinkAccess = async (access: IAccessRepository, request: SharedL } }; -const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessRequest) => { +const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessRequest): Promise> => { const { auth, permission, ids } = request; switch (permission) { @@ -211,6 +217,13 @@ const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessR return await access.authDevice.checkOwnerAccess(auth.user.id, ids); } + case Permission.TAG_ASSET: + case Permission.TAG_READ: + case Permission.TAG_UPDATE: + case Permission.TAG_DELETE: { + return await access.tag.checkOwnerAccess(auth.user.id, ids); + } + case Permission.TIMELINE_READ: { const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); const isPartner = await access.timeline.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); diff --git a/server/src/utils/request.ts b/server/src/utils/request.ts index f6edb2f8b3..19d3cac661 100644 --- a/server/src/utils/request.ts +++ b/server/src/utils/request.ts @@ -2,4 +2,4 @@ export const fromChecksum = (checksum: string): Buffer => { return Buffer.from(checksum, checksum.length === 28 ? 'base64' : 'hex'); }; -export const fromMaybeArray = (param: string | string[] | undefined) => (Array.isArray(param) ? param[0] : param); +export const fromMaybeArray = (param: T | T[]) => (Array.isArray(param) ? param[0] : param); diff --git a/server/src/utils/tag.ts b/server/src/utils/tag.ts new file mode 100644 index 0000000000..12c46d2440 --- /dev/null +++ b/server/src/utils/tag.ts @@ -0,0 +1,30 @@ +import { TagEntity } from 'src/entities/tag.entity'; +import { ITagRepository } from 'src/interfaces/tag.interface'; + +type UpsertRequest = { userId: string; tags: string[] }; +export const upsertTags = async (repository: ITagRepository, { userId, tags }: UpsertRequest) => { + tags = [...new Set(tags)]; + + const results: TagEntity[] = []; + + for (const tag of tags) { + const parts = tag.split('/'); + let parent: TagEntity | undefined; + + for (const part of parts) { + const value = parent ? `${parent.value}/${part}` : part; + let tag = await repository.getByValue(userId, value); + if (!tag) { + tag = await repository.create({ userId, value, parent }); + } + + parent = tag; + } + + if (parent) { + results.push(parent); + } + } + + return results; +}; diff --git a/server/test/fixtures/tag.stub.ts b/server/test/fixtures/tag.stub.ts index 537c65db47..b245bfe9e5 100644 --- a/server/test/fixtures/tag.stub.ts +++ b/server/test/fixtures/tag.stub.ts @@ -1,24 +1,65 @@ import { TagResponseDto } from 'src/dtos/tag.dto'; -import { TagEntity, TagType } from 'src/entities/tag.entity'; +import { TagEntity } from 'src/entities/tag.entity'; import { userStub } from 'test/fixtures/user.stub'; +const parent = Object.freeze({ + id: 'tag-parent', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + value: 'Parent', + color: null, + userId: userStub.admin.id, + user: userStub.admin, +}); + +const child = Object.freeze({ + id: 'tag-child', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + value: 'Parent/Child', + color: null, + parent, + userId: userStub.admin.id, + user: userStub.admin, +}); + export const tagStub = { tag1: Object.freeze({ id: 'tag-1', - name: 'Tag1', - type: TagType.CUSTOM, + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + value: 'Tag1', + color: null, + userId: userStub.admin.id, + user: userStub.admin, + }), + parent, + child, + color1: Object.freeze({ + id: 'tag-1', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + value: 'Tag1', + color: '#000000', userId: userStub.admin.id, user: userStub.admin, - renameTagId: null, - assets: [], }), }; export const tagResponseStub = { tag1: Object.freeze({ id: 'tag-1', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), name: 'Tag1', - type: 'CUSTOM', - userId: 'admin_id', + value: 'Tag1', + }), + color1: Object.freeze({ + id: 'tag-1', + createdAt: new Date('2021-01-01T00:00:00Z'), + updatedAt: new Date('2021-01-01T00:00:00Z'), + color: '#000000', + name: 'Tag1', + value: 'Tag1', }), }; diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index c9db8cd76a..9e9bf5406b 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -11,6 +11,7 @@ export interface IAccessRepositoryMock { partner: Mocked; stack: Mocked; timeline: Mocked; + tag: Mocked; } export const newAccessRepositoryMock = (): IAccessRepositoryMock => { @@ -58,5 +59,9 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { timeline: { checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()), }, + + tag: { + checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + }, }; }; diff --git a/server/test/repositories/tag.repository.mock.ts b/server/test/repositories/tag.repository.mock.ts index a5123e0f36..35b3de1576 100644 --- a/server/test/repositories/tag.repository.mock.ts +++ b/server/test/repositories/tag.repository.mock.ts @@ -4,14 +4,17 @@ import { Mocked, vitest } from 'vitest'; export const newTagRepositoryMock = (): Mocked => { return { getAll: vitest.fn(), - getById: vitest.fn(), + getByValue: vitest.fn(), + upsertAssetTags: vitest.fn(), + + get: vitest.fn(), create: vitest.fn(), update: vitest.fn(), - remove: vitest.fn(), - hasAsset: vitest.fn(), - hasName: vitest.fn(), - getAssets: vitest.fn(), - addAssets: vitest.fn(), - removeAssets: vitest.fn(), + delete: vitest.fn(), + + getAssetIds: vitest.fn(), + addAssetIds: vitest.fn(), + removeAssetIds: vitest.fn(), + upsertAssetIds: vitest.fn(), }; }; diff --git a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte new file mode 100644 index 0000000000..434682f73e --- /dev/null +++ b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte @@ -0,0 +1,80 @@ + + +{#if isOwner && !isSharedLink()} +
+
+

{$t('tags').toUpperCase()}

+
+
+ {#each tags as tag (tag.id)} +
+ +

+ {tag.value} +

+
+ + +
+ {/each} + +
+
+{/if} + +{#if isOpen} + handleTag(tagsIds)} onCancel={handleCancel} /> +{/if} diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 88417f248f..0a105430cc 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -43,6 +43,7 @@ import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte'; import { t } from 'svelte-i18n'; import { goto } from '$app/navigation'; + import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte'; export let asset: AssetResponseDto; export let albums: AlbumResponseDto[] = []; @@ -157,7 +158,7 @@ {#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0} -
+

{$t('people').toUpperCase()}

@@ -472,11 +473,11 @@ {/if} {#if albums.length > 0} -
+

{$t('appears_in').toUpperCase()}

{#each albums as album} -
+
{album.albumName} {/if} +
+ +
+ {#if showEditFaces} (intersecting = false)); + assetStore.taskManager.separatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false)); } else { intersecting = false; } diff --git a/web/src/lib/components/forms/tag-asset-form.svelte b/web/src/lib/components/forms/tag-asset-form.svelte new file mode 100644 index 0000000000..306d25d3f1 --- /dev/null +++ b/web/src/lib/components/forms/tag-asset-form.svelte @@ -0,0 +1,82 @@ + + + +
+
+ handleSelect(option)} + label={$t('tag')} + options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))} + placeholder={$t('search_tags')} + /> +
+
+ +
+ {#each selectedIds as tagId (tagId)} + {@const tag = tagMap[tagId]} + {#if tag} +
+ +

+ {tag.value} +

+
+ + +
+ {/if} + {/each} +
+ + + + + +
diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 8222007d57..6511a9deba 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -35,12 +35,16 @@
- {#if title} + {#if title || $$slots.title || $$slots.buttons}
-
{title}
+ + {#if title} +
{title}
+ {/if} +
{#if description}

{description}

{/if} diff --git a/web/src/lib/components/photos-page/actions/tag-action.svelte b/web/src/lib/components/photos-page/actions/tag-action.svelte new file mode 100644 index 0000000000..77e91d7235 --- /dev/null +++ b/web/src/lib/components/photos-page/actions/tag-action.svelte @@ -0,0 +1,47 @@ + + +{#if menuItem} + +{/if} + +{#if !menuItem} + {#if loading} + + {:else} + + {/if} +{/if} + +{#if isOpen} + handleTag(tagIds)} onCancel={handleCancel} /> +{/if} diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index 5ca29967fe..5cbc2e7dca 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -109,7 +109,7 @@ ); }, onSeparate: () => { - $assetStore.taskManager.seperatedDateGroup(componentId, dateGroup, () => + $assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () => assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }), ); }, @@ -186,9 +186,9 @@
onAssetInGrid?.(asset), - top: `-${TITLE_HEIGHT}px`, - bottom: `-${viewport.height - TITLE_HEIGHT - 1}px`, - right: `-${viewport.width - 1}px`, + top: `${-TITLE_HEIGHT}px`, + bottom: `${-(viewport.height - TITLE_HEIGHT - 1)}px`, + right: `${-(viewport.width - 1)}px`, root: assetGridElement, }} data-asset-id={asset.id} diff --git a/web/src/lib/components/photos-page/asset-grid.svelte b/web/src/lib/components/photos-page/asset-grid.svelte index db030ed14c..40dc79c4f2 100644 --- a/web/src/lib/components/photos-page/asset-grid.svelte +++ b/web/src/lib/components/photos-page/asset-grid.svelte @@ -498,21 +498,21 @@ } }; - function intersectedHandler(bucket: AssetBucket) { + function handleIntersect(bucket: AssetBucket) { updateLastIntersectedBucketDate(); - const intersectedTask = () => { + const task = () => { $assetStore.updateBucket(bucket.bucketDate, { intersecting: true }); void $assetStore.loadBucket(bucket.bucketDate); }; - $assetStore.taskManager.intersectedBucket(componentId, bucket, intersectedTask); + $assetStore.taskManager.intersectedBucket(componentId, bucket, task); } - function seperatedHandler(bucket: AssetBucket) { - const seperatedTask = () => { + function handleSeparate(bucket: AssetBucket) { + const task = () => { $assetStore.updateBucket(bucket.bucketDate, { intersecting: false }); bucket.cancel(); }; - $assetStore.taskManager.seperatedBucket(componentId, bucket, seperatedTask); + $assetStore.taskManager.separatedBucket(componentId, bucket, task); } const handlePrevious = async () => { @@ -809,8 +809,8 @@
intersectedHandler(bucket), - onSeparate: () => seperatedHandler(bucket), + onIntersect: () => handleIntersect(bucket), + onSeparate: () => handleSeparate(bucket), top: BUCKET_INTERSECTION_ROOT_TOP, bottom: BUCKET_INTERSECTION_ROOT_BOTTOM, root: element, diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 64ec16fda6..d3e022a759 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -1,5 +1,6 @@ @@ -13,6 +14,7 @@ import { fly } from 'svelte/transition'; import PasswordField from '../password-field.svelte'; import { t } from 'svelte-i18n'; + import { onMount, tick } from 'svelte'; export let inputType: SettingInputFieldType; export let value: string | number; @@ -25,8 +27,11 @@ export let required = false; export let disabled = false; export let isEdited = false; + export let autofocus = false; export let passwordAutocomplete: string = 'current-password'; + let input: HTMLInputElement; + const handleChange: FormEventHandler = (e) => { value = e.currentTarget.value; @@ -41,6 +46,14 @@ value = newValue; } }; + + onMount(() => { + if (autofocus) { + tick() + .then(() => input?.focus()) + .catch((_) => {}); + } + });
@@ -69,22 +82,46 @@ {/if} {#if inputType !== SettingInputFieldType.PASSWORD} - +
+ {#if inputType === SettingInputFieldType.COLOR} + + {/if} + + +
{:else} {/if}
+ + diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index 1985160b27..dd777d1259 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -21,6 +21,7 @@ mdiToolbox, mdiToolboxOutline, mdiFolderOutline, + mdiTagMultipleOutline, } from '@mdi/js'; import SideBarSection from './side-bar-section.svelte'; import SideBarLink from './side-bar-link.svelte'; @@ -105,6 +106,8 @@ + + import Tree from '$lib/components/shared-components/tree/tree.svelte'; - import type { RecursiveObject } from '$lib/utils/tree-utils'; + import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils'; export let items: RecursiveObject; export let parent = ''; export let active = ''; + export let icons: { default: string; active: string }; export let getLink: (path: string) => string; + export let getColor: (path: string) => string | undefined = () => undefined;
    - {#each Object.entries(items) as [path, tree], index (index)} -
  • - -
  • + {#each Object.entries(items) as [path, tree]} + {@const value = normalizeTreePath(`${parent}/${path}`)} + {@const key = value + getColor(value)} + {#key key} +
  • + +
  • + {/key} {/each}
diff --git a/web/src/lib/components/shared-components/tree/tree.svelte b/web/src/lib/components/shared-components/tree/tree.svelte index 7975825c5e..99928f5bbd 100644 --- a/web/src/lib/components/shared-components/tree/tree.svelte +++ b/web/src/lib/components/shared-components/tree/tree.svelte @@ -2,18 +2,21 @@ import Icon from '$lib/components/elements/icon.svelte'; import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte'; import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils'; - import { mdiChevronDown, mdiChevronRight, mdiFolder, mdiFolderOutline } from '@mdi/js'; + import { mdiChevronDown, mdiChevronRight } from '@mdi/js'; export let tree: RecursiveObject; export let parent: string; export let value: string; export let active = ''; + export let icons: { default: string; active: string }; export let getLink: (path: string) => string; + export let getColor: (path: string) => string | undefined; $: path = normalizeTreePath(`${parent}/${value}`); $: isActive = active.startsWith(path); $: isOpen = isActive; $: isTarget = active === path; + $: color = getColor(path); -
@@ -35,5 +43,5 @@
{#if isOpen} - + {/if} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 34d6409848..ce5cefd815 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -47,6 +47,7 @@ export enum AppRoute { DUPLICATES = '/utilities/duplicates', FOLDERS = '/folders', + TAGS = '/tags', } export enum ProjectionType { diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 8b1ec452d7..684cb0e319 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -440,6 +440,7 @@ "close": "Close", "collapse": "Collapse", "collapse_all": "Collapse all", + "color": "Color", "color_theme": "Color theme", "comment_deleted": "Comment deleted", "comment_options": "Comment options", @@ -473,6 +474,8 @@ "create_new_person": "Create new person", "create_new_person_hint": "Assign selected assets to a new person", "create_new_user": "Create new user", + "create_tag": "Create tag", + "create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.", "create_user": "Create user", "created": "Created", "current_device": "Current device", @@ -496,6 +499,8 @@ "delete_library": "Delete library", "delete_link": "Delete link", "delete_shared_link": "Delete shared link", + "delete_tag": "Delete tag", + "delete_tag_confirmation_prompt": "Are you sure you want to delete {tagName} tag?", "delete_user": "Delete user", "deleted_shared_link": "Deleted shared link", "description": "Description", @@ -537,6 +542,7 @@ "edit_location": "Edit location", "edit_name": "Edit name", "edit_people": "Edit people", + "edit_tag": "Edit tag", "edit_title": "Edit Title", "edit_user": "Edit user", "edited": "Edited", @@ -1007,6 +1013,7 @@ "removed_from_archive": "Removed from archive", "removed_from_favorites": "Removed from favorites", "removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites", + "removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}", "rename": "Rename", "repair": "Repair", "repair_no_results_message": "Untracked and missing files will show up here", @@ -1055,6 +1062,7 @@ "search_people": "Search people", "search_places": "Search places", "search_state": "Search state...", + "search_tags": "Search tags...", "search_timezone": "Search timezone...", "search_type": "Search type", "search_your_photos": "Search your photos", @@ -1158,6 +1166,12 @@ "sunrise_on_the_beach": "Sunrise on the beach", "swap_merge_direction": "Swap merge direction", "sync": "Sync", + "tag": "Tag", + "tag_assets": "Tag assets", + "tag_created": "Created tag: {tag}", + "tag_updated": "Updated tag: {tag}", + "tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}", + "tags": "Tags", "template": "Template", "theme": "Theme", "theme_selection": "Theme selection", @@ -1169,6 +1183,7 @@ "to_change_password": "Change password", "to_favorite": "Favorite", "to_login": "Login", + "to_root": "To root", "to_trash": "Trash", "toggle_settings": "Toggle settings", "toggle_theme": "Toggle dark theme", diff --git a/web/src/lib/utils/asset-store-task-manager.ts b/web/src/lib/utils/asset-store-task-manager.ts index 6ece1327c4..6ca4f057bd 100644 --- a/web/src/lib/utils/asset-store-task-manager.ts +++ b/web/src/lib/utils/asset-store-task-manager.ts @@ -256,9 +256,9 @@ export class AssetGridTaskManager { bucketTask.scheduleIntersected(componentId, task); } - seperatedBucket(componentId: string, bucket: AssetBucket, seperated: Task) { + separatedBucket(componentId: string, bucket: AssetBucket, separated: Task) { const bucketTask = this.getOrCreateBucketTask(bucket); - bucketTask.scheduleSeparated(componentId, seperated); + bucketTask.scheduleSeparated(componentId, separated); } intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) { @@ -266,9 +266,9 @@ export class AssetGridTaskManager { bucketTask.intersectedDateGroup(componentId, dateGroup, intersected); } - seperatedDateGroup(componentId: string, dateGroup: DateGroup, seperated: Task) { + separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) { const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); - bucketTask.separatedDateGroup(componentId, dateGroup, seperated); + bucketTask.separatedDateGroup(componentId, dateGroup, separated); } intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) { @@ -277,16 +277,16 @@ export class AssetGridTaskManager { dateGroupTask.intersectedThumbnail(componentId, asset, intersected); } - seperatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, seperated: Task) { + separatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, separated: Task) { const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket); const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup); - dateGroupTask.separatedThumbnail(componentId, asset, seperated); + dateGroupTask.separatedThumbnail(componentId, asset, separated); } } class IntersectionTask { internalTaskManager: InternalTaskManager; - seperatedKey; + separatedKey; intersectedKey; priority; @@ -295,7 +295,7 @@ class IntersectionTask { constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) { this.internalTaskManager = internalTaskManager; - this.seperatedKey = keyPrefix + ':s:' + key; + this.separatedKey = keyPrefix + ':s:' + key; this.intersectedKey = keyPrefix + ':i:' + key; this.priority = priority; } @@ -325,14 +325,14 @@ class IntersectionTask { this.separated = execTask; const cleanup = () => { this.separated = undefined; - this.internalTaskManager.deleteFromComponentTasks(componentId, this.seperatedKey); + this.internalTaskManager.deleteFromComponentTasks(componentId, this.separatedKey); }; return { task: execTask, cleanup }; } removePendingSeparated() { if (this.separated) { - this.internalTaskManager.removeSeparateTask(this.seperatedKey); + this.internalTaskManager.removeSeparateTask(this.separatedKey); } } removePendingIntersected() { @@ -368,7 +368,7 @@ class IntersectionTask { task, cleanup, componentId: componentId, - taskId: this.seperatedKey, + taskId: this.separatedKey, }); } } @@ -448,9 +448,9 @@ class DateGroupTask extends IntersectionTask { thumbnailTask.scheduleIntersected(componentId, intersected); } - separatedThumbnail(componentId: string, asset: AssetResponseDto, seperated: Task) { + separatedThumbnail(componentId: string, asset: AssetResponseDto, separated: Task) { const thumbnailTask = this.getOrCreateThumbnailTask(asset); - thumbnailTask.scheduleSeparated(componentId, seperated); + thumbnailTask.scheduleSeparated(componentId, separated); } } class ThumbnailTask extends IntersectionTask { diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 576b14b201..ce7944b9c9 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -10,6 +10,7 @@ import { preferences } from '$lib/stores/user.store'; import { downloadRequest, getKey, withError } from '$lib/utils'; import { createAlbum } from '$lib/utils/album-utils'; import { getByteUnitString } from '$lib/utils/byte-units'; +import { getFormatter } from '$lib/utils/i18n'; import { addAssetsToAlbum as addAssets, createStack, @@ -18,6 +19,8 @@ import { getBaseUrl, getDownloadInfo, getStack, + tagAssets as tagAllAssets, + untagAssets, updateAsset, updateAssets, type AlbumResponseDto, @@ -61,6 +64,54 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[], show } }; +export const tagAssets = async ({ + assetIds, + tagIds, + showNotification = true, +}: { + assetIds: string[]; + tagIds: string[]; + showNotification?: boolean; +}) => { + for (const tagId of tagIds) { + await tagAllAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }); + } + + if (showNotification) { + const $t = await getFormatter(); + notificationController.show({ + message: $t('tagged_assets', { values: { count: assetIds.length } }), + type: NotificationType.Info, + }); + } + + return assetIds; +}; + +export const removeTag = async ({ + assetIds, + tagIds, + showNotification = true, +}: { + assetIds: string[]; + tagIds: string[]; + showNotification?: boolean; +}) => { + for (const tagId of tagIds) { + await untagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }); + } + + if (showNotification) { + const $t = await getFormatter(); + notificationController.show({ + message: $t('removed_tagged_assets', { values: { count: assetIds.length } }), + type: NotificationType.Info, + }); + } + + return assetIds; +}; + export const addAssetsToNewAlbum = async (albumName: string, assetIds: string[]) => { const album = await createAlbum(albumName, assetIds); if (!album) { diff --git a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte index b530184342..a8b8602c02 100644 --- a/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/folders/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -13,7 +13,7 @@ import { foldersStore } from '$lib/stores/folders.store'; import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; import { type AssetResponseDto } from '@immich/sdk'; - import { mdiArrowUpLeft, mdiChevronRight, mdiFolder, mdiFolderHome } from '@mdi/js'; + import { mdiArrowUpLeft, mdiChevronRight, mdiFolder, mdiFolderHome, mdiFolderOutline } from '@mdi/js'; import { onMount } from 'svelte'; import { t } from 'svelte-i18n'; import type { PageData } from './$types'; @@ -60,7 +60,12 @@
{$t('explorer').toUpperCase()}
- +
@@ -73,7 +78,7 @@
- + {#each pathSegments as segment, index} diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 9bcbdbeea0..e15c20cbbe 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -25,6 +25,7 @@ import { preferences, user } from '$lib/stores/user.store'; import { t } from 'svelte-i18n'; import { onDestroy } from 'svelte'; + import TagAction from '$lib/components/photos-page/actions/tag-action.svelte'; let { isViewing: showAssetViewer } = assetViewingStore; const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true }); @@ -80,6 +81,7 @@ assetStore.removeAssets(assetIds)} /> + assetStore.removeAssets(assetIds)} />
diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte new file mode 100644 index 0000000000..7335bf83c1 --- /dev/null +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,251 @@ + + + + +
+
{$t('explorer').toUpperCase()}
+
+ +
+
+
+ +
+ +
+ + +
+
+ + +
+ + +
+
+ + {#if pathSegments.length > 0 && tag} + +
+ + +
+
+ {/if} +
+ +
+ + + + {#each pathSegments as segment, index} + +

+ {#if index < pathSegments.length - 1} + + {/if} +

+ {/each} +
+ +
+ {#key $page.url.href} + {#if tag} + + + + {:else} + + {/if} + {/key} +
+
+ +{#if isNewOpen} + +
+

+ {$t('create_tag_description')} +

+
+ +
+
+ +
+
+ + + + +
+{/if} + +{#if isEditOpen} + +
+
+ +
+
+ + + + +
+{/if} diff --git a/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000..23846e57c4 --- /dev/null +++ b/web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,32 @@ +import { QueryParameter } from '$lib/constants'; +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils'; +import { getAllTags } from '@immich/sdk'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params, url }) => { + await authenticate(); + const asset = await getAssetInfoFromParam(params); + const $t = await getFormatter(); + + const path = url.searchParams.get(QueryParameter.PATH); + const tags = await getAllTags(); + const tree = buildTree(tags.map((tag) => tag.value)); + let currentTree = tree; + const parts = normalizeTreePath(path || '').split('/'); + for (const part of parts) { + currentTree = currentTree?.[part]; + } + + return { + tags, + asset, + path, + children: Object.keys(currentTree || {}), + meta: { + title: $t('tags'), + }, + }; +}) satisfies PageLoad;