From d08a20bd5708670f21fc7a65bc29c65f17111446 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 29 Aug 2024 12:14:03 -0400 Subject: [PATCH] feat: tags (#11980) * feat: tags * fix: folder tree icons * navigate to tag from detail panel * delete tag * Tag position and add tag button * Tag asset in detail panel * refactor form * feat: navigate to tag page from clicking on a tag * feat: delete tags from the tag page * refactor: moving tag section in detail panel and add + tag button * feat: tag asset action in detail panel * refactor add tag form * fdisable add tag button when there is no selection * feat: tag bulk endpoint * feat: tag colors * chore: clean up * chore: unit tests * feat: write tags to sidecar * Remove tag and auto focus on tag creation form opened * chore: regenerate migration * chore: linting * add color picker to tag edit form * fix: force render tags timeline on navigating back from asset viewer * feat: read tags from keywords * chore: clean up --------- Co-authored-by: Alex Tran --- e2e/src/api/specs/tag.e2e-spec.ts | 559 ++++++++++++++++++ e2e/src/utils.ts | 1 + mobile/openapi/README.md | 13 +- mobile/openapi/lib/api.dart | 8 +- mobile/openapi/lib/api/tags_api.dart | 208 ++++--- mobile/openapi/lib/api/timeline_api.dart | 26 +- mobile/openapi/lib/api_client.dart | 16 +- mobile/openapi/lib/api_helper.dart | 3 - mobile/openapi/lib/model/permission.dart | 3 + .../lib/model/tag_bulk_assets_dto.dart | 110 ++++ .../model/tag_bulk_assets_response_dto.dart | 98 +++ ...pdate_tag_dto.dart => tag_create_dto.dart} | 71 ++- .../openapi/lib/model/tag_response_dto.dart | 55 +- mobile/openapi/lib/model/tag_type_enum.dart | 88 --- ...reate_tag_dto.dart => tag_update_dto.dart} | 61 +- mobile/openapi/lib/model/tag_upsert_dto.dart | 100 ++++ open-api/immich-openapi-specs.json | 285 ++++++--- open-api/typescript-sdk/src/fetch-client.ts | 103 ++-- server/src/controllers/tag.controller.ts | 54 +- server/src/dtos/asset-response.dto.ts | 2 +- server/src/dtos/tag.dto.ts | 62 +- server/src/dtos/time-bucket.dto.ts | 3 + server/src/entities/tag.entity.ts | 63 +- server/src/enum.ts | 1 + server/src/interfaces/access.interface.ts | 4 + server/src/interfaces/asset.interface.ts | 1 + server/src/interfaces/event.interface.ts | 4 + server/src/interfaces/job.interface.ts | 1 + server/src/interfaces/tag.interface.ts | 22 +- .../1724790460210-NestedTagTable.ts | 57 ++ server/src/queries/access.repository.sql | 11 + server/src/queries/asset.repository.sql | 8 +- server/src/queries/tag.repository.sql | 30 + server/src/repositories/access.repository.ts | 27 + server/src/repositories/asset.repository.ts | 9 + server/src/repositories/tag.repository.ts | 193 +++--- server/src/services/metadata.service.spec.ts | 72 +++ server/src/services/metadata.service.ts | 113 ++-- server/src/services/tag.service.spec.ts | 233 +++++--- server/src/services/tag.service.ts | 159 +++-- server/src/services/timeline.service.ts | 4 + server/src/utils/access.ts | 19 +- server/src/utils/request.ts | 2 +- server/src/utils/tag.ts | 30 + server/test/fixtures/tag.stub.ts | 55 +- .../repositories/access.repository.mock.ts | 5 + .../test/repositories/tag.repository.mock.ts | 17 +- .../asset-viewer/detail-panel-tags.svelte | 80 +++ .../asset-viewer/detail-panel.svelte | 11 +- .../assets/thumbnail/thumbnail.svelte | 2 +- .../components/forms/tag-asset-form.svelte | 82 +++ .../layouts/user-page-layout.svelte | 8 +- .../photos-page/actions/tag-action.svelte | 47 ++ .../photos-page/asset-date-group.svelte | 8 +- .../components/photos-page/asset-grid.svelte | 16 +- .../shared-components/combobox.svelte | 5 +- .../settings/setting-input-field.svelte | 94 ++- .../side-bar/side-bar.svelte | 3 + .../shared-components/tree/tree-items.svelte | 16 +- .../shared-components/tree/tree.svelte | 16 +- web/src/lib/constants.ts | 1 + web/src/lib/i18n/en.json | 15 + web/src/lib/utils/asset-store-task-manager.ts | 26 +- web/src/lib/utils/asset-utils.ts | 51 ++ .../[[assetId=id]]/+page.svelte | 11 +- .../(user)/photos/[[assetId=id]]/+page.svelte | 2 + .../[[assetId=id]]/+page.svelte | 251 ++++++++ .../[[photos=photos]]/[[assetId=id]]/+page.ts | 32 + 68 files changed, 3032 insertions(+), 814 deletions(-) create mode 100644 e2e/src/api/specs/tag.e2e-spec.ts create mode 100644 mobile/openapi/lib/model/tag_bulk_assets_dto.dart create mode 100644 mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart rename mobile/openapi/lib/model/{update_tag_dto.dart => tag_create_dto.dart} (56%) delete mode 100644 mobile/openapi/lib/model/tag_type_enum.dart rename mobile/openapi/lib/model/{create_tag_dto.dart => tag_update_dto.dart} (57%) create mode 100644 mobile/openapi/lib/model/tag_upsert_dto.dart create mode 100644 server/src/migrations/1724790460210-NestedTagTable.ts create mode 100644 server/src/queries/tag.repository.sql create mode 100644 server/src/utils/tag.ts create mode 100644 web/src/lib/components/asset-viewer/detail-panel-tags.svelte create mode 100644 web/src/lib/components/forms/tag-asset-form.svelte create mode 100644 web/src/lib/components/photos-page/actions/tag-action.svelte create mode 100644 web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.svelte create mode 100644 web/src/routes/(user)/tags/[[photos=photos]]/[[assetId=id]]/+page.ts 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 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -210,14 +210,15 @@ Class | Method | HTTP request | Description *SystemMetadataApi* | [**getAdminOnboarding**](doc//SystemMetadataApi.md#getadminonboarding) | **GET** /system-metadata/admin-onboarding | *SystemMetadataApi* | [**getReverseGeocodingState**](doc//SystemMetadataApi.md#getreversegeocodingstate) | **GET** /system-metadata/reverse-geocoding-state | *SystemMetadataApi* | [**updateAdminOnboarding**](doc//SystemMetadataApi.md#updateadminonboarding) | **POST** /system-metadata/admin-onboarding | +*TagsApi* | [**bulkTagAssets**](doc//TagsApi.md#bulktagassets) | **PUT** /tags/assets | *TagsApi* | [**createTag**](doc//TagsApi.md#createtag) | **POST** /tags | *TagsApi* | [**deleteTag**](doc//TagsApi.md#deletetag) | **DELETE** /tags/{id} | *TagsApi* | [**getAllTags**](doc//TagsApi.md#getalltags) | **GET** /tags | -*TagsApi* | [**getTagAssets**](doc//TagsApi.md#gettagassets) | **GET** /tags/{id}/assets | *TagsApi* | [**getTagById**](doc//TagsApi.md#gettagbyid) | **GET** /tags/{id} | *TagsApi* | [**tagAssets**](doc//TagsApi.md#tagassets) | **PUT** /tags/{id}/assets | *TagsApi* | [**untagAssets**](doc//TagsApi.md#untagassets) | **DELETE** /tags/{id}/assets | -*TagsApi* | [**updateTag**](doc//TagsApi.md#updatetag) | **PATCH** /tags/{id} | +*TagsApi* | [**updateTag**](doc//TagsApi.md#updatetag) | **PUT** /tags/{id} | +*TagsApi* | [**upsertTags**](doc//TagsApi.md#upserttags) | **PUT** /tags | *TimelineApi* | [**getTimeBucket**](doc//TimelineApi.md#gettimebucket) | **GET** /timeline/bucket | *TimelineApi* | [**getTimeBuckets**](doc//TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets | *TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty | @@ -305,7 +306,6 @@ Class | Method | HTTP request | Description - [CreateAlbumDto](doc//CreateAlbumDto.md) - [CreateLibraryDto](doc//CreateLibraryDto.md) - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md) - - [CreateTagDto](doc//CreateTagDto.md) - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) - [DownloadInfoDto](doc//DownloadInfoDto.md) - [DownloadResponse](doc//DownloadResponse.md) @@ -429,8 +429,12 @@ Class | Method | HTTP request | Description - [SystemConfigThemeDto](doc//SystemConfigThemeDto.md) - [SystemConfigTrashDto](doc//SystemConfigTrashDto.md) - [SystemConfigUserDto](doc//SystemConfigUserDto.md) + - [TagBulkAssetsDto](doc//TagBulkAssetsDto.md) + - [TagBulkAssetsResponseDto](doc//TagBulkAssetsResponseDto.md) + - [TagCreateDto](doc//TagCreateDto.md) - [TagResponseDto](doc//TagResponseDto.md) - - [TagTypeEnum](doc//TagTypeEnum.md) + - [TagUpdateDto](doc//TagUpdateDto.md) + - [TagUpsertDto](doc//TagUpsertDto.md) - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - [TimeBucketSize](doc//TimeBucketSize.md) - [ToneMapping](doc//ToneMapping.md) @@ -441,7 +445,6 @@ Class | Method | HTTP request | Description - [UpdateAssetDto](doc//UpdateAssetDto.md) - [UpdateLibraryDto](doc//UpdateLibraryDto.md) - [UpdatePartnerDto](doc//UpdatePartnerDto.md) - - [UpdateTagDto](doc//UpdateTagDto.md) - [UsageByUserDto](doc//UsageByUserDto.md) - [UserAdminCreateDto](doc//UserAdminCreateDto.md) - [UserAdminDeleteDto](doc//UserAdminDeleteDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 05a43c8af7..532d7e22cd 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -120,7 +120,6 @@ part 'model/colorspace.dart'; part 'model/create_album_dto.dart'; part 'model/create_library_dto.dart'; part 'model/create_profile_image_response_dto.dart'; -part 'model/create_tag_dto.dart'; part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; part 'model/download_response.dart'; @@ -244,8 +243,12 @@ part 'model/system_config_template_storage_option_dto.dart'; part 'model/system_config_theme_dto.dart'; part 'model/system_config_trash_dto.dart'; part 'model/system_config_user_dto.dart'; +part 'model/tag_bulk_assets_dto.dart'; +part 'model/tag_bulk_assets_response_dto.dart'; +part 'model/tag_create_dto.dart'; part 'model/tag_response_dto.dart'; -part 'model/tag_type_enum.dart'; +part 'model/tag_update_dto.dart'; +part 'model/tag_upsert_dto.dart'; part 'model/time_bucket_response_dto.dart'; part 'model/time_bucket_size.dart'; part 'model/tone_mapping.dart'; @@ -256,7 +259,6 @@ part 'model/update_album_user_dto.dart'; part 'model/update_asset_dto.dart'; part 'model/update_library_dto.dart'; part 'model/update_partner_dto.dart'; -part 'model/update_tag_dto.dart'; part 'model/usage_by_user_dto.dart'; part 'model/user_admin_create_dto.dart'; part 'model/user_admin_delete_dto.dart'; diff --git a/mobile/openapi/lib/api/tags_api.dart b/mobile/openapi/lib/api/tags_api.dart index e5d1e9c650..87c9001a3c 100644 --- a/mobile/openapi/lib/api/tags_api.dart +++ b/mobile/openapi/lib/api/tags_api.dart @@ -16,16 +16,63 @@ class TagsApi { final ApiClient apiClient; + /// Performs an HTTP 'PUT /tags/assets' operation and returns the [Response]. + /// Parameters: + /// + /// * [TagBulkAssetsDto] tagBulkAssetsDto (required): + Future bulkTagAssetsWithHttpInfo(TagBulkAssetsDto tagBulkAssetsDto,) async { + // ignore: prefer_const_declarations + final path = r'/tags/assets'; + + // ignore: prefer_final_locals + Object? postBody = tagBulkAssetsDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [TagBulkAssetsDto] tagBulkAssetsDto (required): + Future bulkTagAssets(TagBulkAssetsDto tagBulkAssetsDto,) async { + final response = await bulkTagAssetsWithHttpInfo(tagBulkAssetsDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TagBulkAssetsResponseDto',) as TagBulkAssetsResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /tags' operation and returns the [Response]. /// Parameters: /// - /// * [CreateTagDto] createTagDto (required): - Future createTagWithHttpInfo(CreateTagDto createTagDto,) async { + /// * [TagCreateDto] tagCreateDto (required): + Future createTagWithHttpInfo(TagCreateDto tagCreateDto,) async { // ignore: prefer_const_declarations final path = r'/tags'; // ignore: prefer_final_locals - Object? postBody = createTagDto; + Object? postBody = tagCreateDto; final queryParams = []; final headerParams = {}; @@ -47,9 +94,9 @@ class TagsApi { /// Parameters: /// - /// * [CreateTagDto] createTagDto (required): - Future createTag(CreateTagDto createTagDto,) async { - final response = await createTagWithHttpInfo(createTagDto,); + /// * [TagCreateDto] tagCreateDto (required): + Future createTag(TagCreateDto tagCreateDto,) async { + final response = await createTagWithHttpInfo(tagCreateDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -147,57 +194,6 @@ class TagsApi { return null; } - /// Performs an HTTP 'GET /tags/{id}/assets' operation and returns the [Response]. - /// Parameters: - /// - /// * [String] id (required): - Future getTagAssetsWithHttpInfo(String id,) async { - // ignore: prefer_const_declarations - final path = r'/tags/{id}/assets' - .replaceAll('{id}', id); - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [String] id (required): - Future?> getTagAssets(String id,) async { - final response = await getTagAssetsWithHttpInfo(id,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() - .toList(growable: false); - - } - return null; - } - /// Performs an HTTP 'GET /tags/{id}' operation and returns the [Response]. /// Parameters: /// @@ -251,14 +247,14 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [AssetIdsDto] assetIdsDto (required): - Future tagAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async { + /// * [BulkIdsDto] bulkIdsDto (required): + Future tagAssetsWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations final path = r'/tags/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = assetIdsDto; + Object? postBody = bulkIdsDto; final queryParams = []; final headerParams = {}; @@ -282,9 +278,9 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [AssetIdsDto] assetIdsDto (required): - Future?> tagAssets(String id, AssetIdsDto assetIdsDto,) async { - final response = await tagAssetsWithHttpInfo(id, assetIdsDto,); + /// * [BulkIdsDto] bulkIdsDto (required): + Future?> tagAssets(String id, BulkIdsDto bulkIdsDto,) async { + final response = await tagAssetsWithHttpInfo(id, bulkIdsDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -293,8 +289,8 @@ class TagsApi { // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() .toList(growable: false); } @@ -306,14 +302,14 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [AssetIdsDto] assetIdsDto (required): - Future untagAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async { + /// * [BulkIdsDto] bulkIdsDto (required): + Future untagAssetsWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations final path = r'/tags/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = assetIdsDto; + Object? postBody = bulkIdsDto; final queryParams = []; final headerParams = {}; @@ -337,9 +333,9 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [AssetIdsDto] assetIdsDto (required): - Future?> untagAssets(String id, AssetIdsDto assetIdsDto,) async { - final response = await untagAssetsWithHttpInfo(id, assetIdsDto,); + /// * [BulkIdsDto] bulkIdsDto (required): + Future?> untagAssets(String id, BulkIdsDto bulkIdsDto,) async { + final response = await untagAssetsWithHttpInfo(id, bulkIdsDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -348,27 +344,27 @@ class TagsApi { // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() .toList(growable: false); } return null; } - /// Performs an HTTP 'PATCH /tags/{id}' operation and returns the [Response]. + /// Performs an HTTP 'PUT /tags/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): /// - /// * [UpdateTagDto] updateTagDto (required): - Future updateTagWithHttpInfo(String id, UpdateTagDto updateTagDto,) async { + /// * [TagUpdateDto] tagUpdateDto (required): + Future updateTagWithHttpInfo(String id, TagUpdateDto tagUpdateDto,) async { // ignore: prefer_const_declarations final path = r'/tags/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = updateTagDto; + Object? postBody = tagUpdateDto; final queryParams = []; final headerParams = {}; @@ -379,7 +375,7 @@ class TagsApi { return apiClient.invokeAPI( path, - 'PATCH', + 'PUT', queryParams, postBody, headerParams, @@ -392,9 +388,9 @@ class TagsApi { /// /// * [String] id (required): /// - /// * [UpdateTagDto] updateTagDto (required): - Future updateTag(String id, UpdateTagDto updateTagDto,) async { - final response = await updateTagWithHttpInfo(id, updateTagDto,); + /// * [TagUpdateDto] tagUpdateDto (required): + Future updateTag(String id, TagUpdateDto tagUpdateDto,) async { + final response = await updateTagWithHttpInfo(id, tagUpdateDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -407,4 +403,54 @@ class TagsApi { } return null; } + + /// Performs an HTTP 'PUT /tags' operation and returns the [Response]. + /// Parameters: + /// + /// * [TagUpsertDto] tagUpsertDto (required): + Future upsertTagsWithHttpInfo(TagUpsertDto tagUpsertDto,) async { + // ignore: prefer_const_declarations + final path = r'/tags'; + + // ignore: prefer_final_locals + Object? postBody = tagUpsertDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [TagUpsertDto] tagUpsertDto (required): + Future?> upsertTags(TagUpsertDto tagUpsertDto,) async { + final response = await upsertTagsWithHttpInfo(tagUpsertDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } } diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 4acb98bdf2..8c94e09bf5 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -37,12 +37,14 @@ class TimelineApi { /// /// * [String] personId: /// + /// * [String] tagId: + /// /// * [String] userId: /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final path = r'/timeline/bucket'; @@ -75,6 +77,9 @@ class TimelineApi { queryParams.addAll(_queryParams('', 'personId', personId)); } queryParams.addAll(_queryParams('', 'size', size)); + if (tagId != null) { + queryParams.addAll(_queryParams('', 'tagId', tagId)); + } queryParams.addAll(_queryParams('', 'timeBucket', timeBucket)); if (userId != null) { queryParams.addAll(_queryParams('', 'userId', userId)); @@ -120,13 +125,15 @@ class TimelineApi { /// /// * [String] personId: /// + /// * [String] tagId: + /// /// * [String] userId: /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + Future?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -162,12 +169,14 @@ class TimelineApi { /// /// * [String] personId: /// + /// * [String] tagId: + /// /// * [String] userId: /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final path = r'/timeline/buckets'; @@ -200,6 +209,9 @@ class TimelineApi { queryParams.addAll(_queryParams('', 'personId', personId)); } queryParams.addAll(_queryParams('', 'size', size)); + if (tagId != null) { + queryParams.addAll(_queryParams('', 'tagId', tagId)); + } if (userId != null) { queryParams.addAll(_queryParams('', 'userId', userId)); } @@ -242,13 +254,15 @@ class TimelineApi { /// /// * [String] personId: /// + /// * [String] tagId: + /// /// * [String] userId: /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + Future?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index c9ed2a508d..54873a5955 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -295,8 +295,6 @@ class ApiClient { return CreateLibraryDto.fromJson(value); case 'CreateProfileImageResponseDto': return CreateProfileImageResponseDto.fromJson(value); - case 'CreateTagDto': - return CreateTagDto.fromJson(value); case 'DownloadArchiveInfo': return DownloadArchiveInfo.fromJson(value); case 'DownloadInfoDto': @@ -543,10 +541,18 @@ class ApiClient { return SystemConfigTrashDto.fromJson(value); case 'SystemConfigUserDto': return SystemConfigUserDto.fromJson(value); + case 'TagBulkAssetsDto': + return TagBulkAssetsDto.fromJson(value); + case 'TagBulkAssetsResponseDto': + return TagBulkAssetsResponseDto.fromJson(value); + case 'TagCreateDto': + return TagCreateDto.fromJson(value); case 'TagResponseDto': return TagResponseDto.fromJson(value); - case 'TagTypeEnum': - return TagTypeEnumTypeTransformer().decode(value); + case 'TagUpdateDto': + return TagUpdateDto.fromJson(value); + case 'TagUpsertDto': + return TagUpsertDto.fromJson(value); case 'TimeBucketResponseDto': return TimeBucketResponseDto.fromJson(value); case 'TimeBucketSize': @@ -567,8 +573,6 @@ class ApiClient { return UpdateLibraryDto.fromJson(value); case 'UpdatePartnerDto': return UpdatePartnerDto.fromJson(value); - case 'UpdateTagDto': - return UpdateTagDto.fromJson(value); case 'UsageByUserDto': return UsageByUserDto.fromJson(value); case 'UserAdminCreateDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 7f46e145b1..a486551cc5 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -127,9 +127,6 @@ String parameterToString(dynamic value) { if (value is SharedLinkType) { return SharedLinkTypeTypeTransformer().encode(value).toString(); } - if (value is TagTypeEnum) { - return TagTypeEnumTypeTransformer().encode(value).toString(); - } if (value is TimeBucketSize) { return TimeBucketSizeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 3f89c9826d..1244a434b6 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -96,6 +96,7 @@ class Permission { static const tagPeriodRead = Permission._(r'tag.read'); static const tagPeriodUpdate = Permission._(r'tag.update'); static const tagPeriodDelete = Permission._(r'tag.delete'); + static const tagPeriodAsset = Permission._(r'tag.asset'); static const adminPeriodUserPeriodCreate = Permission._(r'admin.user.create'); static const adminPeriodUserPeriodRead = Permission._(r'admin.user.read'); static const adminPeriodUserPeriodUpdate = Permission._(r'admin.user.update'); @@ -176,6 +177,7 @@ class Permission { tagPeriodRead, tagPeriodUpdate, tagPeriodDelete, + tagPeriodAsset, adminPeriodUserPeriodCreate, adminPeriodUserPeriodRead, adminPeriodUserPeriodUpdate, @@ -291,6 +293,7 @@ class PermissionTypeTransformer { case r'tag.read': return Permission.tagPeriodRead; case r'tag.update': return Permission.tagPeriodUpdate; case r'tag.delete': return Permission.tagPeriodDelete; + case r'tag.asset': return Permission.tagPeriodAsset; case r'admin.user.create': return Permission.adminPeriodUserPeriodCreate; case r'admin.user.read': return Permission.adminPeriodUserPeriodRead; case r'admin.user.update': return Permission.adminPeriodUserPeriodUpdate; 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 --- /dev/null +++ b/mobile/openapi/lib/model/tag_bulk_assets_dto.dart @@ -0,0 +1,110 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TagBulkAssetsDto { + /// Returns a new [TagBulkAssetsDto] instance. + TagBulkAssetsDto({ + this.assetIds = const [], + this.tagIds = const [], + }); + + List assetIds; + + List tagIds; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagBulkAssetsDto && + _deepEquality.equals(other.assetIds, assetIds) && + _deepEquality.equals(other.tagIds, tagIds); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetIds.hashCode) + + (tagIds.hashCode); + + @override + String toString() => 'TagBulkAssetsDto[assetIds=$assetIds, tagIds=$tagIds]'; + + Map toJson() { + final json = {}; + json[r'assetIds'] = this.assetIds; + json[r'tagIds'] = this.tagIds; + return json; + } + + /// Returns a new [TagBulkAssetsDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagBulkAssetsDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return TagBulkAssetsDto( + assetIds: json[r'assetIds'] is Iterable + ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) + : const [], + tagIds: json[r'tagIds'] is Iterable + ? (json[r'tagIds'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TagBulkAssetsDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TagBulkAssetsDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagBulkAssetsDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TagBulkAssetsDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetIds', + 'tagIds', + }; +} + 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 --- /dev/null +++ b/mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TagBulkAssetsResponseDto { + /// Returns a new [TagBulkAssetsResponseDto] instance. + TagBulkAssetsResponseDto({ + required this.count, + }); + + int count; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagBulkAssetsResponseDto && + other.count == count; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (count.hashCode); + + @override + String toString() => 'TagBulkAssetsResponseDto[count=$count]'; + + Map toJson() { + final json = {}; + json[r'count'] = this.count; + return json; + } + + /// Returns a new [TagBulkAssetsResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagBulkAssetsResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return TagBulkAssetsResponseDto( + count: mapValueOfType(json, r'count')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TagBulkAssetsResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TagBulkAssetsResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagBulkAssetsResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TagBulkAssetsResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'count', + }; +} + 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 --- a/mobile/openapi/lib/model/update_tag_dto.dart +++ b/mobile/openapi/lib/model/tag_create_dto.dart @@ -10,10 +10,12 @@ part of openapi.api; -class UpdateTagDto { - /// Returns a new [UpdateTagDto] instance. - UpdateTagDto({ - this.name, +class TagCreateDto { + /// Returns a new [TagCreateDto] instance. + TagCreateDto({ + this.color, + required this.name, + this.parentId, }); /// @@ -22,49 +24,65 @@ class UpdateTagDto { /// source code must fall back to having a nullable type. /// Consider adding a "default:" property in the specification file to hide this note. /// - String? name; + String? color; + + String name; + + String? parentId; @override - bool operator ==(Object other) => identical(this, other) || other is UpdateTagDto && - other.name == name; + bool operator ==(Object other) => identical(this, other) || other is TagCreateDto && + other.color == color && + other.name == name && + other.parentId == parentId; @override int get hashCode => // ignore: unnecessary_parenthesis - (name == null ? 0 : name!.hashCode); + (color == null ? 0 : color!.hashCode) + + (name.hashCode) + + (parentId == null ? 0 : parentId!.hashCode); @override - String toString() => 'UpdateTagDto[name=$name]'; + String toString() => 'TagCreateDto[color=$color, name=$name, parentId=$parentId]'; Map toJson() { final json = {}; - if (this.name != null) { - json[r'name'] = this.name; + if (this.color != null) { + json[r'color'] = this.color; } else { - // json[r'name'] = null; + // json[r'color'] = null; + } + json[r'name'] = this.name; + if (this.parentId != null) { + json[r'parentId'] = this.parentId; + } else { + // json[r'parentId'] = null; } return json; } - /// Returns a new [UpdateTagDto] instance and imports its values from + /// Returns a new [TagCreateDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static UpdateTagDto? fromJson(dynamic value) { + static TagCreateDto? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return UpdateTagDto( - name: mapValueOfType(json, r'name'), + return TagCreateDto( + color: mapValueOfType(json, r'color'), + name: mapValueOfType(json, r'name')!, + parentId: mapValueOfType(json, r'parentId'), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = UpdateTagDto.fromJson(row); + final value = TagCreateDto.fromJson(row); if (value != null) { result.add(value); } @@ -73,12 +91,12 @@ class UpdateTagDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = UpdateTagDto.fromJson(entry.value); + final value = TagCreateDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -87,14 +105,14 @@ class UpdateTagDto { return map; } - // maps a json object with a list of UpdateTagDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of TagCreateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = UpdateTagDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = TagCreateDto.listFromJson(entry.value, growable: growable,); } } return map; @@ -102,6 +120,7 @@ class UpdateTagDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'name', }; } diff --git a/mobile/openapi/lib/model/tag_response_dto.dart b/mobile/openapi/lib/model/tag_response_dto.dart index d371bd1c04..4f0a62a8b9 100644 --- a/mobile/openapi/lib/model/tag_response_dto.dart +++ b/mobile/openapi/lib/model/tag_response_dto.dart @@ -13,44 +13,66 @@ part of openapi.api; class TagResponseDto { /// Returns a new [TagResponseDto] instance. TagResponseDto({ + this.color, + required this.createdAt, required this.id, required this.name, - required this.type, - required this.userId, + required this.updatedAt, + required this.value, }); + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? color; + + DateTime createdAt; + String id; String name; - TagTypeEnum type; + DateTime updatedAt; - String userId; + String value; @override bool operator ==(Object other) => identical(this, other) || other is TagResponseDto && + other.color == color && + other.createdAt == createdAt && other.id == id && other.name == name && - other.type == type && - other.userId == userId; + other.updatedAt == updatedAt && + other.value == value; @override int get hashCode => // ignore: unnecessary_parenthesis + (color == null ? 0 : color!.hashCode) + + (createdAt.hashCode) + (id.hashCode) + (name.hashCode) + - (type.hashCode) + - (userId.hashCode); + (updatedAt.hashCode) + + (value.hashCode); @override - String toString() => 'TagResponseDto[id=$id, name=$name, type=$type, userId=$userId]'; + String toString() => 'TagResponseDto[color=$color, createdAt=$createdAt, id=$id, name=$name, updatedAt=$updatedAt, value=$value]'; Map toJson() { final json = {}; + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); json[r'id'] = this.id; json[r'name'] = this.name; - json[r'type'] = this.type; - json[r'userId'] = this.userId; + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + json[r'value'] = this.value; return json; } @@ -62,10 +84,12 @@ class TagResponseDto { final json = value.cast(); return TagResponseDto( + color: mapValueOfType(json, r'color'), + createdAt: mapDateTime(json, r'createdAt', r'')!, id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name')!, - type: TagTypeEnum.fromJson(json[r'type'])!, - userId: mapValueOfType(json, r'userId')!, + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + value: mapValueOfType(json, r'value')!, ); } return null; @@ -113,10 +137,11 @@ class TagResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'createdAt', 'id', 'name', - 'type', - 'userId', + 'updatedAt', + 'value', }; } 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 --- a/mobile/openapi/lib/model/tag_type_enum.dart +++ /dev/null @@ -1,88 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class TagTypeEnum { - /// Instantiate a new enum with the provided [value]. - const TagTypeEnum._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const OBJECT = TagTypeEnum._(r'OBJECT'); - static const FACE = TagTypeEnum._(r'FACE'); - static const CUSTOM = TagTypeEnum._(r'CUSTOM'); - - /// List of all possible values in this [enum][TagTypeEnum]. - static const values = [ - OBJECT, - FACE, - CUSTOM, - ]; - - static TagTypeEnum? fromJson(dynamic value) => TagTypeEnumTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = TagTypeEnum.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [TagTypeEnum] to String, -/// and [decode] dynamic data back to [TagTypeEnum]. -class TagTypeEnumTypeTransformer { - factory TagTypeEnumTypeTransformer() => _instance ??= const TagTypeEnumTypeTransformer._(); - - const TagTypeEnumTypeTransformer._(); - - String encode(TagTypeEnum data) => data.value; - - /// Decodes a [dynamic value][data] to a TagTypeEnum. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - TagTypeEnum? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'OBJECT': return TagTypeEnum.OBJECT; - case r'FACE': return TagTypeEnum.FACE; - case r'CUSTOM': return TagTypeEnum.CUSTOM; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [TagTypeEnumTypeTransformer] instance. - static TagTypeEnumTypeTransformer? _instance; -} - 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 --- a/mobile/openapi/lib/model/create_tag_dto.dart +++ b/mobile/openapi/lib/model/tag_update_dto.dart @@ -10,58 +10,55 @@ part of openapi.api; -class CreateTagDto { - /// Returns a new [CreateTagDto] instance. - CreateTagDto({ - required this.name, - required this.type, +class TagUpdateDto { + /// Returns a new [TagUpdateDto] instance. + TagUpdateDto({ + this.color, }); - String name; - - TagTypeEnum type; + String? color; @override - bool operator ==(Object other) => identical(this, other) || other is CreateTagDto && - other.name == name && - other.type == type; + bool operator ==(Object other) => identical(this, other) || other is TagUpdateDto && + other.color == color; @override int get hashCode => // ignore: unnecessary_parenthesis - (name.hashCode) + - (type.hashCode); + (color == null ? 0 : color!.hashCode); @override - String toString() => 'CreateTagDto[name=$name, type=$type]'; + String toString() => 'TagUpdateDto[color=$color]'; Map toJson() { final json = {}; - json[r'name'] = this.name; - json[r'type'] = this.type; + if (this.color != null) { + json[r'color'] = this.color; + } else { + // json[r'color'] = null; + } return json; } - /// Returns a new [CreateTagDto] instance and imports its values from + /// Returns a new [TagUpdateDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static CreateTagDto? fromJson(dynamic value) { + static TagUpdateDto? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return CreateTagDto( - name: mapValueOfType(json, r'name')!, - type: TagTypeEnum.fromJson(json[r'type'])!, + return TagUpdateDto( + color: mapValueOfType(json, r'color'), ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = CreateTagDto.fromJson(row); + final value = TagUpdateDto.fromJson(row); if (value != null) { result.add(value); } @@ -70,12 +67,12 @@ class CreateTagDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = CreateTagDto.fromJson(entry.value); + final value = TagUpdateDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -84,14 +81,14 @@ class CreateTagDto { return map; } - // maps a json object with a list of CreateTagDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of TagUpdateDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = CreateTagDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = TagUpdateDto.listFromJson(entry.value, growable: growable,); } } return map; @@ -99,8 +96,6 @@ class CreateTagDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'name', - 'type', }; } 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 --- /dev/null +++ b/mobile/openapi/lib/model/tag_upsert_dto.dart @@ -0,0 +1,100 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class TagUpsertDto { + /// Returns a new [TagUpsertDto] instance. + TagUpsertDto({ + this.tags = const [], + }); + + List tags; + + @override + bool operator ==(Object other) => identical(this, other) || other is TagUpsertDto && + _deepEquality.equals(other.tags, tags); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (tags.hashCode); + + @override + String toString() => 'TagUpsertDto[tags=$tags]'; + + Map toJson() { + final json = {}; + json[r'tags'] = this.tags; + return json; + } + + /// Returns a new [TagUpsertDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TagUpsertDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return TagUpsertDto( + tags: json[r'tags'] is Iterable + ? (json[r'tags'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TagUpsertDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TagUpsertDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TagUpsertDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TagUpsertDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'tags', + }; +} + 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;