1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-27 22:22:45 +01:00

feat: tags ()

* 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:
Jason Rasmussen 2024-08-29 12:14:03 -04:00 committed by GitHub
parent 682adaa334
commit d08a20bd57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 2438 additions and 548 deletions
e2e/src
mobile/openapi
open-api
server
web/src

View 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' }),
]);
});
});
});

View file

@ -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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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": {

View file

@ -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",

View file

@ -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);
} }
} }

View file

@ -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'),

View file

@ -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,
}; };
} }

View file

@ -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;

View file

@ -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',
} }

View file

@ -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',

View file

@ -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>>;
};
} }

View file

@ -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;

View file

@ -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 }];
}; };

View file

@ -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 {

View file

@ -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>;
} }

View 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`);
}
}

View file

@ -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",

View file

@ -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",

View 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)

View file

@ -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);
} }
} }

View file

@ -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) {

View file

@ -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 } });
} }
} }

View file

@ -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);

View file

@ -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 {

View file

@ -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']);
}); });
}); });
}); });

View file

@ -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');
} }

View file

@ -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;

View file

@ -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));

View file

@ -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
View 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;
};

View file

@ -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',
}), }),
}; };

View file

@ -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()),
},
}; };
}; };

View file

@ -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(),
}; };
}; };

View 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}

View file

@ -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}

View file

@ -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;
} }

View 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>

View file

@ -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}

View 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}

View file

@ -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}

View file

@ -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,

View file

@ -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}

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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}

View file

@ -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 {

View file

@ -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",

View file

@ -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 {

View file

@ -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) {

View file

@ -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}

View file

@ -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 />

View file

@ -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}

View file

@ -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;