mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:27:09 -05:00 
			
		
		
		
	feat: tags (#11980)
* feat: tags * fix: folder tree icons * navigate to tag from detail panel * delete tag * Tag position and add tag button * Tag asset in detail panel * refactor form * feat: navigate to tag page from clicking on a tag * feat: delete tags from the tag page * refactor: moving tag section in detail panel and add + tag button * feat: tag asset action in detail panel * refactor add tag form * fdisable add tag button when there is no selection * feat: tag bulk endpoint * feat: tag colors * chore: clean up * chore: unit tests * feat: write tags to sidecar * Remove tag and auto focus on tag creation form opened * chore: regenerate migration * chore: linting * add color picker to tag edit form * fix: force render tags timeline on navigating back from asset viewer * feat: read tags from keywords * chore: clean up --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		
							parent
							
								
									682adaa334
								
							
						
					
					
						commit
						d08a20bd57
					
				
							
								
								
									
										559
									
								
								e2e/src/api/specs/tag.e2e-spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										559
									
								
								e2e/src/api/specs/tag.e2e-spec.ts
									
									
									
									
									
										Normal file
									
								
							@ -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' }),
 | 
				
			||||||
 | 
					      ]);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
@ -148,6 +148,7 @@ export const utils = {
 | 
				
			|||||||
        'sessions',
 | 
					        'sessions',
 | 
				
			||||||
        'users',
 | 
					        'users',
 | 
				
			||||||
        'system_metadata',
 | 
					        'system_metadata',
 | 
				
			||||||
 | 
					        'tags',
 | 
				
			||||||
      ];
 | 
					      ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const sql: string[] = [];
 | 
					      const sql: string[] = [];
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										13
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										13
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							@ -210,14 +210,15 @@ Class | Method | HTTP request | Description
 | 
				
			|||||||
