1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-24 08:52:28 +02:00

feat: tags (#11980)

* feat: tags

* fix: folder tree icons

* navigate to tag from detail panel

* delete tag

* Tag position and add tag button

* Tag asset in detail panel

* refactor form

* feat: navigate to tag page from clicking on a tag

* feat: delete tags from the tag page

* refactor: moving tag section in detail panel and add + tag button

* feat: tag asset action in detail panel

* refactor add tag form

* fdisable add tag button when there is no selection

* feat: tag bulk endpoint

* feat: tag colors

* chore: clean up

* chore: unit tests

* feat: write tags to sidecar

* Remove tag and auto focus on tag creation form opened

* chore: regenerate migration

* chore: linting

* add color picker to tag edit form

* fix: force render tags timeline on navigating back from asset viewer

* feat: read tags from keywords

* chore: clean up

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
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

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',
'users',
'system_metadata',
'tags',
];
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": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateTagDto"
"$ref": "#/components/schemas/TagCreateDto"
}
}
},
@ -6201,6 +6201,91 @@
"tags": [
"Tags"
]
},
"put": {
"operationId": "upsertTags",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TagUpsertDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/TagResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Tags"
]
}
},
"/tags/assets": {
"put": {
"operationId": "bulkTagAssets",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TagBulkAssetsDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TagBulkAssetsResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Tags"
]
}
},
"/tags/{id}": {
@ -6218,7 +6303,7 @@
}
],
"responses": {
"200": {
"204": {
"description": ""
}
},
@ -6277,7 +6362,7 @@
"Tags"
]
},
"patch": {
"put": {
"operationId": "updateTag",
"parameters": [
{
@ -6294,7 +6379,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateTagDto"
"$ref": "#/components/schemas/TagUpdateDto"
}
}
},
@ -6346,7 +6431,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetIdsDto"
"$ref": "#/components/schemas/BulkIdsDto"
}
}
},
@ -6358,50 +6443,7 @@
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/AssetIdsResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Tags"
]
},
"get": {
"operationId": "getTagAssets",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
"$ref": "#/components/schemas/BulkIdResponseDto"
},
"type": "array"
}
@ -6442,7 +6484,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetIdsDto"
"$ref": "#/components/schemas/BulkIdsDto"
}
}
},
@ -6454,7 +6496,7 @@
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/AssetIdsResponseDto"
"$ref": "#/components/schemas/BulkIdResponseDto"
},
"type": "array"
}
@ -6549,6 +6591,15 @@
"$ref": "#/components/schemas/TimeBucketSize"
}
},
{
"name": "tagId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "timeBucket",
"required": true,
@ -6684,6 +6735,15 @@
"$ref": "#/components/schemas/TimeBucketSize"
}
},
{
"name": "tagId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "userId",
"required": false,
@ -8685,21 +8745,6 @@
],
"type": "object"
},
"CreateTagDto": {
"properties": {
"name": {
"type": "string"
},
"type": {
"$ref": "#/components/schemas/TagTypeEnum"
}
},
"required": [
"name",
"type"
],
"type": "object"
},
"DownloadArchiveInfo": {
"properties": {
"assetIds": {
@ -10053,6 +10098,7 @@
"tag.read",
"tag.update",
"tag.delete",
"tag.asset",
"admin.user.create",
"admin.user.read",
"admin.user.update",
@ -11848,36 +11894,113 @@
],
"type": "object"
},
"TagBulkAssetsDto": {
"properties": {
"assetIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
},
"tagIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
}
},
"required": [
"assetIds",
"tagIds"
],
"type": "object"
},
"TagBulkAssetsResponseDto": {
"properties": {
"count": {
"type": "integer"
}
},
"required": [
"count"
],
"type": "object"
},
"TagCreateDto": {
"properties": {
"color": {
"type": "string"
},
"name": {
"type": "string"
},
"parentId": {
"format": "uuid",
"nullable": true,
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"TagResponseDto": {
"properties": {
"color": {
"type": "string"
},
"createdAt": {
"format": "date-time",
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"type": {
"$ref": "#/components/schemas/TagTypeEnum"
"updatedAt": {
"format": "date-time",
"type": "string"
},
"userId": {
"value": {
"type": "string"
}
},
"required": [
"createdAt",
"id",
"name",
"type",
"userId"
"updatedAt",
"value"
],
"type": "object"
},
"TagTypeEnum": {
"enum": [
"OBJECT",
"FACE",
"CUSTOM"
"TagUpdateDto": {
"properties": {
"color": {
"nullable": true,
"type": "string"
}
},
"type": "object"
},
"TagUpsertDto": {
"properties": {
"tags": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"tags"
],
"type": "string"
"type": "object"
},
"TimeBucketResponseDto": {
"properties": {
@ -12021,14 +12144,6 @@
],
"type": "object"
},
"UpdateTagDto": {
"properties": {
"name": {
"type": "string"
}
},
"type": "object"
},
"UsageByUserDto": {
"properties": {
"photos": {

View File

@ -198,10 +198,12 @@ export type AssetStackResponseDto = {
primaryAssetId: string;
};
export type TagResponseDto = {
color?: string;
createdAt: string;
id: string;
name: string;
"type": TagTypeEnum;
userId: string;
updatedAt: string;
value: string;
};
export type AssetResponseDto = {
/** base64 encoded sha1 hash */
@ -1171,12 +1173,23 @@ export type ReverseGeocodingStateResponseDto = {
lastImportFileName: string | null;
lastUpdate: string | null;
};
export type CreateTagDto = {
export type TagCreateDto = {
color?: string;
name: string;
"type": TagTypeEnum;
parentId?: string | null;
};
export type UpdateTagDto = {
name?: string;
export type TagUpsertDto = {
tags: string[];
};
export type TagBulkAssetsDto = {
assetIds: string[];
tagIds: string[];
};
export type TagBulkAssetsResponseDto = {
count: number;
};
export type TagUpdateDto = {
color?: string | null;
};
export type TimeBucketResponseDto = {
count: number;
@ -2835,8 +2848,8 @@ export function getAllTags(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
export function createTag({ createTagDto }: {
createTagDto: CreateTagDto;
export function createTag({ tagCreateDto }: {
tagCreateDto: TagCreateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
@ -2844,7 +2857,31 @@ export function createTag({ createTagDto }: {
}>("/tags", oazapfts.json({
...opts,
method: "POST",
body: createTagDto
body: tagCreateDto
})));
}
export function upsertTags({ tagUpsertDto }: {
tagUpsertDto: TagUpsertDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TagResponseDto[];
}>("/tags", oazapfts.json({
...opts,
method: "PUT",
body: tagUpsertDto
})));
}
export function bulkTagAssets({ tagBulkAssetsDto }: {
tagBulkAssetsDto: TagBulkAssetsDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TagBulkAssetsResponseDto;
}>("/tags/assets", oazapfts.json({
...opts,
method: "PUT",
body: tagBulkAssetsDto
})));
}
export function deleteTag({ id }: {
@ -2865,56 +2902,46 @@ export function getTagById({ id }: {
...opts
}));
}
export function updateTag({ id, updateTagDto }: {
export function updateTag({ id, tagUpdateDto }: {
id: string;
updateTagDto: UpdateTagDto;
tagUpdateDto: TagUpdateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TagResponseDto;
}>(`/tags/${encodeURIComponent(id)}`, oazapfts.json({
...opts,
method: "PATCH",
body: updateTagDto
method: "PUT",
body: tagUpdateDto
})));
}
export function untagAssets({ id, assetIdsDto }: {
export function untagAssets({ id, bulkIdsDto }: {
id: string;
assetIdsDto: AssetIdsDto;
bulkIdsDto: BulkIdsDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetIdsResponseDto[];
data: BulkIdResponseDto[];
}>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({
...opts,
method: "DELETE",
body: assetIdsDto
body: bulkIdsDto
})));
}
export function getTagAssets({ id }: {
export function tagAssets({ id, bulkIdsDto }: {
id: string;
bulkIdsDto: BulkIdsDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetResponseDto[];
}>(`/tags/${encodeURIComponent(id)}/assets`, {
...opts
}));
}
export function tagAssets({ id, assetIdsDto }: {
id: string;
assetIdsDto: AssetIdsDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetIdsResponseDto[];
data: BulkIdResponseDto[];
}>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({
...opts,
method: "PUT",
body: assetIdsDto
body: bulkIdsDto
})));
}
export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, timeBucket, userId, withPartners, withStacked }: {
export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, withPartners, withStacked }: {
albumId?: string;
isArchived?: boolean;
isFavorite?: boolean;
@ -2923,6 +2950,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
order?: AssetOrder;
personId?: string;
size: TimeBucketSize;
tagId?: string;
timeBucket: string;
userId?: string;
withPartners?: boolean;
@ -2940,6 +2968,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
order,
personId,
size,
tagId,
timeBucket,
userId,
withPartners,
@ -2948,7 +2977,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
...opts
}));
}
export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, userId, withPartners, withStacked }: {
export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, userId, withPartners, withStacked }: {
albumId?: string;
isArchived?: boolean;
isFavorite?: boolean;
@ -2957,6 +2986,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
order?: AssetOrder;
personId?: string;
size: TimeBucketSize;
tagId?: string;
userId?: string;
withPartners?: boolean;
withStacked?: boolean;
@ -2973,6 +3003,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
order,
personId,
size,
tagId,
userId,
withPartners,
withStacked
@ -3162,11 +3193,6 @@ export enum AlbumUserRole {
Editor = "editor",
Viewer = "viewer"
}
export enum TagTypeEnum {
Object = "OBJECT",
Face = "FACE",
Custom = "CUSTOM"
}
export enum AssetTypeEnum {
Image = "IMAGE",
Video = "VIDEO",
@ -3257,6 +3283,7 @@ export enum Permission {
TagRead = "tag.read",
TagUpdate = "tag.update",
TagDelete = "tag.delete",
TagAsset = "tag.asset",
AdminUserCreate = "admin.user.create",
AdminUserRead = "admin.user.read",
AdminUserUpdate = "admin.user.update",

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 { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { CreateTagDto, TagResponseDto, UpdateTagDto } from 'src/dtos/tag.dto';
import {
TagBulkAssetsDto,
TagBulkAssetsResponseDto,
TagCreateDto,
TagResponseDto,
TagUpdateDto,
TagUpsertDto,
} from 'src/dtos/tag.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { TagService } from 'src/services/tag.service';
@ -17,7 +22,7 @@ export class TagController {
@Post()
@Authenticated({ permission: Permission.TAG_CREATE })
createTag(@Auth() auth: AuthDto, @Body() dto: CreateTagDto): Promise<TagResponseDto> {
createTag(@Auth() auth: AuthDto, @Body() dto: TagCreateDto): Promise<TagResponseDto> {
return this.service.create(auth, dto);
}
@ -27,47 +32,54 @@ export class TagController {
return this.service.getAll(auth);
}
@Put()
@Authenticated({ permission: Permission.TAG_CREATE })
upsertTags(@Auth() auth: AuthDto, @Body() dto: TagUpsertDto): Promise<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')
@Authenticated({ permission: Permission.TAG_READ })
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 })
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);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.TAG_DELETE })
deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
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')
@Authenticated()
@Authenticated({ permission: Permission.TAG_ASSET })
tagAssets(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: AssetIdsDto,
): Promise<AssetIdsResponseDto[]> {
@Body() dto: BulkIdsDto,
): Promise<BulkIdResponseDto[]> {
return this.service.addAssets(auth, id, dto);
}
@Delete(':id/assets')
@Authenticated()
@Authenticated({ permission: Permission.TAG_ASSET })
untagAssets(
@Auth() auth: AuthDto,
@Body() dto: AssetIdsDto,
@Body() dto: BulkIdsDto,
@Param() { id }: UUIDParamDto,
): Promise<AssetIdsResponseDto[]> {
): Promise<BulkIdResponseDto[]> {
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,
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map(mapTag),
tags: entity.tags?.map((tag) => mapTag(tag)),
people: peopleWithFaces(entity.faces),
unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
checksum: entity.checksum.toString('base64'),

View File

@ -1,38 +1,64 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { TagEntity, TagType } from 'src/entities/tag.entity';
import { Optional } from 'src/validation';
import { Transform } from 'class-transformer';
import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
import { TagEntity } from 'src/entities/tag.entity';
import { Optional, ValidateUUID } from 'src/validation';
export class CreateTagDto {
export class TagCreateDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsEnum(TagType)
@IsNotEmpty()
@ApiProperty({ enumName: 'TagTypeEnum', enum: TagType })
type!: TagType;
@ValidateUUID({ optional: true, nullable: true })
parentId?: string | null;
@IsHexColor()
@Optional({ nullable: true, emptyToNull: true })
color?: string;
}
export class UpdateTagDto {
@IsString()
@Optional()
name?: string;
export class TagUpdateDto {
@Optional({ nullable: true, emptyToNull: true })
@IsHexColor()
@Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value))
color?: string | null;
}
export class TagUpsertDto {
@IsString({ each: true })
@IsNotEmpty({ each: true })
tags!: string[];
}
export class TagBulkAssetsDto {
@ValidateUUID({ each: true })
tagIds!: string[];
@ValidateUUID({ each: true })
assetIds!: string[];
}
export class TagBulkAssetsResponseDto {
@ApiProperty({ type: 'integer' })
count!: number;
}
export class TagResponseDto {
id!: string;
@ApiProperty({ enumName: 'TagTypeEnum', enum: TagType })
type!: string;
name!: string;
userId!: string;
value!: string;
createdAt!: Date;
updatedAt!: Date;
color?: string;
}
export function mapTag(entity: TagEntity): TagResponseDto {
return {
id: entity.id,
type: entity.type,
name: entity.name,
userId: entity.userId,
name: entity.value.split('/').at(-1) as string,
value: entity.value,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
color: entity.color ?? undefined,
};
}

View File

@ -19,6 +19,9 @@ export class TimeBucketDto {
@ValidateUUID({ optional: true })
personId?: string;
@ValidateUUID({ optional: true })
tagId?: string;
@ValidateBoolean({ optional: true })
isArchived?: boolean;

View File

@ -1,45 +1,48 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
import { Column, Entity, ManyToMany, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm';
import {
Column,
CreateDateColumn,
Entity,
ManyToMany,
ManyToOne,
PrimaryGeneratedColumn,
Tree,
TreeChildren,
TreeParent,
UpdateDateColumn,
} from 'typeorm';
@Entity('tags')
@Unique('UQ_tag_name_userId', ['name', 'userId'])
@Tree('closure-table')
export class TagEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
type!: TagType;
@Column({ unique: true })
value!: string;
@Column()
name!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@ManyToOne(() => UserEntity, (user) => user.tags)
user!: UserEntity;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Column({ type: 'varchar', nullable: true, default: null })
color!: string | null;
@TreeParent({ onDelete: 'CASCADE' })
parent?: TagEntity;
@TreeChildren()
children?: TagEntity[];
@ManyToOne(() => UserEntity, (user) => user.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
user?: UserEntity;
@Column()
userId!: string;
@Column({ type: 'uuid', comment: 'The new renamed tagId', nullable: true })
renameTagId!: string | null;
@ManyToMany(() => AssetEntity, (asset) => asset.tags)
assets!: AssetEntity[];
}
export enum TagType {
/**
* Tag that is detected by the ML model for object detection will use this type
*/
OBJECT = 'OBJECT',
/**
* Face that is detected by the ML model for facial detection (TBD/NOT YET IMPLEMENTED) will use this type
*/
FACE = 'FACE',
/**
* Tag that is created by the user will use this type
*/
CUSTOM = 'CUSTOM',
@ManyToMany(() => AssetEntity, (asset) => asset.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
assets?: AssetEntity[];
}

View File

@ -130,6 +130,7 @@ export enum Permission {
TAG_READ = 'tag.read',
TAG_UPDATE = 'tag.update',
TAG_DELETE = 'tag.delete',
TAG_ASSET = 'tag.asset',
ADMIN_USER_CREATE = 'admin.user.create',
ADMIN_USER_READ = 'admin.user.read',

View File

@ -46,4 +46,8 @@ export interface IAccessRepository {
stack: {
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;
isDuplicate?: boolean;
albumId?: string;
tagId?: string;
personId?: string;
userIds?: string[];
withStacked?: boolean;

View File

@ -17,6 +17,10 @@ type EmitEventMap = {
'album.update': [{ id: string; updatedBy: string }];
'album.invite': [{ id: string; userId: string }];
// tag events
'asset.tag': [{ assetId: string }];
'asset.untag': [{ assetId: string }];
// user events
'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
};

View File

@ -155,6 +155,7 @@ export interface ISidecarWriteJob extends IEntityJob {
latitude?: number;
longitude?: number;
rating?: number;
tags?: true;
}
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 { IBulkAsset } from 'src/utils/asset.util';
export const ITagRepository = 'ITagRepository';
export interface ITagRepository {
getById(userId: string, tagId: string): Promise<TagEntity | null>;
export type AssetTagItem = { assetId: string; tagId: string };
export interface ITagRepository extends IBulkAsset {
getAll(userId: string): Promise<TagEntity[]>;
getByValue(userId: string, value: string): Promise<TagEntity | null>;
create(tag: Partial<TagEntity>): Promise<TagEntity>;
update(tag: Partial<TagEntity>): Promise<TagEntity>;
remove(tag: TagEntity): Promise<void>;
hasName(userId: string, name: string): Promise<boolean>;
hasAsset(userId: string, tagId: string, assetId: string): Promise<boolean>;
getAssets(userId: string, tagId: string): Promise<AssetEntity[]>;
addAssets(userId: string, tagId: string, assetIds: string[]): Promise<void>;
removeAssets(userId: string, tagId: string, assetIds: string[]): Promise<void>;
get(id: string): Promise<TagEntity | null>;
update(tag: { id: string } & Partial<TagEntity>): Promise<TagEntity>;
delete(id: string): Promise<void>;
upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise<void>;
upsertAssetIds(items: AssetTagItem[]): Promise<AssetTagItem[]>;
}

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)
)
-- AccessRepository.tag.checkOwnerAccess
SELECT
"TagEntity"."id" AS "TagEntity_id"
FROM
"tags" "TagEntity"
WHERE
(
("TagEntity"."id" IN ($1))
AND ("TagEntity"."userId" = $2)
)
-- AccessRepository.timeline.checkPartnerAccess
SELECT
"partner"."sharedById" AS "partner_sharedById",

View File

@ -184,10 +184,12 @@ SELECT
"AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags",
"AssetEntity__AssetEntity_smartInfo"."objects" AS "AssetEntity__AssetEntity_smartInfo_objects",
"AssetEntity__AssetEntity_tags"."id" AS "AssetEntity__AssetEntity_tags_id",
"AssetEntity__AssetEntity_tags"."type" AS "AssetEntity__AssetEntity_tags_type",
"AssetEntity__AssetEntity_tags"."name" AS "AssetEntity__AssetEntity_tags_name",
"AssetEntity__AssetEntity_tags"."value" AS "AssetEntity__AssetEntity_tags_value",
"AssetEntity__AssetEntity_tags"."createdAt" AS "AssetEntity__AssetEntity_tags_createdAt",
"AssetEntity__AssetEntity_tags"."updatedAt" AS "AssetEntity__AssetEntity_tags_updatedAt",
"AssetEntity__AssetEntity_tags"."color" AS "AssetEntity__AssetEntity_tags_color",
"AssetEntity__AssetEntity_tags"."userId" AS "AssetEntity__AssetEntity_tags_userId",
"AssetEntity__AssetEntity_tags"."renameTagId" AS "AssetEntity__AssetEntity_tags_renameTagId",
"AssetEntity__AssetEntity_tags"."parentId" AS "AssetEntity__AssetEntity_tags_parentId",
"AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id",
"AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId",
"AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId",

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 { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { StackEntity } from 'src/entities/stack.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { AlbumUserRole } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { Instrumentation } from 'src/utils/instrumentation';
@ -25,6 +26,7 @@ type IMemoryAccess = IAccessRepository['memory'];
type IPersonAccess = IAccessRepository['person'];
type IPartnerAccess = IAccessRepository['partner'];
type IStackAccess = IAccessRepository['stack'];
type ITagAccess = IAccessRepository['tag'];
type ITimelineAccess = IAccessRepository['timeline'];
@Instrumentation()
@ -444,6 +446,28 @@ class PartnerAccess implements IPartnerAccess {
}
}
class TagAccess implements ITagAccess {
constructor(private tagRepository: Repository<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 {
activity: IActivityAccess;
album: IAlbumAccess;
@ -453,6 +477,7 @@ export class AccessRepository implements IAccessRepository {
person: IPersonAccess;
partner: IPartnerAccess;
stack: IStackAccess;
tag: ITagAccess;
timeline: ITimelineAccess;
constructor(
@ -467,6 +492,7 @@ export class AccessRepository implements IAccessRepository {
@InjectRepository(SharedLinkEntity) sharedLinkRepository: Repository<SharedLinkEntity>,
@InjectRepository(SessionEntity) sessionRepository: Repository<SessionEntity>,
@InjectRepository(StackEntity) stackRepository: Repository<StackEntity>,
@InjectRepository(TagEntity) tagRepository: Repository<TagEntity>,
) {
this.activity = new ActivityAccess(activityRepository, albumRepository);
this.album = new AlbumAccess(albumRepository, sharedLinkRepository);
@ -476,6 +502,7 @@ export class AccessRepository implements IAccessRepository {
this.person = new PersonAccess(assetFaceRepository, personRepository);
this.partner = new PartnerAccess(partnerRepository);
this.stack = new StackAccess(stackRepository);
this.tag = new TagAccess(tagRepository);
this.timeline = new TimelineAccess(partnerRepository);
}
}

View File

@ -723,6 +723,15 @@ export class AssetRepository implements IAssetRepository {
builder.andWhere('asset.type = :assetType', { assetType: options.assetType });
}
if (options.tagId) {
builder.innerJoin(
'asset.tags',
'asset_tags',
'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)',
{ tagId: options.tagId },
);
}
let stackJoined = false;
if (options.exifInfo !== false) {

View File

@ -1,33 +1,36 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { AssetEntity } from 'src/entities/asset.entity';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { TagEntity } from 'src/entities/tag.entity';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
import { Instrumentation } from 'src/utils/instrumentation';
import { Repository } from 'typeorm';
import { DataSource, In, Repository } from 'typeorm';
@Instrumentation()
@Injectable()
export class TagRepository implements ITagRepository {
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectDataSource() private dataSource: DataSource,
@InjectRepository(TagEntity) private repository: Repository<TagEntity>,
) {}
getById(userId: string, id: string): Promise<TagEntity | null> {
return this.repository.findOne({
where: {
id,
userId,
},
relations: {
user: true,
},
});
get(id: string): Promise<TagEntity | null> {
return this.repository.findOne({ where: { id } });
}
getAll(userId: string): Promise<TagEntity[]> {
return this.repository.find({ where: { userId } });
getByValue(userId: string, value: string): Promise<TagEntity | null> {
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> {
@ -38,89 +41,99 @@ export class TagRepository implements ITagRepository {
return this.save(tag);
}
async remove(tag: TagEntity): Promise<void> {
await this.repository.remove(tag);
async delete(id: string): Promise<void> {
await this.repository.delete(id);
}
async getAssets(userId: string, tagId: string): Promise<AssetEntity[]> {
return this.assetRepository.find({
where: {
tags: {
userId,
id: tagId,
},
},
relations: {
exifInfo: true,
tags: true,
faces: {
person: true,
},
},
order: {
createdAt: 'ASC',
},
});
}
async addAssets(userId: string, id: string, assetIds: string[]): Promise<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);
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@ChunkedSet({ paramIndex: 1 })
async getAssetIds(tagId: string, assetIds: string[]): Promise<Set<string>> {
if (assetIds.length === 0) {
return new Set();
}
const results = await this.dataSource
.createQueryBuilder()
.select('tag_asset.assetsId', 'assetId')
.from('tag_asset', 'tag_asset')
.where('"tag_asset"."tagsId" = :tagId', { tagId })
.andWhere('"tag_asset"."assetsId" IN (:...assetIds)', { assetIds })
.getRawMany<{ assetId: string }>();
return new Set(results.map(({ assetId }) => assetId));
}
async removeAssets(userId: string, id: string, assetIds: string[]): Promise<void> {
for (const assetId of assetIds) {
const asset = await this.assetRepository.findOneOrFail({
where: {
ownerId: userId,
id: assetId,
},
relations: {
tags: true,
},
});
asset.tags = asset.tags.filter((tag) => tag.id !== id);
await this.assetRepository.save(asset);
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
async addAssetIds(tagId: string, assetIds: string[]): Promise<void> {
if (assetIds.length === 0) {
return;
}
await this.dataSource.manager
.createQueryBuilder()
.insert()
.into('tag_asset', ['tagsId', 'assetsId'])
.values(assetIds.map((assetId) => ({ tagsId: tagId, assetsId: assetId })))
.execute();
}
hasAsset(userId: string, tagId: string, assetId: string): Promise<boolean> {
return this.repository.exists({
where: {
id: tagId,
userId,
assets: {
id: assetId,
},
},
relations: {
assets: true,
},
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
@Chunked({ paramIndex: 1 })
async removeAssetIds(tagId: string, assetIds: string[]): Promise<void> {
if (assetIds.length === 0) {
return;
}
await this.dataSource
.createQueryBuilder()
.delete()
.from('tag_asset')
.where({
tagsId: tagId,
assetsId: In(assetIds),
})
.execute();
}
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagId: DummyValue.UUID }]] })
@Chunked()
async upsertAssetIds(items: AssetTagItem[]): Promise<AssetTagItem[]> {
if (items.length === 0) {
return [];
}
const { identifiers } = await this.dataSource
.createQueryBuilder()
.insert()
.into('tag_asset', ['assetsId', 'tagsId'])
.values(items.map(({ assetId, tagId }) => ({ assetsId: assetId, tagsId: tagId })))
.execute();
return (identifiers as Array<{ assetsId: string; tagsId: string }>).map(({ assetsId, tagsId }) => ({
assetId: assetsId,
tagId: tagsId,
}));
}
async upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }) {
await this.dataSource.transaction(async (manager) => {
await manager.createQueryBuilder().delete().from('tag_asset').where({ assetsId: assetId }).execute();
if (tagIds.length === 0) {
return;
}
await manager
.createQueryBuilder()
.insert()
.into('tag_asset', ['tagsId', 'assetsId'])
.values(tagIds.map((tagId) => ({ tagsId: tagId, assetsId: assetId })))
.execute();
});
}
hasName(userId: string, name: string): Promise<boolean> {
return this.repository.exists({
where: {
name,
userId,
},
});
}
private async save(tag: Partial<TagEntity>): Promise<TagEntity> {
const { id } = await this.repository.save(tag);
return this.repository.findOneOrFail({ where: { id }, relations: { user: true } });
private async save(partial: Partial<TagEntity>): Promise<TagEntity> {
const { id } = await this.repository.save(partial);
return this.repository.findOneOrFail({ where: { id } });
}
}

View File

@ -18,11 +18,13 @@ import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { MetadataService, Orientation } from 'src/services/metadata.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { probeStub } from 'test/fixtures/media.stub';
import { tagStub } from 'test/fixtures/tag.stub';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
@ -37,6 +39,7 @@ import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest';
@ -56,6 +59,7 @@ describe(MetadataService.name, () => {
let databaseMock: Mocked<IDatabaseRepository>;
let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let tagMock: Mocked<ITagRepository>;
let sut: MetadataService;
beforeEach(() => {
@ -74,6 +78,7 @@ describe(MetadataService.name, () => {
databaseMock = newDatabaseRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
tagMock = newTagRepositoryMock();
sut = new MetadataService(
albumMock,
@ -89,6 +94,7 @@ describe(MetadataService.name, () => {
personMock,
storageMock,
systemMock,
tagMock,
userMock,
loggerMock,
);
@ -356,6 +362,72 @@ describe(MetadataService.name, () => {
expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ latitude: null, longitude: null }));
});
it('should extract tags from TagsList', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ TagsList: ['Parent'] });
tagMock.getByValue.mockResolvedValue(null);
tagMock.create.mockResolvedValue(tagStub.parent);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
});
it('should extract hierarchy from TagsList', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ TagsList: ['Parent/Child'] });
tagMock.getByValue.mockResolvedValue(null);
tagMock.create.mockResolvedValueOnce(tagStub.parent);
tagMock.create.mockResolvedValueOnce(tagStub.child);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(tagMock.create).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined });
expect(tagMock.create).toHaveBeenNthCalledWith(2, {
userId: 'user-id',
value: 'Parent/Child',
parent: tagStub.parent,
});
});
it('should extract tags from Keywords as a string', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ Keywords: 'Parent' });
tagMock.getByValue.mockResolvedValue(null);
tagMock.create.mockResolvedValue(tagStub.parent);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
});
it('should extract tags from Keywords as a list', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent'] });
tagMock.getByValue.mockResolvedValue(null);
tagMock.create.mockResolvedValue(tagStub.parent);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(tagMock.create).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined });
});
it('should extract hierarchal tags from Keywords', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ Keywords: 'Parent/Child' });
tagMock.getByValue.mockResolvedValue(null);
tagMock.create.mockResolvedValue(tagStub.parent);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(tagMock.create).toHaveBeenNthCalledWith(1, { userId: 'user-id', value: 'Parent', parent: undefined });
expect(tagMock.create).toHaveBeenNthCalledWith(2, {
userId: 'user-id',
value: 'Parent/Child',
parent: tagStub.parent,
});
});
it('should not apply motion photos if asset is video', async () => {
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoMotionAsset, isVisible: true }]);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);

View File

@ -22,8 +22,8 @@ import {
IEntityJob,
IJobRepository,
ISidecarWriteJob,
JOBS_ASSET_PAGINATION_SIZE,
JobName,
JOBS_ASSET_PAGINATION_SIZE,
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
@ -35,8 +35,10 @@ import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { usePagination } from 'src/utils/pagination';
import { upsertTags } from 'src/utils/tag';
/** look for a date from these tags (in order) */
const EXIF_DATE_TAGS: Array<keyof Tags> = [
@ -105,6 +107,7 @@ export class MetadataService {
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ITagRepository) private tagRepository: ITagRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
@ -217,24 +220,27 @@ export class MetadataService {
return JobStatus.FAILED;
}
const { exifData, tags } = await this.exifData(asset);
const { exifData, exifTags } = await this.exifData(asset);
if (asset.type === AssetType.VIDEO) {
await this.applyVideoMetadata(asset, exifData);
}
await this.applyMotionPhotos(asset, tags);
await this.applyMotionPhotos(asset, exifTags);
await this.applyReverseGeocoding(asset, exifData);
await this.applyTagList(asset, exifTags);
await this.assetRepository.upsertExif(exifData);
const dateTimeOriginal = exifData.dateTimeOriginal;
let localDateTime = dateTimeOriginal ?? undefined;
const timeZoneOffset = tzOffset(firstDateTime(tags as Tags)) ?? 0;
const timeZoneOffset = tzOffset(firstDateTime(exifTags as Tags)) ?? 0;
if (dateTimeOriginal && timeZoneOffset) {
localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000);
}
await this.assetRepository.update({
id: asset.id,
duration: asset.duration,
@ -278,22 +284,35 @@ export class MetadataService {
return this.processSidecar(id, false);
}
@OnEmit({ event: 'asset.tag' })
async handleTagAsset({ assetId }: ArgOf<'asset.tag'>) {
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
}
@OnEmit({ event: 'asset.untag' })
async handleUntagAsset({ assetId }: ArgOf<'asset.untag'>) {
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
}
async handleSidecarWrite(job: ISidecarWriteJob): Promise<JobStatus> {
const { id, description, dateTimeOriginal, latitude, longitude, rating } = job;
const [asset] = await this.assetRepository.getByIds([id]);
const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job;
const [asset] = await this.assetRepository.getByIds([id], { tags: true });
if (!asset) {
return JobStatus.FAILED;
}
const tagsList = (asset.tags || []).map((tag) => tag.value);
const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
const exif = _.omitBy<Tags>(
{
const exif = _.omitBy(
<Tags>{
Description: description,
ImageDescription: description,
DateTimeOriginal: dateTimeOriginal,
GPSLatitude: latitude,
GPSLongitude: longitude,
Rating: rating,
TagsList: tags ? tagsList : undefined,
},
_.isUndefined,
);
@ -332,6 +351,28 @@ export class MetadataService {
}
}
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
const tags: string[] = [];
if (exifTags.TagsList) {
tags.push(...exifTags.TagsList);
}
if (exifTags.Keywords) {
let keywords = exifTags.Keywords;
if (typeof keywords === 'string') {
keywords = [keywords];
}
tags.push(...keywords);
}
if (tags.length > 0) {
const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags });
const tagIds = results.map((tag) => tag.id);
await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds });
}
}
private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) {
if (asset.type !== AssetType.IMAGE) {
return;
@ -466,7 +507,7 @@ export class MetadataService {
private async exifData(
asset: AssetEntity,
): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; tags: ImmichTags }> {
): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> {
const stats = await this.storageRepository.stat(asset.originalPath);
const mediaTags = await this.repository.readTags(asset.originalPath);
const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null;
@ -479,38 +520,38 @@ export class MetadataService {
}
}
const tags = { ...mediaTags, ...sidecarTags };
const exifTags = { ...mediaTags, ...sidecarTags };
this.logger.verbose('Exif Tags', tags);
this.logger.verbose('Exif Tags', exifTags);
const exifData = {
// altitude: tags.GPSAltitude ?? null,
assetId: asset.id,
bitsPerSample: this.getBitsPerSample(tags),
colorspace: tags.ColorSpace ?? null,
dateTimeOriginal: this.getDateTimeOriginal(tags) ?? asset.fileCreatedAt,
description: String(tags.ImageDescription || tags.Description || '').trim(),
exifImageHeight: validate(tags.ImageHeight),
exifImageWidth: validate(tags.ImageWidth),
exposureTime: tags.ExposureTime ?? null,
bitsPerSample: this.getBitsPerSample(exifTags),
colorspace: exifTags.ColorSpace ?? null,
dateTimeOriginal: this.getDateTimeOriginal(exifTags) ?? asset.fileCreatedAt,
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
exifImageHeight: validate(exifTags.ImageHeight),
exifImageWidth: validate(exifTags.ImageWidth),
exposureTime: exifTags.ExposureTime ?? null,
fileSizeInByte: stats.size,
fNumber: validate(tags.FNumber),
focalLength: validate(tags.FocalLength),
fps: validate(Number.parseFloat(tags.VideoFrameRate!)),
iso: validate(tags.ISO),
latitude: validate(tags.GPSLatitude),
lensModel: tags.LensModel ?? null,
livePhotoCID: (tags.ContentIdentifier || tags.MediaGroupUUID) ?? null,
autoStackId: this.getAutoStackId(tags),
longitude: validate(tags.GPSLongitude),
make: tags.Make ?? null,
model: tags.Model ?? null,
modifyDate: exifDate(tags.ModifyDate) ?? asset.fileModifiedAt,
orientation: validate(tags.Orientation)?.toString() ?? null,
profileDescription: tags.ProfileDescription || null,
projectionType: tags.ProjectionType ? String(tags.ProjectionType).toUpperCase() : null,
timeZone: tags.tz ?? null,
rating: tags.Rating ?? null,
fNumber: validate(exifTags.FNumber),
focalLength: validate(exifTags.FocalLength),
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
iso: validate(exifTags.ISO),
latitude: validate(exifTags.GPSLatitude),
lensModel: exifTags.LensModel ?? null,
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
autoStackId: this.getAutoStackId(exifTags),
longitude: validate(exifTags.GPSLongitude),
make: exifTags.Make ?? null,
model: exifTags.Model ?? null,
modifyDate: exifDate(exifTags.ModifyDate) ?? asset.fileModifiedAt,
orientation: validate(exifTags.Orientation)?.toString() ?? null,
profileDescription: exifTags.ProfileDescription || null,
projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
timeZone: exifTags.tz ?? null,
rating: exifTags.Rating ?? null,
};
if (exifData.latitude === 0 && exifData.longitude === 0) {
@ -519,7 +560,7 @@ export class MetadataService {
exifData.longitude = null;
}
return { exifData, tags };
return { exifData, exifTags };
}
private getAutoStackId(tags: ImmichTags | null): string | null {

View File

@ -1,21 +1,28 @@
import { BadRequestException } from '@nestjs/common';
import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { TagType } from 'src/entities/tag.entity';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { TagService } from 'src/services/tag.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock';
import { Mocked } from 'vitest';
describe(TagService.name, () => {
let sut: TagService;
let accessMock: IAccessRepositoryMock;
let eventMock: Mocked<IEventRepository>;
let tagMock: Mocked<ITagRepository>;
beforeEach(() => {
accessMock = newAccessRepositoryMock();
eventMock = newEventRepositoryMock();
tagMock = newTagRepositoryMock();
sut = new TagService(tagMock);
sut = new TagService(accessMock, eventMock, tagMock);
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1']));
});
it('should work', () => {
@ -30,148 +37,216 @@ describe(TagService.name, () => {
});
});
describe('getById', () => {
describe('get', () => {
it('should throw an error for an invalid id', async () => {
tagMock.getById.mockResolvedValue(null);
await expect(sut.getById(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
tagMock.get.mockResolvedValue(null);
await expect(sut.get(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
expect(tagMock.get).toHaveBeenCalledWith('tag-1');
});
it('should return a tag for a user', async () => {
tagMock.getById.mockResolvedValue(tagStub.tag1);
await expect(sut.getById(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
tagMock.get.mockResolvedValue(tagStub.tag1);
await expect(sut.get(authStub.admin, 'tag-1')).resolves.toEqual(tagResponseStub.tag1);
expect(tagMock.get).toHaveBeenCalledWith('tag-1');
});
});
describe('create', () => {
it('should throw an error for no parent tag access', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.create(authStub.admin, { name: 'tag', parentId: 'tag-parent' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tagMock.create).not.toHaveBeenCalled();
});
it('should create a tag with a parent', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent']));
tagMock.create.mockResolvedValue(tagStub.tag1);
tagMock.get.mockResolvedValueOnce(tagStub.parent);
tagMock.get.mockResolvedValueOnce(tagStub.child);
await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).resolves.toBeDefined();
expect(tagMock.create).toHaveBeenCalledWith(expect.objectContaining({ value: 'Parent/tagA' }));
});
it('should handle invalid parent ids', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-parent']));
await expect(sut.create(authStub.admin, { name: 'tagA', parentId: 'tag-parent' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tagMock.create).not.toHaveBeenCalled();
});
});
describe('create', () => {
it('should throw an error for a duplicate tag', async () => {
tagMock.hasName.mockResolvedValue(true);
await expect(sut.create(authStub.admin, { name: 'tag-1', type: TagType.CUSTOM })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tagMock.hasName).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
tagMock.getByValue.mockResolvedValue(tagStub.tag1);
await expect(sut.create(authStub.admin, { name: 'tag-1' })).rejects.toBeInstanceOf(BadRequestException);
expect(tagMock.getByValue).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.create).not.toHaveBeenCalled();
});
it('should create a new tag', async () => {
tagMock.create.mockResolvedValue(tagStub.tag1);
await expect(sut.create(authStub.admin, { name: 'tag-1', type: TagType.CUSTOM })).resolves.toEqual(
tagResponseStub.tag1,
);
await expect(sut.create(authStub.admin, { name: 'tag-1' })).resolves.toEqual(tagResponseStub.tag1);
expect(tagMock.create).toHaveBeenCalledWith({
userId: authStub.admin.user.id,
name: 'tag-1',
type: TagType.CUSTOM,
value: 'tag-1',
});
});
});
describe('update', () => {
it('should throw an error for an invalid id', async () => {
tagMock.getById.mockResolvedValue(null);
await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag-2' })).rejects.toBeInstanceOf(BadRequestException);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.remove).not.toHaveBeenCalled();
it('should throw an error for no update permission', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tagMock.update).not.toHaveBeenCalled();
});
it('should update a tag', async () => {
tagMock.getById.mockResolvedValue(tagStub.tag1);
tagMock.update.mockResolvedValue(tagStub.tag1);
await expect(sut.update(authStub.admin, 'tag-1', { name: 'tag-2' })).resolves.toEqual(tagResponseStub.tag1);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', name: 'tag-2' });
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1']));
tagMock.update.mockResolvedValue(tagStub.color1);
await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).resolves.toEqual(tagResponseStub.color1);
expect(tagMock.update).toHaveBeenCalledWith({ id: 'tag-1', color: '#000000' });
});
});
describe('upsert', () => {
it('should upsert a new tag', async () => {
tagMock.create.mockResolvedValue(tagStub.parent);
await expect(sut.upsert(authStub.admin, { tags: ['Parent'] })).resolves.toBeDefined();
expect(tagMock.create).toHaveBeenCalledWith({
value: 'Parent',
userId: 'admin_id',
parentId: undefined,
});
});
it('should upsert a nested tag', async () => {
tagMock.getByValue.mockResolvedValueOnce(null);
tagMock.create.mockResolvedValueOnce(tagStub.parent);
tagMock.create.mockResolvedValueOnce(tagStub.child);
await expect(sut.upsert(authStub.admin, { tags: ['Parent/Child'] })).resolves.toBeDefined();
expect(tagMock.create).toHaveBeenNthCalledWith(1, {
value: 'Parent',
userId: 'admin_id',
parentId: undefined,
});
expect(tagMock.create).toHaveBeenNthCalledWith(2, {
value: 'Parent/Child',
userId: 'admin_id',
parent: expect.objectContaining({ id: 'tag-parent' }),
});
});
});
describe('remove', () => {
it('should throw an error for an invalid id', async () => {
tagMock.getById.mockResolvedValue(null);
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.remove).not.toHaveBeenCalled();
expect(tagMock.delete).not.toHaveBeenCalled();
});
it('should remove a tag', async () => {
tagMock.getById.mockResolvedValue(tagStub.tag1);
tagMock.get.mockResolvedValue(tagStub.tag1);
await sut.remove(authStub.admin, 'tag-1');
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.remove).toHaveBeenCalledWith(tagStub.tag1);
expect(tagMock.delete).toHaveBeenCalledWith('tag-1');
});
});
describe('getAssets', () => {
it('should throw an error for an invalid id', async () => {
tagMock.getById.mockResolvedValue(null);
await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.remove).not.toHaveBeenCalled();
describe('bulkTagAssets', () => {
it('should handle invalid requests', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set());
tagMock.upsertAssetIds.mockResolvedValue([]);
await expect(sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1'], assetIds: ['asset-1'] })).resolves.toEqual({
count: 0,
});
expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([]);
});
it('should get the assets for a tag', async () => {
tagMock.getById.mockResolvedValue(tagStub.tag1);
tagMock.getAssets.mockResolvedValue([assetStub.image]);
await sut.getAssets(authStub.admin, 'tag-1');
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.getAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
it('should upsert records', async () => {
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2']));
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3']));
tagMock.upsertAssetIds.mockResolvedValue([
{ tagId: 'tag-1', assetId: 'asset-1' },
{ tagId: 'tag-1', assetId: 'asset-2' },
{ tagId: 'tag-1', assetId: 'asset-3' },
{ tagId: 'tag-2', assetId: 'asset-1' },
{ tagId: 'tag-2', assetId: 'asset-2' },
{ tagId: 'tag-2', assetId: 'asset-3' },
]);
await expect(
sut.bulkTagAssets(authStub.admin, { tagIds: ['tag-1', 'tag-2'], assetIds: ['asset-1', 'asset-2', 'asset-3'] }),
).resolves.toEqual({
count: 6,
});
expect(tagMock.upsertAssetIds).toHaveBeenCalledWith([
{ tagId: 'tag-1', assetId: 'asset-1' },
{ tagId: 'tag-1', assetId: 'asset-2' },
{ tagId: 'tag-1', assetId: 'asset-3' },
{ tagId: 'tag-2', assetId: 'asset-1' },
{ tagId: 'tag-2', assetId: 'asset-2' },
{ tagId: 'tag-2', assetId: 'asset-3' },
]);
});
});
describe('addAssets', () => {
it('should throw an error for an invalid id', async () => {
tagMock.getById.mockResolvedValue(null);
await expect(sut.addAssets(authStub.admin, 'tag-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.addAssets).not.toHaveBeenCalled();
it('should handle invalid ids', async () => {
tagMock.get.mockResolvedValue(null);
tagMock.getAssetIds.mockResolvedValue(new Set([]));
await expect(sut.addAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
{ id: 'asset-1', success: false, error: 'no_permission' },
]);
expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']);
expect(tagMock.addAssetIds).not.toHaveBeenCalled();
});
it('should reject duplicate asset ids and accept new ones', async () => {
tagMock.getById.mockResolvedValue(tagStub.tag1);
tagMock.hasAsset.mockImplementation((userId, tagId, assetId) => Promise.resolve(assetId === 'asset-1'));
it('should accept accept ids that are new and reject the rest', async () => {
tagMock.get.mockResolvedValue(tagStub.tag1);
tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1']));
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2']));
await expect(
sut.addAssets(authStub.admin, 'tag-1', {
assetIds: ['asset-1', 'asset-2'],
ids: ['asset-1', 'asset-2'],
}),
).resolves.toEqual([
{ assetId: 'asset-1', success: false, error: AssetIdErrorReason.DUPLICATE },
{ assetId: 'asset-2', success: true },
{ id: 'asset-1', success: false, error: BulkIdErrorReason.DUPLICATE },
{ id: 'asset-2', success: true },
]);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.hasAsset).toHaveBeenCalledTimes(2);
expect(tagMock.addAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1', ['asset-2']);
expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
expect(tagMock.addAssetIds).toHaveBeenCalledWith('tag-1', ['asset-2']);
});
});
describe('removeAssets', () => {
it('should throw an error for an invalid id', async () => {
tagMock.getById.mockResolvedValue(null);
await expect(sut.removeAssets(authStub.admin, 'tag-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.removeAssets).not.toHaveBeenCalled();
tagMock.get.mockResolvedValue(null);
tagMock.getAssetIds.mockResolvedValue(new Set());
await expect(sut.removeAssets(authStub.admin, 'tag-1', { ids: ['asset-1'] })).resolves.toEqual([
{ id: 'asset-1', success: false, error: 'not_found' },
]);
});
it('should accept accept ids that are tagged and reject the rest', async () => {
tagMock.getById.mockResolvedValue(tagStub.tag1);
tagMock.hasAsset.mockImplementation((userId, tagId, assetId) => Promise.resolve(assetId === 'asset-1'));
tagMock.get.mockResolvedValue(tagStub.tag1);
tagMock.getAssetIds.mockResolvedValue(new Set(['asset-1']));
await expect(
sut.removeAssets(authStub.admin, 'tag-1', {
assetIds: ['asset-1', 'asset-2'],
ids: ['asset-1', 'asset-2'],
}),
).resolves.toEqual([
{ assetId: 'asset-1', success: true },
{ assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND },
{ id: 'asset-1', success: true },
{ id: 'asset-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
]);
expect(tagMock.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1');
expect(tagMock.hasAsset).toHaveBeenCalledTimes(2);
expect(tagMock.removeAssets).toHaveBeenCalledWith(authStub.admin.user.id, 'tag-1', ['asset-1']);
expect(tagMock.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
expect(tagMock.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']);
});
});
});

View File

@ -1,102 +1,145 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { CreateTagDto, TagResponseDto, UpdateTagDto, mapTag } from 'src/dtos/tag.dto';
import { ITagRepository } from 'src/interfaces/tag.interface';
import {
TagBulkAssetsDto,
TagBulkAssetsResponseDto,
TagCreateDto,
TagResponseDto,
TagUpdateDto,
TagUpsertDto,
mapTag,
} from 'src/dtos/tag.dto';
import { TagEntity } from 'src/entities/tag.entity';
import { Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
import { checkAccess, requireAccess } from 'src/utils/access';
import { addAssets, removeAssets } from 'src/utils/asset.util';
import { upsertTags } from 'src/utils/tag';
@Injectable()
export class TagService {
constructor(@Inject(ITagRepository) private repository: ITagRepository) {}
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ITagRepository) private repository: ITagRepository,
) {}
getAll(auth: AuthDto) {
return this.repository.getAll(auth.user.id).then((tags) => tags.map((tag) => mapTag(tag)));
async getAll(auth: AuthDto) {
const tags = await this.repository.getAll(auth.user.id);
return tags.map((tag) => mapTag(tag));
}
async getById(auth: AuthDto, id: string): Promise<TagResponseDto> {
const tag = await this.findOrFail(auth, id);
async get(auth: AuthDto, id: string): Promise<TagResponseDto> {
await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [id] });
const tag = await this.findOrFail(id);
return mapTag(tag);
}
async create(auth: AuthDto, dto: CreateTagDto) {
const duplicate = await this.repository.hasName(auth.user.id, dto.name);
async create(auth: AuthDto, dto: TagCreateDto) {
let parent: TagEntity | undefined;
if (dto.parentId) {
await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.parentId] });
parent = (await this.repository.get(dto.parentId)) || undefined;
if (!parent) {
throw new BadRequestException('Tag not found');
}
}
const userId = auth.user.id;
const value = parent ? `${parent.value}/${dto.name}` : dto.name;
const duplicate = await this.repository.getByValue(userId, value);
if (duplicate) {
throw new BadRequestException(`A tag with that name already exists`);
}
const tag = await this.repository.create({
userId: auth.user.id,
name: dto.name,
type: dto.type,
});
const tag = await this.repository.create({ userId, value, parent });
return mapTag(tag);
}
async update(auth: AuthDto, id: string, dto: UpdateTagDto): Promise<TagResponseDto> {
await this.findOrFail(auth, id);
const tag = await this.repository.update({ id, name: dto.name });
async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise<TagResponseDto> {
await requireAccess(this.access, { auth, permission: Permission.TAG_UPDATE, ids: [id] });
const { color } = dto;
const tag = await this.repository.update({ id, color });
return mapTag(tag);
}
async upsert(auth: AuthDto, dto: TagUpsertDto) {
const tags = await upsertTags(this.repository, { userId: auth.user.id, tags: dto.tags });
return tags.map((tag) => mapTag(tag));
}
async remove(auth: AuthDto, id: string): Promise<void> {
const tag = await this.findOrFail(auth, id);
await this.repository.remove(tag);
await requireAccess(this.access, { auth, permission: Permission.TAG_DELETE, ids: [id] });
// TODO sync tag changes for affected assets
await this.repository.delete(id);
}
async getAssets(auth: AuthDto, id: string): Promise<AssetResponseDto[]> {
await this.findOrFail(auth, id);
const assets = await this.repository.getAssets(auth.user.id, id);
return assets.map((asset) => mapAsset(asset));
}
async bulkTagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise<TagBulkAssetsResponseDto> {
const [tagIds, assetIds] = await Promise.all([
checkAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }),
checkAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }),
]);
async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
await this.findOrFail(auth, id);
const results: AssetIdsResponseDto[] = [];
for (const assetId of dto.assetIds) {
const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId);
if (hasAsset) {
results.push({ assetId, success: false, error: AssetIdErrorReason.DUPLICATE });
} else {
results.push({ assetId, success: true });
const items: AssetTagItem[] = [];
for (const tagId of tagIds) {
for (const assetId of assetIds) {
items.push({ tagId, assetId });
}
}
await this.repository.addAssets(
auth.user.id,
id,
results.filter((result) => result.success).map((result) => result.assetId),
const results = await this.repository.upsertAssetIds(items);
for (const assetId of new Set(results.map((item) => item.assetId))) {
await this.eventRepository.emit('asset.tag', { assetId });
}
return { count: results.length };
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<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;
}
async removeAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
await this.findOrFail(auth, id);
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] });
const results: AssetIdsResponseDto[] = [];
for (const assetId of dto.assetIds) {
const hasAsset = await this.repository.hasAsset(auth.user.id, id, assetId);
if (hasAsset) {
results.push({ assetId, success: true });
} else {
results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND });
const results = await removeAssets(
auth,
{ access: this.access, bulk: this.repository },
{ parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.TAG_DELETE },
);
for (const { id: assetId, success } of results) {
if (success) {
await this.eventRepository.emit('asset.untag', { assetId });
}
}
await this.repository.removeAssets(
auth.user.id,
id,
results.filter((result) => result.success).map((result) => result.assetId),
);
return results;
}
private async findOrFail(auth: AuthDto, id: string) {
const tag = await this.repository.getById(auth.user.id, id);
private async findOrFail(id: string) {
const tag = await this.repository.get(id);
if (!tag) {
throw new BadRequestException('Tag not found');
}

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) {
const requestedArchived = dto.isArchived === true || dto.isArchived === undefined;
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;
if (idSet.size === 0) {
return new Set<string>();
@ -52,7 +55,10 @@ export const checkAccess = async (access: IAccessRepository, { ids, auth, permis
: checkOtherAccess(access, { auth, permission, ids: idSet });
};
const checkSharedLinkAccess = async (access: IAccessRepository, request: SharedLinkAccessRequest) => {
const checkSharedLinkAccess = async (
access: IAccessRepository,
request: SharedLinkAccessRequest,
): Promise<Set<string>> => {
const { sharedLink, permission, ids } = request;
const sharedLinkId = sharedLink.id;
@ -96,7 +102,7 @@ const checkSharedLinkAccess = async (access: IAccessRepository, request: SharedL
}
};
const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessRequest) => {
const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessRequest): Promise<Set<string>> => {
const { auth, permission, ids } = request;
switch (permission) {
@ -211,6 +217,13 @@ const checkOtherAccess = async (access: IAccessRepository, request: OtherAccessR
return await access.authDevice.checkOwnerAccess(auth.user.id, ids);
}
case Permission.TAG_ASSET:
case Permission.TAG_READ:
case Permission.TAG_UPDATE:
case Permission.TAG_DELETE: {
return await access.tag.checkOwnerAccess(auth.user.id, ids);
}
case Permission.TIMELINE_READ: {
const isOwner = ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set<string>();
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');
};
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 { TagEntity, TagType } from 'src/entities/tag.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { userStub } from 'test/fixtures/user.stub';
const parent = Object.freeze<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 = {
tag1: Object.freeze<TagEntity>({
id: 'tag-1',
name: 'Tag1',
type: TagType.CUSTOM,
createdAt: new Date('2021-01-01T00:00:00Z'),
updatedAt: new Date('2021-01-01T00:00:00Z'),
value: 'Tag1',
color: null,
userId: userStub.admin.id,
user: userStub.admin,
}),
parent,
child,
color1: Object.freeze<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,
user: userStub.admin,
renameTagId: null,
assets: [],
}),
};
export const tagResponseStub = {
tag1: Object.freeze<TagResponseDto>({
id: 'tag-1',
createdAt: new Date('2021-01-01T00:00:00Z'),
updatedAt: new Date('2021-01-01T00:00:00Z'),
name: 'Tag1',
type: 'CUSTOM',
userId: 'admin_id',
value: 'Tag1',
}),
color1: Object.freeze<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']>;
stack: Mocked<IAccessRepository['stack']>;
timeline: Mocked<IAccessRepository['timeline']>;
tag: Mocked<IAccessRepository['tag']>;
}
export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
@ -58,5 +59,9 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
timeline: {
checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()),
},
tag: {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},
};
};

View File

@ -4,14 +4,17 @@ import { Mocked, vitest } from 'vitest';
export const newTagRepositoryMock = (): Mocked<ITagRepository> => {
return {
getAll: vitest.fn(),
getById: vitest.fn(),
getByValue: vitest.fn(),
upsertAssetTags: vitest.fn(),
get: vitest.fn(),
create: vitest.fn(),
update: vitest.fn(),
remove: vitest.fn(),
hasAsset: vitest.fn(),
hasName: vitest.fn(),
getAssets: vitest.fn(),
addAssets: vitest.fn(),
removeAssets: vitest.fn(),
delete: vitest.fn(),
getAssetIds: vitest.fn(),
addAssetIds: vitest.fn(),
removeAssetIds: vitest.fn(),
upsertAssetIds: vitest.fn(),
};
};

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 { t } from 'svelte-i18n';
import { goto } from '$app/navigation';
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
export let asset: AssetResponseDto;
export let albums: AlbumResponseDto[] = [];
@ -157,7 +158,7 @@
<DetailPanelRating {asset} {isOwner} />
{#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">
<h2>{$t('people').toUpperCase()}</h2>
<div class="flex gap-2 items-center">
@ -472,11 +473,11 @@
{/if}
{#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>
{#each albums as album}
<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>
<img
alt={album.albumName}
@ -501,6 +502,10 @@
</section>
{/if}
<section class="relative px-2 pb-12 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
<DetailPanelTags {asset} {isOwner} />
</section>
{#if showEditFaces}
<PersonSidePanel
assetId={asset.id}

View File

@ -153,7 +153,7 @@
return;
}
if (dateGroup && assetStore) {
assetStore.taskManager.seperatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false));
assetStore.taskManager.separatedThumbnail(componentId, dateGroup, asset, () => (intersecting = false));
} else {
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>
<section class="relative">
{#if title}
{#if title || $$slots.title || $$slots.buttons}
<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"
>
<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}
<p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>
{/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: () => {
$assetStore.taskManager.seperatedDateGroup(componentId, dateGroup, () =>
$assetStore.taskManager.separatedDateGroup(componentId, dateGroup, () =>
assetStore.updateBucketDateGroup(bucket, dateGroup, { intersecting: false }),
);
},
@ -186,9 +186,9 @@
<div
use:intersectionObserver={{
onIntersect: () => onAssetInGrid?.(asset),
top: `-${TITLE_HEIGHT}px`,
bottom: `-${viewport.height - TITLE_HEIGHT - 1}px`,
right: `-${viewport.width - 1}px`,
top: `${-TITLE_HEIGHT}px`,
bottom: `${-(viewport.height - TITLE_HEIGHT - 1)}px`,
right: `${-(viewport.width - 1)}px`,
root: assetGridElement,
}}
data-asset-id={asset.id}

View File

@ -498,21 +498,21 @@
}
};
function intersectedHandler(bucket: AssetBucket) {
function handleIntersect(bucket: AssetBucket) {
updateLastIntersectedBucketDate();
const intersectedTask = () => {
const task = () => {
$assetStore.updateBucket(bucket.bucketDate, { intersecting: true });
void $assetStore.loadBucket(bucket.bucketDate);
};
$assetStore.taskManager.intersectedBucket(componentId, bucket, intersectedTask);
$assetStore.taskManager.intersectedBucket(componentId, bucket, task);
}
function seperatedHandler(bucket: AssetBucket) {
const seperatedTask = () => {
function handleSeparate(bucket: AssetBucket) {
const task = () => {
$assetStore.updateBucket(bucket.bucketDate, { intersecting: false });
bucket.cancel();
};
$assetStore.taskManager.seperatedBucket(componentId, bucket, seperatedTask);
$assetStore.taskManager.separatedBucket(componentId, bucket, task);
}
const handlePrevious = async () => {
@ -809,8 +809,8 @@
<div
id="bucket"
use:intersectionObserver={{
onIntersect: () => intersectedHandler(bucket),
onSeparate: () => seperatedHandler(bucket),
onIntersect: () => handleIntersect(bucket),
onSeparate: () => handleSeparate(bucket),
top: BUCKET_INTERSECTION_ROOT_TOP,
bottom: BUCKET_INTERSECTION_ROOT_BOTTOM,
root: element,

View File

@ -1,5 +1,6 @@
<script lang="ts" context="module">
export type ComboBoxOption = {
id?: string;
label: string;
value: string;
};
@ -32,7 +33,7 @@
export let label: string;
export let hideLabel = false;
export let options: ComboBoxOption[] = [];
export let selectedOption: ComboBoxOption | undefined;
export let selectedOption: ComboBoxOption | undefined = undefined;
export let placeholder = '';
/**
@ -237,7 +238,7 @@
{$t('no_results')}
</li>
{/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 -->
<li
aria-selected={index === selectedIndex}

View File

@ -4,6 +4,7 @@
TEXT = 'text',
NUMBER = 'number',
PASSWORD = 'password',
COLOR = 'color',
}
</script>
@ -13,6 +14,7 @@
import { fly } from 'svelte/transition';
import PasswordField from '../password-field.svelte';
import { t } from 'svelte-i18n';
import { onMount, tick } from 'svelte';
export let inputType: SettingInputFieldType;
export let value: string | number;
@ -25,8 +27,11 @@
export let required = false;
export let disabled = false;
export let isEdited = false;
export let autofocus = false;
export let passwordAutocomplete: string = 'current-password';
let input: HTMLInputElement;
const handleChange: FormEventHandler<HTMLInputElement> = (e) => {
value = e.currentTarget.value;
@ -41,6 +46,14 @@
value = newValue;
}
};
onMount(() => {
if (autofocus) {
tick()
.then(() => input?.focus())
.catch((_) => {});
}
});
</script>
<div class="mb-4 w-full">
@ -69,22 +82,46 @@
{/if}
{#if inputType !== SettingInputFieldType.PASSWORD}
<input
class="immich-form-input w-full pb-2"
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 class="flex place-items-center place-content-center">
{#if inputType === SettingInputFieldType.COLOR}
<input
bind:this={input}
class="immich-form-input w-full pb-2 rounded-none mr-1"
aria-describedby={desc ? `${label}-desc` : undefined}
aria-labelledby="{label}-label"
id={label}
name={label}
type="text"
min={min.toString()}
max={max.toString()}
{step}
{required}
{value}
on:change={handleChange}
{disabled}
{title}
/>
{/if}
<input
bind:this={input}
class="immich-form-input w-full pb-2"
class: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}
<PasswordField
aria-describedby={desc ? `${label}-desc` : undefined}
@ -100,3 +137,28 @@
/>
{/if}
</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,
mdiToolboxOutline,
mdiFolderOutline,
mdiTagMultipleOutline,
} from '@mdi/js';
import SideBarSection from './side-bar-section.svelte';
import SideBarLink from './side-bar-link.svelte';
@ -105,6 +106,8 @@
</svelte:fragment>
</SideBarLink>
<SideBarLink title={$t('tags')} routeId="/(user)/tags" icon={mdiTagMultipleOutline} flippedLogo />
<SideBarLink title={$t('folders')} routeId="/(user)/folders" icon={mdiFolderOutline} flippedLogo />
<SideBarLink

View File

@ -1,17 +1,23 @@
<script lang="ts">
import Tree from '$lib/components/shared-components/tree/tree.svelte';
import type { RecursiveObject } from '$lib/utils/tree-utils';
import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils';
export let items: RecursiveObject;
export let parent = '';
export let active = '';
export let icons: { default: string; active: string };
export let getLink: (path: string) => string;
export let getColor: (path: string) => string | undefined = () => undefined;
</script>
<ul class="list-none ml-2">
{#each Object.entries(items) as [path, tree], index (index)}
<li>
<Tree {parent} value={path} {tree} {active} {getLink} />
</li>
{#each Object.entries(items) as [path, tree]}
{@const value = normalizeTreePath(`${parent}/${path}`)}
{@const key = value + getColor(value)}
{#key key}
<li>
<Tree {parent} value={path} {tree} {icons} {active} {getLink} {getColor} />
</li>
{/key}
{/each}
</ul>

View File

@ -2,18 +2,21 @@
import Icon from '$lib/components/elements/icon.svelte';
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
import { normalizeTreePath, type RecursiveObject } from '$lib/utils/tree-utils';
import { mdiChevronDown, mdiChevronRight, mdiFolder, mdiFolderOutline } from '@mdi/js';
import { mdiChevronDown, mdiChevronRight } from '@mdi/js';
export let tree: RecursiveObject;
export let parent: string;
export let value: string;
export let active = '';
export let icons: { default: string; active: string };
export let getLink: (path: string) => string;
export let getColor: (path: string) => string | undefined;
$: path = normalizeTreePath(`${parent}/${value}`);
$: isActive = active.startsWith(path);
$: isOpen = isActive;
$: isTarget = active === path;
$: color = getColor(path);
</script>
<a
@ -21,13 +24,18 @@
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'}`}
>
<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} />
</button>
<div>
<Icon
path={isActive ? mdiFolder : mdiFolderOutline}
path={isActive ? icons.active : icons.default}
class={isActive ? 'text-immich-primary dark:text-immich-dark-primary' : 'text-gray-400'}
{color}
size={20}
/>
</div>
@ -35,5 +43,5 @@
</a>
{#if isOpen}
<TreeItems parent={path} items={tree} {active} {getLink} />
<TreeItems parent={path} items={tree} {icons} {active} {getLink} {getColor} />
{/if}

View File

@ -47,6 +47,7 @@ export enum AppRoute {
DUPLICATES = '/utilities/duplicates',
FOLDERS = '/folders',
TAGS = '/tags',
}
export enum ProjectionType {

View File

@ -440,6 +440,7 @@
"close": "Close",
"collapse": "Collapse",
"collapse_all": "Collapse all",
"color": "Color",
"color_theme": "Color theme",
"comment_deleted": "Comment deleted",
"comment_options": "Comment options",
@ -473,6 +474,8 @@
"create_new_person": "Create new person",
"create_new_person_hint": "Assign selected assets to a new person",
"create_new_user": "Create new user",
"create_tag": "Create tag",
"create_tag_description": "Create a new tag. For nested tags, please enter the full path of the tag including forward slashes.",
"create_user": "Create user",
"created": "Created",
"current_device": "Current device",
@ -496,6 +499,8 @@
"delete_library": "Delete library",
"delete_link": "Delete link",
"delete_shared_link": "Delete shared link",
"delete_tag": "Delete tag",
"delete_tag_confirmation_prompt": "Are you sure you want to delete {tagName} tag?",
"delete_user": "Delete user",
"deleted_shared_link": "Deleted shared link",
"description": "Description",
@ -537,6 +542,7 @@
"edit_location": "Edit location",
"edit_name": "Edit name",
"edit_people": "Edit people",
"edit_tag": "Edit tag",
"edit_title": "Edit Title",
"edit_user": "Edit user",
"edited": "Edited",
@ -1007,6 +1013,7 @@
"removed_from_archive": "Removed from archive",
"removed_from_favorites": "Removed from favorites",
"removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",
"removed_tagged_assets": "Removed tag from {count, plural, one {# asset} other {# assets}}",
"rename": "Rename",
"repair": "Repair",
"repair_no_results_message": "Untracked and missing files will show up here",
@ -1055,6 +1062,7 @@
"search_people": "Search people",
"search_places": "Search places",
"search_state": "Search state...",
"search_tags": "Search tags...",
"search_timezone": "Search timezone...",
"search_type": "Search type",
"search_your_photos": "Search your photos",
@ -1158,6 +1166,12 @@
"sunrise_on_the_beach": "Sunrise on the beach",
"swap_merge_direction": "Swap merge direction",
"sync": "Sync",
"tag": "Tag",
"tag_assets": "Tag assets",
"tag_created": "Created tag: {tag}",
"tag_updated": "Updated tag: {tag}",
"tagged_assets": "Tagged {count, plural, one {# asset} other {# assets}}",
"tags": "Tags",
"template": "Template",
"theme": "Theme",
"theme_selection": "Theme selection",
@ -1169,6 +1183,7 @@
"to_change_password": "Change password",
"to_favorite": "Favorite",
"to_login": "Login",
"to_root": "To root",
"to_trash": "Trash",
"toggle_settings": "Toggle settings",
"toggle_theme": "Toggle dark theme",

View File

@ -256,9 +256,9 @@ export class AssetGridTaskManager {
bucketTask.scheduleIntersected(componentId, task);
}
seperatedBucket(componentId: string, bucket: AssetBucket, seperated: Task) {
separatedBucket(componentId: string, bucket: AssetBucket, separated: Task) {
const bucketTask = this.getOrCreateBucketTask(bucket);
bucketTask.scheduleSeparated(componentId, seperated);
bucketTask.scheduleSeparated(componentId, separated);
}
intersectedDateGroup(componentId: string, dateGroup: DateGroup, intersected: Task) {
@ -266,9 +266,9 @@ export class AssetGridTaskManager {
bucketTask.intersectedDateGroup(componentId, dateGroup, intersected);
}
seperatedDateGroup(componentId: string, dateGroup: DateGroup, seperated: Task) {
separatedDateGroup(componentId: string, dateGroup: DateGroup, separated: Task) {
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
bucketTask.separatedDateGroup(componentId, dateGroup, seperated);
bucketTask.separatedDateGroup(componentId, dateGroup, separated);
}
intersectedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, intersected: Task) {
@ -277,16 +277,16 @@ export class AssetGridTaskManager {
dateGroupTask.intersectedThumbnail(componentId, asset, intersected);
}
seperatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, seperated: Task) {
separatedThumbnail(componentId: string, dateGroup: DateGroup, asset: AssetResponseDto, separated: Task) {
const bucketTask = this.getOrCreateBucketTask(dateGroup.bucket);
const dateGroupTask = bucketTask.getOrCreateDateGroupTask(dateGroup);
dateGroupTask.separatedThumbnail(componentId, asset, seperated);
dateGroupTask.separatedThumbnail(componentId, asset, separated);
}
}
class IntersectionTask {
internalTaskManager: InternalTaskManager;
seperatedKey;
separatedKey;
intersectedKey;
priority;
@ -295,7 +295,7 @@ class IntersectionTask {
constructor(internalTaskManager: InternalTaskManager, keyPrefix: string, key: string, priority: number) {
this.internalTaskManager = internalTaskManager;
this.seperatedKey = keyPrefix + ':s:' + key;
this.separatedKey = keyPrefix + ':s:' + key;
this.intersectedKey = keyPrefix + ':i:' + key;
this.priority = priority;
}
@ -325,14 +325,14 @@ class IntersectionTask {
this.separated = execTask;
const cleanup = () => {
this.separated = undefined;
this.internalTaskManager.deleteFromComponentTasks(componentId, this.seperatedKey);
this.internalTaskManager.deleteFromComponentTasks(componentId, this.separatedKey);
};
return { task: execTask, cleanup };
}
removePendingSeparated() {
if (this.separated) {
this.internalTaskManager.removeSeparateTask(this.seperatedKey);
this.internalTaskManager.removeSeparateTask(this.separatedKey);
}
}
removePendingIntersected() {
@ -368,7 +368,7 @@ class IntersectionTask {
task,
cleanup,
componentId: componentId,
taskId: this.seperatedKey,
taskId: this.separatedKey,
});
}
}
@ -448,9 +448,9 @@ class DateGroupTask extends IntersectionTask {
thumbnailTask.scheduleIntersected(componentId, intersected);
}
separatedThumbnail(componentId: string, asset: AssetResponseDto, seperated: Task) {
separatedThumbnail(componentId: string, asset: AssetResponseDto, separated: Task) {
const thumbnailTask = this.getOrCreateThumbnailTask(asset);
thumbnailTask.scheduleSeparated(componentId, seperated);
thumbnailTask.scheduleSeparated(componentId, separated);
}
}
class ThumbnailTask extends IntersectionTask {

View File

@ -10,6 +10,7 @@ import { preferences } from '$lib/stores/user.store';
import { downloadRequest, getKey, withError } from '$lib/utils';
import { createAlbum } from '$lib/utils/album-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { getFormatter } from '$lib/utils/i18n';
import {
addAssetsToAlbum as addAssets,
createStack,
@ -18,6 +19,8 @@ import {
getBaseUrl,
getDownloadInfo,
getStack,
tagAssets as tagAllAssets,
untagAssets,
updateAsset,
updateAssets,
type AlbumResponseDto,
@ -61,6 +64,54 @@ export const addAssetsToAlbum = async (albumId: string, assetIds: string[], show
}
};
export const tagAssets = async ({
assetIds,
tagIds,
showNotification = true,
}: {
assetIds: string[];
tagIds: string[];
showNotification?: boolean;
}) => {
for (const tagId of tagIds) {
await tagAllAssets({ id: tagId, bulkIdsDto: { ids: assetIds } });
}
if (showNotification) {
const $t = await getFormatter();
notificationController.show({
message: $t('tagged_assets', { values: { count: assetIds.length } }),
type: NotificationType.Info,
});
}
return assetIds;
};
export const removeTag = async ({
assetIds,
tagIds,
showNotification = true,
}: {
assetIds: string[];
tagIds: string[];
showNotification?: boolean;
}) => {
for (const tagId of tagIds) {
await untagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } });
}
if (showNotification) {
const $t = await getFormatter();
notificationController.show({
message: $t('removed_tagged_assets', { values: { count: assetIds.length } }),
type: NotificationType.Info,
});
}
return assetIds;
};
export const addAssetsToNewAlbum = async (albumName: string, assetIds: string[]) => {
const album = await createAlbum(albumName, assetIds);
if (!album) {

View File

@ -13,7 +13,7 @@
import { foldersStore } from '$lib/stores/folders.store';
import { buildTree, normalizeTreePath } from '$lib/utils/tree-utils';
import { type AssetResponseDto } from '@immich/sdk';
import { mdiArrowUpLeft, mdiChevronRight, mdiFolder, mdiFolderHome } from '@mdi/js';
import { mdiArrowUpLeft, mdiChevronRight, mdiFolder, mdiFolderHome, mdiFolderOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@ -60,7 +60,12 @@
<section>
<div class="text-xs pl-4 mb-2 dark:text-white">{$t('explorer').toUpperCase()}</div>
<div class="h-full">
<TreeItems items={tree} active={currentPath} {getLink} />
<TreeItems
icons={{ default: mdiFolderOutline, active: mdiFolder }}
items={tree}
active={currentPath}
{getLink}
/>
</div>
</section>
</SideBarSection>
@ -73,7 +78,7 @@
<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"
>
<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} />
</a>
{#each pathSegments as segment, index}

View File

@ -25,6 +25,7 @@
import { preferences, user } from '$lib/stores/user.store';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
let { isViewing: showAssetViewer } = assetViewingStore;
const assetStore = new AssetStore({ isArchived: false, withStacked: true, withPartners: true });
@ -80,6 +81,7 @@
<ChangeDate menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem onArchive={(assetIds) => assetStore.removeAssets(assetIds)} />
<TagAction menuItem />
<DeleteAssets menuItem onAssetDelete={(assetIds) => assetStore.removeAssets(assetIds)} />
<hr />
<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;