mirror of
https://github.com/immich-app/immich.git
synced 2025-01-27 22:22:45 +01: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
68 changed files with 2438 additions and 548 deletions
e2e/src
mobile/openapi
README.md
lib
open-api
server
web/src
lib
routes/(user)
folders/[[photos=photos]]/[[assetId=id]]
photos/[[assetId=id]]
tags/[[photos=photos]]/[[assetId=id]]
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[] = [];
|
||||||
|
|
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/tags_api.dart
generated
BIN
mobile/openapi/lib/api/tags_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/timeline_api.dart
generated
BIN
mobile/openapi/lib/api/timeline_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_helper.dart
generated
BIN
mobile/openapi/lib/api_helper.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/permission.dart
generated
BIN
mobile/openapi/lib/model/permission.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/tag_bulk_assets_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/tag_bulk_assets_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/tag_bulk_assets_response_dto.dart
generated
Normal file
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/lib/model/tag_response_dto.dart
generated
BIN
mobile/openapi/lib/model/tag_response_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/tag_type_enum.dart
generated
BIN
mobile/openapi/lib/model/tag_type_enum.dart
generated
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/lib/model/tag_upsert_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/tag_upsert_dto.dart
generated
Normal file
Binary file not shown.
|
@ -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": "object"
|
||||||
|
},
|
||||||
|
"TagUpsertDto": {
|
||||||
|
"properties": {
|
||||||
|
"tags": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"tags"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
relations: {
|
|
||||||
exifInfo: true,
|
|
||||||
tags: true,
|
|
||||||
faces: {
|
|
||||||
person: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
order: {
|
|
||||||
createdAt: 'ASC',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async addAssets(userId: string, id: string, assetIds: string[]): Promise<void> {
|
|
||||||
for (const assetId of assetIds) {
|
|
||||||
const asset = await this.assetRepository.findOneOrFail({
|
|
||||||
where: {
|
|
||||||
ownerId: userId,
|
|
||||||
id: assetId,
|
|
||||||
},
|
|
||||||
relations: {
|
|
||||||
tags: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
asset.tags.push({ id } as TagEntity);
|
|
||||||
await this.assetRepository.save(asset);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const results = await this.dataSource
|
||||||
|
.createQueryBuilder()
|
||||||
|
.select('tag_asset.assetsId', 'assetId')
|
||||||
|
.from('tag_asset', 'tag_asset')
|
||||||
|
.where('"tag_asset"."tagsId" = :tagId', { tagId })
|
||||||
|
.andWhere('"tag_asset"."assetsId" IN (:...assetIds)', { assetIds })
|
||||||
|
.getRawMany<{ assetId: string }>();
|
||||||
|
|
||||||
|
return new Set(results.map(({ assetId }) => assetId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeAssets(userId: string, id: string, assetIds: string[]): Promise<void> {
|
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
|
||||||
for (const assetId of assetIds) {
|
async addAssetIds(tagId: string, assetIds: string[]): Promise<void> {
|
||||||
const asset = await this.assetRepository.findOneOrFail({
|
if (assetIds.length === 0) {
|
||||||
where: {
|
return;
|
||||||
ownerId: userId,
|
|
||||||
id: assetId,
|
|
||||||
},
|
|
||||||
relations: {
|
|
||||||
tags: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
asset.tags = asset.tags.filter((tag) => tag.id !== id);
|
|
||||||
await this.assetRepository.save(asset);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.dataSource.manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.insert()
|
||||||
|
.into('tag_asset', ['tagsId', 'assetsId'])
|
||||||
|
.values(assetIds.map((assetId) => ({ tagsId: tagId, assetsId: assetId })))
|
||||||
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAsset(userId: string, tagId: string, assetId: string): Promise<boolean> {
|
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
|
||||||
return this.repository.exists({
|
@Chunked({ paramIndex: 1 })
|
||||||
where: {
|
async removeAssetIds(tagId: string, assetIds: string[]): Promise<void> {
|
||||||
id: tagId,
|
if (assetIds.length === 0) {
|
||||||
userId,
|
return;
|
||||||
assets: {
|
}
|
||||||
id: assetId,
|
|
||||||
},
|
await this.dataSource
|
||||||
},
|
.createQueryBuilder()
|
||||||
relations: {
|
.delete()
|
||||||
assets: true,
|
.from('tag_asset')
|
||||||
},
|
.where({
|
||||||
|
tagsId: tagId,
|
||||||
|
assetsId: In(assetIds),
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagId: DummyValue.UUID }]] })
|
||||||
|
@Chunked()
|
||||||
|
async upsertAssetIds(items: AssetTagItem[]): Promise<AssetTagItem[]> {
|
||||||
|
if (items.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { identifiers } = await this.dataSource
|
||||||
|
.createQueryBuilder()
|
||||||
|
.insert()
|
||||||
|
.into('tag_asset', ['assetsId', 'tagsId'])
|
||||||
|
.values(items.map(({ assetId, tagId }) => ({ assetsId: assetId, tagsId: tagId })))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return (identifiers as Array<{ assetsId: string; tagsId: string }>).map(({ assetsId, tagsId }) => ({
|
||||||
|
assetId: assetsId,
|
||||||
|
tagId: tagsId,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }) {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.createQueryBuilder().delete().from('tag_asset').where({ assetsId: assetId }).execute();
|
||||||
|
|
||||||
|
if (tagIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.insert()
|
||||||
|
.into('tag_asset', ['tagsId', 'assetsId'])
|
||||||
|
.values(tagIds.map((tagId) => ({ tagsId: tagId, assetsId: assetId })))
|
||||||
|
.execute();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
hasName(userId: string, name: string): Promise<boolean> {
|
private async save(partial: Partial<TagEntity>): Promise<TagEntity> {
|
||||||
return this.repository.exists({
|
const { id } = await this.repository.save(partial);
|
||||||
where: {
|
return this.repository.findOneOrFail({ where: { id } });
|
||||||
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 });
|
for (const { id: assetId, success } of results) {
|
||||||
|
if (success) {
|
||||||
|
await this.eventRepository.emit('asset.untag', { assetId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.repository.removeAssets(
|
|
||||||
auth.user.id,
|
|
||||||
id,
|
|
||||||
results.filter((result) => result.success).map((result) => result.assetId),
|
|
||||||
);
|
|
||||||
|
|
||||||
return results;
|
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">
|
||||||
<div class="font-medium">{title}</div>
|
<slot name="title">
|
||||||
|
{#if title}
|
||||||
|
<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,22 +82,46 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if inputType !== SettingInputFieldType.PASSWORD}
|
{#if inputType !== SettingInputFieldType.PASSWORD}
|
||||||
<input
|
<div class="flex place-items-center place-content-center">
|
||||||
class="immich-form-input w-full pb-2"
|
{#if inputType === SettingInputFieldType.COLOR}
|
||||||
aria-describedby={desc ? `${label}-desc` : undefined}
|
<input
|
||||||
aria-labelledby="{label}-label"
|
bind:this={input}
|
||||||
id={label}
|
class="immich-form-input w-full pb-2 rounded-none mr-1"
|
||||||
name={label}
|
aria-describedby={desc ? `${label}-desc` : undefined}
|
||||||
type={inputType}
|
aria-labelledby="{label}-label"
|
||||||
min={min.toString()}
|
id={label}
|
||||||
max={max.toString()}
|
name={label}
|
||||||
{step}
|
type="text"
|
||||||
{required}
|
min={min.toString()}
|
||||||
{value}
|
max={max.toString()}
|
||||||
on:change={handleChange}
|
{step}
|
||||||
{disabled}
|
{required}
|
||||||
{title}
|
{value}
|
||||||
/>
|
on:change={handleChange}
|
||||||
|
{disabled}
|
||||||
|
{title}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={input}
|
||||||
|
class="immich-form-input w-full pb-2"
|
||||||
|
class:color-picker={inputType === SettingInputFieldType.COLOR}
|
||||||
|
aria-describedby={desc ? `${label}-desc` : undefined}
|
||||||
|
aria-labelledby="{label}-label"
|
||||||
|
id={label}
|
||||||
|
name={label}
|
||||||
|
type={inputType}
|
||||||
|
min={min.toString()}
|
||||||
|
max={max.toString()}
|
||||||
|
{step}
|
||||||
|
{required}
|
||||||
|
{value}
|
||||||
|
on:change={handleChange}
|
||||||
|
{disabled}
|
||||||
|
{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]}
|
||||||
<li>
|
{@const value = normalizeTreePath(`${parent}/${path}`)}
|
||||||
<Tree {parent} value={path} {tree} {active} {getLink} />
|
{@const key = value + getColor(value)}
|
||||||
</li>
|
{#key key}
|
||||||
|
<li>
|
||||||
|
<Tree {parent} value={path} {tree} {icons} {active} {getLink} {getColor} />
|
||||||
|
</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…
Reference in a new issue