*SystemMetadataApi* | [**getAdminOnboarding**](doc//SystemMetadataApi.md#getadminonboarding) | **GET** /system-metadata/admin-onboarding | 
 | 
					*SystemMetadataApi* | [**getAdminOnboarding**](doc//SystemMetadataApi.md#getadminonboarding) | **GET** /system-metadata/admin-onboarding | 
 | 
				
			||||||
*SystemMetadataApi* | [**getReverseGeocodingState**](doc//SystemMetadataApi.md#getreversegeocodingstate) | **GET** /system-metadata/reverse-geocoding-state | 
 | 
					*SystemMetadataApi* | [**getReverseGeocodingState**](doc//SystemMetadataApi.md#getreversegeocodingstate) | **GET** /system-metadata/reverse-geocoding-state | 
 | 
				
			||||||
*SystemMetadataApi* | [**updateAdminOnboarding**](doc//SystemMetadataApi.md#updateadminonboarding) | **POST** /system-metadata/admin-onboarding | 
 | 
					*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* | [**createTag**](doc//TagsApi.md#createtag) | **POST** /tags | 
 | 
				
			||||||
*TagsApi* | [**deleteTag**](doc//TagsApi.md#deletetag) | **DELETE** /tags/{id} | 
 | 
					*TagsApi* | [**deleteTag**](doc//TagsApi.md#deletetag) | **DELETE** /tags/{id} | 
 | 
				
			||||||
*TagsApi* | [**getAllTags**](doc//TagsApi.md#getalltags) | **GET** /tags | 
 | 
					*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* | [**getTagById**](doc//TagsApi.md#gettagbyid) | **GET** /tags/{id} | 
 | 
				
			||||||
*TagsApi* | [**tagAssets**](doc//TagsApi.md#tagassets) | **PUT** /tags/{id}/assets | 
 | 
					*TagsApi* | [**tagAssets**](doc//TagsApi.md#tagassets) | **PUT** /tags/{id}/assets | 
 | 
				
			||||||
*TagsApi* | [**untagAssets**](doc//TagsApi.md#untagassets) | **DELETE** /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* | [**getTimeBucket**](doc//TimelineApi.md#gettimebucket) | **GET** /timeline/bucket | 
 | 
				
			||||||
*TimelineApi* | [**getTimeBuckets**](doc//TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets | 
 | 
					*TimelineApi* | [**getTimeBuckets**](doc//TimelineApi.md#gettimebuckets) | **GET** /timeline/buckets | 
 | 
				
			||||||
*TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty | 
 | 
					*TrashApi* | [**emptyTrash**](doc//TrashApi.md#emptytrash) | **POST** /trash/empty | 
 | 
				
			||||||
@ -305,7 +306,6 @@ Class | Method | HTTP request | Description
 | 
				
			|||||||
 - [CreateAlbumDto](doc//CreateAlbumDto.md)
 | 
					 - [CreateAlbumDto](doc//CreateAlbumDto.md)
 | 
				
			||||||
 - [CreateLibraryDto](doc//CreateLibraryDto.md)
 | 
					 - [CreateLibraryDto](doc//CreateLibraryDto.md)
 | 
				
			||||||
 - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
 | 
					 - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
 | 
				
			||||||
 - [CreateTagDto](doc//CreateTagDto.md)
 | 
					 | 
				
			||||||
 - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
 | 
					 - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
 | 
				
			||||||
 - [DownloadInfoDto](doc//DownloadInfoDto.md)
 | 
					 - [DownloadInfoDto](doc//DownloadInfoDto.md)
 | 
				
			||||||
 - [DownloadResponse](doc//DownloadResponse.md)
 | 
					 - [DownloadResponse](doc//DownloadResponse.md)
 | 
				
			||||||
@ -429,8 +429,12 @@ Class | Method | HTTP request | Description
 | 
				
			|||||||
 - [SystemConfigThemeDto](doc//SystemConfigThemeDto.md)
 | 
					 - [SystemConfigThemeDto](doc//SystemConfigThemeDto.md)
 | 
				
			||||||
 - [SystemConfigTrashDto](doc//SystemConfigTrashDto.md)
 | 
					 - [SystemConfigTrashDto](doc//SystemConfigTrashDto.md)
 | 
				
			||||||
 - [SystemConfigUserDto](doc//SystemConfigUserDto.md)
 | 
					 - [SystemConfigUserDto](doc//SystemConfigUserDto.md)
 | 
				
			||||||
 | 
					 - [TagBulkAssetsDto](doc//TagBulkAssetsDto.md)
 | 
				
			||||||
 | 
					 - [TagBulkAssetsResponseDto](doc//TagBulkAssetsResponseDto.md)
 | 
				
			||||||
 | 
					 - [TagCreateDto](doc//TagCreateDto.md)
 | 
				
			||||||
 - [TagResponseDto](doc//TagResponseDto.md)
 | 
					 - [TagResponseDto](doc//TagResponseDto.md)
 | 
				
			||||||
 - [TagTypeEnum](doc//TagTypeEnum.md)
 | 
					 - [TagUpdateDto](doc//TagUpdateDto.md)
 | 
				
			||||||
 | 
					 - [TagUpsertDto](doc//TagUpsertDto.md)
 | 
				
			||||||
 - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md)
 | 
					 - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md)
 | 
				
			||||||
 - [TimeBucketSize](doc//TimeBucketSize.md)
 | 
					 - [TimeBucketSize](doc//TimeBucketSize.md)
 | 
				
			||||||
 - [ToneMapping](doc//ToneMapping.md)
 | 
					 - [ToneMapping](doc//ToneMapping.md)
 | 
				
			||||||
@ -441,7 +445,6 @@ Class | Method | HTTP request | Description
 | 
				
			|||||||
 - [UpdateAssetDto](doc//UpdateAssetDto.md)
 | 
					 - [UpdateAssetDto](doc//UpdateAssetDto.md)
 | 
				
			||||||
 - [UpdateLibraryDto](doc//UpdateLibraryDto.md)
 | 
					 - [UpdateLibraryDto](doc//UpdateLibraryDto.md)
 | 
				
			||||||
 - [UpdatePartnerDto](doc//UpdatePartnerDto.md)
 | 
					 - [UpdatePartnerDto](doc//UpdatePartnerDto.md)
 | 
				
			||||||
 - [UpdateTagDto](doc//UpdateTagDto.md)
 | 
					 | 
				
			||||||
 - [UsageByUserDto](doc//UsageByUserDto.md)
 | 
					 - [UsageByUserDto](doc//UsageByUserDto.md)
 | 
				
			||||||
 - [UserAdminCreateDto](doc//UserAdminCreateDto.md)
 | 
					 - [UserAdminCreateDto](doc//UserAdminCreateDto.md)
 | 
				
			||||||
 - [UserAdminDeleteDto](doc//UserAdminDeleteDto.md)
 | 
					 - [UserAdminDeleteDto](doc//UserAdminDeleteDto.md)
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										8
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							@ -120,7 +120,6 @@ part 'model/colorspace.dart';
 | 
				
			|||||||
part 'model/create_album_dto.dart';
 | 
					part 'model/create_album_dto.dart';
 | 
				
			||||||
part 'model/create_library_dto.dart';
 | 
					part 'model/create_library_dto.dart';
 | 
				
			||||||
part 'model/create_profile_image_response_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_archive_info.dart';
 | 
				
			||||||
part 'model/download_info_dto.dart';
 | 
					part 'model/download_info_dto.dart';
 | 
				
			||||||
part 'model/download_response.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_theme_dto.dart';
 | 
				
			||||||
part 'model/system_config_trash_dto.dart';
 | 
					part 'model/system_config_trash_dto.dart';
 | 
				
			||||||
part 'model/system_config_user_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_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_response_dto.dart';
 | 
				
			||||||
part 'model/time_bucket_size.dart';
 | 
					part 'model/time_bucket_size.dart';
 | 
				
			||||||
part 'model/tone_mapping.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_asset_dto.dart';
 | 
				
			||||||
part 'model/update_library_dto.dart';
 | 
					part 'model/update_library_dto.dart';
 | 
				
			||||||
part 'model/update_partner_dto.dart';
 | 
					part 'model/update_partner_dto.dart';
 | 
				
			||||||
part 'model/update_tag_dto.dart';
 | 
					 | 
				
			||||||
part 'model/usage_by_user_dto.dart';
 | 
					part 'model/usage_by_user_dto.dart';
 | 
				
			||||||
part 'model/user_admin_create_dto.dart';
 | 
					part 'model/user_admin_create_dto.dart';
 | 
				
			||||||
part 'model/user_admin_delete_dto.dart';
 | 
					part 'model/user_admin_delete_dto.dart';
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										208
									
								
								mobile/openapi/lib/api/tags_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										208
									
								
								mobile/openapi/lib/api/tags_api.dart
									
									
									
										generated
									
									
									
								
							@ -16,16 +16,63 @@ class TagsApi {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  final ApiClient apiClient;
 | 
					  final ApiClient apiClient;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Performs an HTTP 'PUT /tags/assets' operation and returns the [Response].
 | 
				
			||||||
 | 
					  /// Parameters:
 | 
				
			||||||
 | 
					  ///
 | 
				
			||||||
 | 
					  /// * [TagBulkAssetsDto] tagBulkAssetsDto (required):
 | 
				
			||||||
 | 
					  Future<Response> bulkTagAssetsWithHttpInfo(TagBulkAssetsDto tagBulkAssetsDto,) async {
 | 
				
			||||||
 | 
					    // ignore: prefer_const_declarations
 | 
				
			||||||
 | 
					    final path = r'/tags/assets';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // ignore: prefer_final_locals
 | 
				
			||||||
 | 
					    Object? postBody = tagBulkAssetsDto;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final queryParams = <QueryParam>[];
 | 
				
			||||||
 | 
					    final headerParams = <String, String>{};
 | 
				
			||||||
 | 
					    final formParams = <String, String>{};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const contentTypes = <String>['application/json'];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return apiClient.invokeAPI(
 | 
				
			||||||
 | 
					      path,
 | 
				
			||||||
 | 
					      'PUT',
 | 
				
			||||||
 | 
					      queryParams,
 | 
				
			||||||
 | 
					      postBody,
 | 
				
			||||||
 | 
					      headerParams,
 | 
				
			||||||
 | 
					      formParams,
 | 
				
			||||||
 | 
					      contentTypes.isEmpty ? null : contentTypes.first,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Parameters:
 | 
				
			||||||
 | 
					  ///
 | 
				
			||||||
 | 
					  /// * [TagBulkAssetsDto] tagBulkAssetsDto (required):
 | 
				
			||||||
 | 
					  Future<TagBulkAssetsResponseDto?> 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].
 | 
					  /// Performs an HTTP 'POST /tags' operation and returns the [Response].
 | 
				
			||||||
  /// Parameters:
 | 
					  /// Parameters:
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [CreateTagDto] createTagDto (required):
 | 
					  /// * [TagCreateDto] tagCreateDto (required):
 | 
				
			||||||
  Future<Response> createTagWithHttpInfo(CreateTagDto createTagDto,) async {
 | 
					  Future<Response> createTagWithHttpInfo(TagCreateDto tagCreateDto,) async {
 | 
				
			||||||
    // ignore: prefer_const_declarations
 | 
					    // ignore: prefer_const_declarations
 | 
				
			||||||
    final path = r'/tags';
 | 
					    final path = r'/tags';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // ignore: prefer_final_locals
 | 
					    // ignore: prefer_final_locals
 | 
				
			||||||
    Object? postBody = createTagDto;
 | 
					    Object? postBody = tagCreateDto;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final queryParams = <QueryParam>[];
 | 
					    final queryParams = <QueryParam>[];
 | 
				
			||||||
    final headerParams = <String, String>{};
 | 
					    final headerParams = <String, String>{};
 | 
				
			||||||
@ -47,9 +94,9 @@ class TagsApi {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  /// Parameters:
 | 
					  /// Parameters:
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [CreateTagDto] createTagDto (required):
 | 
					  /// * [TagCreateDto] tagCreateDto (required):
 | 
				
			||||||
  Future<TagResponseDto?> createTag(CreateTagDto createTagDto,) async {
 | 
					  Future<TagResponseDto?> createTag(TagCreateDto tagCreateDto,) async {
 | 
				
			||||||
    final response = await createTagWithHttpInfo(createTagDto,);
 | 
					    final response = await createTagWithHttpInfo(tagCreateDto,);
 | 
				
			||||||
    if (response.statusCode >= HttpStatus.badRequest) {
 | 
					    if (response.statusCode >= HttpStatus.badRequest) {
 | 
				
			||||||
      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
					      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -147,57 +194,6 @@ class TagsApi {
 | 
				
			|||||||
    return null;
 | 
					    return null;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /// Performs an HTTP 'GET /tags/{id}/assets' operation and returns the [Response].
 | 
					 | 
				
			||||||
  /// Parameters:
 | 
					 | 
				
			||||||
  ///
 | 
					 | 
				
			||||||
  /// * [String] id (required):
 | 
					 | 
				
			||||||
  Future<Response> 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 = <QueryParam>[];
 | 
					 | 
				
			||||||
    final headerParams = <String, String>{};
 | 
					 | 
				
			||||||
    final formParams = <String, String>{};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const contentTypes = <String>[];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return apiClient.invokeAPI(
 | 
					 | 
				
			||||||
      path,
 | 
					 | 
				
			||||||
      'GET',
 | 
					 | 
				
			||||||
      queryParams,
 | 
					 | 
				
			||||||
      postBody,
 | 
					 | 
				
			||||||
      headerParams,
 | 
					 | 
				
			||||||
      formParams,
 | 
					 | 
				
			||||||
      contentTypes.isEmpty ? null : contentTypes.first,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /// Parameters:
 | 
					 | 
				
			||||||
  ///
 | 
					 | 
				
			||||||
  /// * [String] id (required):
 | 
					 | 
				
			||||||
  Future<List<AssetResponseDto>?> 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<AssetResponseDto>') as List)
 | 
					 | 
				
			||||||
        .cast<AssetResponseDto>()
 | 
					 | 
				
			||||||
        .toList(growable: false);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return null;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /// Performs an HTTP 'GET /tags/{id}' operation and returns the [Response].
 | 
					  /// Performs an HTTP 'GET /tags/{id}' operation and returns the [Response].
 | 
				
			||||||
  /// Parameters:
 | 
					  /// Parameters:
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
@ -251,14 +247,14 @@ class TagsApi {
 | 
				
			|||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [String] id (required):
 | 
					  /// * [String] id (required):
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [AssetIdsDto] assetIdsDto (required):
 | 
					  /// * [BulkIdsDto] bulkIdsDto (required):
 | 
				
			||||||
  Future<Response> tagAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async {
 | 
					  Future<Response> tagAssetsWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async {
 | 
				
			||||||
    // ignore: prefer_const_declarations
 | 
					    // ignore: prefer_const_declarations
 | 
				
			||||||
    final path = r'/tags/{id}/assets'
 | 
					    final path = r'/tags/{id}/assets'
 | 
				
			||||||
      .replaceAll('{id}', id);
 | 
					      .replaceAll('{id}', id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // ignore: prefer_final_locals
 | 
					    // ignore: prefer_final_locals
 | 
				
			||||||
    Object? postBody = assetIdsDto;
 | 
					    Object? postBody = bulkIdsDto;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final queryParams = <QueryParam>[];
 | 
					    final queryParams = <QueryParam>[];
 | 
				
			||||||
    final headerParams = <String, String>{};
 | 
					    final headerParams = <String, String>{};
 | 
				
			||||||
@ -282,9 +278,9 @@ class TagsApi {
 | 
				
			|||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [String] id (required):
 | 
					  /// * [String] id (required):
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [AssetIdsDto] assetIdsDto (required):
 | 
					  /// * [BulkIdsDto] bulkIdsDto (required):
 | 
				
			||||||
  Future<List<AssetIdsResponseDto>?> tagAssets(String id, AssetIdsDto assetIdsDto,) async {
 | 
					  Future<List<BulkIdResponseDto>?> tagAssets(String id, BulkIdsDto bulkIdsDto,) async {
 | 
				
			||||||
    final response = await tagAssetsWithHttpInfo(id, assetIdsDto,);
 | 
					    final response = await tagAssetsWithHttpInfo(id, bulkIdsDto,);
 | 
				
			||||||
    if (response.statusCode >= HttpStatus.badRequest) {
 | 
					    if (response.statusCode >= HttpStatus.badRequest) {
 | 
				
			||||||
      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
					      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -293,8 +289,8 @@ class TagsApi {
 | 
				
			|||||||
    // FormatException when trying to decode an empty string.
 | 
					    // FormatException when trying to decode an empty string.
 | 
				
			||||||
    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
 | 
					    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
 | 
				
			||||||
      final responseBody = await _decodeBodyBytes(response);
 | 
					      final responseBody = await _decodeBodyBytes(response);
 | 
				
			||||||
      return (await apiClient.deserializeAsync(responseBody, 'List<AssetIdsResponseDto>') as List)
 | 
					      return (await apiClient.deserializeAsync(responseBody, 'List<BulkIdResponseDto>') as List)
 | 
				
			||||||
        .cast<AssetIdsResponseDto>()
 | 
					        .cast<BulkIdResponseDto>()
 | 
				
			||||||
        .toList(growable: false);
 | 
					        .toList(growable: false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -306,14 +302,14 @@ class TagsApi {
 | 
				
			|||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [String] id (required):
 | 
					  /// * [String] id (required):
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [AssetIdsDto] assetIdsDto (required):
 | 
					  /// * [BulkIdsDto] bulkIdsDto (required):
 | 
				
			||||||
  Future<Response> untagAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto,) async {
 | 
					  Future<Response> untagAssetsWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async {
 | 
				
			||||||
    // ignore: prefer_const_declarations
 | 
					    // ignore: prefer_const_declarations
 | 
				
			||||||
    final path = r'/tags/{id}/assets'
 | 
					    final path = r'/tags/{id}/assets'
 | 
				
			||||||
      .replaceAll('{id}', id);
 | 
					      .replaceAll('{id}', id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // ignore: prefer_final_locals
 | 
					    // ignore: prefer_final_locals
 | 
				
			||||||
    Object? postBody = assetIdsDto;
 | 
					    Object? postBody = bulkIdsDto;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final queryParams = <QueryParam>[];
 | 
					    final queryParams = <QueryParam>[];
 | 
				
			||||||
    final headerParams = <String, String>{};
 | 
					    final headerParams = <String, String>{};
 | 
				
			||||||
@ -337,9 +333,9 @@ class TagsApi {
 | 
				
			|||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [String] id (required):
 | 
					  /// * [String] id (required):
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [AssetIdsDto] assetIdsDto (required):
 | 
					  /// * [BulkIdsDto] bulkIdsDto (required):
 | 
				
			||||||
  Future<List<AssetIdsResponseDto>?> untagAssets(String id, AssetIdsDto assetIdsDto,) async {
 | 
					  Future<List<BulkIdResponseDto>?> untagAssets(String id, BulkIdsDto bulkIdsDto,) async {
 | 
				
			||||||
    final response = await untagAssetsWithHttpInfo(id, assetIdsDto,);
 | 
					    final response = await untagAssetsWithHttpInfo(id, bulkIdsDto,);
 | 
				
			||||||
    if (response.statusCode >= HttpStatus.badRequest) {
 | 
					    if (response.statusCode >= HttpStatus.badRequest) {
 | 
				
			||||||
      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
					      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -348,27 +344,27 @@ class TagsApi {
 | 
				
			|||||||
    // FormatException when trying to decode an empty string.
 | 
					    // FormatException when trying to decode an empty string.
 | 
				
			||||||
    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
 | 
					    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
 | 
				
			||||||
      final responseBody = await _decodeBodyBytes(response);
 | 
					      final responseBody = await _decodeBodyBytes(response);
 | 
				
			||||||
      return (await apiClient.deserializeAsync(responseBody, 'List<AssetIdsResponseDto>') as List)
 | 
					      return (await apiClient.deserializeAsync(responseBody, 'List<BulkIdResponseDto>') as List)
 | 
				
			||||||
        .cast<AssetIdsResponseDto>()
 | 
					        .cast<BulkIdResponseDto>()
 | 
				
			||||||
        .toList(growable: false);
 | 
					        .toList(growable: false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    return null;
 | 
					    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:
 | 
					  /// Parameters:
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [String] id (required):
 | 
					  /// * [String] id (required):
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [UpdateTagDto] updateTagDto (required):
 | 
					  /// * [TagUpdateDto] tagUpdateDto (required):
 | 
				
			||||||
  Future<Response> updateTagWithHttpInfo(String id, UpdateTagDto updateTagDto,) async {
 | 
					  Future<Response> updateTagWithHttpInfo(String id, TagUpdateDto tagUpdateDto,) async {
 | 
				
			||||||
    // ignore: prefer_const_declarations
 | 
					    // ignore: prefer_const_declarations
 | 
				
			||||||
    final path = r'/tags/{id}'
 | 
					    final path = r'/tags/{id}'
 | 
				
			||||||
      .replaceAll('{id}', id);
 | 
					      .replaceAll('{id}', id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // ignore: prefer_final_locals
 | 
					    // ignore: prefer_final_locals
 | 
				
			||||||
    Object? postBody = updateTagDto;
 | 
					    Object? postBody = tagUpdateDto;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    final queryParams = <QueryParam>[];
 | 
					    final queryParams = <QueryParam>[];
 | 
				
			||||||
    final headerParams = <String, String>{};
 | 
					    final headerParams = <String, String>{};
 | 
				
			||||||
@ -379,7 +375,7 @@ class TagsApi {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    return apiClient.invokeAPI(
 | 
					    return apiClient.invokeAPI(
 | 
				
			||||||
      path,
 | 
					      path,
 | 
				
			||||||
      'PATCH',
 | 
					      'PUT',
 | 
				
			||||||
      queryParams,
 | 
					      queryParams,
 | 
				
			||||||
      postBody,
 | 
					      postBody,
 | 
				
			||||||
      headerParams,
 | 
					      headerParams,
 | 
				
			||||||
@ -392,9 +388,9 @@ class TagsApi {
 | 
				
			|||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [String] id (required):
 | 
					  /// * [String] id (required):
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [UpdateTagDto] updateTagDto (required):
 | 
					  /// * [TagUpdateDto] tagUpdateDto (required):
 | 
				
			||||||
  Future<TagResponseDto?> updateTag(String id, UpdateTagDto updateTagDto,) async {
 | 
					  Future<TagResponseDto?> updateTag(String id, TagUpdateDto tagUpdateDto,) async {
 | 
				
			||||||
    final response = await updateTagWithHttpInfo(id, updateTagDto,);
 | 
					    final response = await updateTagWithHttpInfo(id, tagUpdateDto,);
 | 
				
			||||||
    if (response.statusCode >= HttpStatus.badRequest) {
 | 
					    if (response.statusCode >= HttpStatus.badRequest) {
 | 
				
			||||||
      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
					      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -407,4 +403,54 @@ class TagsApi {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
    return null;
 | 
					    return null;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Performs an HTTP 'PUT /tags' operation and returns the [Response].
 | 
				
			||||||
 | 
					  /// Parameters:
 | 
				
			||||||
 | 
					  ///
 | 
				
			||||||
 | 
					  /// * [TagUpsertDto] tagUpsertDto (required):
 | 
				
			||||||
 | 
					  Future<Response> upsertTagsWithHttpInfo(TagUpsertDto tagUpsertDto,) async {
 | 
				
			||||||
 | 
					    // ignore: prefer_const_declarations
 | 
				
			||||||
 | 
					    final path = r'/tags';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // ignore: prefer_final_locals
 | 
				
			||||||
 | 
					    Object? postBody = tagUpsertDto;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final queryParams = <QueryParam>[];
 | 
				
			||||||
 | 
					    final headerParams = <String, String>{};
 | 
				
			||||||
 | 
					    final formParams = <String, String>{};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const contentTypes = <String>['application/json'];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return apiClient.invokeAPI(
 | 
				
			||||||
 | 
					      path,
 | 
				
			||||||
 | 
					      'PUT',
 | 
				
			||||||
 | 
					      queryParams,
 | 
				
			||||||
 | 
					      postBody,
 | 
				
			||||||
 | 
					      headerParams,
 | 
				
			||||||
 | 
					      formParams,
 | 
				
			||||||
 | 
					      contentTypes.isEmpty ? null : contentTypes.first,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /// Parameters:
 | 
				
			||||||
 | 
					  ///
 | 
				
			||||||
 | 
					  /// * [TagUpsertDto] tagUpsertDto (required):
 | 
				
			||||||
 | 
					  Future<List<TagResponseDto>?> 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<TagResponseDto>') as List)
 | 
				
			||||||
 | 
					        .cast<TagResponseDto>()
 | 
				
			||||||
 | 
					        .toList(growable: false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										26
									
								
								mobile/openapi/lib/api/timeline_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										26
									
								
								mobile/openapi/lib/api/timeline_api.dart
									
									
									
										generated
									
									
									
								
							@ -37,12 +37,14 @@ class TimelineApi {
 | 
				
			|||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [String] personId:
 | 
					  /// * [String] personId:
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
 | 
					  /// * [String] tagId:
 | 
				
			||||||
 | 
					  ///
 | 
				
			||||||
  /// * [String] userId:
 | 
					  /// * [String] userId:
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [bool] withPartners:
 | 
					  /// * [bool] withPartners:
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [bool] withStacked:
 | 
					  /// * [bool] withStacked:
 | 
				
			||||||
  Future<Response> 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<Response> 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
 | 
					    // ignore: prefer_const_declarations
 | 
				
			||||||
    final path = r'/timeline/bucket';
 | 
					    final path = r'/timeline/bucket';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -75,6 +77,9 @@ class TimelineApi {
 | 
				
			|||||||
      queryParams.addAll(_queryParams('', 'personId', personId));
 | 
					      queryParams.addAll(_queryParams('', 'personId', personId));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
      queryParams.addAll(_queryParams('', 'size', size));
 | 
					      queryParams.addAll(_queryParams('', 'size', size));
 | 
				
			||||||
 | 
					    if (tagId != null) {
 | 
				
			||||||
 | 
					      queryParams.addAll(_queryParams('', 'tagId', tagId));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
      queryParams.addAll(_queryParams('', 'timeBucket', timeBucket));
 | 
					      queryParams.addAll(_queryParams('', 'timeBucket', timeBucket));
 | 
				
			||||||
    if (userId != null) {
 | 
					    if (userId != null) {
 | 
				
			||||||
      queryParams.addAll(_queryParams('', 'userId', userId));
 | 
					      queryParams.addAll(_queryParams('', 'userId', userId));
 | 
				
			||||||
@ -120,13 +125,15 @@ class TimelineApi {
 | 
				
			|||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [String] personId:
 | 
					  /// * [String] personId:
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
 | 
					  /// * [String] tagId:
 | 
				
			||||||
 | 
					  ///
 | 
				
			||||||
  /// * [String] userId:
 | 
					  /// * [String] userId:
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [bool] withPartners:
 | 
					  /// * [bool] withPartners:
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [bool] withStacked:
 | 
					  /// * [bool] withStacked:
 | 
				
			||||||
  Future<List<AssetResponseDto>?> 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 {
 | 
					  Future<List<AssetResponseDto>?> 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, userId: userId, withPartners: withPartners, withStacked: withStacked, );
 | 
					    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) {
 | 
					    if (response.statusCode >= HttpStatus.badRequest) {
 | 
				
			||||||
      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
					      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -162,12 +169,14 @@ class TimelineApi {
 | 
				
			|||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [String] personId:
 | 
					  /// * [String] personId:
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
 | 
					  /// * [String] tagId:
 | 
				
			||||||
 | 
					  ///
 | 
				
			||||||
  /// * [String] userId:
 | 
					  /// * [String] userId:
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [bool] withPartners:
 | 
					  /// * [bool] withPartners:
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [bool] withStacked:
 | 
					  /// * [bool] withStacked:
 | 
				
			||||||
  Future<Response> 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<Response> 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
 | 
					    // ignore: prefer_const_declarations
 | 
				
			||||||
    final path = r'/timeline/buckets';
 | 
					    final path = r'/timeline/buckets';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -200,6 +209,9 @@ class TimelineApi {
 | 
				
			|||||||
      queryParams.addAll(_queryParams('', 'personId', personId));
 | 
					      queryParams.addAll(_queryParams('', 'personId', personId));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
      queryParams.addAll(_queryParams('', 'size', size));
 | 
					      queryParams.addAll(_queryParams('', 'size', size));
 | 
				
			||||||
 | 
					    if (tagId != null) {
 | 
				
			||||||
 | 
					      queryParams.addAll(_queryParams('', 'tagId', tagId));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    if (userId != null) {
 | 
					    if (userId != null) {
 | 
				
			||||||
      queryParams.addAll(_queryParams('', 'userId', userId));
 | 
					      queryParams.addAll(_queryParams('', 'userId', userId));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -242,13 +254,15 @@ class TimelineApi {
 | 
				
			|||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [String] personId:
 | 
					  /// * [String] personId:
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
 | 
					  /// * [String] tagId:
 | 
				
			||||||
 | 
					  ///
 | 
				
			||||||
  /// * [String] userId:
 | 
					  /// * [String] userId:
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [bool] withPartners:
 | 
					  /// * [bool] withPartners:
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  /// * [bool] withStacked:
 | 
					  /// * [bool] withStacked:
 | 
				
			||||||
  Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? userId, bool? withPartners, bool? withStacked, }) async {
 | 
					  Future<List<TimeBucketResponseDto>?> 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, userId: userId, withPartners: withPartners, withStacked: withStacked, );
 | 
					    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) {
 | 
					    if (response.statusCode >= HttpStatus.badRequest) {
 | 
				
			||||||
      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
					      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										16
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										16
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							@ -295,8 +295,6 @@ class ApiClient {
 | 
				
			|||||||
          return CreateLibraryDto.fromJson(value);
 | 
					          return CreateLibraryDto.fromJson(value);
 | 
				
			||||||
        case 'CreateProfileImageResponseDto':
 | 
					        case 'CreateProfileImageResponseDto':
 | 
				
			||||||
          return CreateProfileImageResponseDto.fromJson(value);
 | 
					          return CreateProfileImageResponseDto.fromJson(value);
 | 
				
			||||||
        case 'CreateTagDto':
 | 
					 | 
				
			||||||
          return CreateTagDto.fromJson(value);
 | 
					 | 
				
			||||||
        case 'DownloadArchiveInfo':
 | 
					        case 'DownloadArchiveInfo':
 | 
				
			||||||
          return DownloadArchiveInfo.fromJson(value);
 | 
					          return DownloadArchiveInfo.fromJson(value);
 | 
				
			||||||
        case 'DownloadInfoDto':
 | 
					        case 'DownloadInfoDto':
 | 
				
			||||||
@ -543,10 +541,18 @@ class ApiClient {
 | 
				
			|||||||
          return SystemConfigTrashDto.fromJson(value);
 | 
					          return SystemConfigTrashDto.fromJson(value);
 | 
				
			||||||
        case 'SystemConfigUserDto':
 | 
					        case 'SystemConfigUserDto':
 | 
				
			||||||
          return SystemConfigUserDto.fromJson(value);
 | 
					          return SystemConfigUserDto.fromJson(value);
 | 
				
			||||||
 | 
					        case 'TagBulkAssetsDto':
 | 
				
			||||||
 | 
					          return TagBulkAssetsDto.fromJson(value);
 | 
				
			||||||
 | 
					        case 'TagBulkAssetsResponseDto':
 | 
				
			||||||
 | 
					          return TagBulkAssetsResponseDto.fromJson(value);
 | 
				
			||||||
 | 
					        case 'TagCreateDto':
 | 
				
			||||||
 | 
					          return TagCreateDto.fromJson(value);
 | 
				
			||||||
        case 'TagResponseDto':
 | 
					        case 'TagResponseDto':
 | 
				
			||||||
          return TagResponseDto.fromJson(value);
 | 
					          return TagResponseDto.fromJson(value);
 | 
				
			||||||
        case 'TagTypeEnum':
 | 
					        case 'TagUpdateDto':
 | 
				
			||||||
          return TagTypeEnumTypeTransformer().decode(value);
 | 
					          return TagUpdateDto.fromJson(value);
 | 
				
			||||||
 | 
					        case 'TagUpsertDto':
 | 
				
			||||||
 | 
					          return TagUpsertDto.fromJson(value);
 | 
				
			||||||
        case 'TimeBucketResponseDto':
 | 
					        case 'TimeBucketResponseDto':
 | 
				
			||||||
          return TimeBucketResponseDto.fromJson(value);
 | 
					          return TimeBucketResponseDto.fromJson(value);
 | 
				
			||||||
        case 'TimeBucketSize':
 | 
					        case 'TimeBucketSize':
 | 
				
			||||||
@ -567,8 +573,6 @@ class ApiClient {
 | 
				
			|||||||
          return UpdateLibraryDto.fromJson(value);
 | 
					          return UpdateLibraryDto.fromJson(value);
 | 
				
			||||||
        case 'UpdatePartnerDto':
 | 
					        case 'UpdatePartnerDto':
 | 
				
			||||||
          return UpdatePartnerDto.fromJson(value);
 | 
					          return UpdatePartnerDto.fromJson(value);
 | 
				
			||||||
        case 'UpdateTagDto':
 | 
					 | 
				
			||||||
          return UpdateTagDto.fromJson(value);
 | 
					 | 
				
			||||||
        case 'UsageByUserDto':
 | 
					        case 'UsageByUserDto':
 | 
				
			||||||
          return UsageByUserDto.fromJson(value);
 | 
					          return UsageByUserDto.fromJson(value);
 | 
				
			||||||
        case 'UserAdminCreateDto':
 | 
					        case 'UserAdminCreateDto':
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										3
									
								
								mobile/openapi/lib/api_helper.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/lib/api_helper.dart
									
									
									
										generated
									
									
									
								
							@ -127,9 +127,6 @@ String parameterToString(dynamic value) {
 | 
				
			|||||||
  if (value is SharedLinkType) {
 | 
					  if (value is SharedLinkType) {
 | 
				
			||||||
    return SharedLinkTypeTypeTransformer().encode(value).toString();
 | 
					    return SharedLinkTypeTypeTransformer().encode(value).toString();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  if (value is TagTypeEnum) {
 | 
					 | 
				
			||||||
    return TagTypeEnumTypeTransformer().encode(value).toString();
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
  if (value is TimeBucketSize) {
 | 
					  if (value is TimeBucketSize) {
 | 
				
			||||||
    return TimeBucketSizeTypeTransformer().encode(value).toString();
 | 
					    return TimeBucketSizeTypeTransformer().encode(value).toString();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										3
									
								
								mobile/openapi/lib/model/permission.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/lib/model/permission.dart
									
									
									
										generated
									
									
									
								
							@ -96,6 +96,7 @@ class Permission {
 | 
				
			|||||||
  static const tagPeriodRead = Permission._(r'tag.read');
 | 
					  static const tagPeriodRead = Permission._(r'tag.read');
 | 
				
			||||||
  static const tagPeriodUpdate = Permission._(r'tag.update');
 | 
					  static const tagPeriodUpdate = Permission._(r'tag.update');
 | 
				
			||||||
  static const tagPeriodDelete = Permission._(r'tag.delete');
 | 
					  static const tagPeriodDelete = Permission._(r'tag.delete');
 | 
				
			||||||
 | 
					  static const tagPeriodAsset = Permission._(r'tag.asset');
 | 
				
			||||||
  static const adminPeriodUserPeriodCreate = Permission._(r'admin.user.create');
 | 
					  static const adminPeriodUserPeriodCreate = Permission._(r'admin.user.create');
 | 
				
			||||||
  static const adminPeriodUserPeriodRead = Permission._(r'admin.user.read');
 | 
					  static const adminPeriodUserPeriodRead = Permission._(r'admin.user.read');
 | 
				
			||||||
  static const adminPeriodUserPeriodUpdate = Permission._(r'admin.user.update');
 | 
					  static const adminPeriodUserPeriodUpdate = Permission._(r'admin.user.update');
 | 
				
			||||||
@ -176,6 +177,7 @@ class Permission {
 | 
				
			|||||||
    tagPeriodRead,
 | 
					    tagPeriodRead,
 | 
				
			||||||
    tagPeriodUpdate,
 | 
					    tagPeriodUpdate,
 | 
				
			||||||
    tagPeriodDelete,
 | 
					    tagPeriodDelete,
 | 
				
			||||||
 | 
					    tagPeriodAsset,
 | 
				
			||||||
    adminPeriodUserPeriodCreate,
 | 
					    adminPeriodUserPeriodCreate,
 | 
				
			||||||
    adminPeriodUserPeriodRead,
 | 
					    adminPeriodUserPeriodRead,
 | 
				
			||||||
    adminPeriodUserPeriodUpdate,
 | 
					    adminPeriodUserPeriodUpdate,
 | 
				
			||||||
@ -291,6 +293,7 @@ class PermissionTypeTransformer {
 | 
				
			|||||||
        case r'tag.read': return Permission.tagPeriodRead;
 | 
					        case r'tag.read': return Permission.tagPeriodRead;
 | 
				
			||||||
        case r'tag.update': return Permission.tagPeriodUpdate;
 | 
					        case r'tag.update': return Permission.tagPeriodUpdate;
 | 
				
			||||||
        case r'tag.delete': return Permission.tagPeriodDelete;
 | 
					        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.create': return Permission.adminPeriodUserPeriodCreate;
 | 
				
			||||||
        case r'admin.user.read': return Permission.adminPeriodUserPeriodRead;
 | 
					        case r'admin.user.read': return Permission.adminPeriodUserPeriodRead;
 | 
				
			||||||
        case r'admin.user.update': return Permission.adminPeriodUserPeriodUpdate;
 | 
					        case r'admin.user.update': return Permission.adminPeriodUserPeriodUpdate;
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										110
									
								
								mobile/openapi/lib/model/tag_bulk_assets_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								mobile/openapi/lib/model/tag_bulk_assets_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -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<String> assetIds;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  List<String> 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<String, dynamic> toJson() {
 | 
				
			||||||
 | 
					    final json = <String, dynamic>{};
 | 
				
			||||||
 | 
					      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<String, dynamic>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return TagBulkAssetsDto(
 | 
				
			||||||
 | 
					        assetIds: json[r'assetIds'] is Iterable
 | 
				
			||||||
 | 
					            ? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
 | 
				
			||||||
 | 
					            : const [],
 | 
				
			||||||
 | 
					        tagIds: json[r'tagIds'] is Iterable
 | 
				
			||||||
 | 
					            ? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
 | 
				
			||||||
 | 
					            : const [],
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static List<TagBulkAssetsDto> listFromJson(dynamic json, {bool growable = false,}) {
 | 
				
			||||||
 | 
					    final result = <TagBulkAssetsDto>[];
 | 
				
			||||||
 | 
					    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<String, TagBulkAssetsDto> mapFromJson(dynamic json) {
 | 
				
			||||||
 | 
					    final map = <String, TagBulkAssetsDto>{};
 | 
				
			||||||
 | 
					    if (json is Map && json.isNotEmpty) {
 | 
				
			||||||
 | 
					      json = json.cast<String, dynamic>(); // 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<String, List<TagBulkAssetsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
 | 
				
			||||||
 | 
					    final map = <String, List<TagBulkAssetsDto>>{};
 | 
				
			||||||
 | 
					    if (json is Map && json.isNotEmpty) {
 | 
				
			||||||
 | 
					      // ignore: parameter_assignments
 | 
				
			||||||
 | 
					      json = json.cast<String, dynamic>();
 | 
				
			||||||
 | 
					      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 = <String>{
 | 
				
			||||||
 | 
					    'assetIds',
 | 
				
			||||||
 | 
					    'tagIds',
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										98
									
								
								mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -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<String, dynamic> toJson() {
 | 
				
			||||||
 | 
					    final json = <String, dynamic>{};
 | 
				
			||||||
 | 
					      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<String, dynamic>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return TagBulkAssetsResponseDto(
 | 
				
			||||||
 | 
					        count: mapValueOfType<int>(json, r'count')!,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static List<TagBulkAssetsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
 | 
				
			||||||
 | 
					    final result = <TagBulkAssetsResponseDto>[];
 | 
				
			||||||
 | 
					    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<String, TagBulkAssetsResponseDto> mapFromJson(dynamic json) {
 | 
				
			||||||
 | 
					    final map = <String, TagBulkAssetsResponseDto>{};
 | 
				
			||||||
 | 
					    if (json is Map && json.isNotEmpty) {
 | 
				
			||||||
 | 
					      json = json.cast<String, dynamic>(); // 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<String, List<TagBulkAssetsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
 | 
				
			||||||
 | 
					    final map = <String, List<TagBulkAssetsResponseDto>>{};
 | 
				
			||||||
 | 
					    if (json is Map && json.isNotEmpty) {
 | 
				
			||||||
 | 
					      // ignore: parameter_assignments
 | 
				
			||||||
 | 
					      json = json.cast<String, dynamic>();
 | 
				
			||||||
 | 
					      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 = <String>{
 | 
				
			||||||
 | 
					    'count',
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -10,10 +10,12 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
part of openapi.api;
 | 
					part of openapi.api;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class UpdateTagDto {
 | 
					class TagCreateDto {
 | 
				
			||||||
  /// Returns a new [UpdateTagDto] instance.
 | 
					  /// Returns a new [TagCreateDto] instance.
 | 
				
			||||||
  UpdateTagDto({
 | 
					  TagCreateDto({
 | 
				
			||||||
    this.name,
 | 
					    this.color,
 | 
				
			||||||
 | 
					    required this.name,
 | 
				
			||||||
 | 
					    this.parentId,
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
@ -22,49 +24,65 @@ class UpdateTagDto {
 | 
				
			|||||||
  /// source code must fall back to having a nullable type.
 | 
					  /// source code must fall back to having a nullable type.
 | 
				
			||||||
  /// Consider adding a "default:" property in the specification file to hide this note.
 | 
					  /// Consider adding a "default:" property in the specification file to hide this note.
 | 
				
			||||||
  ///
 | 
					  ///
 | 
				
			||||||
  String? name;
 | 
					  String? color;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  String name;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  String? parentId;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  bool operator ==(Object other) => identical(this, other) || other is UpdateTagDto &&
 | 
					  bool operator ==(Object other) => identical(this, other) || other is TagCreateDto &&
 | 
				
			||||||
    other.name == name;
 | 
					    other.color == color &&
 | 
				
			||||||
 | 
					    other.name == name &&
 | 
				
			||||||
 | 
					    other.parentId == parentId;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  int get hashCode =>
 | 
					  int get hashCode =>
 | 
				
			||||||
    // ignore: unnecessary_parenthesis
 | 
					    // ignore: unnecessary_parenthesis
 | 
				
			||||||
    (name == null ? 0 : name!.hashCode);
 | 
					    (color == null ? 0 : color!.hashCode) +
 | 
				
			||||||
 | 
					    (name.hashCode) +
 | 
				
			||||||
 | 
					    (parentId == null ? 0 : parentId!.hashCode);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  String toString() => 'UpdateTagDto[name=$name]';
 | 
					  String toString() => 'TagCreateDto[color=$color, name=$name, parentId=$parentId]';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Map<String, dynamic> toJson() {
 | 
					  Map<String, dynamic> toJson() {
 | 
				
			||||||
    final json = <String, dynamic>{};
 | 
					    final json = <String, dynamic>{};
 | 
				
			||||||
    if (this.name != null) {
 | 
					    if (this.color != null) {
 | 
				
			||||||
      json[r'name'] = this.name;
 | 
					      json[r'color'] = this.color;
 | 
				
			||||||
    } else {
 | 
					    } 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;
 | 
					    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.
 | 
					  /// [value] if it's a [Map], null otherwise.
 | 
				
			||||||
  // ignore: prefer_constructors_over_static_methods
 | 
					  // ignore: prefer_constructors_over_static_methods
 | 
				
			||||||
  static UpdateTagDto? fromJson(dynamic value) {
 | 
					  static TagCreateDto? fromJson(dynamic value) {
 | 
				
			||||||
    if (value is Map) {
 | 
					    if (value is Map) {
 | 
				
			||||||
      final json = value.cast<String, dynamic>();
 | 
					      final json = value.cast<String, dynamic>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      return UpdateTagDto(
 | 
					      return TagCreateDto(
 | 
				
			||||||
        name: mapValueOfType<String>(json, r'name'),
 | 
					        color: mapValueOfType<String>(json, r'color'),
 | 
				
			||||||
 | 
					        name: mapValueOfType<String>(json, r'name')!,
 | 
				
			||||||
 | 
					        parentId: mapValueOfType<String>(json, r'parentId'),
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    return null;
 | 
					    return null;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static List<UpdateTagDto> listFromJson(dynamic json, {bool growable = false,}) {
 | 
					  static List<TagCreateDto> listFromJson(dynamic json, {bool growable = false,}) {
 | 
				
			||||||
    final result = <UpdateTagDto>[];
 | 
					    final result = <TagCreateDto>[];
 | 
				
			||||||
    if (json is List && json.isNotEmpty) {
 | 
					    if (json is List && json.isNotEmpty) {
 | 
				
			||||||
      for (final row in json) {
 | 
					      for (final row in json) {
 | 
				
			||||||
        final value = UpdateTagDto.fromJson(row);
 | 
					        final value = TagCreateDto.fromJson(row);
 | 
				
			||||||
        if (value != null) {
 | 
					        if (value != null) {
 | 
				
			||||||
          result.add(value);
 | 
					          result.add(value);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@ -73,12 +91,12 @@ class UpdateTagDto {
 | 
				
			|||||||
    return result.toList(growable: growable);
 | 
					    return result.toList(growable: growable);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static Map<String, UpdateTagDto> mapFromJson(dynamic json) {
 | 
					  static Map<String, TagCreateDto> mapFromJson(dynamic json) {
 | 
				
			||||||
    final map = <String, UpdateTagDto>{};
 | 
					    final map = <String, TagCreateDto>{};
 | 
				
			||||||
    if (json is Map && json.isNotEmpty) {
 | 
					    if (json is Map && json.isNotEmpty) {
 | 
				
			||||||
      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
 | 
					      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
 | 
				
			||||||
      for (final entry in json.entries) {
 | 
					      for (final entry in json.entries) {
 | 
				
			||||||
        final value = UpdateTagDto.fromJson(entry.value);
 | 
					        final value = TagCreateDto.fromJson(entry.value);
 | 
				
			||||||
        if (value != null) {
 | 
					        if (value != null) {
 | 
				
			||||||
          map[entry.key] = value;
 | 
					          map[entry.key] = value;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@ -87,14 +105,14 @@ class UpdateTagDto {
 | 
				
			|||||||
    return map;
 | 
					    return map;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // maps a json object with a list of UpdateTagDto-objects as value to a dart map
 | 
					  // maps a json object with a list of TagCreateDto-objects as value to a dart map
 | 
				
			||||||
  static Map<String, List<UpdateTagDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
 | 
					  static Map<String, List<TagCreateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
 | 
				
			||||||
    final map = <String, List<UpdateTagDto>>{};
 | 
					    final map = <String, List<TagCreateDto>>{};
 | 
				
			||||||
    if (json is Map && json.isNotEmpty) {
 | 
					    if (json is Map && json.isNotEmpty) {
 | 
				
			||||||
      // ignore: parameter_assignments
 | 
					      // ignore: parameter_assignments
 | 
				
			||||||
      json = json.cast<String, dynamic>();
 | 
					      json = json.cast<String, dynamic>();
 | 
				
			||||||
      for (final entry in json.entries) {
 | 
					      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;
 | 
					    return map;
 | 
				
			||||||
@ -102,6 +120,7 @@ class UpdateTagDto {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  /// The list of required keys that must be present in a JSON.
 | 
					  /// The list of required keys that must be present in a JSON.
 | 
				
			||||||
  static const requiredKeys = <String>{
 | 
					  static const requiredKeys = <String>{
 | 
				
			||||||
 | 
					    'name',
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										55
									
								
								mobile/openapi/lib/model/tag_response_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										55
									
								
								mobile/openapi/lib/model/tag_response_dto.dart
									
									
									
										generated
									
									
									
								
							@ -13,44 +13,66 @@ part of openapi.api;
 | 
				
			|||||||
class TagResponseDto {
 | 
					class TagResponseDto {
 | 
				
			||||||
  /// Returns a new [TagResponseDto] instance.
 | 
					  /// Returns a new [TagResponseDto] instance.
 | 
				
			||||||
  TagResponseDto({
 | 
					  TagResponseDto({
 | 
				
			||||||
 | 
					    this.color,
 | 
				
			||||||
 | 
					    required this.createdAt,
 | 
				
			||||||
    required this.id,
 | 
					    required this.id,
 | 
				
			||||||
    required this.name,
 | 
					    required this.name,
 | 
				
			||||||
    required this.type,
 | 
					    required this.updatedAt,
 | 
				
			||||||
    required this.userId,
 | 
					    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 id;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  String name;
 | 
					  String name;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  TagTypeEnum type;
 | 
					  DateTime updatedAt;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  String userId;
 | 
					  String value;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  bool operator ==(Object other) => identical(this, other) || other is TagResponseDto &&
 | 
					  bool operator ==(Object other) => identical(this, other) || other is TagResponseDto &&
 | 
				
			||||||
 | 
					    other.color == color &&
 | 
				
			||||||
 | 
					    other.createdAt == createdAt &&
 | 
				
			||||||
    other.id == id &&
 | 
					    other.id == id &&
 | 
				
			||||||
    other.name == name &&
 | 
					    other.name == name &&
 | 
				
			||||||
    other.type == type &&
 | 
					    other.updatedAt == updatedAt &&
 | 
				
			||||||
    other.userId == userId;
 | 
					    other.value == value;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  int get hashCode =>
 | 
					  int get hashCode =>
 | 
				
			||||||
    // ignore: unnecessary_parenthesis
 | 
					    // ignore: unnecessary_parenthesis
 | 
				
			||||||
 | 
					    (color == null ? 0 : color!.hashCode) +
 | 
				
			||||||
 | 
					    (createdAt.hashCode) +
 | 
				
			||||||
    (id.hashCode) +
 | 
					    (id.hashCode) +
 | 
				
			||||||
    (name.hashCode) +
 | 
					    (name.hashCode) +
 | 
				
			||||||
    (type.hashCode) +
 | 
					    (updatedAt.hashCode) +
 | 
				
			||||||
    (userId.hashCode);
 | 
					    (value.hashCode);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @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<String, dynamic> toJson() {
 | 
					  Map<String, dynamic> toJson() {
 | 
				
			||||||
    final json = <String, dynamic>{};
 | 
					    final json = <String, dynamic>{};
 | 
				
			||||||
 | 
					    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'id'] = this.id;
 | 
				
			||||||
      json[r'name'] = this.name;
 | 
					      json[r'name'] = this.name;
 | 
				
			||||||
      json[r'type'] = this.type;
 | 
					      json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
 | 
				
			||||||
      json[r'userId'] = this.userId;
 | 
					      json[r'value'] = this.value;
 | 
				
			||||||
    return json;
 | 
					    return json;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -62,10 +84,12 @@ class TagResponseDto {
 | 
				
			|||||||
      final json = value.cast<String, dynamic>();
 | 
					      final json = value.cast<String, dynamic>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      return TagResponseDto(
 | 
					      return TagResponseDto(
 | 
				
			||||||
 | 
					        color: mapValueOfType<String>(json, r'color'),
 | 
				
			||||||
 | 
					        createdAt: mapDateTime(json, r'createdAt', r'')!,
 | 
				
			||||||
        id: mapValueOfType<String>(json, r'id')!,
 | 
					        id: mapValueOfType<String>(json, r'id')!,
 | 
				
			||||||
        name: mapValueOfType<String>(json, r'name')!,
 | 
					        name: mapValueOfType<String>(json, r'name')!,
 | 
				
			||||||
        type: TagTypeEnum.fromJson(json[r'type'])!,
 | 
					        updatedAt: mapDateTime(json, r'updatedAt', r'')!,
 | 
				
			||||||
        userId: mapValueOfType<String>(json, r'userId')!,
 | 
					        value: mapValueOfType<String>(json, r'value')!,
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    return null;
 | 
					    return null;
 | 
				
			||||||
@ -113,10 +137,11 @@ class TagResponseDto {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  /// The list of required keys that must be present in a JSON.
 | 
					  /// The list of required keys that must be present in a JSON.
 | 
				
			||||||
  static const requiredKeys = <String>{
 | 
					  static const requiredKeys = <String>{
 | 
				
			||||||
 | 
					    'createdAt',
 | 
				
			||||||
    'id',
 | 
					    'id',
 | 
				
			||||||
    'name',
 | 
					    'name',
 | 
				
			||||||
    'type',
 | 
					    'updatedAt',
 | 
				
			||||||
    'userId',
 | 
					    'value',
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										88
									
								
								mobile/openapi/lib/model/tag_type_enum.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										88
									
								
								mobile/openapi/lib/model/tag_type_enum.dart
									
									
									
										generated
									
									
									
								
							@ -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 = <TagTypeEnum>[
 | 
					 | 
				
			||||||
    OBJECT,
 | 
					 | 
				
			||||||
    FACE,
 | 
					 | 
				
			||||||
    CUSTOM,
 | 
					 | 
				
			||||||
  ];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  static TagTypeEnum? fromJson(dynamic value) => TagTypeEnumTypeTransformer().decode(value);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  static List<TagTypeEnum> listFromJson(dynamic json, {bool growable = false,}) {
 | 
					 | 
				
			||||||
    final result = <TagTypeEnum>[];
 | 
					 | 
				
			||||||
    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;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@ -10,58 +10,55 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
part of openapi.api;
 | 
					part of openapi.api;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class CreateTagDto {
 | 
					class TagUpdateDto {
 | 
				
			||||||
  /// Returns a new [CreateTagDto] instance.
 | 
					  /// Returns a new [TagUpdateDto] instance.
 | 
				
			||||||
  CreateTagDto({
 | 
					  TagUpdateDto({
 | 
				
			||||||
    required this.name,
 | 
					    this.color,
 | 
				
			||||||
    required this.type,
 | 
					 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  String name;
 | 
					  String? color;
 | 
				
			||||||
 | 
					 | 
				
			||||||
  TagTypeEnum type;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  bool operator ==(Object other) => identical(this, other) || other is CreateTagDto &&
 | 
					  bool operator ==(Object other) => identical(this, other) || other is TagUpdateDto &&
 | 
				
			||||||
    other.name == name &&
 | 
					    other.color == color;
 | 
				
			||||||
    other.type == type;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  int get hashCode =>
 | 
					  int get hashCode =>
 | 
				
			||||||
    // ignore: unnecessary_parenthesis
 | 
					    // ignore: unnecessary_parenthesis
 | 
				
			||||||
    (name.hashCode) +
 | 
					    (color == null ? 0 : color!.hashCode);
 | 
				
			||||||
    (type.hashCode);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  String toString() => 'CreateTagDto[name=$name, type=$type]';
 | 
					  String toString() => 'TagUpdateDto[color=$color]';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  Map<String, dynamic> toJson() {
 | 
					  Map<String, dynamic> toJson() {
 | 
				
			||||||
    final json = <String, dynamic>{};
 | 
					    final json = <String, dynamic>{};
 | 
				
			||||||
      json[r'name'] = this.name;
 | 
					    if (this.color != null) {
 | 
				
			||||||
      json[r'type'] = this.type;
 | 
					      json[r'color'] = this.color;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					    //  json[r'color'] = null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
    return json;
 | 
					    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.
 | 
					  /// [value] if it's a [Map], null otherwise.
 | 
				
			||||||
  // ignore: prefer_constructors_over_static_methods
 | 
					  // ignore: prefer_constructors_over_static_methods
 | 
				
			||||||
  static CreateTagDto? fromJson(dynamic value) {
 | 
					  static TagUpdateDto? fromJson(dynamic value) {
 | 
				
			||||||
    if (value is Map) {
 | 
					    if (value is Map) {
 | 
				
			||||||
      final json = value.cast<String, dynamic>();
 | 
					      final json = value.cast<String, dynamic>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      return CreateTagDto(
 | 
					      return TagUpdateDto(
 | 
				
			||||||
        name: mapValueOfType<String>(json, r'name')!,
 | 
					        color: mapValueOfType<String>(json, r'color'),
 | 
				
			||||||
        type: TagTypeEnum.fromJson(json[r'type'])!,
 | 
					 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    return null;
 | 
					    return null;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static List<CreateTagDto> listFromJson(dynamic json, {bool growable = false,}) {
 | 
					  static List<TagUpdateDto> listFromJson(dynamic json, {bool growable = false,}) {
 | 
				
			||||||
    final result = <CreateTagDto>[];
 | 
					    final result = <TagUpdateDto>[];
 | 
				
			||||||
    if (json is List && json.isNotEmpty) {
 | 
					    if (json is List && json.isNotEmpty) {
 | 
				
			||||||
      for (final row in json) {
 | 
					      for (final row in json) {
 | 
				
			||||||
        final value = CreateTagDto.fromJson(row);
 | 
					        final value = TagUpdateDto.fromJson(row);
 | 
				
			||||||
        if (value != null) {
 | 
					        if (value != null) {
 | 
				
			||||||
          result.add(value);
 | 
					          result.add(value);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@ -70,12 +67,12 @@ class CreateTagDto {
 | 
				
			|||||||
    return result.toList(growable: growable);
 | 
					    return result.toList(growable: growable);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static Map<String, CreateTagDto> mapFromJson(dynamic json) {
 | 
					  static Map<String, TagUpdateDto> mapFromJson(dynamic json) {
 | 
				
			||||||
    final map = <String, CreateTagDto>{};
 | 
					    final map = <String, TagUpdateDto>{};
 | 
				
			||||||
    if (json is Map && json.isNotEmpty) {
 | 
					    if (json is Map && json.isNotEmpty) {
 | 
				
			||||||
      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
 | 
					      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
 | 
				
			||||||
      for (final entry in json.entries) {
 | 
					      for (final entry in json.entries) {
 | 
				
			||||||
        final value = CreateTagDto.fromJson(entry.value);
 | 
					        final value = TagUpdateDto.fromJson(entry.value);
 | 
				
			||||||
        if (value != null) {
 | 
					        if (value != null) {
 | 
				
			||||||
          map[entry.key] = value;
 | 
					          map[entry.key] = value;
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
@ -84,14 +81,14 @@ class CreateTagDto {
 | 
				
			|||||||
    return map;
 | 
					    return map;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // maps a json object with a list of CreateTagDto-objects as value to a dart map
 | 
					  // maps a json object with a list of TagUpdateDto-objects as value to a dart map
 | 
				
			||||||
  static Map<String, List<CreateTagDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
 | 
					  static Map<String, List<TagUpdateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
 | 
				
			||||||
    final map = <String, List<CreateTagDto>>{};
 | 
					    final map = <String, List<TagUpdateDto>>{};
 | 
				
			||||||
    if (json is Map && json.isNotEmpty) {
 | 
					    if (json is Map && json.isNotEmpty) {
 | 
				
			||||||
      // ignore: parameter_assignments
 | 
					      // ignore: parameter_assignments
 | 
				
			||||||
      json = json.cast<String, dynamic>();
 | 
					      json = json.cast<String, dynamic>();
 | 
				
			||||||
      for (final entry in json.entries) {
 | 
					      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;
 | 
					    return map;
 | 
				
			||||||
@ -99,8 +96,6 @@ class CreateTagDto {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  /// The list of required keys that must be present in a JSON.
 | 
					  /// The list of required keys that must be present in a JSON.
 | 
				
			||||||
  static const requiredKeys = <String>{
 | 
					  static const requiredKeys = <String>{
 | 
				
			||||||
    'name',
 | 
					 | 
				
			||||||
    'type',
 | 
					 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
							
								
								
									
										100
									
								
								mobile/openapi/lib/model/tag_upsert_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								mobile/openapi/lib/model/tag_upsert_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							@ -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<String> 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<String, dynamic> toJson() {
 | 
				
			||||||
 | 
					    final json = <String, dynamic>{};
 | 
				
			||||||
 | 
					      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<String, dynamic>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return TagUpsertDto(
 | 
				
			||||||
 | 
					        tags: json[r'tags'] is Iterable
 | 
				
			||||||
 | 
					            ? (json[r'tags'] as Iterable).cast<String>().toList(growable: false)
 | 
				
			||||||
 | 
					            : const [],
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static List<TagUpsertDto> listFromJson(dynamic json, {bool growable = false,}) {
 | 
				
			||||||
 | 
					    final result = <TagUpsertDto>[];
 | 
				
			||||||
 | 
					    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<String, TagUpsertDto> mapFromJson(dynamic json) {
 | 
				
			||||||
 | 
					    final map = <String, TagUpsertDto>{};
 | 
				
			||||||
 | 
					    if (json is Map && json.isNotEmpty) {
 | 
				
			||||||
 | 
					      json = json.cast<String, dynamic>(); // 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<String, List<TagUpsertDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
 | 
				
			||||||
 | 
					    final map = <String, List<TagUpsertDto>>{};
 | 
				
			||||||
 | 
					    if (json is Map && json.isNotEmpty) {
 | 
				
			||||||
 | 
					      // ignore: parameter_assignments
 | 
				
			||||||
 | 
					      json = json.cast<String, dynamic>();
 | 
				
			||||||
 | 
					      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 = <String>{
 | 
				
			||||||
 | 
					    'tags',
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -6169,7 +6169,7 @@
 | 
				
			|||||||
          "content": {
 | 
					          "content": {
 | 
				
			||||||
            "application/json": {
 | 
					            "application/json": {
 | 
				
			||||||
              "schema": {
 | 
					              "schema": {
 | 
				
			||||||
                "$ref": "#/components/schemas/CreateTagDto"
 | 
					                "$ref": "#/components/schemas/TagCreateDto"
 | 
				
			||||||
              }
 | 
					              }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
@ -6201,6 +6201,91 @@
 | 
				
			|||||||
        "tags": [
 | 
					        "tags": [
 | 
				
			||||||
          "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}": {
 | 
					    "/tags/{id}": {
 | 
				
			||||||
@ -6218,7 +6303,7 @@
 | 
				
			|||||||
          }
 | 
					          }
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
        "responses": {
 | 
					        "responses": {
 | 
				
			||||||
          "200": {
 | 
					          "204": {
 | 
				
			||||||
            "description": ""
 | 
					            "description": ""
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
@ -6277,7 +6362,7 @@
 | 
				
			|||||||
          "Tags"
 | 
					          "Tags"
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      "patch": {
 | 
					      "put": {
 | 
				
			||||||
        "operationId": "updateTag",
 | 
					        "operationId": "updateTag",
 | 
				
			||||||
        "parameters": [
 | 
					        "parameters": [
 | 
				
			||||||
          {
 | 
					          {
 | 
				
			||||||
@ -6294,7 +6379,7 @@
 | 
				
			|||||||
          "content": {
 | 
					          "content": {
 | 
				
			||||||
            "application/json": {
 | 
					            "application/json": {
 | 
				
			||||||
              "schema": {
 | 
					              "schema": {
 | 
				
			||||||
                "$ref": "#/components/schemas/UpdateTagDto"
 | 
					                "$ref": "#/components/schemas/TagUpdateDto"
 | 
				
			||||||
              }
 | 
					              }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
@ -6346,7 +6431,7 @@
 | 
				
			|||||||
          "content": {
 | 
					          "content": {
 | 
				
			||||||
            "application/json": {
 | 
					            "application/json": {
 | 
				
			||||||
              "schema": {
 | 
					              "schema": {
 | 
				
			||||||
                "$ref": "#/components/schemas/AssetIdsDto"
 | 
					                "$ref": "#/components/schemas/BulkIdsDto"
 | 
				
			||||||
              }
 | 
					              }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
@ -6358,50 +6443,7 @@
 | 
				
			|||||||
              "application/json": {
 | 
					              "application/json": {
 | 
				
			||||||
                "schema": {
 | 
					                "schema": {
 | 
				
			||||||
                  "items": {
 | 
					                  "items": {
 | 
				
			||||||
                    "$ref": "#/components/schemas/AssetIdsResponseDto"
 | 
					                    "$ref": "#/components/schemas/BulkIdResponseDto"
 | 
				
			||||||
                  },
 | 
					 | 
				
			||||||
                  "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"
 | 
					 | 
				
			||||||
                  },
 | 
					                  },
 | 
				
			||||||
                  "type": "array"
 | 
					                  "type": "array"
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
@ -6442,7 +6484,7 @@
 | 
				
			|||||||
          "content": {
 | 
					          "content": {
 | 
				
			||||||
            "application/json": {
 | 
					            "application/json": {
 | 
				
			||||||
              "schema": {
 | 
					              "schema": {
 | 
				
			||||||
                "$ref": "#/components/schemas/AssetIdsDto"
 | 
					                "$ref": "#/components/schemas/BulkIdsDto"
 | 
				
			||||||
              }
 | 
					              }
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
@ -6454,7 +6496,7 @@
 | 
				
			|||||||
              "application/json": {
 | 
					              "application/json": {
 | 
				
			||||||
                "schema": {
 | 
					                "schema": {
 | 
				
			||||||
                  "items": {
 | 
					                  "items": {
 | 
				
			||||||
                    "$ref": "#/components/schemas/AssetIdsResponseDto"
 | 
					                    "$ref": "#/components/schemas/BulkIdResponseDto"
 | 
				
			||||||
                  },
 | 
					                  },
 | 
				
			||||||
                  "type": "array"
 | 
					                  "type": "array"
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
@ -6549,6 +6591,15 @@
 | 
				
			|||||||
              "$ref": "#/components/schemas/TimeBucketSize"
 | 
					              "$ref": "#/components/schemas/TimeBucketSize"
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            "name": "tagId",
 | 
				
			||||||
 | 
					            "required": false,
 | 
				
			||||||
 | 
					            "in": "query",
 | 
				
			||||||
 | 
					            "schema": {
 | 
				
			||||||
 | 
					              "format": "uuid",
 | 
				
			||||||
 | 
					              "type": "string"
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
          {
 | 
					          {
 | 
				
			||||||
            "name": "timeBucket",
 | 
					            "name": "timeBucket",
 | 
				
			||||||
            "required": true,
 | 
					            "required": true,
 | 
				
			||||||
@ -6684,6 +6735,15 @@
 | 
				
			|||||||
              "$ref": "#/components/schemas/TimeBucketSize"
 | 
					              "$ref": "#/components/schemas/TimeBucketSize"
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            "name": "tagId",
 | 
				
			||||||
 | 
					            "required": false,
 | 
				
			||||||
 | 
					            "in": "query",
 | 
				
			||||||
 | 
					            "schema": {
 | 
				
			||||||
 | 
					              "format": "uuid",
 | 
				
			||||||
 | 
					              "type": "string"
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
          {
 | 
					          {
 | 
				
			||||||
            "name": "userId",
 | 
					            "name": "userId",
 | 
				
			||||||
            "required": false,
 | 
					            "required": false,
 | 
				
			||||||
@ -8685,21 +8745,6 @@
 | 
				
			|||||||
        ],
 | 
					        ],
 | 
				
			||||||
        "type": "object"
 | 
					        "type": "object"
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      "CreateTagDto": {
 | 
					 | 
				
			||||||
        "properties": {
 | 
					 | 
				
			||||||
          "name": {
 | 
					 | 
				
			||||||
            "type": "string"
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
          "type": {
 | 
					 | 
				
			||||||
            "$ref": "#/components/schemas/TagTypeEnum"
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        "required": [
 | 
					 | 
				
			||||||
          "name",
 | 
					 | 
				
			||||||
          "type"
 | 
					 | 
				
			||||||
        ],
 | 
					 | 
				
			||||||
        "type": "object"
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      "DownloadArchiveInfo": {
 | 
					      "DownloadArchiveInfo": {
 | 
				
			||||||
        "properties": {
 | 
					        "properties": {
 | 
				
			||||||
          "assetIds": {
 | 
					          "assetIds": {
 | 
				
			||||||
@ -10053,6 +10098,7 @@
 | 
				
			|||||||
          "tag.read",
 | 
					          "tag.read",
 | 
				
			||||||
          "tag.update",
 | 
					          "tag.update",
 | 
				
			||||||
          "tag.delete",
 | 
					          "tag.delete",
 | 
				
			||||||
 | 
					          "tag.asset",
 | 
				
			||||||
          "admin.user.create",
 | 
					          "admin.user.create",
 | 
				
			||||||
          "admin.user.read",
 | 
					          "admin.user.read",
 | 
				
			||||||
          "admin.user.update",
 | 
					          "admin.user.update",
 | 
				
			||||||
@ -11848,36 +11894,113 @@
 | 
				
			|||||||
        ],
 | 
					        ],
 | 
				
			||||||
        "type": "object"
 | 
					        "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": {
 | 
					      "TagResponseDto": {
 | 
				
			||||||
        "properties": {
 | 
					        "properties": {
 | 
				
			||||||
 | 
					          "color": {
 | 
				
			||||||
 | 
					            "type": "string"
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          "createdAt": {
 | 
				
			||||||
 | 
					            "format": "date-time",
 | 
				
			||||||
 | 
					            "type": "string"
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
          "id": {
 | 
					          "id": {
 | 
				
			||||||
            "type": "string"
 | 
					            "type": "string"
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
          "name": {
 | 
					          "name": {
 | 
				
			||||||
            "type": "string"
 | 
					            "type": "string"
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
          "type": {
 | 
					          "updatedAt": {
 | 
				
			||||||
            "$ref": "#/components/schemas/TagTypeEnum"
 | 
					            "format": "date-time",
 | 
				
			||||||
 | 
					            "type": "string"
 | 
				
			||||||
          },
 | 
					          },
 | 
				
			||||||
          "userId": {
 | 
					          "value": {
 | 
				
			||||||
            "type": "string"
 | 
					            "type": "string"
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        "required": [
 | 
					        "required": [
 | 
				
			||||||
 | 
					          "createdAt",
 | 
				
			||||||
          "id",
 | 
					          "id",
 | 
				
			||||||
          "name",
 | 
					          "name",
 | 
				
			||||||
          "type",
 | 
					          "updatedAt",
 | 
				
			||||||
          "userId"
 | 
					          "value"
 | 
				
			||||||
        ],
 | 
					        ],
 | 
				
			||||||
        "type": "object"
 | 
					        "type": "object"
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      "TagTypeEnum": {
 | 
					      "TagUpdateDto": {
 | 
				
			||||||
        "enum": [
 | 
					        "properties": {
 | 
				
			||||||
          "OBJECT",
 | 
					          "color": {
 | 
				
			||||||
          "FACE",
 | 
					            "nullable": true,
 | 
				
			||||||
          "CUSTOM"
 | 
					 | 
				
			||||||
        ],
 | 
					 | 
				
			||||||
            "type": "string"
 | 
					            "type": "string"
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "type": "object"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "TagUpsertDto": {
 | 
				
			||||||
 | 
					        "properties": {
 | 
				
			||||||
 | 
					          "tags": {
 | 
				
			||||||
 | 
					            "items": {
 | 
				
			||||||
 | 
					              "type": "string"
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            "type": "array"
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "required": [
 | 
				
			||||||
 | 
					          "tags"
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					        "type": "object"
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      "TimeBucketResponseDto": {
 | 
					      "TimeBucketResponseDto": {
 | 
				
			||||||
        "properties": {
 | 
					        "properties": {
 | 
				
			||||||
@ -12021,14 +12144,6 @@
 | 
				
			|||||||
        ],
 | 
					        ],
 | 
				
			||||||
        "type": "object"
 | 
					        "type": "object"
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      "UpdateTagDto": {
 | 
					 | 
				
			||||||
        "properties": {
 | 
					 | 
				
			||||||
          "name": {
 | 
					 | 
				
			||||||
            "type": "string"
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        "type": "object"
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      "UsageByUserDto": {
 | 
					      "UsageByUserDto": {
 | 
				
			||||||
        "properties": {
 | 
					        "properties": {
 | 
				
			||||||
          "photos": {
 | 
					          "photos": {
 | 
				
			||||||
 | 
				
			|||||||
@ -198,10 +198,12 @@ export type AssetStackResponseDto = {
 | 
				
			|||||||
    primaryAssetId: string;
 | 
					    primaryAssetId: string;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export type TagResponseDto = {
 | 
					export type TagResponseDto = {
 | 
				
			||||||
 | 
					    color?: string;
 | 
				
			||||||
 | 
					    createdAt: string;
 | 
				
			||||||
    id: string;
 | 
					    id: string;
 | 
				
			||||||
    name: string;
 | 
					    name: string;
 | 
				
			||||||
    "type": TagTypeEnum;
 | 
					    updatedAt: string;
 | 
				
			||||||
    userId: string;
 | 
					    value: string;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export type AssetResponseDto = {
 | 
					export type AssetResponseDto = {
 | 
				
			||||||
    /** base64 encoded sha1 hash */
 | 
					    /** base64 encoded sha1 hash */
 | 
				
			||||||
@ -1171,12 +1173,23 @@ export type ReverseGeocodingStateResponseDto = {
 | 
				
			|||||||
    lastImportFileName: string | null;
 | 
					    lastImportFileName: string | null;
 | 
				
			||||||
    lastUpdate: string | null;
 | 
					    lastUpdate: string | null;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export type CreateTagDto = {
 | 
					export type TagCreateDto = {
 | 
				
			||||||
 | 
					    color?: string;
 | 
				
			||||||
    name: string;
 | 
					    name: string;
 | 
				
			||||||
    "type": TagTypeEnum;
 | 
					    parentId?: string | null;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export type UpdateTagDto = {
 | 
					export type TagUpsertDto = {
 | 
				
			||||||
    name?: string;
 | 
					    tags: string[];
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					export type TagBulkAssetsDto = {
 | 
				
			||||||
 | 
					    assetIds: string[];
 | 
				
			||||||
 | 
					    tagIds: string[];
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					export type TagBulkAssetsResponseDto = {
 | 
				
			||||||
 | 
					    count: number;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					export type TagUpdateDto = {
 | 
				
			||||||
 | 
					    color?: string | null;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
export type TimeBucketResponseDto = {
 | 
					export type TimeBucketResponseDto = {
 | 
				
			||||||
    count: number;
 | 
					    count: number;
 | 
				
			||||||
@ -2835,8 +2848,8 @@ export function getAllTags(opts?: Oazapfts.RequestOpts) {
 | 
				
			|||||||
        ...opts
 | 
					        ...opts
 | 
				
			||||||
    }));
 | 
					    }));
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
export function createTag({ createTagDto }: {
 | 
					export function createTag({ tagCreateDto }: {
 | 
				
			||||||
    createTagDto: CreateTagDto;
 | 
					    tagCreateDto: TagCreateDto;
 | 
				
			||||||
}, opts?: Oazapfts.RequestOpts) {
 | 
					}, opts?: Oazapfts.RequestOpts) {
 | 
				
			||||||
    return oazapfts.ok(oazapfts.fetchJson<{
 | 
					    return oazapfts.ok(oazapfts.fetchJson<{
 | 
				
			||||||
        status: 201;
 | 
					        status: 201;
 | 
				
			||||||
@ -2844,7 +2857,31 @@ export function createTag({ createTagDto }: {
 | 
				
			|||||||
    }>("/tags", oazapfts.json({
 | 
					    }>("/tags", oazapfts.json({
 | 
				
			||||||
        ...opts,
 | 
					        ...opts,
 | 
				
			||||||
        method: "POST",
 | 
					        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 }: {
 | 
					export function deleteTag({ id }: {
 | 
				
			||||||
@ -2865,56 +2902,46 @@ export function getTagById({ id }: {
 | 
				
			|||||||
        ...opts
 | 
					        ...opts
 | 
				
			||||||
    }));
 | 
					    }));
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
export function updateTag({ id, updateTagDto }: {
 | 
					export function updateTag({ id, tagUpdateDto }: {
 | 
				
			||||||
    id: string;
 | 
					    id: string;
 | 
				
			||||||
    updateTagDto: UpdateTagDto;
 | 
					    tagUpdateDto: TagUpdateDto;
 | 
				
			||||||
}, opts?: Oazapfts.RequestOpts) {
 | 
					}, opts?: Oazapfts.RequestOpts) {
 | 
				
			||||||
    return oazapfts.ok(oazapfts.fetchJson<{
 | 
					    return oazapfts.ok(oazapfts.fetchJson<{
 | 
				
			||||||
        status: 200;
 | 
					        status: 200;
 | 
				
			||||||
        data: TagResponseDto;
 | 
					        data: TagResponseDto;
 | 
				
			||||||
    }>(`/tags/${encodeURIComponent(id)}`, oazapfts.json({
 | 
					    }>(`/tags/${encodeURIComponent(id)}`, oazapfts.json({
 | 
				
			||||||
        ...opts,
 | 
					        ...opts,
 | 
				
			||||||
        method: "PATCH",
 | 
					        method: "PUT",
 | 
				
			||||||
        body: updateTagDto
 | 
					        body: tagUpdateDto
 | 
				
			||||||
    })));
 | 
					    })));
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
export function untagAssets({ id, assetIdsDto }: {
 | 
					export function untagAssets({ id, bulkIdsDto }: {
 | 
				
			||||||
    id: string;
 | 
					    id: string;
 | 
				
			||||||
    assetIdsDto: AssetIdsDto;
 | 
					    bulkIdsDto: BulkIdsDto;
 | 
				
			||||||
}, opts?: Oazapfts.RequestOpts) {
 | 
					}, opts?: Oazapfts.RequestOpts) {
 | 
				
			||||||
    return oazapfts.ok(oazapfts.fetchJson<{
 | 
					    return oazapfts.ok(oazapfts.fetchJson<{
 | 
				
			||||||
        status: 200;
 | 
					        status: 200;
 | 
				
			||||||
        data: AssetIdsResponseDto[];
 | 
					        data: BulkIdResponseDto[];
 | 
				
			||||||
    }>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({
 | 
					    }>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({
 | 
				
			||||||
        ...opts,
 | 
					        ...opts,
 | 
				
			||||||
        method: "DELETE",
 | 
					        method: "DELETE",
 | 
				
			||||||
        body: assetIdsDto
 | 
					        body: bulkIdsDto
 | 
				
			||||||
    })));
 | 
					    })));
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
export function getTagAssets({ id }: {
 | 
					export function tagAssets({ id, bulkIdsDto }: {
 | 
				
			||||||
    id: string;
 | 
					    id: string;
 | 
				
			||||||
 | 
					    bulkIdsDto: BulkIdsDto;
 | 
				
			||||||
}, opts?: Oazapfts.RequestOpts) {
 | 
					}, opts?: Oazapfts.RequestOpts) {
 | 
				
			||||||
    return oazapfts.ok(oazapfts.fetchJson<{
 | 
					    return oazapfts.ok(oazapfts.fetchJson<{
 | 
				
			||||||
        status: 200;
 | 
					        status: 200;
 | 
				
			||||||
        data: AssetResponseDto[];
 | 
					        data: BulkIdResponseDto[];
 | 
				
			||||||
    }>(`/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[];
 | 
					 | 
				
			||||||
    }>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({
 | 
					    }>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({
 | 
				
			||||||
        ...opts,
 | 
					        ...opts,
 | 
				
			||||||
        method: "PUT",
 | 
					        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;
 | 
					    albumId?: string;
 | 
				
			||||||
    isArchived?: boolean;
 | 
					    isArchived?: boolean;
 | 
				
			||||||
    isFavorite?: boolean;
 | 
					    isFavorite?: boolean;
 | 
				
			||||||
@ -2923,6 +2950,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
 | 
				
			|||||||
    order?: AssetOrder;
 | 
					    order?: AssetOrder;
 | 
				
			||||||
    personId?: string;
 | 
					    personId?: string;
 | 
				
			||||||
    size: TimeBucketSize;
 | 
					    size: TimeBucketSize;
 | 
				
			||||||
 | 
					    tagId?: string;
 | 
				
			||||||
    timeBucket: string;
 | 
					    timeBucket: string;
 | 
				
			||||||
    userId?: string;
 | 
					    userId?: string;
 | 
				
			||||||
    withPartners?: boolean;
 | 
					    withPartners?: boolean;
 | 
				
			||||||
@ -2940,6 +2968,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
 | 
				
			|||||||
        order,
 | 
					        order,
 | 
				
			||||||
        personId,
 | 
					        personId,
 | 
				
			||||||
        size,
 | 
					        size,
 | 
				
			||||||
 | 
					        tagId,
 | 
				
			||||||
        timeBucket,
 | 
					        timeBucket,
 | 
				
			||||||
        userId,
 | 
					        userId,
 | 
				
			||||||
        withPartners,
 | 
					        withPartners,
 | 
				
			||||||
@ -2948,7 +2977,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
 | 
				
			|||||||
        ...opts
 | 
					        ...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;
 | 
					    albumId?: string;
 | 
				
			||||||
    isArchived?: boolean;
 | 
					    isArchived?: boolean;
 | 
				
			||||||
    isFavorite?: boolean;
 | 
					    isFavorite?: boolean;
 | 
				
			||||||
@ -2957,6 +2986,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
 | 
				
			|||||||
    order?: AssetOrder;
 | 
					    order?: AssetOrder;
 | 
				
			||||||
    personId?: string;
 | 
					    personId?: string;
 | 
				
			||||||
    size: TimeBucketSize;
 | 
					    size: TimeBucketSize;
 | 
				
			||||||
 | 
					    tagId?: string;
 | 
				
			||||||
    userId?: string;
 | 
					    userId?: string;
 | 
				
			||||||
    withPartners?: boolean;
 | 
					    withPartners?: boolean;
 | 
				
			||||||
    withStacked?: boolean;
 | 
					    withStacked?: boolean;
 | 
				
			||||||
@ -2973,6 +3003,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
 | 
				
			|||||||
        order,
 | 
					        order,
 | 
				
			||||||
        personId,
 | 
					        personId,
 | 
				
			||||||
        size,
 | 
					        size,
 | 
				
			||||||
 | 
					        tagId,
 | 
				
			||||||
        userId,
 | 
					        userId,
 | 
				
			||||||
        withPartners,
 | 
					        withPartners,
 | 
				
			||||||
        withStacked
 | 
					        withStacked
 | 
				
			||||||
@ -3162,11 +3193,6 @@ export enum AlbumUserRole {
 | 
				
			|||||||
    Editor = "editor",
 | 
					    Editor = "editor",
 | 
				
			||||||
    Viewer = "viewer"
 | 
					    Viewer = "viewer"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
export enum TagTypeEnum {
 | 
					 | 
				
			||||||
    Object = "OBJECT",
 | 
					 | 
				
			||||||
    Face = "FACE",
 | 
					 | 
				
			||||||
    Custom = "CUSTOM"
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
export enum AssetTypeEnum {
 | 
					export enum AssetTypeEnum {
 | 
				
			||||||
    Image = "IMAGE",
 | 
					    Image = "IMAGE",
 | 
				
			||||||
    Video = "VIDEO",
 | 
					    Video = "VIDEO",
 | 
				
			||||||
@ -3257,6 +3283,7 @@ export enum Permission {
 | 
				
			|||||||
    TagRead = "tag.read",
 | 
					    TagRead = "tag.read",
 | 
				
			||||||
    TagUpdate = "tag.update",
 | 
					    TagUpdate = "tag.update",
 | 
				
			||||||
    TagDelete = "tag.delete",
 | 
					    TagDelete = "tag.delete",
 | 
				
			||||||
 | 
					    TagAsset = "tag.asset",
 | 
				
			||||||
    AdminUserCreate = "admin.user.create",
 | 
					    AdminUserCreate = "admin.user.create",
 | 
				
			||||||
    AdminUserRead = "admin.user.read",
 | 
					    AdminUserRead = "admin.user.read",
 | 
				
			||||||
    AdminUserUpdate = "admin.user.update",
 | 
					    AdminUserUpdate = "admin.user.update",
 | 
				
			||||||
 | 
				
			|||||||
@ -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 { ApiTags } from '@nestjs/swagger';
 | 
				
			||||||
import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
 | 
					import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
 | 
				
			||||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
 | 
					 | 
				
			||||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
 | 
					 | 
				
			||||||
import { AuthDto } from 'src/dtos/auth.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 { Permission } from 'src/enum';
 | 
				
			||||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
 | 
					import { Auth, Authenticated } from 'src/middleware/auth.guard';
 | 
				
			||||||
import { TagService } from 'src/services/tag.service';
 | 
					import { TagService } from 'src/services/tag.service';
 | 
				
			||||||
@ -17,7 +22,7 @@ export class TagController {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  @Post()
 | 
					  @Post()
 | 
				
			||||||
  @Authenticated({ permission: Permission.TAG_CREATE })
 | 
					  @Authenticated({ permission: Permission.TAG_CREATE })
 | 
				
			||||||
  createTag(@Auth() auth: AuthDto, @Body() dto: CreateTagDto): Promise<TagResponseDto> {
 | 
					  createTag(@Auth() auth: AuthDto, @Body() dto: TagCreateDto): Promise<TagResponseDto> {
 | 
				
			||||||
    return this.service.create(auth, dto);
 | 
					    return this.service.create(auth, dto);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -27,47 +32,54 @@ export class TagController {
 | 
				
			|||||||
    return this.service.getAll(auth);
 | 
					    return this.service.getAll(auth);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Put()
 | 
				
			||||||
 | 
					  @Authenticated({ permission: Permission.TAG_CREATE })
 | 
				
			||||||
 | 
					  upsertTags(@Auth() auth: AuthDto, @Body() dto: TagUpsertDto): Promise<TagResponseDto[]> {
 | 
				
			||||||
 | 
					    return this.service.upsert(auth, dto);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Put('assets')
 | 
				
			||||||
 | 
					  @Authenticated({ permission: Permission.TAG_ASSET })
 | 
				
			||||||
 | 
					  bulkTagAssets(@Auth() auth: AuthDto, @Body() dto: TagBulkAssetsDto): Promise<TagBulkAssetsResponseDto> {
 | 
				
			||||||
 | 
					    return this.service.bulkTagAssets(auth, dto);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Get(':id')
 | 
					  @Get(':id')
 | 
				
			||||||
  @Authenticated({ permission: Permission.TAG_READ })
 | 
					  @Authenticated({ permission: Permission.TAG_READ })
 | 
				
			||||||
  getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<TagResponseDto> {
 | 
					  getTagById(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<TagResponseDto> {
 | 
				
			||||||
    return this.service.getById(auth, id);
 | 
					    return this.service.get(auth, id);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Patch(':id')
 | 
					  @Put(':id')
 | 
				
			||||||
  @Authenticated({ permission: Permission.TAG_UPDATE })
 | 
					  @Authenticated({ permission: Permission.TAG_UPDATE })
 | 
				
			||||||
  updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateTagDto): Promise<TagResponseDto> {
 | 
					  updateTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: TagUpdateDto): Promise<TagResponseDto> {
 | 
				
			||||||
    return this.service.update(auth, id, dto);
 | 
					    return this.service.update(auth, id, dto);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Delete(':id')
 | 
					  @Delete(':id')
 | 
				
			||||||
 | 
					  @HttpCode(HttpStatus.NO_CONTENT)
 | 
				
			||||||
  @Authenticated({ permission: Permission.TAG_DELETE })
 | 
					  @Authenticated({ permission: Permission.TAG_DELETE })
 | 
				
			||||||
  deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
 | 
					  deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
 | 
				
			||||||
    return this.service.remove(auth, id);
 | 
					    return this.service.remove(auth, id);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Get(':id/assets')
 | 
					 | 
				
			||||||
  @Authenticated()
 | 
					 | 
				
			||||||
  getTagAssets(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto[]> {
 | 
					 | 
				
			||||||
    return this.service.getAssets(auth, id);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  @Put(':id/assets')
 | 
					  @Put(':id/assets')
 | 
				
			||||||
  @Authenticated()
 | 
					  @Authenticated({ permission: Permission.TAG_ASSET })
 | 
				
			||||||
  tagAssets(
 | 
					  tagAssets(
 | 
				
			||||||
    @Auth() auth: AuthDto,
 | 
					    @Auth() auth: AuthDto,
 | 
				
			||||||
    @Param() { id }: UUIDParamDto,
 | 
					    @Param() { id }: UUIDParamDto,
 | 
				
			||||||
    @Body() dto: AssetIdsDto,
 | 
					    @Body() dto: BulkIdsDto,
 | 
				
			||||||
  ): Promise<AssetIdsResponseDto[]> {
 | 
					  ): Promise<BulkIdResponseDto[]> {
 | 
				
			||||||
    return this.service.addAssets(auth, id, dto);
 | 
					    return this.service.addAssets(auth, id, dto);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Delete(':id/assets')
 | 
					  @Delete(':id/assets')
 | 
				
			||||||
  @Authenticated()
 | 
					  @Authenticated({ permission: Permission.TAG_ASSET })
 | 
				
			||||||
  untagAssets(
 | 
					  untagAssets(
 | 
				
			||||||
    @Auth() auth: AuthDto,
 | 
					    @Auth() auth: AuthDto,
 | 
				
			||||||
    @Body() dto: AssetIdsDto,
 | 
					    @Body() dto: BulkIdsDto,
 | 
				
			||||||
    @Param() { id }: UUIDParamDto,
 | 
					    @Param() { id }: UUIDParamDto,
 | 
				
			||||||
  ): Promise<AssetIdsResponseDto[]> {
 | 
					  ): Promise<BulkIdResponseDto[]> {
 | 
				
			||||||
    return this.service.removeAssets(auth, id, dto);
 | 
					    return this.service.removeAssets(auth, id, dto);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -140,7 +140,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
 | 
				
			|||||||
    exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
 | 
					    exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
 | 
				
			||||||
    smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
 | 
					    smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
 | 
				
			||||||
    livePhotoVideoId: entity.livePhotoVideoId,
 | 
					    livePhotoVideoId: entity.livePhotoVideoId,
 | 
				
			||||||
    tags: entity.tags?.map(mapTag),
 | 
					    tags: entity.tags?.map((tag) => mapTag(tag)),
 | 
				
			||||||
    people: peopleWithFaces(entity.faces),
 | 
					    people: peopleWithFaces(entity.faces),
 | 
				
			||||||
    unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
 | 
					    unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
 | 
				
			||||||
    checksum: entity.checksum.toString('base64'),
 | 
					    checksum: entity.checksum.toString('base64'),
 | 
				
			||||||
 | 
				
			|||||||
@ -1,38 +1,64 @@
 | 
				
			|||||||
import { ApiProperty } from '@nestjs/swagger';
 | 
					import { ApiProperty } from '@nestjs/swagger';
 | 
				
			||||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
 | 
					import { Transform } from 'class-transformer';
 | 
				
			||||||
import { TagEntity, TagType } from 'src/entities/tag.entity';
 | 
					import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
 | 
				
			||||||
import { Optional } from 'src/validation';
 | 
					import { TagEntity } from 'src/entities/tag.entity';
 | 
				
			||||||
 | 
					import { Optional, ValidateUUID } from 'src/validation';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class CreateTagDto {
 | 
					export class TagCreateDto {
 | 
				
			||||||
  @IsString()
 | 
					  @IsString()
 | 
				
			||||||
  @IsNotEmpty()
 | 
					  @IsNotEmpty()
 | 
				
			||||||
  name!: string;
 | 
					  name!: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @IsEnum(TagType)
 | 
					  @ValidateUUID({ optional: true, nullable: true })
 | 
				
			||||||
  @IsNotEmpty()
 | 
					  parentId?: string | null;
 | 
				
			||||||
  @ApiProperty({ enumName: 'TagTypeEnum', enum: TagType })
 | 
					
 | 
				
			||||||
  type!: TagType;
 | 
					  @IsHexColor()
 | 
				
			||||||
 | 
					  @Optional({ nullable: true, emptyToNull: true })
 | 
				
			||||||
 | 
					  color?: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class UpdateTagDto {
 | 
					export class TagUpdateDto {
 | 
				
			||||||
  @IsString()
 | 
					  @Optional({ nullable: true, emptyToNull: true })
 | 
				
			||||||
  @Optional()
 | 
					  @IsHexColor()
 | 
				
			||||||
  name?: string;
 | 
					  @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 {
 | 
					export class TagResponseDto {
 | 
				
			||||||
  id!: string;
 | 
					  id!: string;
 | 
				
			||||||
  @ApiProperty({ enumName: 'TagTypeEnum', enum: TagType })
 | 
					 | 
				
			||||||
  type!: string;
 | 
					 | 
				
			||||||
  name!: string;
 | 
					  name!: string;
 | 
				
			||||||
  userId!: string;
 | 
					  value!: string;
 | 
				
			||||||
 | 
					  createdAt!: Date;
 | 
				
			||||||
 | 
					  updatedAt!: Date;
 | 
				
			||||||
 | 
					  color?: string;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function mapTag(entity: TagEntity): TagResponseDto {
 | 
					export function mapTag(entity: TagEntity): TagResponseDto {
 | 
				
			||||||
  return {
 | 
					  return {
 | 
				
			||||||
    id: entity.id,
 | 
					    id: entity.id,
 | 
				
			||||||
    type: entity.type,
 | 
					    name: entity.value.split('/').at(-1) as string,
 | 
				
			||||||
    name: entity.name,
 | 
					    value: entity.value,
 | 
				
			||||||
    userId: entity.userId,
 | 
					    createdAt: entity.createdAt,
 | 
				
			||||||
 | 
					    updatedAt: entity.updatedAt,
 | 
				
			||||||
 | 
					    color: entity.color ?? undefined,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -19,6 +19,9 @@ export class TimeBucketDto {
 | 
				
			|||||||
  @ValidateUUID({ optional: true })
 | 
					  @ValidateUUID({ optional: true })
 | 
				
			||||||
  personId?: string;
 | 
					  personId?: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @ValidateUUID({ optional: true })
 | 
				
			||||||
 | 
					  tagId?: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @ValidateBoolean({ optional: true })
 | 
					  @ValidateBoolean({ optional: true })
 | 
				
			||||||
  isArchived?: boolean;
 | 
					  isArchived?: boolean;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,45 +1,48 @@
 | 
				
			|||||||
import { AssetEntity } from 'src/entities/asset.entity';
 | 
					import { AssetEntity } from 'src/entities/asset.entity';
 | 
				
			||||||
import { UserEntity } from 'src/entities/user.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')
 | 
					@Entity('tags')
 | 
				
			||||||
@Unique('UQ_tag_name_userId', ['name', 'userId'])
 | 
					@Tree('closure-table')
 | 
				
			||||||
export class TagEntity {
 | 
					export class TagEntity {
 | 
				
			||||||
  @PrimaryGeneratedColumn('uuid')
 | 
					  @PrimaryGeneratedColumn('uuid')
 | 
				
			||||||
  id!: string;
 | 
					  id!: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Column()
 | 
					  @Column({ unique: true })
 | 
				
			||||||
  type!: TagType;
 | 
					  value!: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Column()
 | 
					  @CreateDateColumn({ type: 'timestamptz' })
 | 
				
			||||||
  name!: string;
 | 
					  createdAt!: Date;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @ManyToOne(() => UserEntity, (user) => user.tags)
 | 
					  @UpdateDateColumn({ type: 'timestamptz' })
 | 
				
			||||||
  user!: UserEntity;
 | 
					  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()
 | 
					  @Column()
 | 
				
			||||||
  userId!: string;
 | 
					  userId!: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Column({ type: 'uuid', comment: 'The new renamed tagId', nullable: true })
 | 
					  @ManyToMany(() => AssetEntity, (asset) => asset.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
 | 
				
			||||||
  renameTagId!: string | null;
 | 
					  assets?: AssetEntity[];
 | 
				
			||||||
 | 
					 | 
				
			||||||
  @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',
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -130,6 +130,7 @@ export enum Permission {
 | 
				
			|||||||
  TAG_READ = 'tag.read',
 | 
					  TAG_READ = 'tag.read',
 | 
				
			||||||
  TAG_UPDATE = 'tag.update',
 | 
					  TAG_UPDATE = 'tag.update',
 | 
				
			||||||
  TAG_DELETE = 'tag.delete',
 | 
					  TAG_DELETE = 'tag.delete',
 | 
				
			||||||
 | 
					  TAG_ASSET = 'tag.asset',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  ADMIN_USER_CREATE = 'admin.user.create',
 | 
					  ADMIN_USER_CREATE = 'admin.user.create',
 | 
				
			||||||
  ADMIN_USER_READ = 'admin.user.read',
 | 
					  ADMIN_USER_READ = 'admin.user.read',
 | 
				
			||||||
 | 
				
			|||||||
@ -46,4 +46,8 @@ export interface IAccessRepository {
 | 
				
			|||||||
  stack: {
 | 
					  stack: {
 | 
				
			||||||
    checkOwnerAccess(userId: string, stackIds: Set<string>): Promise<Set<string>>;
 | 
					    checkOwnerAccess(userId: string, stackIds: Set<string>): Promise<Set<string>>;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  tag: {
 | 
				
			||||||
 | 
					    checkOwnerAccess(userId: string, tagIds: Set<string>): Promise<Set<string>>;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -51,6 +51,7 @@ export interface AssetBuilderOptions {
 | 
				
			|||||||
  isTrashed?: boolean;
 | 
					  isTrashed?: boolean;
 | 
				
			||||||
  isDuplicate?: boolean;
 | 
					  isDuplicate?: boolean;
 | 
				
			||||||
  albumId?: string;
 | 
					  albumId?: string;
 | 
				
			||||||
 | 
					  tagId?: string;
 | 
				
			||||||
  personId?: string;
 | 
					  personId?: string;
 | 
				
			||||||
  userIds?: string[];
 | 
					  userIds?: string[];
 | 
				
			||||||
  withStacked?: boolean;
 | 
					  withStacked?: boolean;
 | 
				
			||||||
 | 
				
			|||||||
@ -17,6 +17,10 @@ type EmitEventMap = {
 | 
				
			|||||||
  'album.update': [{ id: string; updatedBy: string }];
 | 
					  'album.update': [{ id: string; updatedBy: string }];
 | 
				
			||||||
  'album.invite': [{ id: string; userId: string }];
 | 
					  'album.invite': [{ id: string; userId: string }];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // tag events
 | 
				
			||||||
 | 
					  'asset.tag': [{ assetId: string }];
 | 
				
			||||||
 | 
					  'asset.untag': [{ assetId: string }];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // user events
 | 
					  // user events
 | 
				
			||||||
  'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
 | 
					  'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -155,6 +155,7 @@ export interface ISidecarWriteJob extends IEntityJob {
 | 
				
			|||||||
  latitude?: number;
 | 
					  latitude?: number;
 | 
				
			||||||
  longitude?: number;
 | 
					  longitude?: number;
 | 
				
			||||||
  rating?: number;
 | 
					  rating?: number;
 | 
				
			||||||
 | 
					  tags?: true;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface IDeferrableJob extends IEntityJob {
 | 
					export interface IDeferrableJob extends IEntityJob {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,17 +1,19 @@
 | 
				
			|||||||
import { AssetEntity } from 'src/entities/asset.entity';
 | 
					 | 
				
			||||||
import { TagEntity } from 'src/entities/tag.entity';
 | 
					import { TagEntity } from 'src/entities/tag.entity';
 | 
				
			||||||
 | 
					import { IBulkAsset } from 'src/utils/asset.util';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const ITagRepository = 'ITagRepository';
 | 
					export const ITagRepository = 'ITagRepository';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface ITagRepository {
 | 
					export type AssetTagItem = { assetId: string; tagId: string };
 | 
				
			||||||
  getById(userId: string, tagId: string): Promise<TagEntity | null>;
 | 
					
 | 
				
			||||||
 | 
					export interface ITagRepository extends IBulkAsset {
 | 
				
			||||||
  getAll(userId: string): Promise<TagEntity[]>;
 | 
					  getAll(userId: string): Promise<TagEntity[]>;
 | 
				
			||||||
 | 
					  getByValue(userId: string, value: string): Promise<TagEntity | null>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  create(tag: Partial<TagEntity>): Promise<TagEntity>;
 | 
					  create(tag: Partial<TagEntity>): Promise<TagEntity>;
 | 
				
			||||||
  update(tag: Partial<TagEntity>): Promise<TagEntity>;
 | 
					  get(id: string): Promise<TagEntity | null>;
 | 
				
			||||||
  remove(tag: TagEntity): Promise<void>;
 | 
					  update(tag: { id: string } & Partial<TagEntity>): Promise<TagEntity>;
 | 
				
			||||||
  hasName(userId: string, name: string): Promise<boolean>;
 | 
					  delete(id: string): Promise<void>;
 | 
				
			||||||
  hasAsset(userId: string, tagId: string, assetId: string): Promise<boolean>;
 | 
					
 | 
				
			||||||
  getAssets(userId: string, tagId: string): Promise<AssetEntity[]>;
 | 
					  upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise<void>;
 | 
				
			||||||
  addAssets(userId: string, tagId: string, assetIds: string[]): Promise<void>;
 | 
					  upsertAssetIds(items: AssetTagItem[]): Promise<AssetTagItem[]>;
 | 
				
			||||||
  removeAssets(userId: string, tagId: string, assetIds: string[]): Promise<void>;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										57
									
								
								server/src/migrations/1724790460210-NestedTagTable.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								server/src/migrations/1724790460210-NestedTagTable.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,57 @@
 | 
				
			|||||||
 | 
					import { MigrationInterface, QueryRunner } from "typeorm";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class NestedTagTable1724790460210 implements MigrationInterface {
 | 
				
			||||||
 | 
					    name = 'NestedTagTable1724790460210'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    public async up(queryRunner: QueryRunner): Promise<void> {
 | 
				
			||||||
 | 
					        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<void> {
 | 
				
			||||||
 | 
					        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`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -259,6 +259,17 @@ WHERE
 | 
				
			|||||||
    AND ("StackEntity"."ownerId" = $2)
 | 
					    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
 | 
					-- AccessRepository.timeline.checkPartnerAccess
 | 
				
			||||||
SELECT
 | 
					SELECT
 | 
				
			||||||
  "partner"."sharedById" AS "partner_sharedById",
 | 
					  "partner"."sharedById" AS "partner_sharedById",
 | 
				
			||||||
 | 
				
			|||||||
@ -184,10 +184,12 @@ SELECT
 | 
				
			|||||||
  "AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags",
 | 
					  "AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags",
 | 
				
			||||||
  "AssetEntity__AssetEntity_smartInfo"."objects" AS "AssetEntity__AssetEntity_smartInfo_objects",
 | 
					  "AssetEntity__AssetEntity_smartInfo"."objects" AS "AssetEntity__AssetEntity_smartInfo_objects",
 | 
				
			||||||
  "AssetEntity__AssetEntity_tags"."id" AS "AssetEntity__AssetEntity_tags_id",
 | 
					  "AssetEntity__AssetEntity_tags"."id" AS "AssetEntity__AssetEntity_tags_id",
 | 
				
			||||||
  "AssetEntity__AssetEntity_tags"."type" AS "AssetEntity__AssetEntity_tags_type",
 | 
					  "AssetEntity__AssetEntity_tags"."value" AS "AssetEntity__AssetEntity_tags_value",
 | 
				
			||||||
  "AssetEntity__AssetEntity_tags"."name" AS "AssetEntity__AssetEntity_tags_name",
 | 
					  "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"."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"."id" AS "AssetEntity__AssetEntity_faces_id",
 | 
				
			||||||
  "AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId",
 | 
					  "AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId",
 | 
				
			||||||
  "AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId",
 | 
					  "AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId",
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										30
									
								
								server/src/queries/tag.repository.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								server/src/queries/tag.repository.sql
									
									
									
									
									
										Normal file
									
								
							@ -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)
 | 
				
			||||||
@ -12,6 +12,7 @@ import { PersonEntity } from 'src/entities/person.entity';
 | 
				
			|||||||
import { SessionEntity } from 'src/entities/session.entity';
 | 
					import { SessionEntity } from 'src/entities/session.entity';
 | 
				
			||||||
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
 | 
					import { SharedLinkEntity } from 'src/entities/shared-link.entity';
 | 
				
			||||||
import { StackEntity } from 'src/entities/stack.entity';
 | 
					import { StackEntity } from 'src/entities/stack.entity';
 | 
				
			||||||
 | 
					import { TagEntity } from 'src/entities/tag.entity';
 | 
				
			||||||
import { AlbumUserRole } from 'src/enum';
 | 
					import { AlbumUserRole } from 'src/enum';
 | 
				
			||||||
import { IAccessRepository } from 'src/interfaces/access.interface';
 | 
					import { IAccessRepository } from 'src/interfaces/access.interface';
 | 
				
			||||||
import { Instrumentation } from 'src/utils/instrumentation';
 | 
					import { Instrumentation } from 'src/utils/instrumentation';
 | 
				
			||||||
@ -25,6 +26,7 @@ type IMemoryAccess = IAccessRepository['memory'];
 | 
				
			|||||||
type IPersonAccess = IAccessRepository['person'];
 | 
					type IPersonAccess = IAccessRepository['person'];
 | 
				
			||||||
type IPartnerAccess = IAccessRepository['partner'];
 | 
					type IPartnerAccess = IAccessRepository['partner'];
 | 
				
			||||||
type IStackAccess = IAccessRepository['stack'];
 | 
					type IStackAccess = IAccessRepository['stack'];
 | 
				
			||||||
 | 
					type ITagAccess = IAccessRepository['tag'];
 | 
				
			||||||
type ITimelineAccess = IAccessRepository['timeline'];
 | 
					type ITimelineAccess = IAccessRepository['timeline'];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Instrumentation()
 | 
					@Instrumentation()
 | 
				
			||||||
@ -444,6 +446,28 @@ class PartnerAccess implements IPartnerAccess {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class TagAccess implements ITagAccess {
 | 
				
			||||||
 | 
					  constructor(private tagRepository: Repository<TagEntity>) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
 | 
				
			||||||
 | 
					  @ChunkedSet({ paramIndex: 1 })
 | 
				
			||||||
 | 
					  async checkOwnerAccess(userId: string, tagIds: Set<string>): Promise<Set<string>> {
 | 
				
			||||||
 | 
					    if (tagIds.size === 0) {
 | 
				
			||||||
 | 
					      return new Set();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return this.tagRepository
 | 
				
			||||||
 | 
					      .find({
 | 
				
			||||||
 | 
					        select: { id: true },
 | 
				
			||||||
 | 
					        where: {
 | 
				
			||||||
 | 
					          id: In([...tagIds]),
 | 
				
			||||||
 | 
					          userId,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					      .then((tags) => new Set(tags.map((tag) => tag.id)));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class AccessRepository implements IAccessRepository {
 | 
					export class AccessRepository implements IAccessRepository {
 | 
				
			||||||
  activity: IActivityAccess;
 | 
					  activity: IActivityAccess;
 | 
				
			||||||
  album: IAlbumAccess;
 | 
					  album: IAlbumAccess;
 | 
				
			||||||
@ -453,6 +477,7 @@ export class AccessRepository implements IAccessRepository {
 | 
				
			|||||||
  person: IPersonAccess;
 | 
					  person: IPersonAccess;
 | 
				
			||||||
  partner: IPartnerAccess;
 | 
					  partner: IPartnerAccess;
 | 
				
			||||||
  stack: IStackAccess;
 | 
					  stack: IStackAccess;
 | 
				
			||||||
 | 
					  tag: ITagAccess;
 | 
				
			||||||
  timeline: ITimelineAccess;
 | 
					  timeline: ITimelineAccess;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  constructor(
 | 
					  constructor(
 | 
				
			||||||
@ -467,6 +492,7 @@ export class AccessRepository implements IAccessRepository {
 | 
				
			|||||||
    @InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository<SharedLinkEntity>,
 | 
					    @InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository<SharedLinkEntity>,
 | 
				
			||||||
    @InjectRepository(SessionEntity) sessionRepository: Repository<SessionEntity>,
 | 
					    @InjectRepository(SessionEntity) sessionRepository: Repository<SessionEntity>,
 | 
				
			||||||
    @InjectRepository(StackEntity) stackRepository: Repository<StackEntity>,
 | 
					    @InjectRepository(StackEntity) stackRepository: Repository<StackEntity>,
 | 
				
			||||||
 | 
					    @InjectRepository(TagEntity) tagRepository: Repository<TagEntity>,
 | 
				
			||||||
  ) {
 | 
					  ) {
 | 
				
			||||||
    this.activity = new ActivityAccess(activityRepository, albumRepository);
 | 
					    this.activity = new ActivityAccess(activityRepository, albumRepository);
 | 
				
			||||||
    this.album = new AlbumAccess(albumRepository, sharedLinkRepository);
 | 
					    this.album = new AlbumAccess(albumRepository, sharedLinkRepository);
 | 
				
			||||||
@ -476,6 +502,7 @@ export class AccessRepository implements IAccessRepository {
 | 
				
			|||||||
    this.person = new PersonAccess(assetFaceRepository, personRepository);
 | 
					    this.person = new PersonAccess(assetFaceRepository, personRepository);
 | 
				
			||||||
    this.partner = new PartnerAccess(partnerRepository);
 | 
					    this.partner = new PartnerAccess(partnerRepository);
 | 
				
			||||||
    this.stack = new StackAccess(stackRepository);
 | 
					    this.stack = new StackAccess(stackRepository);
 | 
				
			||||||
 | 
					    this.tag = new TagAccess(tagRepository);
 | 
				
			||||||
    this.timeline = new TimelineAccess(partnerRepository);
 | 
					    this.timeline = new TimelineAccess(partnerRepository);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -723,6 +723,15 @@ export class AssetRepository implements IAssetRepository {
 | 
				
			|||||||
      builder.andWhere('asset.type = :assetType', { assetType: options.assetType });
 | 
					      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;
 | 
					    let stackJoined = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (options.exifInfo !== false) {
 | 
					    if (options.exifInfo !== false) {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,33 +1,36 @@
 | 
				
			|||||||
import { Injectable } from '@nestjs/common';
 | 
					import { Injectable } from '@nestjs/common';
 | 
				
			||||||
import { InjectRepository } from '@nestjs/typeorm';
 | 
					import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
 | 
				
			||||||
import { AssetEntity } from 'src/entities/asset.entity';
 | 
					import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
 | 
				
			||||||
import { TagEntity } from 'src/entities/tag.entity';
 | 
					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 { Instrumentation } from 'src/utils/instrumentation';
 | 
				
			||||||
import { Repository } from 'typeorm';
 | 
					import { DataSource, In, Repository } from 'typeorm';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Instrumentation()
 | 
					@Instrumentation()
 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
export class TagRepository implements ITagRepository {
 | 
					export class TagRepository implements ITagRepository {
 | 
				
			||||||
  constructor(
 | 
					  constructor(
 | 
				
			||||||
    @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
 | 
					    @InjectDataSource() private dataSource: DataSource,
 | 
				
			||||||
    @InjectRepository(TagEntity) private repository: Repository<TagEntity>,
 | 
					    @InjectRepository(TagEntity) private repository: Repository<TagEntity>,
 | 
				
			||||||
  ) {}
 | 
					  ) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getById(userId: string, id: string): Promise<TagEntity | null> {
 | 
					  get(id: string): Promise<TagEntity | null> {
 | 
				
			||||||
    return this.repository.findOne({
 | 
					    return this.repository.findOne({ where: { id } });
 | 
				
			||||||
      where: {
 | 
					 | 
				
			||||||
        id,
 | 
					 | 
				
			||||||
        userId,
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      relations: {
 | 
					 | 
				
			||||||
        user: true,
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  getAll(userId: string): Promise<TagEntity[]> {
 | 
					  getByValue(userId: string, value: string): Promise<TagEntity | null> {
 | 
				
			||||||
    return this.repository.find({ where: { userId } });
 | 
					    return this.repository.findOne({ where: { userId, value } });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async getAll(userId: string): Promise<TagEntity[]> {
 | 
				
			||||||
 | 
					    const tags = await this.repository.find({
 | 
				
			||||||
 | 
					      where: { userId },
 | 
				
			||||||
 | 
					      order: {
 | 
				
			||||||
 | 
					        value: 'ASC',
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return tags;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  create(tag: Partial<TagEntity>): Promise<TagEntity> {
 | 
					  create(tag: Partial<TagEntity>): Promise<TagEntity> {
 | 
				
			||||||
@ -38,89 +41,99 @@ export class TagRepository implements ITagRepository {
 | 
				
			|||||||
    return this.save(tag);
 | 
					    return this.save(tag);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async remove(tag: TagEntity): Promise<void> {
 | 
					  async delete(id: string): Promise<void> {
 | 
				
			||||||
    await this.repository.remove(tag);
 | 
					    await this.repository.delete(id);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async getAssets(userId: string, tagId: string): Promise<AssetEntity[]> {
 | 
					  @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
 | 
				
			||||||
    return this.assetRepository.find({
 | 
					  @ChunkedSet({ paramIndex: 1 })
 | 
				
			||||||
      where: {
 | 
					  async getAssetIds(tagId: string, assetIds: string[]): Promise<Set<string>> {
 | 
				
			||||||
        tags: {
 | 
					    if (assetIds.length === 0) {
 | 
				
			||||||
          userId,
 | 
					      return new Set();
 | 
				
			||||||
          id: tagId,
 | 
					    }
 | 
				
			||||||
        },
 | 
					
 | 
				
			||||||
      },
 | 
					    const results = await this.dataSource
 | 
				
			||||||
      relations: {
 | 
					      .createQueryBuilder()
 | 
				
			||||||
        exifInfo: true,
 | 
					      .select('tag_asset.assetsId', 'assetId')
 | 
				
			||||||
        tags: true,
 | 
					      .from('tag_asset', 'tag_asset')
 | 
				
			||||||
        faces: {
 | 
					      .where('"tag_asset"."tagsId" = :tagId', { tagId })
 | 
				
			||||||
          person: true,
 | 
					      .andWhere('"tag_asset"."assetsId" IN (:...assetIds)', { assetIds })
 | 
				
			||||||
        },
 | 
					      .getRawMany<{ assetId: string }>();
 | 
				
			||||||
      },
 | 
					
 | 
				
			||||||
      order: {
 | 
					    return new Set(results.map(({ assetId }) => assetId));
 | 
				
			||||||
        createdAt: 'ASC',
 | 
					  }
 | 
				
			||||||
      },
 | 
					
 | 
				
			||||||
 | 
					  @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
 | 
				
			||||||
 | 
					  async addAssetIds(tagId: string, assetIds: string[]): Promise<void> {
 | 
				
			||||||
 | 
					    if (assetIds.length === 0) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await this.dataSource.manager
 | 
				
			||||||
 | 
					      .createQueryBuilder()
 | 
				
			||||||
 | 
					      .insert()
 | 
				
			||||||
 | 
					      .into('tag_asset', ['tagsId', 'assetsId'])
 | 
				
			||||||
 | 
					      .values(assetIds.map((assetId) => ({ tagsId: tagId, assetsId: assetId })))
 | 
				
			||||||
 | 
					      .execute();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
 | 
				
			||||||
 | 
					  @Chunked({ paramIndex: 1 })
 | 
				
			||||||
 | 
					  async removeAssetIds(tagId: string, assetIds: string[]): Promise<void> {
 | 
				
			||||||
 | 
					    if (assetIds.length === 0) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await this.dataSource
 | 
				
			||||||
 | 
					      .createQueryBuilder()
 | 
				
			||||||
 | 
					      .delete()
 | 
				
			||||||
 | 
					      .from('tag_asset')
 | 
				
			||||||
 | 
					      .where({
 | 
				
			||||||
 | 
					        tagsId: tagId,
 | 
				
			||||||
 | 
					        assetsId: In(assetIds),
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					      .execute();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagId: DummyValue.UUID }]] })
 | 
				
			||||||
 | 
					  @Chunked()
 | 
				
			||||||
 | 
					  async upsertAssetIds(items: AssetTagItem[]): Promise<AssetTagItem[]> {
 | 
				
			||||||
 | 
					    if (items.length === 0) {
 | 
				
			||||||
 | 
					      return [];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const { identifiers } = await this.dataSource
 | 
				
			||||||
 | 
					      .createQueryBuilder()
 | 
				
			||||||
 | 
					      .insert()
 | 
				
			||||||
 | 
					      .into('tag_asset', ['assetsId', 'tagsId'])
 | 
				
			||||||
 | 
					      .values(items.map(({ assetId, tagId }) => ({ assetsId: assetId, tagsId: tagId })))
 | 
				
			||||||
 | 
					      .execute();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return (identifiers as Array<{ assetsId: string; tagsId: string }>).map(({ assetsId, tagsId }) => ({
 | 
				
			||||||
 | 
					      assetId: assetsId,
 | 
				
			||||||
 | 
					      tagId: tagsId,
 | 
				
			||||||
 | 
					    }));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }) {
 | 
				
			||||||
 | 
					    await this.dataSource.transaction(async (manager) => {
 | 
				
			||||||
 | 
					      await manager.createQueryBuilder().delete().from('tag_asset').where({ assetsId: assetId }).execute();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (tagIds.length === 0) {
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await manager
 | 
				
			||||||
 | 
					        .createQueryBuilder()
 | 
				
			||||||
 | 
					        .insert()
 | 
				
			||||||
 | 
					        .into('tag_asset', ['tagsId', 'assetsId'])
 | 
				
			||||||
 | 
					        .values(tagIds.map((tagId) => ({ tagsId: tagId, assetsId: assetId })))
 | 
				
			||||||
 | 
					        .execute();
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async addAssets(userId: string, id: string, assetIds: string[]): Promise<void> {
 | 
					  private async save(partial: Partial<TagEntity>): Promise<TagEntity> {
 | 
				
			||||||
    for (const assetId of assetIds) {
 | 
					    const { id } = await this.repository.save(partial);
 | 
				
			||||||
      const asset = await this.assetRepository.findOneOrFail({
 | 
					    return this.repository.findOneOrFail({ where: { id } });
 | 
				
			||||||
        where: {
 | 
					 | 
				
			||||||
          ownerId: userId,
 | 
					 | 
				
			||||||
          id: assetId,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        relations: {
 | 
					 | 
				
			||||||
          tags: true,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
      asset.tags.push({ id } as TagEntity);
 | 
					 | 
				
			||||||
      await this.assetRepository.save(asset);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async removeAssets(userId: string, id: string, assetIds: string[]): Promise<void> {
 | 
					 | 
				
			||||||
    for (const assetId of assetIds) {
 | 
					 | 
				
			||||||
      const asset = await this.assetRepository.findOneOrFail({
 | 
					 | 
				
			||||||
        where: {
 | 
					 | 
				
			||||||
          ownerId: userId,
 | 
					 | 
				
			||||||
          id: assetId,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        relations: {
 | 
					 | 
				
			||||||
          tags: true,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
      asset.tags = asset.tags.filter((tag) => tag.id !== id);
 | 
					 | 
				
			||||||
      await this.assetRepository.save(asset);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  hasAsset(userId: string, tagId: string, assetId: string): Promise<boolean> {
 | 
					 | 
				
			||||||
    return this.repository.exists({
 | 
					 | 
				
			||||||
      where: {
 | 
					 | 
				
			||||||
        id: tagId,
 | 
					 | 
				
			||||||
        userId,
 | 
					 | 
				
			||||||
        assets: {
 | 
					 | 
				
			||||||
          id: assetId,
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      relations: {
 | 
					 | 
				
			||||||
        assets: true,
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  hasName(userId: string, name: string): Promise<boolean> {
 | 
					 | 
				
			||||||
    return this.repository.exists({
 | 
					 | 
				
			||||||
      where: {
 | 
					 | 
				
			||||||
        name,
 | 
					 | 
				
			||||||
        userId,
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  private async save(tag: Partial<TagEntity>): Promise<TagEntity> {
 | 
					 | 
				
			||||||
    const { id } = await this.repository.save(tag);
 | 
					 | 
				
			||||||
    return this.repository.findOneOrFail({ where: { id }, relations: { user: true } });
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -18,11 +18,13 @@ import { IMoveRepository } from 'src/interfaces/move.interface';
 | 
				
			|||||||
import { IPersonRepository } from 'src/interfaces/person.interface';
 | 
					import { IPersonRepository } from 'src/interfaces/person.interface';
 | 
				
			||||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
 | 
					import { IStorageRepository } from 'src/interfaces/storage.interface';
 | 
				
			||||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
 | 
					import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
 | 
				
			||||||
 | 
					import { ITagRepository } from 'src/interfaces/tag.interface';
 | 
				
			||||||
import { IUserRepository } from 'src/interfaces/user.interface';
 | 
					import { IUserRepository } from 'src/interfaces/user.interface';
 | 
				
			||||||
import { MetadataService, Orientation } from 'src/services/metadata.service';
 | 
					import { MetadataService, Orientation } from 'src/services/metadata.service';
 | 
				
			||||||
import { assetStub } from 'test/fixtures/asset.stub';
 | 
					import { assetStub } from 'test/fixtures/asset.stub';
 | 
				
			||||||
import { fileStub } from 'test/fixtures/file.stub';
 | 
					import { fileStub } from 'test/fixtures/file.stub';
 | 
				
			||||||
import { probeStub } from 'test/fixtures/media.stub';
 | 
					import { probeStub } from 'test/fixtures/media.stub';
 | 
				
			||||||
 | 
					import { tagStub } from 'test/fixtures/tag.stub';
 | 
				
			||||||
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
 | 
					import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
 | 
				
			||||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
 | 
					import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
 | 
				
			||||||
import { newCryptoRepositoryMock } from 'test/repositories/crypto.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 { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
 | 
				
			||||||
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
 | 
					import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
 | 
				
			||||||
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.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 { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
 | 
				
			||||||
import { Mocked } from 'vitest';
 | 
					import { Mocked } from 'vitest';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -56,6 +59,7 @@ describe(MetadataService.name, () => {
 | 
				
			|||||||
  let databaseMock: Mocked<IDatabaseRepository>;
 | 
					  let databaseMock: Mocked<IDatabaseRepository>;
 | 
				
			||||||
  let userMock: Mocked<IUserRepository>;
 | 
					  let userMock: Mocked<IUserRepository>;
 | 
				
			||||||
  let loggerMock: Mocked<ILoggerRepository>;
 | 
					  let loggerMock: Mocked<ILoggerRepository>;
 | 
				
			||||||
 | 
					  let tagMock: Mocked<ITagRepository>;
 | 
				
			||||||
  let sut: MetadataService;
 | 
					  let sut: MetadataService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  beforeEach(() => {
 | 
					  beforeEach(() => {
 | 
				
			||||||
@ -74,6 +78,7 @@ describe(MetadataService.name, () => {
 | 
				
			|||||||
    databaseMock = newDatabaseRepositoryMock();
 | 
					    databaseMock = newDatabaseRepositoryMock();
 | 
				
			||||||
    userMock = newUserRepositoryMock();
 | 
					    userMock = newUserRepositoryMock();
 | 
				
			||||||
    loggerMock = newLoggerRepositoryMock();
 | 
					    loggerMock = newLoggerRepositoryMock();
 | 
				
			||||||
 | 
					    tagMock = newTagRepositoryMock();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    sut = new MetadataService(
 | 
					    sut = new MetadataService(
 | 
				
			||||||
      albumMock,
 | 
					      albumMock,
 | 
				
			||||||
@ -89,6 +94,7 @@ describe(MetadataService.name, () => {
 | 
				
			|||||||
      personMock,
 | 
					      personMock,
 | 
				
			||||||
      storageMock,
 | 
					      storageMock,
 | 
				
			||||||
      systemMock,
 | 
					      systemMock,
 | 
				
			||||||
 | 
					      tagMock,
 | 
				
			||||||
      userMock,
 | 
					      userMock,
 | 
				
			||||||
      loggerMock,
 | 
					      loggerMock,
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
@ -356,6 +362,72 @@ describe(MetadataService.name, () => {
 | 
				
			|||||||
      expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null }));
 | 
					      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 () => {
 | 
					    it('should not apply motion photos if asset is video', async () => {
 | 
				
			||||||
      assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]);
 | 
					      assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]);
 | 
				
			||||||
      mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
 | 
					      mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
 | 
				
			||||||
 | 
				
			|||||||
@ -22,8 +22,8 @@ import {
 | 
				
			|||||||
  IEntityJob,
 | 
					  IEntityJob,
 | 
				
			||||||
  IJobRepository,
 | 
					  IJobRepository,
 | 
				
			||||||
  ISidecarWriteJob,
 | 
					  ISidecarWriteJob,
 | 
				
			||||||
  JOBS_ASSET_PAGINATION_SIZE,
 | 
					 | 
				
			||||||
  JobName,
 | 
					  JobName,
 | 
				
			||||||
 | 
					  JOBS_ASSET_PAGINATION_SIZE,
 | 
				
			||||||
  JobStatus,
 | 
					  JobStatus,
 | 
				
			||||||
  QueueName,
 | 
					  QueueName,
 | 
				
			||||||
} from 'src/interfaces/job.interface';
 | 
					} from 'src/interfaces/job.interface';
 | 
				
			||||||
@ -35,8 +35,10 @@ import { IMoveRepository } from 'src/interfaces/move.interface';
 | 
				
			|||||||
import { IPersonRepository } from 'src/interfaces/person.interface';
 | 
					import { IPersonRepository } from 'src/interfaces/person.interface';
 | 
				
			||||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
 | 
					import { IStorageRepository } from 'src/interfaces/storage.interface';
 | 
				
			||||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
 | 
					import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
 | 
				
			||||||
 | 
					import { ITagRepository } from 'src/interfaces/tag.interface';
 | 
				
			||||||
import { IUserRepository } from 'src/interfaces/user.interface';
 | 
					import { IUserRepository } from 'src/interfaces/user.interface';
 | 
				
			||||||
import { usePagination } from 'src/utils/pagination';
 | 
					import { usePagination } from 'src/utils/pagination';
 | 
				
			||||||
 | 
					import { upsertTags } from 'src/utils/tag';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/** look for a date from these tags (in order) */
 | 
					/** look for a date from these tags (in order) */
 | 
				
			||||||
const EXIF_DATE_TAGS: Array<keyof Tags> = [
 | 
					const EXIF_DATE_TAGS: Array<keyof Tags> = [
 | 
				
			||||||
@ -105,6 +107,7 @@ export class MetadataService {
 | 
				
			|||||||
    @Inject(IPersonRepository) personRepository: IPersonRepository,
 | 
					    @Inject(IPersonRepository) personRepository: IPersonRepository,
 | 
				
			||||||
    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
					    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
 | 
				
			||||||
    @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
 | 
					    @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
 | 
				
			||||||
 | 
					    @Inject(ITagRepository) private tagRepository: ITagRepository,
 | 
				
			||||||
    @Inject(IUserRepository) private userRepository: IUserRepository,
 | 
					    @Inject(IUserRepository) private userRepository: IUserRepository,
 | 
				
			||||||
    @Inject(ILoggerRepository) private logger: ILoggerRepository,
 | 
					    @Inject(ILoggerRepository) private logger: ILoggerRepository,
 | 
				
			||||||
  ) {
 | 
					  ) {
 | 
				
			||||||
@ -217,24 +220,27 @@ export class MetadataService {
 | 
				
			|||||||
      return JobStatus.FAILED;
 | 
					      return JobStatus.FAILED;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { exifData, tags } = await this.exifData(asset);
 | 
					    const { exifData, exifTags } = await this.exifData(asset);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (asset.type === AssetType.VIDEO) {
 | 
					    if (asset.type === AssetType.VIDEO) {
 | 
				
			||||||
      await this.applyVideoMetadata(asset, exifData);
 | 
					      await this.applyVideoMetadata(asset, exifData);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    await this.applyMotionPhotos(asset, tags);
 | 
					    await this.applyMotionPhotos(asset, exifTags);
 | 
				
			||||||
    await this.applyReverseGeocoding(asset, exifData);
 | 
					    await this.applyReverseGeocoding(asset, exifData);
 | 
				
			||||||
 | 
					    await this.applyTagList(asset, exifTags);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    await this.assetRepository.upsertExif(exifData);
 | 
					    await this.assetRepository.upsertExif(exifData);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const dateTimeOriginal = exifData.dateTimeOriginal;
 | 
					    const dateTimeOriginal = exifData.dateTimeOriginal;
 | 
				
			||||||
    let localDateTime = dateTimeOriginal ?? undefined;
 | 
					    let localDateTime = dateTimeOriginal ?? undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const timeZoneOffset = tzOffset(firstDateTime(tags as Tags)) ?? 0;
 | 
					    const timeZoneOffset = tzOffset(firstDateTime(exifTags as Tags)) ?? 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (dateTimeOriginal && timeZoneOffset) {
 | 
					    if (dateTimeOriginal && timeZoneOffset) {
 | 
				
			||||||
      localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000);
 | 
					      localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    await this.assetRepository.update({
 | 
					    await this.assetRepository.update({
 | 
				
			||||||
      id: asset.id,
 | 
					      id: asset.id,
 | 
				
			||||||
      duration: asset.duration,
 | 
					      duration: asset.duration,
 | 
				
			||||||
@ -278,22 +284,35 @@ export class MetadataService {
 | 
				
			|||||||
    return this.processSidecar(id, false);
 | 
					    return this.processSidecar(id, false);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @OnEmit({ event: 'asset.tag' })
 | 
				
			||||||
 | 
					  async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) {
 | 
				
			||||||
 | 
					    await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @OnEmit({ event: 'asset.untag' })
 | 
				
			||||||
 | 
					  async handleUntagAsset({ assetId }: ArgOf<'asset.untag'>) {
 | 
				
			||||||
 | 
					    await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async handleSidecarWrite(job: ISidecarWriteJob): Promise<JobStatus> {
 | 
					  async handleSidecarWrite(job: ISidecarWriteJob): Promise<JobStatus> {
 | 
				
			||||||
    const { id, description, dateTimeOriginal, latitude, longitude, rating } = job;
 | 
					    const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job;
 | 
				
			||||||
    const [asset] = await this.assetRepository.getByIds([id]);
 | 
					    const [asset] = await this.assetRepository.getByIds([id], { tags: true });
 | 
				
			||||||
    if (!asset) {
 | 
					    if (!asset) {
 | 
				
			||||||
      return JobStatus.FAILED;
 | 
					      return JobStatus.FAILED;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const tagsList = (asset.tags || []).map((tag) => tag.value);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
 | 
					    const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
 | 
				
			||||||
    const exif = _.omitBy<Tags>(
 | 
					    const exif = _.omitBy(
 | 
				
			||||||
      {
 | 
					      <Tags>{
 | 
				
			||||||
        Description: description,
 | 
					        Description: description,
 | 
				
			||||||
        ImageDescription: description,
 | 
					        ImageDescription: description,
 | 
				
			||||||
        DateTimeOriginal: dateTimeOriginal,
 | 
					        DateTimeOriginal: dateTimeOriginal,
 | 
				
			||||||
        GPSLatitude: latitude,
 | 
					        GPSLatitude: latitude,
 | 
				
			||||||
        GPSLongitude: longitude,
 | 
					        GPSLongitude: longitude,
 | 
				
			||||||
        Rating: rating,
 | 
					        Rating: rating,
 | 
				
			||||||
 | 
					        TagsList: tags ? tagsList : undefined,
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      _.isUndefined,
 | 
					      _.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) {
 | 
					  private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) {
 | 
				
			||||||
    if (asset.type !== AssetType.IMAGE) {
 | 
					    if (asset.type !== AssetType.IMAGE) {
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
@ -466,7 +507,7 @@ export class MetadataService {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  private async exifData(
 | 
					  private async exifData(
 | 
				
			||||||
    asset: AssetEntity,
 | 
					    asset: AssetEntity,
 | 
				
			||||||
  ): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; tags: ImmichTags }> {
 | 
					  ): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> {
 | 
				
			||||||
    const stats = await this.storageRepository.stat(asset.originalPath);
 | 
					    const stats = await this.storageRepository.stat(asset.originalPath);
 | 
				
			||||||
    const mediaTags = await this.repository.readTags(asset.originalPath);
 | 
					    const mediaTags = await this.repository.readTags(asset.originalPath);
 | 
				
			||||||
    const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null;
 | 
					    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 = {
 | 
					    const exifData = {
 | 
				
			||||||
      // altitude: tags.GPSAltitude ?? null,
 | 
					      // altitude: tags.GPSAltitude ?? null,
 | 
				
			||||||
      assetId: asset.id,
 | 
					      assetId: asset.id,
 | 
				
			||||||
      bitsPerSample: this.getBitsPerSample(tags),
 | 
					      bitsPerSample: this.getBitsPerSample(exifTags),
 | 
				
			||||||
      colorspace: tags.ColorSpace ?? null,
 | 
					      colorspace: exifTags.ColorSpace ?? null,
 | 
				
			||||||
      dateTimeOriginal: this.getDateTimeOriginal(tags) ?? asset.fileCreatedAt,
 | 
					      dateTimeOriginal: this.getDateTimeOriginal(exifTags) ?? asset.fileCreatedAt,
 | 
				
			||||||
      description: String(tags.ImageDescription || tags.Description || '').trim(),
 | 
					      description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
 | 
				
			||||||
      exifImageHeight: validate(tags.ImageHeight),
 | 
					      exifImageHeight: validate(exifTags.ImageHeight),
 | 
				
			||||||
      exifImageWidth: validate(tags.ImageWidth),
 | 
					      exifImageWidth: validate(exifTags.ImageWidth),
 | 
				
			||||||
      exposureTime: tags.ExposureTime ?? null,
 | 
					      exposureTime: exifTags.ExposureTime ?? null,
 | 
				
			||||||
      fileSizeInByte: stats.size,
 | 
					      fileSizeInByte: stats.size,
 | 
				
			||||||
      fNumber: validate(tags.FNumber),
 | 
					      fNumber: validate(exifTags.FNumber),
 | 
				
			||||||
      focalLength: validate(tags.FocalLength),
 | 
					      focalLength: validate(exifTags.FocalLength),
 | 
				
			||||||
      fps: validate(Number.parseFloat(tags.VideoFrameRate!)),
 | 
					      fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
 | 
				
			||||||
      iso: validate(tags.ISO),
 | 
					      iso: validate(exifTags.ISO),
 | 
				
			||||||
      latitude: validate(tags.GPSLatitude),
 | 
					      latitude: validate(exifTags.GPSLatitude),
 | 
				
			||||||
      lensModel: tags.LensModel ?? null,
 | 
					      lensModel: exifTags.LensModel ?? null,
 | 
				
			||||||
      livePhotoCID: (tags.ContentIdentifier || tags.MediaGroupUUID) ?? null,
 | 
					      livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
 | 
				
			||||||
      autoStackId: this.getAutoStackId(tags),
 | 
					      autoStackId: this.getAutoStackId(exifTags),
 | 
				
			||||||
      longitude: validate(tags.GPSLongitude),
 | 
					      longitude: validate(exifTags.GPSLongitude),
 | 
				
			||||||
      make: tags.Make ?? null,
 | 
					      make: exifTags.Make ?? null,
 | 
				
			||||||
      model: tags.Model ?? null,
 | 
					      model: exifTags.Model ?? null,
 | 
				
			||||||
      modifyDate: exifDate(tags.ModifyDate) ?? asset.fileModifiedAt,
 | 
					      modifyDate: exifDate(exifTags.ModifyDate) ?? asset.fileModifiedAt,
 | 
				
			||||||
      orientation: validate(tags.Orientation)?.toString() ?? null,
 | 
					      orientation: validate(exifTags.Orientation)?.toString() ?? null,
 | 
				
			||||||
      profileDescription: tags.ProfileDescription || null,
 | 
					      profileDescription: exifTags.ProfileDescription || null,
 | 
				
			||||||
      projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null,
 | 
					      projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
 | 
				
			||||||
      timeZone: tags.tz ?? null,
 | 
					      timeZone: exifTags.tz ?? null,
 | 
				
			||||||
      rating: tags.Rating ?? null,
 | 
					      rating: exifTags.Rating ?? null,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (exifData.latitude === 0 && exifData.longitude === 0) {
 | 
					    if (exifData.latitude === 0 && exifData.longitude === 0) {
 | 
				
			||||||
@ -519,7 +560,7 @@ export class MetadataService {
 | 
				
			|||||||
      exifData.longitude = null;
 | 
					      exifData.longitude = null;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return { exifData, tags };
 | 
					    return { exifData, exifTags };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private getAutoStackId(tags: ImmichTags | null): string | null {
 | 
					  private getAutoStackId(tags: ImmichTags | null): string | null {
 | 
				
			||||||
 | 
				
			|||||||
@ -1,21 +1,28 @@
 | 
				
			|||||||
import { BadRequestException } from '@nestjs/common';
 | 
					import { BadRequestException } from '@nestjs/common';
 | 
				
			||||||
import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto';
 | 
					import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
 | 
				
			||||||
import { TagType } from 'src/entities/tag.entity';
 | 
					import { IEventRepository } from 'src/interfaces/event.interface';
 | 
				
			||||||
import { ITagRepository } from 'src/interfaces/tag.interface';
 | 
					import { ITagRepository } from 'src/interfaces/tag.interface';
 | 
				
			||||||
import { TagService } from 'src/services/tag.service';
 | 
					import { TagService } from 'src/services/tag.service';
 | 
				
			||||||
import { assetStub } from 'test/fixtures/asset.stub';
 | 
					 | 
				
			||||||
import { authStub } from 'test/fixtures/auth.stub';
 | 
					import { authStub } from 'test/fixtures/auth.stub';
 | 
				
			||||||
import { tagResponseStub, tagStub } from 'test/fixtures/tag.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 { newTagRepositoryMock } from 'test/repositories/tag.repository.mock';
 | 
				
			||||||
import { Mocked } from 'vitest';
 | 
					import { Mocked } from 'vitest';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
describe(TagService.name, () => {
 | 
					describe(TagService.name, () => {
 | 
				
			||||||
  let sut: TagService;
 | 
					  let sut: TagService;
 | 
				
			||||||
 | 
					  let accessMock: IAccessRepositoryMock;
 | 
				
			||||||
 | 
					  let eventMock: Mocked<IEventRepository>;
 | 
				
			||||||
  let tagMock: Mocked<ITagRepository>;
 | 
					  let tagMock: Mocked<ITagRepository>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  beforeEach(() => {
 | 
					  beforeEach(() => {
 | 
				
			||||||
 | 
					    accessMock = newAccessRepositoryMock();
 | 
				
			||||||
 | 
					    eventMock = newEventRepositoryMock();
 | 
				
			||||||
    tagMock = newTagRepositoryMock();
 | 
					    tagMock = newTagRepositoryMock();
 | 
				
			||||||
    sut = new TagService(tagMock);
 | 
					    sut = new TagService(accessMock, eventMock, tagMock);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1']));
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  it('should work', () => {
 | 
					  it('should work', () => {
 | 
				
			||||||
@ -30,148 +37,216 @@ describe(TagService.name, () => {
 | 
				
			|||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('getById', () => {
 | 
					  describe('get', () => {
 | 
				
			||||||
    it('should throw an error for an invalid id', async () => {
 | 
					    it('should throw an error for an invalid id', async () => {
 | 
				
			||||||
      tagMock.getById.mockResolvedValue(null);
 | 
					      tagMock.get.mockResolvedValue(null);
 | 
				
			||||||
      await expect(sut.getById(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
 | 
					      await expect(sut.get(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
 | 
				
			||||||
      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
 | 
					      expect(tagMock.get).toHaveBeenCalledWith('tag-1');
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('should return a tag for a user', async () => {
 | 
					    it('should return a tag for a user', async () => {
 | 
				
			||||||
      tagMock.getById.mockResolvedValue(tagStub.tag1);
 | 
					      tagMock.get.mockResolvedValue(tagStub.tag1);
 | 
				
			||||||
      await expect(sut.getById(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1);
 | 
					      await expect(sut.get(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1);
 | 
				
			||||||
      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
 | 
					      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', () => {
 | 
					  describe('create', () => {
 | 
				
			||||||
    it('should throw an error for a duplicate tag', async () => {
 | 
					    it('should throw an error for a duplicate tag', async () => {
 | 
				
			||||||
      tagMock.hasName.mockResolvedValue(true);
 | 
					      tagMock.getByValue.mockResolvedValue(tagStub.tag1);
 | 
				
			||||||
      await expect(sut.create(authStub.admin, { name: 'tag-1', type: TagType.CUSTOM })).rejects.toBeInstanceOf(
 | 
					      await expect(sut.create(authStub.admin, { name: 'tag-1' })).rejects.toBeInstanceOf(BadRequestException);
 | 
				
			||||||
        BadRequestException,
 | 
					      expect(tagMock.getByValue).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      expect(tagMock.hasName).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
 | 
					 | 
				
			||||||
      expect(tagMock.create).not.toHaveBeenCalled();
 | 
					      expect(tagMock.create).not.toHaveBeenCalled();
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('should create a new tag', async () => {
 | 
					    it('should create a new tag', async () => {
 | 
				
			||||||
      tagMock.create.mockResolvedValue(tagStub.tag1);
 | 
					      tagMock.create.mockResolvedValue(tagStub.tag1);
 | 
				
			||||||
      await expect(sut.create(authStub.admin, { name: 'tag-1', type: TagType.CUSTOM })).resolves.toEqual(
 | 
					      await expect(sut.create(authStub.admin, { name: 'tag-1' })).resolves.toEqual(tagResponseStub.tag1);
 | 
				
			||||||
        tagResponseStub.tag1,
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      expect(tagMock.create).toHaveBeenCalledWith({
 | 
					      expect(tagMock.create).toHaveBeenCalledWith({
 | 
				
			||||||
        userId: authStub.admin.user.id,
 | 
					        userId: authStub.admin.user.id,
 | 
				
			||||||
        name: 'tag-1',
 | 
					        value: 'tag-1',
 | 
				
			||||||
        type: TagType.CUSTOM,
 | 
					 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('update', () => {
 | 
					  describe('update', () => {
 | 
				
			||||||
    it('should throw an error for an invalid id', async () => {
 | 
					    it('should throw an error for no update permission', async () => {
 | 
				
			||||||
      tagMock.getById.mockResolvedValue(null);
 | 
					      accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
 | 
				
			||||||
      await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag-2' })).rejects.toBeInstanceOf(BadRequestException);
 | 
					      await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).rejects.toBeInstanceOf(
 | 
				
			||||||
      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
 | 
					        BadRequestException,
 | 
				
			||||||
      expect(tagMock.remove).not.toHaveBeenCalled();
 | 
					      );
 | 
				
			||||||
 | 
					      expect(tagMock.update).not.toHaveBeenCalled();
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('should update a tag', async () => {
 | 
					    it('should update a tag', async () => {
 | 
				
			||||||
      tagMock.getById.mockResolvedValue(tagStub.tag1);
 | 
					      accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1']));
 | 
				
			||||||
      tagMock.update.mockResolvedValue(tagStub.tag1);
 | 
					      tagMock.update.mockResolvedValue(tagStub.color1);
 | 
				
			||||||
      await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag-2' })).resolves.toEqual(tagResponseStub.tag1);
 | 
					      await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).resolves.toEqual(tagResponseStub.color1);
 | 
				
			||||||
      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
 | 
					      expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', color: '#000000' });
 | 
				
			||||||
      expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', name: 'tag-2' });
 | 
					    });
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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', () => {
 | 
					  describe('remove', () => {
 | 
				
			||||||
    it('should throw an error for an invalid id', async () => {
 | 
					    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);
 | 
					      await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
 | 
				
			||||||
      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
 | 
					      expect(tagMock.delete).not.toHaveBeenCalled();
 | 
				
			||||||
      expect(tagMock.remove).not.toHaveBeenCalled();
 | 
					 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('should remove a tag', async () => {
 | 
					    it('should remove a tag', async () => {
 | 
				
			||||||
      tagMock.getById.mockResolvedValue(tagStub.tag1);
 | 
					      tagMock.get.mockResolvedValue(tagStub.tag1);
 | 
				
			||||||
      await sut.remove(authStub.admin, 'tag-1');
 | 
					      await sut.remove(authStub.admin, 'tag-1');
 | 
				
			||||||
      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
 | 
					      expect(tagMock.delete).toHaveBeenCalledWith('tag-1');
 | 
				
			||||||
      expect(tagMock.remove).toHaveBeenCalledWith(tagStub.tag1);
 | 
					 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('getAssets', () => {
 | 
					  describe('bulkTagAssets', () => {
 | 
				
			||||||
    it('should throw an error for an invalid id', async () => {
 | 
					    it('should handle invalid requests', async () => {
 | 
				
			||||||
      tagMock.getById.mockResolvedValue(null);
 | 
					      accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
 | 
				
			||||||
      await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
 | 
					      tagMock.upsertAssetIds.mockResolvedValue([]);
 | 
				
			||||||
      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
 | 
					      await expect(sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1'], assetIds: ['asset-1'] })).resolves.toEqual({
 | 
				
			||||||
      expect(tagMock.remove).not.toHaveBeenCalled();
 | 
					        count: 0,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					      expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([]);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('should get the assets for a tag', async () => {
 | 
					    it('should upsert records', async () => {
 | 
				
			||||||
      tagMock.getById.mockResolvedValue(tagStub.tag1);
 | 
					      accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
 | 
				
			||||||
      tagMock.getAssets.mockResolvedValue([assetStub.image]);
 | 
					      accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
 | 
				
			||||||
      await sut.getAssets(authStub.admin, 'tag-1');
 | 
					      tagMock.upsertAssetIds.mockResolvedValue([
 | 
				
			||||||
      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
 | 
					        { tagId: 'tag-1', assetId: 'asset-1' },
 | 
				
			||||||
      expect(tagMock.getAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-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', () => {
 | 
					  describe('addAssets', () => {
 | 
				
			||||||
    it('should throw an error for an invalid id', async () => {
 | 
					    it('should handle invalid ids', async () => {
 | 
				
			||||||
      tagMock.getById.mockResolvedValue(null);
 | 
					      tagMock.get.mockResolvedValue(null);
 | 
				
			||||||
      await expect(sut.addAssets(authStub.admin, 'tag-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
 | 
					      tagMock.getAssetIds.mockResolvedValue(new Set([]));
 | 
				
			||||||
        BadRequestException,
 | 
					      await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
 | 
				
			||||||
      );
 | 
					        { id: 'asset-1', success: false, error: 'no_permission' },
 | 
				
			||||||
      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
 | 
					      ]);
 | 
				
			||||||
      expect(tagMock.addAssets).not.toHaveBeenCalled();
 | 
					      expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']);
 | 
				
			||||||
 | 
					      expect(tagMock.addAssetIds).not.toHaveBeenCalled();
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('should reject duplicate asset ids and accept new ones', async () => {
 | 
					    it('should accept accept ids that are new and reject the rest', async () => {
 | 
				
			||||||
      tagMock.getById.mockResolvedValue(tagStub.tag1);
 | 
					      tagMock.get.mockResolvedValue(tagStub.tag1);
 | 
				
			||||||
      tagMock.hasAsset.mockImplementation((userId, tagId, assetId) => Promise.resolve(assetId === 'asset-1'));
 | 
					      tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1']));
 | 
				
			||||||
 | 
					      accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await expect(
 | 
					      await expect(
 | 
				
			||||||
        sut.addAssets(authStub.admin, 'tag-1', {
 | 
					        sut.addAssets(authStub.admin, 'tag-1', {
 | 
				
			||||||
          assetIds: ['asset-1', 'asset-2'],
 | 
					          ids: ['asset-1', 'asset-2'],
 | 
				
			||||||
        }),
 | 
					        }),
 | 
				
			||||||
      ).resolves.toEqual([
 | 
					      ).resolves.toEqual([
 | 
				
			||||||
        { assetId: 'asset-1', success: false, error: AssetIdErrorReason.DUPLICATE },
 | 
					        { id: 'asset-1', success: false, error: BulkIdErrorReason.DUPLICATE },
 | 
				
			||||||
        { assetId: 'asset-2', success: true },
 | 
					        { id: 'asset-2', success: true },
 | 
				
			||||||
      ]);
 | 
					      ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
 | 
					      expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
 | 
				
			||||||
      expect(tagMock.hasAsset).toHaveBeenCalledTimes(2);
 | 
					      expect(tagMock.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']);
 | 
				
			||||||
      expect(tagMock.addAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1', ['asset-2']);
 | 
					 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe('removeAssets', () => {
 | 
					  describe('removeAssets', () => {
 | 
				
			||||||
    it('should throw an error for an invalid id', async () => {
 | 
					    it('should throw an error for an invalid id', async () => {
 | 
				
			||||||
      tagMock.getById.mockResolvedValue(null);
 | 
					      tagMock.get.mockResolvedValue(null);
 | 
				
			||||||
      await expect(sut.removeAssets(authStub.admin, 'tag-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
 | 
					      tagMock.getAssetIds.mockResolvedValue(new Set());
 | 
				
			||||||
        BadRequestException,
 | 
					      await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
 | 
				
			||||||
      );
 | 
					        { id: 'asset-1', success: false, error: 'not_found' },
 | 
				
			||||||
      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
 | 
					      ]);
 | 
				
			||||||
      expect(tagMock.removeAssets).not.toHaveBeenCalled();
 | 
					 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it('should accept accept ids that are tagged and reject the rest', async () => {
 | 
					    it('should accept accept ids that are tagged and reject the rest', async () => {
 | 
				
			||||||
      tagMock.getById.mockResolvedValue(tagStub.tag1);
 | 
					      tagMock.get.mockResolvedValue(tagStub.tag1);
 | 
				
			||||||
      tagMock.hasAsset.mockImplementation((userId, tagId, assetId) => Promise.resolve(assetId === 'asset-1'));
 | 
					      tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1']));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      await expect(
 | 
					      await expect(
 | 
				
			||||||
        sut.removeAssets(authStub.admin, 'tag-1', {
 | 
					        sut.removeAssets(authStub.admin, 'tag-1', {
 | 
				
			||||||
          assetIds: ['asset-1', 'asset-2'],
 | 
					          ids: ['asset-1', 'asset-2'],
 | 
				
			||||||
        }),
 | 
					        }),
 | 
				
			||||||
      ).resolves.toEqual([
 | 
					      ).resolves.toEqual([
 | 
				
			||||||
        { assetId: 'asset-1', success: true },
 | 
					        { id: 'asset-1', success: true },
 | 
				
			||||||
        { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND },
 | 
					        { id: 'asset-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
 | 
				
			||||||
      ]);
 | 
					      ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
 | 
					      expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
 | 
				
			||||||
      expect(tagMock.hasAsset).toHaveBeenCalledTimes(2);
 | 
					      expect(tagMock.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']);
 | 
				
			||||||
      expect(tagMock.removeAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1', ['asset-1']);
 | 
					 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
				
			|||||||
@ -1,102 +1,145 @@
 | 
				
			|||||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
 | 
					import { BadRequestException, Inject, Injectable } from '@nestjs/common';
 | 
				
			||||||
import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
 | 
					import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
 | 
				
			||||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
 | 
					 | 
				
			||||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
 | 
					 | 
				
			||||||
import { AuthDto } from 'src/dtos/auth.dto';
 | 
					import { AuthDto } from 'src/dtos/auth.dto';
 | 
				
			||||||
import { CreateTagDto, TagResponseDto, UpdateTagDto, mapTag } from 'src/dtos/tag.dto';
 | 
					import {
 | 
				
			||||||
import { ITagRepository } from 'src/interfaces/tag.interface';
 | 
					  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()
 | 
					@Injectable()
 | 
				
			||||||
export class TagService {
 | 
					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) {
 | 
					  async getAll(auth: AuthDto) {
 | 
				
			||||||
    return this.repository.getAll(auth.user.id).then((tags) => tags.map((tag) => mapTag(tag)));
 | 
					    const tags = await this.repository.getAll(auth.user.id);
 | 
				
			||||||
 | 
					    return tags.map((tag) => mapTag(tag));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async getById(auth: AuthDto, id: string): Promise<TagResponseDto> {
 | 
					  async get(auth: AuthDto, id: string): Promise<TagResponseDto> {
 | 
				
			||||||
    const tag = await this.findOrFail(auth, id);
 | 
					    await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [id] });
 | 
				
			||||||
 | 
					    const tag = await this.findOrFail(id);
 | 
				
			||||||
    return mapTag(tag);
 | 
					    return mapTag(tag);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async create(auth: AuthDto, dto: CreateTagDto) {
 | 
					  async create(auth: AuthDto, dto: TagCreateDto) {
 | 
				
			||||||
    const duplicate = await this.repository.hasName(auth.user.id, dto.name);
 | 
					    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) {
 | 
					    if (duplicate) {
 | 
				
			||||||
      throw new BadRequestException(`A tag with that name already exists`);
 | 
					      throw new BadRequestException(`A tag with that name already exists`);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const tag = await this.repository.create({
 | 
					    const tag = await this.repository.create({ userId, value, parent });
 | 
				
			||||||
      userId: auth.user.id,
 | 
					 | 
				
			||||||
      name: dto.name,
 | 
					 | 
				
			||||||
      type: dto.type,
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return mapTag(tag);
 | 
					    return mapTag(tag);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async update(auth: AuthDto, id: string, dto: UpdateTagDto): Promise<TagResponseDto> {
 | 
					  async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise<TagResponseDto> {
 | 
				
			||||||
    await this.findOrFail(auth, id);
 | 
					    await requireAccess(this.access, { auth, permission: Permission.TAG_UPDATE, ids: [id] });
 | 
				
			||||||
    const tag = await this.repository.update({ id, name: dto.name });
 | 
					
 | 
				
			||||||
 | 
					    const { color } = dto;
 | 
				
			||||||
 | 
					    const tag = await this.repository.update({ id, color });
 | 
				
			||||||
    return mapTag(tag);
 | 
					    return mapTag(tag);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async upsert(auth: AuthDto, dto: TagUpsertDto) {
 | 
				
			||||||
 | 
					    const tags = await upsertTags(this.repository, { userId: auth.user.id, tags: dto.tags });
 | 
				
			||||||
 | 
					    return tags.map((tag) => mapTag(tag));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async remove(auth: AuthDto, id: string): Promise<void> {
 | 
					  async remove(auth: AuthDto, id: string): Promise<void> {
 | 
				
			||||||
    const tag = await this.findOrFail(auth, id);
 | 
					    await requireAccess(this.access, { auth, permission: Permission.TAG_DELETE, ids: [id] });
 | 
				
			||||||
    await this.repository.remove(tag);
 | 
					
 | 
				
			||||||
 | 
					    // TODO sync tag changes for affected assets
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await this.repository.delete(id);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async getAssets(auth: AuthDto, id: string): Promise<AssetResponseDto[]> {
 | 
					  async bulkTagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise<TagBulkAssetsResponseDto> {
 | 
				
			||||||
    await this.findOrFail(auth, id);
 | 
					    const [tagIds, assetIds] = await Promise.all([
 | 
				
			||||||
    const assets = await this.repository.getAssets(auth.user.id, id);
 | 
					      checkAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }),
 | 
				
			||||||
    return assets.map((asset) => mapAsset(asset));
 | 
					      checkAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }),
 | 
				
			||||||
  }
 | 
					    ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
 | 
					    const items: AssetTagItem[] = [];
 | 
				
			||||||
    await this.findOrFail(auth, id);
 | 
					    for (const tagId of tagIds) {
 | 
				
			||||||
 | 
					      for (const assetId of assetIds) {
 | 
				
			||||||
    const results: AssetIdsResponseDto[] = [];
 | 
					        items.push({ tagId, assetId });
 | 
				
			||||||
    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 });
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    await this.repository.addAssets(
 | 
					    const results = await this.repository.upsertAssetIds(items);
 | 
				
			||||||
      auth.user.id,
 | 
					    for (const assetId of new Set(results.map((item) => item.assetId))) {
 | 
				
			||||||
      id,
 | 
					      await this.eventRepository.emit('asset.tag', { assetId });
 | 
				
			||||||
      results.filter((result) => result.success).map((result) => result.assetId),
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return { count: results.length };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
 | 
				
			||||||
 | 
					    await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const results = await addAssets(
 | 
				
			||||||
 | 
					      auth,
 | 
				
			||||||
 | 
					      { access: this.access, bulk: this.repository },
 | 
				
			||||||
 | 
					      { parentId: id, assetIds: dto.ids },
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const { id: assetId, success } of results) {
 | 
				
			||||||
 | 
					      if (success) {
 | 
				
			||||||
 | 
					        await this.eventRepository.emit('asset.tag', { assetId });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return results;
 | 
					    return results;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async removeAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
 | 
					  async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
 | 
				
			||||||
    await this.findOrFail(auth, id);
 | 
					    await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const results: AssetIdsResponseDto[] = [];
 | 
					    const results = await removeAssets(
 | 
				
			||||||
    for (const assetId of dto.assetIds) {
 | 
					      auth,
 | 
				
			||||||
      const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId);
 | 
					      { access: this.access, bulk: this.repository },
 | 
				
			||||||
      if (hasAsset) {
 | 
					      { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.TAG_DELETE },
 | 
				
			||||||
        results.push({ assetId, success: true });
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    await this.repository.removeAssets(
 | 
					 | 
				
			||||||
      auth.user.id,
 | 
					 | 
				
			||||||
      id,
 | 
					 | 
				
			||||||
      results.filter((result) => result.success).map((result) => result.assetId),
 | 
					 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const { id: assetId, success } of results) {
 | 
				
			||||||
 | 
					      if (success) {
 | 
				
			||||||
 | 
					        await this.eventRepository.emit('asset.untag', { assetId });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return results;
 | 
					    return results;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private async findOrFail(auth: AuthDto, id: string) {
 | 
					  private async findOrFail(id: string) {
 | 
				
			||||||
    const tag = await this.repository.getById(auth.user.id, id);
 | 
					    const tag = await this.repository.get(id);
 | 
				
			||||||
    if (!tag) {
 | 
					    if (!tag) {
 | 
				
			||||||
      throw new BadRequestException('Tag not found');
 | 
					      throw new BadRequestException('Tag not found');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
@ -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) {
 | 
					    if (dto.withPartners) {
 | 
				
			||||||
      const requestedArchived = dto.isArchived === true || dto.isArchived === undefined;
 | 
					      const requestedArchived = dto.isArchived === true || dto.isArchived === undefined;
 | 
				
			||||||
      const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
 | 
					      const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
 | 
				
			||||||
 | 
				
			|||||||
@ -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<Set<string>> => {
 | 
				
			||||||
  const idSet = Array.isArray(ids) ? new Set(ids) : ids;
 | 
					  const idSet = Array.isArray(ids) ? new Set(ids) : ids;
 | 
				
			||||||
  if (idSet.size === 0) {
 | 
					  if (idSet.size === 0) {
 | 
				
			||||||
    return new Set<string>();
 | 
					    return new Set<string>();
 | 
				
			||||||
@ -52,7 +55,10 @@ export const checkAccess = async (access: IAccessRepository, { ids, auth, permis
 | 
				
			|||||||
    : checkOtherAccess(access, { auth, permission, ids: idSet });
 | 
					    : checkOtherAccess(access, { auth, permission, ids: idSet });
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const checkSharedLinkAccess = async (access: IAccessRepository, request: SharedLinkAccessRequest) => {
 | 
					const checkSharedLinkAccess = async (
 | 
				
			||||||
 | 
					  access: IAccessRepository,
 | 
				
			||||||
 | 
					  request: SharedLinkAccessRequest,
 | 
				
			||||||
 | 
					): Promise<Set<string>> => {
 | 
				
			||||||
  const { sharedLink, permission, ids } = request;
 | 
					  const { sharedLink, permission, ids } = request;
 | 
				
			||||||
  const sharedLinkId = sharedLink.id;
 | 
					  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<Set<string>> => {
 | 
				
			||||||
  const { auth, permission, ids } = request;
 | 
					  const { auth, permission, ids } = request;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  switch (permission) {
 | 
					  switch (permission) {
 | 
				
			||||||
@ -211,6 +217,13 @@ const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessR
 | 
				
			|||||||
      return await access.authDevice.checkOwnerAccess(auth.user.id, ids);
 | 
					      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: {
 | 
					    case Permission.TIMELINE_READ: {
 | 
				
			||||||
      const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set<string>();
 | 
					      const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set<string>();
 | 
				
			||||||
      const isPartner = await access.timeline.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
 | 
					      const isPartner = await access.timeline.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
 | 
				
			||||||
 | 
				
			|||||||
@ -2,4 +2,4 @@ export const fromChecksum = (checksum: string): Buffer => {
 | 
				
			|||||||
  return Buffer.from(checksum, checksum.length === 28 ? 'base64' : 'hex');
 | 
					  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 = <T>(param: T | T[]) => (Array.isArray(param) ? param[0] : param);
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										30
									
								
								server/src/utils/tag.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								server/src/utils/tag.ts
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										55
									
								
								server/test/fixtures/tag.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										55
									
								
								server/test/fixtures/tag.stub.ts
									
									
									
									
										vendored
									
									
								
							@ -1,24 +1,65 @@
 | 
				
			|||||||
import { TagResponseDto } from 'src/dtos/tag.dto';
 | 
					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';
 | 
					import { userStub } from 'test/fixtures/user.stub';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const parent = Object.freeze<TagEntity>({
 | 
				
			||||||
 | 
					  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<TagEntity>({
 | 
				
			||||||
 | 
					  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 = {
 | 
					export const tagStub = {
 | 
				
			||||||
  tag1: Object.freeze<TagEntity>({
 | 
					  tag1: Object.freeze<TagEntity>({
 | 
				
			||||||
    id: 'tag-1',
 | 
					    id: 'tag-1',
 | 
				
			||||||
    name: 'Tag1',
 | 
					    createdAt: new Date('2021-01-01T00:00:00Z'),
 | 
				
			||||||
    type: TagType.CUSTOM,
 | 
					    updatedAt: new Date('2021-01-01T00:00:00Z'),
 | 
				
			||||||
 | 
					    value: 'Tag1',
 | 
				
			||||||
 | 
					    color: null,
 | 
				
			||||||
 | 
					    userId: userStub.admin.id,
 | 
				
			||||||
 | 
					    user: userStub.admin,
 | 
				
			||||||
 | 
					  }),
 | 
				
			||||||
 | 
					  parent,
 | 
				
			||||||
 | 
					  child,
 | 
				
			||||||
 | 
					  color1: Object.freeze<TagEntity>({
 | 
				
			||||||
 | 
					    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,
 | 
					    userId: userStub.admin.id,
 | 
				
			||||||
    user: userStub.admin,
 | 
					    user: userStub.admin,
 | 
				
			||||||
    renameTagId: null,
 | 
					 | 
				
			||||||
    assets: [],
 | 
					 | 
				
			||||||
  }),
 | 
					  }),
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const tagResponseStub = {
 | 
					export const tagResponseStub = {
 | 
				
			||||||
  tag1: Object.freeze<TagResponseDto>({
 | 
					  tag1: Object.freeze<TagResponseDto>({
 | 
				
			||||||
    id: 'tag-1',
 | 
					    id: 'tag-1',
 | 
				
			||||||
 | 
					    createdAt: new Date('2021-01-01T00:00:00Z'),
 | 
				
			||||||
 | 
					    updatedAt: new Date('2021-01-01T00:00:00Z'),
 | 
				
			||||||
    name: 'Tag1',
 | 
					    name: 'Tag1',
 | 
				
			||||||
    type: 'CUSTOM',
 | 
					    value: 'Tag1',
 | 
				
			||||||
    userId: 'admin_id',
 | 
					  }),
 | 
				
			||||||
 | 
					  color1: Object.freeze<TagResponseDto>({
 | 
				
			||||||
 | 
					    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',
 | 
				
			||||||
  }),
 | 
					  }),
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -11,6 +11,7 @@ export interface IAccessRepositoryMock {
 | 
				
			|||||||
  partner: Mocked<IAccessRepository['partner']>;
 | 
					  partner: Mocked<IAccessRepository['partner']>;
 | 
				
			||||||
  stack: Mocked<IAccessRepository['stack']>;
 | 
					  stack: Mocked<IAccessRepository['stack']>;
 | 
				
			||||||
  timeline: Mocked<IAccessRepository['timeline']>;
 | 
					  timeline: Mocked<IAccessRepository['timeline']>;
 | 
				
			||||||
 | 
					  tag: Mocked<IAccessRepository['tag']>;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
 | 
					export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
 | 
				
			||||||
@ -58,5 +59,9 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
 | 
				
			|||||||
    timeline: {
 | 
					    timeline: {
 | 
				
			||||||
      checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()),
 | 
					      checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()),
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    tag: {
 | 
				
			||||||
 | 
					      checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -4,14 +4,17 @@ import { Mocked, vitest } from 'vitest';
 | 
				
			|||||||
export const newTagRepositoryMock = (): Mocked<ITagRepository> => {
 | 
					export const newTagRepositoryMock = (): Mocked<ITagRepository> => {
 | 
				
			||||||
  return {
 | 
					  return {
 | 
				
			||||||
    getAll: vitest.fn(),
 | 
					    getAll: vitest.fn(),
 | 
				
			||||||
    getById: vitest.fn(),
 | 
					    getByValue: vitest.fn(),
 | 
				
			||||||
 | 
					    upsertAssetTags: vitest.fn(),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    get: vitest.fn(),
 | 
				
			||||||
    create: vitest.fn(),
 | 
					    create: vitest.fn(),
 | 
				
			||||||
    update: vitest.fn(),
 | 
					    update: vitest.fn(),
 | 
				
			||||||
    remove: vitest.fn(),
 | 
					    delete: vitest.fn(),
 | 
				
			||||||
    hasAsset: vitest.fn(),
 | 
					
 | 
				
			||||||
    hasName: vitest.fn(),
 | 
					    getAssetIds: vitest.fn(),
 | 
				
			||||||
    getAssets: vitest.fn(),
 | 
					    addAssetIds: vitest.fn(),
 | 
				
			||||||
    addAssets: vitest.fn(),
 | 
					    removeAssetIds: vitest.fn(),
 | 
				
			||||||
    removeAssets: vitest.fn(),
 | 
					    upsertAssetIds: vitest.fn(),
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										80
									
								
								web/src/lib/components/asset-viewer/detail-panel-tags.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								web/src/lib/components/asset-viewer/detail-panel-tags.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,80 @@
 | 
				
			|||||||
 | 
					<script lang="ts">
 | 
				
			||||||
 | 
					  import Icon from '$lib/components/elements/icon.svelte';
 | 
				
			||||||
 | 
					  import TagAssetForm from '$lib/components/forms/tag-asset-form.svelte';
 | 
				
			||||||
 | 
					  import { AppRoute } from '$lib/constants';
 | 
				
			||||||
 | 
					  import { isSharedLink } from '$lib/utils';
 | 
				
			||||||
 | 
					  import { removeTag, tagAssets } from '$lib/utils/asset-utils';
 | 
				
			||||||
 | 
					  import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
 | 
				
			||||||
 | 
					  import { mdiClose, mdiPlus } from '@mdi/js';
 | 
				
			||||||
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  export let asset: AssetResponseDto;
 | 
				
			||||||
 | 
					  export let isOwner: boolean;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  $: tags = asset.tags || [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let isOpen = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleAdd = () => (isOpen = true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleCancel = () => (isOpen = false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleTag = async (tagIds: string[]) => {
 | 
				
			||||||
 | 
					    const ids = await tagAssets({ tagIds, assetIds: [asset.id], showNotification: false });
 | 
				
			||||||
 | 
					    if (ids) {
 | 
				
			||||||
 | 
					      isOpen = false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    asset = await getAssetInfo({ id: asset.id });
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleRemove = async (tagId: string) => {
 | 
				
			||||||
 | 
					    const ids = await removeTag({ tagIds: [tagId], assetIds: [asset.id], showNotification: false });
 | 
				
			||||||
 | 
					    if (ids) {
 | 
				
			||||||
 | 
					      asset = await getAssetInfo({ id: asset.id });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{#if isOwner && !isSharedLink()}
 | 
				
			||||||
 | 
					  <section class="px-4 mt-4">
 | 
				
			||||||
 | 
					    <div class="flex h-10 w-full items-center justify-between text-sm">
 | 
				
			||||||
 | 
					      <h2>{$t('tags').toUpperCase()}</h2>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					    <section class="flex flex-wrap pt-2 gap-1">
 | 
				
			||||||
 | 
					      {#each tags as tag (tag.id)}
 | 
				
			||||||
 | 
					        <div class="flex group transition-all">
 | 
				
			||||||
 | 
					          <a
 | 
				
			||||||
 | 
					            class="inline-block h-min whitespace-nowrap pl-3 pr-1 group-hover:pl-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary rounded-tl-full rounded-bl-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
 | 
				
			||||||
 | 
					            href={encodeURI(`${AppRoute.TAGS}/?path=${tag.value}`)}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <p class="text-sm">
 | 
				
			||||||
 | 
					              {tag.value}
 | 
				
			||||||
 | 
					            </p>
 | 
				
			||||||
 | 
					          </a>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <button
 | 
				
			||||||
 | 
					            type="button"
 | 
				
			||||||
 | 
					            class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-tr-full rounded-br-full place-items-center place-content-center pr-2 pl-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
 | 
				
			||||||
 | 
					            title="Remove tag"
 | 
				
			||||||
 | 
					            on:click={() => handleRemove(tag.id)}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <Icon path={mdiClose} />
 | 
				
			||||||
 | 
					          </button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      {/each}
 | 
				
			||||||
 | 
					      <button
 | 
				
			||||||
 | 
					        type="button"
 | 
				
			||||||
 | 
					        class="rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-200 flex place-items-center place-content-center gap-1 px-2 py-1"
 | 
				
			||||||
 | 
					        title="Add tag"
 | 
				
			||||||
 | 
					        on:click={handleAdd}
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <span class="text-sm px-1 flex place-items-center place-content-center gap-1"><Icon path={mdiPlus} />Add</span>
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					    </section>
 | 
				
			||||||
 | 
					  </section>
 | 
				
			||||||
 | 
					{/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{#if isOpen}
 | 
				
			||||||
 | 
					  <TagAssetForm onTag={(tagsIds) => handleTag(tagsIds)} onCancel={handleCancel} />
 | 
				
			||||||
 | 
					{/if}
 | 
				
			||||||
@ -43,6 +43,7 @@
 | 
				
			|||||||
  import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
 | 
					  import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
  import { goto } from '$app/navigation';
 | 
					  import { goto } from '$app/navigation';
 | 
				
			||||||
 | 
					  import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  export let asset: AssetResponseDto;
 | 
					  export let asset: AssetResponseDto;
 | 
				
			||||||
  export let albums: AlbumResponseDto[] = [];
 | 
					  export let albums: AlbumResponseDto[] = [];
 | 
				
			||||||
@ -157,7 +158,7 @@
 | 
				
			|||||||
  <DetailPanelRating {asset} {isOwner} />
 | 
					  <DetailPanelRating {asset} {isOwner} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  {#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0}
 | 
					  {#if (!isSharedLink() && unassignedFaces.length > 0) || people.length > 0}
 | 
				
			||||||
    <section class="px-4 py-4 text-sm">
 | 
					    <section class="px-4 pt-4 text-sm">
 | 
				
			||||||
      <div class="flex h-10 w-full items-center justify-between">
 | 
					      <div class="flex h-10 w-full items-center justify-between">
 | 
				
			||||||
        <h2>{$t('people').toUpperCase()}</h2>
 | 
					        <h2>{$t('people').toUpperCase()}</h2>
 | 
				
			||||||
        <div class="flex gap-2 items-center">
 | 
					        <div class="flex gap-2 items-center">
 | 
				
			||||||
@ -472,11 +473,11 @@
 | 
				
			|||||||
{/if}
 | 
					{/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{#if albums.length > 0}
 | 
					{#if albums.length > 0}
 | 
				
			||||||
  <section class="p-6 dark:text-immich-dark-fg">
 | 
					  <section class="px-6 pt-6 dark:text-immich-dark-fg">
 | 
				
			||||||
    <p class="pb-4 text-sm">{$t('appears_in').toUpperCase()}</p>
 | 
					    <p class="pb-4 text-sm">{$t('appears_in').toUpperCase()}</p>
 | 
				
			||||||
    {#each albums as album}
 | 
					    {#each albums as album}
 | 
				
			||||||
      <a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>
 | 
					      <a data-sveltekit-preload-data="hover" href={`/albums/${album.id}`}>
 | 
				
			||||||
        <div class="flex gap-4 py-2 hover:cursor-pointer items-center">
 | 
					        <div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
 | 
				
			||||||
          <div>
 | 
					          <div>
 | 
				
			||||||
            <img
 | 
					            <img
 | 
				
			||||||
              alt={album.albumName}
 | 
					              alt={album.albumName}
 | 
				
			||||||
@ -501,6 +502,10 @@
 | 
				
			|||||||
  </section>
 | 
					  </section>
 | 
				
			||||||
{/if}
 | 
					{/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
 | 
				
			||||||
 | 
					  <DetailPanelTags {asset} {isOwner} />
 | 
				
			||||||
 | 
					</section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{#if showEditFaces}
 | 
					{#if showEditFaces}
 | 
				
			||||||
  <PersonSidePanel
 | 
					  <PersonSidePanel
 | 
				
			||||||
    assetId={asset.id}
 | 
					    assetId={asset.id}
 | 
				
			||||||
 | 
				
			|||||||
@ -153,7 +153,7 @@
 | 
				
			|||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    if (dateGroup && assetStore) {
 | 
					    if (dateGroup && assetStore) {
 | 
				
			||||||
      assetStore.taskManager.seperatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false));
 | 
					      assetStore.taskManager.separatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false));
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      intersecting = false;
 | 
					      intersecting = false;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										82
									
								
								web/src/lib/components/forms/tag-asset-form.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								web/src/lib/components/forms/tag-asset-form.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,82 @@
 | 
				
			|||||||
 | 
					<script lang="ts">
 | 
				
			||||||
 | 
					  import { mdiClose, mdiTag } from '@mdi/js';
 | 
				
			||||||
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					  import Button from '../elements/buttons/button.svelte';
 | 
				
			||||||
 | 
					  import Combobox, { type ComboBoxOption } from '../shared-components/combobox.svelte';
 | 
				
			||||||
 | 
					  import FullScreenModal from '../shared-components/full-screen-modal.svelte';
 | 
				
			||||||
 | 
					  import { onMount } from 'svelte';
 | 
				
			||||||
 | 
					  import { getAllTags, type TagResponseDto } from '@immich/sdk';
 | 
				
			||||||
 | 
					  import Icon from '$lib/components/elements/icon.svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  export let onTag: (tagIds: string[]) => void;
 | 
				
			||||||
 | 
					  export let onCancel: () => void;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let allTags: TagResponseDto[] = [];
 | 
				
			||||||
 | 
					  $: tagMap = Object.fromEntries(allTags.map((tag) => [tag.id, tag]));
 | 
				
			||||||
 | 
					  let selectedIds = new Set<string>();
 | 
				
			||||||
 | 
					  $: disabled = selectedIds.size === 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onMount(async () => {
 | 
				
			||||||
 | 
					    allTags = await getAllTags();
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleSubmit = () => onTag([...selectedIds]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleSelect = (option?: ComboBoxOption) => {
 | 
				
			||||||
 | 
					    if (!option) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    selectedIds.add(option.value);
 | 
				
			||||||
 | 
					    selectedIds = selectedIds;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleRemove = (tag: string) => {
 | 
				
			||||||
 | 
					    selectedIds.delete(tag);
 | 
				
			||||||
 | 
					    selectedIds = selectedIds;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<FullScreenModal title={$t('tag_assets')} icon={mdiTag} onClose={onCancel}>
 | 
				
			||||||
 | 
					  <form on:submit|preventDefault={handleSubmit} autocomplete="off" id="create-tag-form">
 | 
				
			||||||
 | 
					    <div class="my-4 flex flex-col gap-2">
 | 
				
			||||||
 | 
					      <Combobox
 | 
				
			||||||
 | 
					        on:select={({ detail: option }) => handleSelect(option)}
 | 
				
			||||||
 | 
					        label={$t('tag')}
 | 
				
			||||||
 | 
					        options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
 | 
				
			||||||
 | 
					        placeholder={$t('search_tags')}
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <section class="flex flex-wrap pt-2 gap-1">
 | 
				
			||||||
 | 
					    {#each selectedIds as tagId (tagId)}
 | 
				
			||||||
 | 
					      {@const tag = tagMap[tagId]}
 | 
				
			||||||
 | 
					      {#if tag}
 | 
				
			||||||
 | 
					        <div class="flex group transition-all">
 | 
				
			||||||
 | 
					          <span
 | 
				
			||||||
 | 
					            class="inline-block h-min whitespace-nowrap pl-3 pr-1 group-hover:pl-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary rounded-tl-full rounded-bl-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <p class="text-sm">
 | 
				
			||||||
 | 
					              {tag.value}
 | 
				
			||||||
 | 
					            </p>
 | 
				
			||||||
 | 
					          </span>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          <button
 | 
				
			||||||
 | 
					            type="button"
 | 
				
			||||||
 | 
					            class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-tr-full rounded-br-full place-items-center place-content-center pr-2 pl-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
 | 
				
			||||||
 | 
					            title="Remove tag"
 | 
				
			||||||
 | 
					            on:click={() => handleRemove(tagId)}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <Icon path={mdiClose} />
 | 
				
			||||||
 | 
					          </button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      {/if}
 | 
				
			||||||
 | 
					    {/each}
 | 
				
			||||||
 | 
					  </section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <svelte:fragment slot="sticky-bottom">
 | 
				
			||||||
 | 
					    <Button color="gray" fullwidth on:click={onCancel}>{$t('cancel')}</Button>
 | 
				
			||||||
 | 
					    <Button type="submit" fullwidth form="create-tag-form" {disabled}>{$t('tag_assets')}</Button>
 | 
				
			||||||
 | 
					  </svelte:fragment>
 | 
				
			||||||
 | 
					</FullScreenModal>
 | 
				
			||||||
@ -35,12 +35,16 @@
 | 
				
			|||||||
  </slot>
 | 
					  </slot>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <section class="relative">
 | 
					  <section class="relative">
 | 
				
			||||||
    {#if title}
 | 
					    {#if title || $$slots.title || $$slots.buttons}
 | 
				
			||||||
      <div
 | 
					      <div
 | 
				
			||||||
        class="absolute flex h-16 w-full place-items-center justify-between border-b p-4 dark:border-immich-dark-gray dark:text-immich-dark-fg"
 | 
					        class="absolute flex h-16 w-full place-items-center justify-between border-b p-4 dark:border-immich-dark-gray dark:text-immich-dark-fg"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <div class="flex gap-2 items-center">
 | 
					        <div class="flex gap-2 items-center">
 | 
				
			||||||
 | 
					          <slot name="title">
 | 
				
			||||||
 | 
					            {#if title}
 | 
				
			||||||
              <div class="font-medium">{title}</div>
 | 
					              <div class="font-medium">{title}</div>
 | 
				
			||||||
 | 
					            {/if}
 | 
				
			||||||
 | 
					          </slot>
 | 
				
			||||||
          {#if description}
 | 
					          {#if description}
 | 
				
			||||||
            <p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>
 | 
					            <p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>
 | 
				
			||||||
          {/if}
 | 
					          {/if}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										47
									
								
								web/src/lib/components/photos-page/actions/tag-action.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								web/src/lib/components/photos-page/actions/tag-action.svelte
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,47 @@
 | 
				
			|||||||
 | 
					<script lang="ts">
 | 
				
			||||||
 | 
					  import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 | 
				
			||||||
 | 
					  import { tagAssets } from '$lib/utils/asset-utils';
 | 
				
			||||||
 | 
					  import { mdiTagMultipleOutline, mdiTimerSand } from '@mdi/js';
 | 
				
			||||||
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					  import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
 | 
				
			||||||
 | 
					  import { getAssetControlContext } from '../asset-select-control-bar.svelte';
 | 
				
			||||||
 | 
					  import TagAssetForm from '$lib/components/forms/tag-asset-form.svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  export let menuItem = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const text = $t('tag');
 | 
				
			||||||
 | 
					  const icon = mdiTagMultipleOutline;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let loading = false;
 | 
				
			||||||
 | 
					  let isOpen = false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const { clearSelect, getOwnedAssets } = getAssetControlContext();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleOpen = () => (isOpen = true);
 | 
				
			||||||
 | 
					  const handleCancel = () => (isOpen = false);
 | 
				
			||||||
 | 
					  const handleTag = async (tagIds: string[]) => {
 | 
				
			||||||
 | 
					    const assets = [...getOwnedAssets()];
 | 
				
			||||||
 | 
					    loading = true;
 | 
				
			||||||
 | 
					    const ids = await tagAssets({ tagIds, assetIds: assets.map((asset) => asset.id) });
 | 
				
			||||||
 | 
					    if (ids) {
 | 
				
			||||||
 | 
					      clearSelect();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    loading = false;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{#if menuItem}
 | 
				
			||||||
 | 
					  <MenuOption {text} {icon} onClick={handleOpen} />
 | 
				
			||||||
 | 
					{/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{#if !menuItem}
 | 
				
			||||||
 | 
					  {#if loading}
 | 
				
			||||||
 | 
					    <CircleIconButton title={$t('loading')} icon={mdiTimerSand} />
 | 
				
			||||||
 | 
					  {:else}
 | 
				
			||||||
 | 
					    <CircleIconButton title={text} {icon} on:click={handleOpen} />
 | 
				
			||||||
 | 
					  {/if}
 | 
				
			||||||
 | 
					{/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{#if isOpen}
 | 
				
			||||||
 | 
					  <TagAssetForm onTag={(tagIds) => handleTag(tagIds)} onCancel={handleCancel} />
 | 
				
			||||||
 | 
					{/if}
 | 
				
			||||||
@ -109,7 +109,7 @@
 | 
				
			|||||||
          );
 | 
					          );
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        onSeparate: () => {
 | 
					        onSeparate: () => {
 | 
				
			||||||
          $assetStore.taskManager.seperatedDateGroup(componentId, dateGroup, () =>
 | 
					          $assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () =>
 | 
				
			||||||
            assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }),
 | 
					            assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }),
 | 
				
			||||||
          );
 | 
					          );
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
@ -186,9 +186,9 @@
 | 
				
			|||||||
              <div
 | 
					              <div
 | 
				
			||||||
                use:intersectionObserver={{
 | 
					                use:intersectionObserver={{
 | 
				
			||||||
                  onIntersect: () => onAssetInGrid?.(asset),
 | 
					                  onIntersect: () => onAssetInGrid?.(asset),
 | 
				
			||||||
                  top: `-${TITLE_HEIGHT}px`,
 | 
					                  top: `${-TITLE_HEIGHT}px`,
 | 
				
			||||||
                  bottom: `-${viewport.height - TITLE_HEIGHT - 1}px`,
 | 
					                  bottom: `${-(viewport.height - TITLE_HEIGHT - 1)}px`,
 | 
				
			||||||
                  right: `-${viewport.width - 1}px`,
 | 
					                  right: `${-(viewport.width - 1)}px`,
 | 
				
			||||||
                  root: assetGridElement,
 | 
					                  root: assetGridElement,
 | 
				
			||||||
                }}
 | 
					                }}
 | 
				
			||||||
                data-asset-id={asset.id}
 | 
					                data-asset-id={asset.id}
 | 
				
			||||||
 | 
				
			|||||||
@ -498,21 +498,21 @@
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function intersectedHandler(bucket: AssetBucket) {
 | 
					  function handleIntersect(bucket: AssetBucket) {
 | 
				
			||||||
    updateLastIntersectedBucketDate();
 | 
					    updateLastIntersectedBucketDate();
 | 
				
			||||||
    const intersectedTask = () => {
 | 
					    const task = () => {
 | 
				
			||||||
      $assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
 | 
					      $assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
 | 
				
			||||||
      void $assetStore.loadBucket(bucket.bucketDate);
 | 
					      void $assetStore.loadBucket(bucket.bucketDate);
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
    $assetStore.taskManager.intersectedBucket(componentId, bucket, intersectedTask);
 | 
					    $assetStore.taskManager.intersectedBucket(componentId, bucket, task);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function seperatedHandler(bucket: AssetBucket) {
 | 
					  function handleSeparate(bucket: AssetBucket) {
 | 
				
			||||||
    const seperatedTask = () => {
 | 
					    const task = () => {
 | 
				
			||||||
      $assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
 | 
					      $assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
 | 
				
			||||||
      bucket.cancel();
 | 
					      bucket.cancel();
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
    $assetStore.taskManager.seperatedBucket(componentId, bucket, seperatedTask);
 | 
					    $assetStore.taskManager.separatedBucket(componentId, bucket, task);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handlePrevious = async () => {
 | 
					  const handlePrevious = async () => {
 | 
				
			||||||
@ -809,8 +809,8 @@
 | 
				
			|||||||
      <div
 | 
					      <div
 | 
				
			||||||
        id="bucket"
 | 
					        id="bucket"
 | 
				
			||||||
        use:intersectionObserver={{
 | 
					        use:intersectionObserver={{
 | 
				
			||||||
          onIntersect: () => intersectedHandler(bucket),
 | 
					          onIntersect: () => handleIntersect(bucket),
 | 
				
			||||||
          onSeparate: () => seperatedHandler(bucket),
 | 
					          onSeparate: () => handleSeparate(bucket),
 | 
				
			||||||
          top: BUCKET_INTERSECTION_ROOT_TOP,
 | 
					          top: BUCKET_INTERSECTION_ROOT_TOP,
 | 
				
			||||||
          bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
 | 
					          bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
 | 
				
			||||||
          root: element,
 | 
					          root: element,
 | 
				
			||||||
 | 
				
			|||||||
@ -1,5 +1,6 @@
 | 
				
			|||||||
<script lang="ts" context="module">
 | 
					<script lang="ts" context="module">
 | 
				
			||||||
  export type ComboBoxOption = {
 | 
					  export type ComboBoxOption = {
 | 
				
			||||||
 | 
					    id?: string;
 | 
				
			||||||
    label: string;
 | 
					    label: string;
 | 
				
			||||||
    value: string;
 | 
					    value: string;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
@ -32,7 +33,7 @@
 | 
				
			|||||||
  export let label: string;
 | 
					  export let label: string;
 | 
				
			||||||
  export let hideLabel = false;
 | 
					  export let hideLabel = false;
 | 
				
			||||||
  export let options: ComboBoxOption[] = [];
 | 
					  export let options: ComboBoxOption[] = [];
 | 
				
			||||||
  export let selectedOption: ComboBoxOption | undefined;
 | 
					  export let selectedOption: ComboBoxOption | undefined = undefined;
 | 
				
			||||||
  export let placeholder = '';
 | 
					  export let placeholder = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
@ -237,7 +238,7 @@
 | 
				
			|||||||
          {$t('no_results')}
 | 
					          {$t('no_results')}
 | 
				
			||||||
        </li>
 | 
					        </li>
 | 
				
			||||||
      {/if}
 | 
					      {/if}
 | 
				
			||||||
      {#each filteredOptions as option, index (option.label)}
 | 
					      {#each filteredOptions as option, index (option.id || option.label)}
 | 
				
			||||||
        <!-- svelte-ignore a11y-click-events-have-key-events -->
 | 
					        <!-- svelte-ignore a11y-click-events-have-key-events -->
 | 
				
			||||||
        <li
 | 
					        <li
 | 
				
			||||||
          aria-selected={index === selectedIndex}
 | 
					          aria-selected={index === selectedIndex}
 | 
				
			||||||
 | 
				
			|||||||
@ -4,6 +4,7 @@
 | 
				
			|||||||
    TEXT = 'text',
 | 
					    TEXT = 'text',
 | 
				
			||||||
    NUMBER = 'number',
 | 
					    NUMBER = 'number',
 | 
				
			||||||
    PASSWORD = 'password',
 | 
					    PASSWORD = 'password',
 | 
				
			||||||
 | 
					    COLOR = 'color',
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -13,6 +14,7 @@
 | 
				
			|||||||
  import { fly } from 'svelte/transition';
 | 
					  import { fly } from 'svelte/transition';
 | 
				
			||||||
  import PasswordField from '../password-field.svelte';
 | 
					  import PasswordField from '../password-field.svelte';
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					  import { onMount, tick } from 'svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  export let inputType: SettingInputFieldType;
 | 
					  export let inputType: SettingInputFieldType;
 | 
				
			||||||
  export let value: string | number;
 | 
					  export let value: string | number;
 | 
				
			||||||
@ -25,8 +27,11 @@
 | 
				
			|||||||
  export let required = false;
 | 
					  export let required = false;
 | 
				
			||||||
  export let disabled = false;
 | 
					  export let disabled = false;
 | 
				
			||||||
  export let isEdited = false;
 | 
					  export let isEdited = false;
 | 
				
			||||||
 | 
					  export let autofocus = false;
 | 
				
			||||||
  export let passwordAutocomplete: string = 'current-password';
 | 
					  export let passwordAutocomplete: string = 'current-password';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let input: HTMLInputElement;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const handleChange: FormEventHandler<HTMLInputElement> = (e) => {
 | 
					  const handleChange: FormEventHandler<HTMLInputElement> = (e) => {
 | 
				
			||||||
    value = e.currentTarget.value;
 | 
					    value = e.currentTarget.value;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -41,6 +46,14 @@
 | 
				
			|||||||
      value = newValue;
 | 
					      value = newValue;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onMount(() => {
 | 
				
			||||||
 | 
					    if (autofocus) {
 | 
				
			||||||
 | 
					      tick()
 | 
				
			||||||
 | 
					        .then(() => input?.focus())
 | 
				
			||||||
 | 
					        .catch((_) => {});
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<div class="mb-4 w-full">
 | 
					<div class="mb-4 w-full">
 | 
				
			||||||
@ -69,8 +82,31 @@
 | 
				
			|||||||
  {/if}
 | 
					  {/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  {#if inputType !== SettingInputFieldType.PASSWORD}
 | 
					  {#if inputType !== SettingInputFieldType.PASSWORD}
 | 
				
			||||||
 | 
					    <div class="flex place-items-center place-content-center">
 | 
				
			||||||
 | 
					      {#if inputType === SettingInputFieldType.COLOR}
 | 
				
			||||||
        <input
 | 
					        <input
 | 
				
			||||||
 | 
					          bind:this={input}
 | 
				
			||||||
 | 
					          class="immich-form-input w-full pb-2 rounded-none mr-1"
 | 
				
			||||||
 | 
					          aria-describedby={desc ? `${label}-desc` : undefined}
 | 
				
			||||||
 | 
					          aria-labelledby="{label}-label"
 | 
				
			||||||
 | 
					          id={label}
 | 
				
			||||||
 | 
					          name={label}
 | 
				
			||||||
 | 
					          type="text"
 | 
				
			||||||
 | 
					          min={min.toString()}
 | 
				
			||||||
 | 
					          max={max.toString()}
 | 
				
			||||||
 | 
					          {step}
 | 
				
			||||||
 | 
					          {required}
 | 
				
			||||||
 | 
					          {value}
 | 
				
			||||||
 | 
					          on:change={handleChange}
 | 
				
			||||||
 | 
					          {disabled}
 | 
				
			||||||
 | 
					          {title}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      {/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <input
 | 
				
			||||||
 | 
					        bind:this={input}
 | 
				
			||||||
        class="immich-form-input w-full pb-2"
 | 
					        class="immich-form-input w-full pb-2"
 | 
				
			||||||
 | 
					        class:color-picker={inputType === SettingInputFieldType.COLOR}
 | 
				
			||||||
        aria-describedby={desc ? `${label}-desc` : undefined}
 | 
					        aria-describedby={desc ? `${label}-desc` : undefined}
 | 
				
			||||||
        aria-labelledby="{label}-label"
 | 
					        aria-labelledby="{label}-label"
 | 
				
			||||||
        id={label}
 | 
					        id={label}
 | 
				
			||||||
@ -85,6 +121,7 @@
 | 
				
			|||||||
        {disabled}
 | 
					        {disabled}
 | 
				
			||||||
        {title}
 | 
					        {title}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
  {:else}
 | 
					  {:else}
 | 
				
			||||||
    <PasswordField
 | 
					    <PasswordField
 | 
				
			||||||
      aria-describedby={desc ? `${label}-desc` : undefined}
 | 
					      aria-describedby={desc ? `${label}-desc` : undefined}
 | 
				
			||||||
@ -100,3 +137,28 @@
 | 
				
			|||||||
    />
 | 
					    />
 | 
				
			||||||
  {/if}
 | 
					  {/if}
 | 
				
			||||||
</div>
 | 
					</div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style>
 | 
				
			||||||
 | 
					  .color-picker {
 | 
				
			||||||
 | 
					    -webkit-appearance: none;
 | 
				
			||||||
 | 
					    -moz-appearance: none;
 | 
				
			||||||
 | 
					    appearance: none;
 | 
				
			||||||
 | 
					    width: 52px;
 | 
				
			||||||
 | 
					    height: 52px;
 | 
				
			||||||
 | 
					    background-color: transparent;
 | 
				
			||||||
 | 
					    border: none;
 | 
				
			||||||
 | 
					    cursor: pointer;
 | 
				
			||||||
 | 
					    padding: 0;
 | 
				
			||||||
 | 
					    margin: 0;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .color-picker::-webkit-color-swatch {
 | 
				
			||||||
 | 
					    border-radius: 14px;
 | 
				
			||||||
 | 
					    border: none;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  .color-picker::-moz-color-swatch {
 | 
				
			||||||
 | 
					    border-radius: 14px;
 | 
				
			||||||
 | 
					    border: none;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
 | 
				
			|||||||
@ -21,6 +21,7 @@
 | 
				
			|||||||
    mdiToolbox,
 | 
					    mdiToolbox,
 | 
				
			||||||
    mdiToolboxOutline,
 | 
					    mdiToolboxOutline,
 | 
				
			||||||
    mdiFolderOutline,
 | 
					    mdiFolderOutline,
 | 
				
			||||||
 | 
					    mdiTagMultipleOutline,
 | 
				
			||||||
  } from '@mdi/js';
 | 
					  } from '@mdi/js';
 | 
				
			||||||
  import SideBarSection from './side-bar-section.svelte';
 | 
					  import SideBarSection from './side-bar-section.svelte';
 | 
				
			||||||
  import SideBarLink from './side-bar-link.svelte';
 | 
					  import SideBarLink from './side-bar-link.svelte';
 | 
				
			||||||
@ -105,6 +106,8 @@
 | 
				
			|||||||
      </svelte:fragment>
 | 
					      </svelte:fragment>
 | 
				
			||||||
    </SideBarLink>
 | 
					    </SideBarLink>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <SideBarLink title={$t('folders')} routeId="/(user)/folders" icon={mdiFolderOutline} flippedLogo />
 | 
					    <SideBarLink title={$t('folders')} routeId="/(user)/folders" icon={mdiFolderOutline} flippedLogo />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <SideBarLink
 | 
					    <SideBarLink
 | 
				
			||||||
 | 
				
			|||||||
@ -1,17 +1,23 @@
 | 
				
			|||||||
<script lang="ts">
 | 
					<script lang="ts">
 | 
				
			||||||
  import Tree from '$lib/components/shared-components/tree/tree.svelte';
 | 
					  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 items: RecursiveObject;
 | 
				
			||||||
  export let parent = '';
 | 
					  export let parent = '';
 | 
				
			||||||
  export let active = '';
 | 
					  export let active = '';
 | 
				
			||||||
 | 
					  export let icons: { default: string; active: string };
 | 
				
			||||||
  export let getLink: (path: string) => string;
 | 
					  export let getLink: (path: string) => string;
 | 
				
			||||||
 | 
					  export let getColor: (path: string) => string | undefined = () => undefined;
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<ul class="list-none ml-2">
 | 
					<ul class="list-none ml-2">
 | 
				
			||||||
  {#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}
 | 
				
			||||||
      <li>
 | 
					      <li>
 | 
				
			||||||
      <Tree {parent} value={path} {tree} {active} {getLink} />
 | 
					        <Tree {parent} value={path} {tree} {icons} {active} {getLink} {getColor} />
 | 
				
			||||||
      </li>
 | 
					      </li>
 | 
				
			||||||
 | 
					    {/key}
 | 
				
			||||||
  {/each}
 | 
					  {/each}
 | 
				
			||||||
</ul>
 | 
					</ul>
 | 
				
			||||||
 | 
				
			|||||||
@ -2,18 +2,21 @@
 | 
				
			|||||||
  import Icon from '$lib/components/elements/icon.svelte';
 | 
					  import Icon from '$lib/components/elements/icon.svelte';
 | 
				
			||||||
  import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
 | 
					  import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
 | 
				
			||||||
  import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils';
 | 
					  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 tree: RecursiveObject;
 | 
				
			||||||
  export let parent: string;
 | 
					  export let parent: string;
 | 
				
			||||||
  export let value: string;
 | 
					  export let value: string;
 | 
				
			||||||
  export let active = '';
 | 
					  export let active = '';
 | 
				
			||||||
 | 
					  export let icons: { default: string; active: string };
 | 
				
			||||||
  export let getLink: (path: string) => string;
 | 
					  export let getLink: (path: string) => string;
 | 
				
			||||||
 | 
					  export let getColor: (path: string) => string | undefined;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  $: path = normalizeTreePath(`${parent}/${value}`);
 | 
					  $: path = normalizeTreePath(`${parent}/${value}`);
 | 
				
			||||||
  $: isActive = active.startsWith(path);
 | 
					  $: isActive = active.startsWith(path);
 | 
				
			||||||
  $: isOpen = isActive;
 | 
					  $: isOpen = isActive;
 | 
				
			||||||
  $: isTarget = active === path;
 | 
					  $: isTarget = active === path;
 | 
				
			||||||
 | 
					  $: color = getColor(path);
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<a
 | 
					<a
 | 
				
			||||||
@ -21,13 +24,18 @@
 | 
				
			|||||||
  title={value}
 | 
					  title={value}
 | 
				
			||||||
  class={`flex flex-grow place-items-center pl-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-immich-primary dark:text-immich-dark-primary' : 'dark:text-gray-200'}`}
 | 
					  class={`flex flex-grow place-items-center pl-2 py-1 text-sm rounded-lg hover:bg-slate-200 dark:hover:bg-slate-800 hover:font-semibold ${isTarget ? 'bg-slate-100 dark:bg-slate-700 font-semibold text-immich-primary dark:text-immich-dark-primary' : 'dark:text-gray-200'}`}
 | 
				
			||||||
>
 | 
					>
 | 
				
			||||||
  <button type="button" on:click|preventDefault={() => (isOpen = !isOpen)}>
 | 
					  <button
 | 
				
			||||||
 | 
					    type="button"
 | 
				
			||||||
 | 
					    on:click|preventDefault={() => (isOpen = !isOpen)}
 | 
				
			||||||
 | 
					    class={Object.values(tree).length === 0 ? 'invisible' : ''}
 | 
				
			||||||
 | 
					  >
 | 
				
			||||||
    <Icon path={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size={20} />
 | 
					    <Icon path={isOpen ? mdiChevronDown : mdiChevronRight} class="text-gray-400" size={20} />
 | 
				
			||||||
  </button>
 | 
					  </button>
 | 
				
			||||||
  <div>
 | 
					  <div>
 | 
				
			||||||
    <Icon
 | 
					    <Icon
 | 
				
			||||||
      path={isActive ? mdiFolder : mdiFolderOutline}
 | 
					      path={isActive ? icons.active : icons.default}
 | 
				
			||||||
      class={isActive ? 'text-immich-primary dark:text-immich-dark-primary' : 'text-gray-400'}
 | 
					      class={isActive ? 'text-immich-primary dark:text-immich-dark-primary' : 'text-gray-400'}
 | 
				
			||||||
 | 
					      {color}
 | 
				
			||||||
      size={20}
 | 
					      size={20}
 | 
				
			||||||
    />
 | 
					    />
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
@ -35,5 +43,5 @@
 | 
				
			|||||||
</a>
 | 
					</a>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
{#if isOpen}
 | 
					{#if isOpen}
 | 
				
			||||||
  <TreeItems parent={path} items={tree} {active} {getLink} />
 | 
					  <TreeItems parent={path} items={tree} {icons} {active} {getLink} {getColor} />
 | 
				
			||||||
{/if}
 | 
					{/if}
 | 
				
			||||||
 | 
				
			|||||||
@ -47,6 +47,7 @@ export enum AppRoute {
 | 
				
			|||||||
  DUPLICATES = '/utilities/duplicates',
 | 
					  DUPLICATES = '/utilities/duplicates',
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  FOLDERS = '/folders',
 | 
					  FOLDERS = '/folders',
 | 
				
			||||||
 | 
					  TAGS = '/tags',
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export enum ProjectionType {
 | 
					export enum ProjectionType {
 | 
				
			||||||
 | 
				
			|||||||
@ -440,6 +440,7 @@
 | 
				
			|||||||
  "close": "Close",
 | 
					  "close": "Close",
 | 
				
			||||||
  "collapse": "Collapse",
 | 
					  "collapse": "Collapse",
 | 
				
			||||||
  "collapse_all": "Collapse all",
 | 
					  "collapse_all": "Collapse all",
 | 
				
			||||||
 | 
					  "color": "Color",
 | 
				
			||||||
  "color_theme": "Color theme",
 | 
					  "color_theme": "Color theme",
 | 
				
			||||||
  "comment_deleted": "Comment deleted",
 | 
					  "comment_deleted": "Comment deleted",
 | 
				
			||||||
  "comment_options": "Comment options",
 | 
					  "comment_options": "Comment options",
 | 
				
			||||||
@ -473,6 +474,8 @@
 | 
				
			|||||||
  "create_new_person": "Create new person",
 | 
					  "create_new_person": "Create new person",
 | 
				
			||||||
  "create_new_person_hint": "Assign selected assets to a new person",
 | 
					  "create_new_person_hint": "Assign selected assets to a new person",
 | 
				
			||||||
  "create_new_user": "Create new user",
 | 
					  "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",
 | 
					  "create_user": "Create user",
 | 
				
			||||||
  "created": "Created",
 | 
					  "created": "Created",
 | 
				
			||||||
  "current_device": "Current device",
 | 
					  "current_device": "Current device",
 | 
				
			||||||
@ -496,6 +499,8 @@
 | 
				
			|||||||
  "delete_library": "Delete library",
 | 
					  "delete_library": "Delete library",
 | 
				
			||||||
  "delete_link": "Delete link",
 | 
					  "delete_link": "Delete link",
 | 
				
			||||||
  "delete_shared_link": "Delete shared 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",
 | 
					  "delete_user": "Delete user",
 | 
				
			||||||
  "deleted_shared_link": "Deleted shared link",
 | 
					  "deleted_shared_link": "Deleted shared link",
 | 
				
			||||||
  "description": "Description",
 | 
					  "description": "Description",
 | 
				
			||||||
@ -537,6 +542,7 @@
 | 
				
			|||||||
  "edit_location": "Edit location",
 | 
					  "edit_location": "Edit location",
 | 
				
			||||||
  "edit_name": "Edit name",
 | 
					  "edit_name": "Edit name",
 | 
				
			||||||
  "edit_people": "Edit people",
 | 
					  "edit_people": "Edit people",
 | 
				
			||||||
 | 
					  "edit_tag": "Edit tag",
 | 
				
			||||||
  "edit_title": "Edit Title",
 | 
					  "edit_title": "Edit Title",
 | 
				
			||||||
  "edit_user": "Edit user",
 | 
					  "edit_user": "Edit user",
 | 
				
			||||||
  "edited": "Edited",
 | 
					  "edited": "Edited",
 | 
				
			||||||
@ -1007,6 +1013,7 @@
 | 
				
			|||||||
  "removed_from_archive": "Removed from archive",
 | 
					  "removed_from_archive": "Removed from archive",
 | 
				
			||||||
  "removed_from_favorites": "Removed from favorites",
 | 
					  "removed_from_favorites": "Removed from favorites",
 | 
				
			||||||
  "removed_from_favorites_count": "{count, plural, other {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",
 | 
					  "rename": "Rename",
 | 
				
			||||||
  "repair": "Repair",
 | 
					  "repair": "Repair",
 | 
				
			||||||
  "repair_no_results_message": "Untracked and missing files will show up here",
 | 
					  "repair_no_results_message": "Untracked and missing files will show up here",
 | 
				
			||||||
@ -1055,6 +1062,7 @@
 | 
				
			|||||||
  "search_people": "Search people",
 | 
					  "search_people": "Search people",
 | 
				
			||||||
  "search_places": "Search places",
 | 
					  "search_places": "Search places",
 | 
				
			||||||
  "search_state": "Search state...",
 | 
					  "search_state": "Search state...",
 | 
				
			||||||
 | 
					  "search_tags": "Search tags...",
 | 
				
			||||||
  "search_timezone": "Search timezone...",
 | 
					  "search_timezone": "Search timezone...",
 | 
				
			||||||
  "search_type": "Search type",
 | 
					  "search_type": "Search type",
 | 
				
			||||||
  "search_your_photos": "Search your photos",
 | 
					  "search_your_photos": "Search your photos",
 | 
				
			||||||
@ -1158,6 +1166,12 @@
 | 
				
			|||||||
  "sunrise_on_the_beach": "Sunrise on the beach",
 | 
					  "sunrise_on_the_beach": "Sunrise on the beach",
 | 
				
			||||||
  "swap_merge_direction": "Swap merge direction",
 | 
					  "swap_merge_direction": "Swap merge direction",
 | 
				
			||||||
  "sync": "Sync",
 | 
					  "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",
 | 
					  "template": "Template",
 | 
				
			||||||
  "theme": "Theme",
 | 
					  "theme": "Theme",
 | 
				
			||||||
  "theme_selection": "Theme selection",
 | 
					  "theme_selection": "Theme selection",
 | 
				
			||||||
@ -1169,6 +1183,7 @@
 | 
				
			|||||||
  "to_change_password": "Change password",
 | 
					  "to_change_password": "Change password",
 | 
				
			||||||
  "to_favorite": "Favorite",
 | 
					  "to_favorite": "Favorite",
 | 
				
			||||||
  "to_login": "Login",
 | 
					  "to_login": "Login",
 | 
				
			||||||
 | 
					  "to_root": "To root",
 | 
				
			||||||
  "to_trash": "Trash",
 | 
					  "to_trash": "Trash",
 | 
				
			||||||
  "toggle_settings": "Toggle settings",
 | 
					  "toggle_settings": "Toggle settings",
 | 
				
			||||||
  "toggle_theme": "Toggle dark theme",
 | 
					  "toggle_theme": "Toggle dark theme",
 | 
				
			||||||
 | 
				
			|||||||
@ -256,9 +256,9 @@ export class AssetGridTaskManager {
 | 
				
			|||||||
    bucketTask.scheduleIntersected(componentId, task);
 | 
					    bucketTask.scheduleIntersected(componentId, task);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  seperatedBucket(componentId: string, bucket: AssetBucket, seperated: Task) {
 | 
					  separatedBucket(componentId: string, bucket: AssetBucket, separated: Task) {
 | 
				
			||||||
    const bucketTask = this.getOrCreateBucketTask(bucket);
 | 
					    const bucketTask = this.getOrCreateBucketTask(bucket);
 | 
				
			||||||
    bucketTask.scheduleSeparated(componentId, seperated);
 | 
					    bucketTask.scheduleSeparated(componentId, separated);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
 | 
					  intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
 | 
				
			||||||
@ -266,9 +266,9 @@ export class AssetGridTaskManager {
 | 
				
			|||||||
    bucketTask.intersectedDateGroup(componentId, dateGroup, intersected);
 | 
					    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);
 | 
					    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) {
 | 
					  intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) {
 | 
				
			||||||
@ -277,16 +277,16 @@ export class AssetGridTaskManager {
 | 
				
			|||||||
    dateGroupTask.intersectedThumbnail(componentId, asset, intersected);
 | 
					    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 bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
 | 
				
			||||||
    const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
 | 
					    const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
 | 
				
			||||||
    dateGroupTask.separatedThumbnail(componentId, asset, seperated);
 | 
					    dateGroupTask.separatedThumbnail(componentId, asset, separated);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class IntersectionTask {
 | 
					class IntersectionTask {
 | 
				
			||||||
  internalTaskManager: InternalTaskManager;
 | 
					  internalTaskManager: InternalTaskManager;
 | 
				
			||||||
  seperatedKey;
 | 
					  separatedKey;
 | 
				
			||||||
  intersectedKey;
 | 
					  intersectedKey;
 | 
				
			||||||
  priority;
 | 
					  priority;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -295,7 +295,7 @@ class IntersectionTask {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) {
 | 
					  constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) {
 | 
				
			||||||
    this.internalTaskManager = internalTaskManager;
 | 
					    this.internalTaskManager = internalTaskManager;
 | 
				
			||||||
    this.seperatedKey = keyPrefix + ':s:' + key;
 | 
					    this.separatedKey = keyPrefix + ':s:' + key;
 | 
				
			||||||
    this.intersectedKey = keyPrefix + ':i:' + key;
 | 
					    this.intersectedKey = keyPrefix + ':i:' + key;
 | 
				
			||||||
    this.priority = priority;
 | 
					    this.priority = priority;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -325,14 +325,14 @@ class IntersectionTask {
 | 
				
			|||||||
    this.separated = execTask;
 | 
					    this.separated = execTask;
 | 
				
			||||||
    const cleanup = () => {
 | 
					    const cleanup = () => {
 | 
				
			||||||
      this.separated = undefined;
 | 
					      this.separated = undefined;
 | 
				
			||||||
      this.internalTaskManager.deleteFromComponentTasks(componentId, this.seperatedKey);
 | 
					      this.internalTaskManager.deleteFromComponentTasks(componentId, this.separatedKey);
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
    return { task: execTask, cleanup };
 | 
					    return { task: execTask, cleanup };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  removePendingSeparated() {
 | 
					  removePendingSeparated() {
 | 
				
			||||||
    if (this.separated) {
 | 
					    if (this.separated) {
 | 
				
			||||||
      this.internalTaskManager.removeSeparateTask(this.seperatedKey);
 | 
					      this.internalTaskManager.removeSeparateTask(this.separatedKey);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  removePendingIntersected() {
 | 
					  removePendingIntersected() {
 | 
				
			||||||
@ -368,7 +368,7 @@ class IntersectionTask {
 | 
				
			|||||||
      task,
 | 
					      task,
 | 
				
			||||||
      cleanup,
 | 
					      cleanup,
 | 
				
			||||||
      componentId: componentId,
 | 
					      componentId: componentId,
 | 
				
			||||||
      taskId: this.seperatedKey,
 | 
					      taskId: this.separatedKey,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -448,9 +448,9 @@ class DateGroupTask extends IntersectionTask {
 | 
				
			|||||||
    thumbnailTask.scheduleIntersected(componentId, intersected);
 | 
					    thumbnailTask.scheduleIntersected(componentId, intersected);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  separatedThumbnail(componentId: string, asset: AssetResponseDto, seperated: Task) {
 | 
					  separatedThumbnail(componentId: string, asset: AssetResponseDto, separated: Task) {
 | 
				
			||||||
    const thumbnailTask = this.getOrCreateThumbnailTask(asset);
 | 
					    const thumbnailTask = this.getOrCreateThumbnailTask(asset);
 | 
				
			||||||
    thumbnailTask.scheduleSeparated(componentId, seperated);
 | 
					    thumbnailTask.scheduleSeparated(componentId, separated);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
class ThumbnailTask extends IntersectionTask {
 | 
					class ThumbnailTask extends IntersectionTask {
 | 
				
			||||||
 | 
				
			|||||||
@ -10,6 +10,7 @@ import { preferences } from '$lib/stores/user.store';
 | 
				
			|||||||
import { downloadRequest, getKey, withError } from '$lib/utils';
 | 
					import { downloadRequest, getKey, withError } from '$lib/utils';
 | 
				
			||||||
import { createAlbum } from '$lib/utils/album-utils';
 | 
					import { createAlbum } from '$lib/utils/album-utils';
 | 
				
			||||||
import { getByteUnitString } from '$lib/utils/byte-units';
 | 
					import { getByteUnitString } from '$lib/utils/byte-units';
 | 
				
			||||||
 | 
					import { getFormatter } from '$lib/utils/i18n';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  addAssetsToAlbum as addAssets,
 | 
					  addAssetsToAlbum as addAssets,
 | 
				
			||||||
  createStack,
 | 
					  createStack,
 | 
				
			||||||
@ -18,6 +19,8 @@ import {
 | 
				
			|||||||
  getBaseUrl,
 | 
					  getBaseUrl,
 | 
				
			||||||
  getDownloadInfo,
 | 
					  getDownloadInfo,
 | 
				
			||||||
  getStack,
 | 
					  getStack,
 | 
				
			||||||
 | 
					  tagAssets as tagAllAssets,
 | 
				
			||||||
 | 
					  untagAssets,
 | 
				
			||||||
  updateAsset,
 | 
					  updateAsset,
 | 
				
			||||||
  updateAssets,
 | 
					  updateAssets,
 | 
				
			||||||
  type AlbumResponseDto,
 | 
					  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[]) => {
 | 
					export const addAssetsToNewAlbum = async (albumName: string, assetIds: string[]) => {
 | 
				
			||||||
  const album = await createAlbum(albumName, assetIds);
 | 
					  const album = await createAlbum(albumName, assetIds);
 | 
				
			||||||
  if (!album) {
 | 
					  if (!album) {
 | 
				
			||||||
 | 
				
			|||||||
@ -13,7 +13,7 @@
 | 
				
			|||||||
  import { foldersStore } from '$lib/stores/folders.store';
 | 
					  import { foldersStore } from '$lib/stores/folders.store';
 | 
				
			||||||
  import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
 | 
					  import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
 | 
				
			||||||
  import { type AssetResponseDto } from '@immich/sdk';
 | 
					  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 { onMount } from 'svelte';
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
  import type { PageData } from './$types';
 | 
					  import type { PageData } from './$types';
 | 
				
			||||||
@ -60,7 +60,12 @@
 | 
				
			|||||||
    <section>
 | 
					    <section>
 | 
				
			||||||
      <div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
 | 
					      <div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
 | 
				
			||||||
      <div class="h-full">
 | 
					      <div class="h-full">
 | 
				
			||||||
        <TreeItems items={tree} active={currentPath} {getLink} />
 | 
					        <TreeItems
 | 
				
			||||||
 | 
					          icons={{ default: mdiFolderOutline, active: mdiFolder }}
 | 
				
			||||||
 | 
					          items={tree}
 | 
				
			||||||
 | 
					          active={currentPath}
 | 
				
			||||||
 | 
					          {getLink}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </section>
 | 
					    </section>
 | 
				
			||||||
  </SideBarSection>
 | 
					  </SideBarSection>
 | 
				
			||||||
@ -73,7 +78,7 @@
 | 
				
			|||||||
    <div
 | 
					    <div
 | 
				
			||||||
      class="flex place-items-center gap-2 bg-gray-50 dark:bg-immich-dark-gray/50 w-full py-2 px-4 rounded-2xl border border-gray-100 dark:border-gray-900"
 | 
					      class="flex place-items-center gap-2 bg-gray-50 dark:bg-immich-dark-gray/50 w-full py-2 px-4 rounded-2xl border border-gray-100 dark:border-gray-900"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <a href={`${AppRoute.FOLDERS}`} title="To root">
 | 
					      <a href={`${AppRoute.FOLDERS}`} title={$t('to_root')}>
 | 
				
			||||||
        <Icon path={mdiFolderHome} class="text-immich-primary dark:text-immich-dark-primary mr-2" size={28} />
 | 
					        <Icon path={mdiFolderHome} class="text-immich-primary dark:text-immich-dark-primary mr-2" size={28} />
 | 
				
			||||||
      </a>
 | 
					      </a>
 | 
				
			||||||
      {#each pathSegments as segment, index}
 | 
					      {#each pathSegments as segment, index}
 | 
				
			||||||
 | 
				
			|||||||
@ -25,6 +25,7 @@
 | 
				
			|||||||
  import { preferences, user } from '$lib/stores/user.store';
 | 
					  import { preferences, user } from '$lib/stores/user.store';
 | 
				
			||||||
  import { t } from 'svelte-i18n';
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
  import { onDestroy } from 'svelte';
 | 
					  import { onDestroy } from 'svelte';
 | 
				
			||||||
 | 
					  import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  let { isViewing: showAssetViewer } = assetViewingStore;
 | 
					  let { isViewing: showAssetViewer } = assetViewingStore;
 | 
				
			||||||
  const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
 | 
					  const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
 | 
				
			||||||
@ -80,6 +81,7 @@
 | 
				
			|||||||
      <ChangeDate menuItem />
 | 
					      <ChangeDate menuItem />
 | 
				
			||||||
      <ChangeLocation menuItem />
 | 
					      <ChangeLocation menuItem />
 | 
				
			||||||
      <ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
					      <ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
				
			||||||
 | 
					      <TagAction menuItem />
 | 
				
			||||||
      <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
					      <DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
 | 
				
			||||||
      <hr />
 | 
					      <hr />
 | 
				
			||||||
      <AssetJobActions />
 | 
					      <AssetJobActions />
 | 
				
			||||||
 | 
				
			|||||||
@ -0,0 +1,251 @@
 | 
				
			|||||||
 | 
					<script lang="ts">
 | 
				
			||||||
 | 
					  import { goto } from '$app/navigation';
 | 
				
			||||||
 | 
					  import { page } from '$app/stores';
 | 
				
			||||||
 | 
					  import Button from '$lib/components/elements/buttons/button.svelte';
 | 
				
			||||||
 | 
					  import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
 | 
				
			||||||
 | 
					  import Icon from '$lib/components/elements/icon.svelte';
 | 
				
			||||||
 | 
					  import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
 | 
				
			||||||
 | 
					  import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
 | 
				
			||||||
 | 
					  import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
 | 
				
			||||||
 | 
					  import {
 | 
				
			||||||
 | 
					    notificationController,
 | 
				
			||||||
 | 
					    NotificationType,
 | 
				
			||||||
 | 
					  } from '$lib/components/shared-components/notification/notification';
 | 
				
			||||||
 | 
					  import SettingInputField, {
 | 
				
			||||||
 | 
					    SettingInputFieldType,
 | 
				
			||||||
 | 
					  } from '$lib/components/shared-components/settings/setting-input-field.svelte';
 | 
				
			||||||
 | 
					  import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
 | 
				
			||||||
 | 
					  import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
 | 
				
			||||||
 | 
					  import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
 | 
				
			||||||
 | 
					  import { AppRoute, AssetAction, QueryParameter } from '$lib/constants';
 | 
				
			||||||
 | 
					  import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store';
 | 
				
			||||||
 | 
					  import { AssetStore } from '$lib/stores/assets.store';
 | 
				
			||||||
 | 
					  import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
 | 
				
			||||||
 | 
					  import { deleteTag, getAllTags, updateTag, upsertTags, type TagResponseDto } from '@immich/sdk';
 | 
				
			||||||
 | 
					  import { mdiChevronRight, mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
 | 
				
			||||||
 | 
					  import { t } from 'svelte-i18n';
 | 
				
			||||||
 | 
					  import type { PageData } from './$types';
 | 
				
			||||||
 | 
					  import { dialogController } from '$lib/components/shared-components/dialog/dialog';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  export let data: PageData;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  $: pathSegments = data.path ? data.path.split('/') : [];
 | 
				
			||||||
 | 
					  $: currentPath = $page.url.searchParams.get(QueryParameter.PATH) || '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const assetInteractionStore = createAssetInteractionStore();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const buildMap = (tags: TagResponseDto[]) => {
 | 
				
			||||||
 | 
					    return Object.fromEntries(tags.map((tag) => [tag.value, tag]));
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  $: tags = data.tags;
 | 
				
			||||||
 | 
					  $: tagsMap = buildMap(tags);
 | 
				
			||||||
 | 
					  $: tag = currentPath ? tagsMap[currentPath] : null;
 | 
				
			||||||
 | 
					  $: tree = buildTree(tags.map((tag) => tag.value));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleNavigation = async (tag: string) => {
 | 
				
			||||||
 | 
					    await navigateToView(normalizeTreePath(`${data.path || ''}/${tag}`));
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleBreadcrumbNavigation = async (targetPath: string) => {
 | 
				
			||||||
 | 
					    await navigateToView(targetPath);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const getLink = (path: string) => {
 | 
				
			||||||
 | 
					    const url = new URL(AppRoute.TAGS, window.location.href);
 | 
				
			||||||
 | 
					    url.searchParams.set(QueryParameter.PATH, path);
 | 
				
			||||||
 | 
					    return url.href;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const getColor = (path: string) => tagsMap[path]?.color;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const navigateToView = (path: string) => goto(getLink(path));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let isNewOpen = false;
 | 
				
			||||||
 | 
					  let newTagValue = '';
 | 
				
			||||||
 | 
					  const handleCreate = () => {
 | 
				
			||||||
 | 
					    newTagValue = tag ? tag.value + '/' : '';
 | 
				
			||||||
 | 
					    isNewOpen = true;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let isEditOpen = false;
 | 
				
			||||||
 | 
					  let newTagColor = '';
 | 
				
			||||||
 | 
					  const handleEdit = () => {
 | 
				
			||||||
 | 
					    newTagColor = tag?.color ?? '';
 | 
				
			||||||
 | 
					    isEditOpen = true;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleCancel = () => {
 | 
				
			||||||
 | 
					    isNewOpen = false;
 | 
				
			||||||
 | 
					    isEditOpen = false;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleSubmit = async () => {
 | 
				
			||||||
 | 
					    if (tag && isEditOpen && newTagColor) {
 | 
				
			||||||
 | 
					      await updateTag({ id: tag.id, tagUpdateDto: { color: newTagColor } });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      notificationController.show({
 | 
				
			||||||
 | 
					        message: $t('tag_updated', { values: { tag: tag.value } }),
 | 
				
			||||||
 | 
					        type: NotificationType.Info,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      tags = await getAllTags();
 | 
				
			||||||
 | 
					      isEditOpen = false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (isNewOpen && newTagValue) {
 | 
				
			||||||
 | 
					      const [newTag] = await upsertTags({ tagUpsertDto: { tags: [newTagValue] } });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      notificationController.show({
 | 
				
			||||||
 | 
					        message: $t('tag_created', { values: { tag: newTag.value } }),
 | 
				
			||||||
 | 
					        type: NotificationType.Info,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      tags = await getAllTags();
 | 
				
			||||||
 | 
					      isNewOpen = false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleDelete = async () => {
 | 
				
			||||||
 | 
					    if (!tag) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const isConfirm = await dialogController.show({
 | 
				
			||||||
 | 
					      title: $t('delete_tag'),
 | 
				
			||||||
 | 
					      prompt: $t('delete_tag_confirmation_prompt', { values: { tagName: tag.value } }),
 | 
				
			||||||
 | 
					      confirmText: $t('delete'),
 | 
				
			||||||
 | 
					      cancelText: $t('cancel'),
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!isConfirm) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await deleteTag({ id: tag.id });
 | 
				
			||||||
 | 
					    tags = await getAllTags();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // navigate to parent
 | 
				
			||||||
 | 
					    const parentPath = pathSegments.slice(0, -1).join('/');
 | 
				
			||||||
 | 
					    await navigateToView(parentPath);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<UserPageLayout title={data.meta.title} scrollbar={false}>
 | 
				
			||||||
 | 
					  <SideBarSection slot="sidebar">
 | 
				
			||||||
 | 
					    <section>
 | 
				
			||||||
 | 
					      <div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
 | 
				
			||||||
 | 
					      <div class="h-full">
 | 
				
			||||||
 | 
					        <TreeItems icons={{ default: mdiTag, active: mdiTag }} items={tree} active={currentPath} {getLink} {getColor} />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </section>
 | 
				
			||||||
 | 
					  </SideBarSection>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <section slot="buttons">
 | 
				
			||||||
 | 
					    <LinkButton on:click={handleCreate}>
 | 
				
			||||||
 | 
					      <div class="flex place-items-center gap-2 text-sm">
 | 
				
			||||||
 | 
					        <Icon path={mdiPlus} size="18" />
 | 
				
			||||||
 | 
					        <p class="hidden md:block">{$t('create_tag')}</p>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </LinkButton>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <LinkButton on:click={handleEdit}>
 | 
				
			||||||
 | 
					      <div class="flex place-items-center gap-2 text-sm">
 | 
				
			||||||
 | 
					        <Icon path={mdiPencil} size="18" />
 | 
				
			||||||
 | 
					        <p class="hidden md:block">{$t('edit_tag')}</p>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </LinkButton>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    {#if pathSegments.length > 0 && tag}
 | 
				
			||||||
 | 
					      <LinkButton on:click={handleDelete}>
 | 
				
			||||||
 | 
					        <div class="flex place-items-center gap-2 text-sm">
 | 
				
			||||||
 | 
					          <Icon path={mdiTrashCanOutline} size="18" />
 | 
				
			||||||
 | 
					          <p class="hidden md:block">{$t('delete_tag')}</p>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </LinkButton>
 | 
				
			||||||
 | 
					    {/if}
 | 
				
			||||||
 | 
					  </section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <section
 | 
				
			||||||
 | 
					    class="flex place-items-center gap-2 mt-2 text-immich-primary dark:text-immich-dark-primary rounded-2xl bg-gray-50 dark:bg-immich-dark-gray/50 w-full py-2 px-4 border border-gray-100 dark:border-gray-900"
 | 
				
			||||||
 | 
					  >
 | 
				
			||||||
 | 
					    <a href={`${AppRoute.TAGS}`} title={$t('to_root')}>
 | 
				
			||||||
 | 
					      <Icon path={mdiTagMultiple} class="text-immich-primary dark:text-immich-dark-primary mr-2" size={28} />
 | 
				
			||||||
 | 
					    </a>
 | 
				
			||||||
 | 
					    {#each pathSegments as segment, index}
 | 
				
			||||||
 | 
					      <button
 | 
				
			||||||
 | 
					        class="text-sm font-mono underline hover:font-semibold"
 | 
				
			||||||
 | 
					        on:click={() => handleBreadcrumbNavigation(pathSegments.slice(0, index + 1).join('/'))}
 | 
				
			||||||
 | 
					        type="button"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        {segment}
 | 
				
			||||||
 | 
					      </button>
 | 
				
			||||||
 | 
					      <p class="text-gray-500">
 | 
				
			||||||
 | 
					        {#if index < pathSegments.length - 1}
 | 
				
			||||||
 | 
					          <Icon path={mdiChevronRight} class="text-gray-500 dark:text-gray-300" size={16} />
 | 
				
			||||||
 | 
					        {/if}
 | 
				
			||||||
 | 
					      </p>
 | 
				
			||||||
 | 
					    {/each}
 | 
				
			||||||
 | 
					  </section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <section class="mt-2 h-full">
 | 
				
			||||||
 | 
					    {#key $page.url.href}
 | 
				
			||||||
 | 
					      {#if tag}
 | 
				
			||||||
 | 
					        <AssetGrid
 | 
				
			||||||
 | 
					          enableRouting={true}
 | 
				
			||||||
 | 
					          assetStore={new AssetStore({ tagId: tag.id })}
 | 
				
			||||||
 | 
					          {assetInteractionStore}
 | 
				
			||||||
 | 
					          removeAction={AssetAction.UNARCHIVE}
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <TreeItemThumbnails items={data.children} icon={mdiTag} onClick={handleNavigation} slot="empty" />
 | 
				
			||||||
 | 
					        </AssetGrid>
 | 
				
			||||||
 | 
					      {:else}
 | 
				
			||||||
 | 
					        <TreeItemThumbnails items={Object.keys(tree)} icon={mdiTag} onClick={handleNavigation} />
 | 
				
			||||||
 | 
					      {/if}
 | 
				
			||||||
 | 
					    {/key}
 | 
				
			||||||
 | 
					  </section>
 | 
				
			||||||
 | 
					</UserPageLayout>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{#if isNewOpen}
 | 
				
			||||||
 | 
					  <FullScreenModal title={$t('create_tag')} icon={mdiTag} onClose={handleCancel}>
 | 
				
			||||||
 | 
					    <div class="text-immich-primary dark:text-immich-dark-primary">
 | 
				
			||||||
 | 
					      <p class="text-sm dark:text-immich-dark-fg">
 | 
				
			||||||
 | 
					        {$t('create_tag_description')}
 | 
				
			||||||
 | 
					      </p>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <form on:submit|preventDefault={handleSubmit} autocomplete="off" id="create-tag-form">
 | 
				
			||||||
 | 
					      <div class="my-4 flex flex-col gap-2">
 | 
				
			||||||
 | 
					        <SettingInputField
 | 
				
			||||||
 | 
					          inputType={SettingInputFieldType.TEXT}
 | 
				
			||||||
 | 
					          label={$t('tag').toUpperCase()}
 | 
				
			||||||
 | 
					          bind:value={newTagValue}
 | 
				
			||||||
 | 
					          required={true}
 | 
				
			||||||
 | 
					          autofocus={true}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </form>
 | 
				
			||||||
 | 
					    <svelte:fragment slot="sticky-bottom">
 | 
				
			||||||
 | 
					      <Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button>
 | 
				
			||||||
 | 
					      <Button type="submit" fullwidth form="create-tag-form">{$t('create')}</Button>
 | 
				
			||||||
 | 
					    </svelte:fragment>
 | 
				
			||||||
 | 
					  </FullScreenModal>
 | 
				
			||||||
 | 
					{/if}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					{#if isEditOpen}
 | 
				
			||||||
 | 
					  <FullScreenModal title={$t('edit_tag')} icon={mdiTag} onClose={handleCancel}>
 | 
				
			||||||
 | 
					    <form on:submit|preventDefault={handleSubmit} autocomplete="off" id="edit-tag-form">
 | 
				
			||||||
 | 
					      <div class="my-4 flex flex-col gap-2">
 | 
				
			||||||
 | 
					        <SettingInputField
 | 
				
			||||||
 | 
					          inputType={SettingInputFieldType.COLOR}
 | 
				
			||||||
 | 
					          label={$t('color').toUpperCase()}
 | 
				
			||||||
 | 
					          bind:value={newTagColor}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </form>
 | 
				
			||||||
 | 
					    <svelte:fragment slot="sticky-bottom">
 | 
				
			||||||
 | 
					      <Button color="gray" fullwidth on:click={() => handleCancel()}>{$t('cancel')}</Button>
 | 
				
			||||||
 | 
					      <Button type="submit" fullwidth form="edit-tag-form">{$t('save')}</Button>
 | 
				
			||||||
 | 
					    </svelte:fragment>
 | 
				
			||||||
 | 
					  </FullScreenModal>
 | 
				
			||||||
 | 
					{/if}
 | 
				
			||||||
@ -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;
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user