diff --git a/README.md b/README.md index 3cbb545fa4..ca8b1e3970 100644 --- a/README.md +++ b/README.md @@ -128,3 +128,9 @@ If you feel like this is the right cause and the app is something you are seeing + +## Star History + + + Star History Chart + diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index d8804445d2..fb02aff2ff 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -4,6 +4,7 @@ name: immich-e2e x-server-build: &server-common image: immich-server:latest + container_name: immich-e2e-server build: context: ../ dockerfile: server/Dockerfile diff --git a/server/e2e/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts similarity index 52% rename from server/e2e/api/specs/album.e2e-spec.ts rename to e2e/src/api/specs/album.e2e-spec.ts index 312816035c..c131edc49c 100644 --- a/server/e2e/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -1,11 +1,15 @@ -import { AlbumResponseDto, LoginResponseDto } from '@app/domain'; -import { AlbumController } from '@app/immich'; -import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto'; -import { SharedLinkType } from '@app/infra/entities'; -import { errorStub, userDto, uuidStub } from '@test/fixtures'; +import { + AlbumResponseDto, + AssetResponseDto, + LoginResponseDto, + SharedLinkType, + deleteUser, +} from '@immich/sdk'; +import { createUserDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils'; import request from 'supertest'; -import { api } from '../../client'; -import { testApp } from '../utils'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; const user1SharedUser = 'user1SharedUser'; const user1SharedLink = 'user1SharedLink'; @@ -14,193 +18,327 @@ const user2SharedUser = 'user2SharedUser'; const user2SharedLink = 'user2SharedLink'; const user2NotShared = 'user2NotShared'; -describe(`${AlbumController.name} (e2e)`, () => { - let server: any; +describe('/album', () => { let admin: LoginResponseDto; let user1: LoginResponseDto; - let user1Asset: AssetFileUploadResponseDto; + let user1Asset1: AssetResponseDto; + let user1Asset2: AssetResponseDto; let user1Albums: AlbumResponseDto[]; let user2: LoginResponseDto; let user2Albums: AlbumResponseDto[]; + let user3: LoginResponseDto; // deleted beforeAll(async () => { - server = (await testApp.create()).getHttpServer(); - }); + apiUtils.setup(); + await dbUtils.reset(); - afterAll(async () => { - await testApp.teardown(); - }); + admin = await apiUtils.adminSetup(); - beforeEach(async () => { - await testApp.reset(); - await api.authApi.adminSignUp(server); - admin = await api.authApi.adminLogin(server); - - await Promise.all([ - api.userApi.create(server, admin.accessToken, userDto.user1), - api.userApi.create(server, admin.accessToken, userDto.user2), + [user1, user2, user3] = await Promise.all([ + apiUtils.userSetup(admin.accessToken, createUserDto.user1), + apiUtils.userSetup(admin.accessToken, createUserDto.user2), + apiUtils.userSetup(admin.accessToken, createUserDto.user3), ]); - [user1, user2] = await Promise.all([ - api.authApi.login(server, userDto.user1), - api.authApi.login(server, userDto.user2), + [user1Asset1, user1Asset2] = await Promise.all([ + apiUtils.createAsset(user1.accessToken), + apiUtils.createAsset(user1.accessToken), ]); - user1Asset = await api.assetApi.upload(server, user1.accessToken, 'example'); - const albums = await Promise.all([ // user 1 - api.albumApi.create(server, user1.accessToken, { + apiUtils.createAlbum(user1.accessToken, { albumName: user1SharedUser, sharedWithUserIds: [user2.userId], - assetIds: [user1Asset.id], + assetIds: [user1Asset1.id], + }), + apiUtils.createAlbum(user1.accessToken, { + albumName: user1SharedLink, + assetIds: [user1Asset1.id], + }), + apiUtils.createAlbum(user1.accessToken, { + albumName: user1NotShared, + assetIds: [user1Asset1.id, user1Asset2.id], }), - api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset.id] }), - api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset.id] }), // user 2 - api.albumApi.create(server, user2.accessToken, { + apiUtils.createAlbum(user2.accessToken, { albumName: user2SharedUser, sharedWithUserIds: [user1.userId], - assetIds: [user1Asset.id], + assetIds: [user1Asset1.id], + }), + apiUtils.createAlbum(user2.accessToken, { albumName: user2SharedLink }), + apiUtils.createAlbum(user2.accessToken, { albumName: user2NotShared }), + + // user 3 + apiUtils.createAlbum(user3.accessToken, { + albumName: 'Deleted', + sharedWithUserIds: [user1.userId], }), - api.albumApi.create(server, user2.accessToken, { albumName: user2SharedLink }), - api.albumApi.create(server, user2.accessToken, { albumName: user2NotShared }), ]); user1Albums = albums.slice(0, 3); - user2Albums = albums.slice(3); + user2Albums = albums.slice(3, 6); await Promise.all([ // add shared link to user1SharedLink album - api.sharedLinkApi.create(server, user1.accessToken, { - type: SharedLinkType.ALBUM, + apiUtils.createSharedLink(user1.accessToken, { + type: SharedLinkType.Album, albumId: user1Albums[1].id, }), - // add shared link to user2SharedLink album - api.sharedLinkApi.create(server, user2.accessToken, { - type: SharedLinkType.ALBUM, + apiUtils.createSharedLink(user2.accessToken, { + type: SharedLinkType.Album, albumId: user2Albums[1].id, }), ]); + + await deleteUser( + { id: user3.userId }, + { headers: asBearerAuth(admin.accessToken) } + ); }); describe('GET /album', () => { it('should require authentication', async () => { - const { status, body } = await request(server).get('/album'); + const { status, body } = await request(app).get('/album'); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should reject an invalid shared param', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/album?shared=invalid') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toEqual(400); - expect(body).toEqual(errorStub.badRequest(['shared must be a boolean value'])); + expect(body).toEqual( + errorDto.badRequest(['shared must be a boolean value']) + ); }); it('should reject an invalid assetId param', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/album?assetId=invalid') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toEqual(400); - expect(body).toEqual(errorStub.badRequest(['assetId must be a UUID'])); + expect(body).toEqual(errorDto.badRequest(['assetId must be a UUID'])); }); it('should not return shared albums with a deleted owner', async () => { - await api.userApi.delete(server, admin.accessToken, user1.userId); - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/album?shared=true') - .set('Authorization', `Bearer ${user2.accessToken}`); + .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(1); + expect(body).toHaveLength(3); expect(body).toEqual( expect.arrayContaining([ - expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedLink, shared: true }), - ]), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1SharedLink, + shared: true, + }), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1SharedUser, + shared: true, + }), + expect.objectContaining({ + ownerId: user2.userId, + albumName: user2SharedUser, + shared: true, + }), + ]) ); }); it('should return the album collection including owned and shared', async () => { - const { status, body } = await request(server).get('/album').set('Authorization', `Bearer ${user1.accessToken}`); + const { status, body } = await request(app) + .get('/album') + .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(3); expect(body).toEqual( expect.arrayContaining([ - expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }), - expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }), - expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }), - ]), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1SharedUser, + shared: true, + }), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1SharedLink, + shared: true, + }), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1NotShared, + shared: false, + }), + ]) ); }); it('should return the album collection filtered by shared', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/album?shared=true') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(3); expect(body).toEqual( expect.arrayContaining([ - expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }), - expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }), - expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedUser, shared: true }), - ]), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1SharedUser, + shared: true, + }), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1SharedLink, + shared: true, + }), + expect.objectContaining({ + ownerId: user2.userId, + albumName: user2SharedUser, + shared: true, + }), + ]) ); }); it('should return the album collection filtered by NOT shared', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/album?shared=false') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(1); expect(body).toEqual( expect.arrayContaining([ - expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }), - ]), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1NotShared, + shared: false, + }), + ]) ); }); it('should return the album collection filtered by assetId', async () => { - const asset = await api.assetApi.upload(server, user1.accessToken, 'example2'); - await api.albumApi.addAssets(server, user1.accessToken, user1Albums[0].id, { ids: [asset.id] }); - const { status, body } = await request(server) - .get(`/album?assetId=${asset.id}`) + const { status, body } = await request(app) + .get(`/album?assetId=${user1Asset2.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(1); }); it('should return the album collection filtered by assetId and ignores shared=true', async () => { - const { status, body } = await request(server) - .get(`/album?shared=true&assetId=${user1Asset.id}`) + const { status, body } = await request(app) + .get(`/album?shared=true&assetId=${user1Asset1.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(4); }); it('should return the album collection filtered by assetId and ignores shared=false', async () => { - const { status, body } = await request(server) - .get(`/album?shared=false&assetId=${user1Asset.id}`) + const { status, body } = await request(app) + .get(`/album?shared=false&assetId=${user1Asset1.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(4); }); }); + describe('GET /album/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get( + `/album/${user1Albums[0].id}` + ); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should return album info for own album', async () => { + const { status, body } = await request(app) + .get(`/album/${user1Albums[0].id}?withoutAssets=false`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ + ...user1Albums[0], + assets: [expect.objectContaining(user1Albums[0].assets[0])], + }); + }); + + it('should return album info for shared album', async () => { + const { status, body } = await request(app) + .get(`/album/${user2Albums[0].id}?withoutAssets=false`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ + ...user2Albums[0], + assets: [expect.objectContaining(user2Albums[0].assets[0])], + }); + }); + + it('should return album info with assets when withoutAssets is undefined', async () => { + const { status, body } = await request(app) + .get(`/album/${user1Albums[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ + ...user1Albums[0], + assets: [expect.objectContaining(user1Albums[0].assets[0])], + }); + }); + + it('should return album info without assets when withoutAssets is true', async () => { + const { status, body } = await request(app) + .get(`/album/${user1Albums[0].id}?withoutAssets=true`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ + ...user1Albums[0], + assets: [], + assetCount: 1, + }); + }); + }); + + describe('GET /album/count', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/album/count'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should return total count of albums the user has access to', async () => { + const { status, body } = await request(app) + .get('/album/count') + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 }); + }); + }); + describe('POST /album', () => { it('should require authentication', async () => { - const { status, body } = await request(server).post('/album').send({ albumName: 'New album' }); + const { status, body } = await request(app) + .post('/album') + .send({ albumName: 'New album' }); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should create an album', async () => { - const body = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' }); + const { status, body } = await request(app) + .post('/album') + .send({ albumName: 'New album' }) + .set('Authorization', `Bearer ${user1.accessToken}`); + expect(status).toBe(201); expect(body).toEqual({ id: expect.any(String), createdAt: expect.any(String), @@ -220,113 +358,56 @@ describe(`${AlbumController.name} (e2e)`, () => { }); }); - describe('GET /album/count', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).get('/album/count'); - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should return total count of albums the user has access to', async () => { - const { status, body } = await request(server) - .get('/album/count') - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 }); - }); - }); - - describe('GET /album/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).get(`/album/${user1Albums[0].id}`); - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should return album info for own album', async () => { - const { status, body } = await request(server) - .get(`/album/${user1Albums[0].id}?withoutAssets=false`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining(user1Albums[0].assets[0])] }); - }); - - it('should return album info for shared album', async () => { - const { status, body } = await request(server) - .get(`/album/${user2Albums[0].id}?withoutAssets=false`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual({ ...user2Albums[0], assets: [expect.objectContaining(user2Albums[0].assets[0])] }); - }); - - it('should return album info with assets when withoutAssets is undefined', async () => { - const { status, body } = await request(server) - .get(`/album/${user1Albums[0].id}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining(user1Albums[0].assets[0])] }); - }); - - it('should return album info without assets when withoutAssets is true', async () => { - const { status, body } = await request(server) - .get(`/album/${user1Albums[0].id}?withoutAssets=true`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual({ - ...user1Albums[0], - assets: [], - assetCount: 1, - }); - }); - }); - describe('PUT /album/:id/assets', () => { it('should require authentication', async () => { - const { status, body } = await request(server).put(`/album/${user1Albums[0].id}/assets`); + const { status, body } = await request(app).put( + `/album/${user1Albums[0].id}/assets` + ); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should be able to add own asset to own album', async () => { - const asset = await api.assetApi.upload(server, user1.accessToken, 'example1'); - const { status, body } = await request(server) + const asset = await apiUtils.createAsset(user1.accessToken); + const { status, body } = await request(app) .put(`/album/${user1Albums[0].id}/assets`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ ids: [asset.id] }); expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]); + expect(body).toEqual([ + expect.objectContaining({ id: asset.id, success: true }), + ]); }); it('should be able to add own asset to shared album', async () => { - const asset = await api.assetApi.upload(server, user1.accessToken, 'example1'); - const { status, body } = await request(server) + const asset = await apiUtils.createAsset(user1.accessToken); + const { status, body } = await request(app) .put(`/album/${user2Albums[0].id}/assets`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ ids: [asset.id] }); expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]); + expect(body).toEqual([ + expect.objectContaining({ id: asset.id, success: true }), + ]); }); }); describe('PATCH /album/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(server) - .patch(`/album/${uuidStub.notFound}`) + const { status, body } = await request(app) + .patch(`/album/${uuidDto.notFound}`) .send({ albumName: 'New album name' }); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should update an album', async () => { - const album = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' }); - const { status, body } = await request(server) + const album = await apiUtils.createAlbum(user1.accessToken, { + albumName: 'New album', + }); + const { status, body } = await request(app) .patch(`/album/${album.id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ @@ -345,52 +426,68 @@ describe(`${AlbumController.name} (e2e)`, () => { describe('DELETE /album/:id/assets', () => { it('should require authentication', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .delete(`/album/${user1Albums[0].id}/assets`) - .send({ ids: [user1Asset.id] }); + .send({ ids: [user1Asset1.id] }); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should be able to remove own asset from own album', async () => { - const { status, body } = await request(server) - .delete(`/album/${user1Albums[0].id}/assets`) - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ ids: [user1Asset.id] }); - - expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]); - }); - - it('should be able to remove own asset from shared album', async () => { - const { status, body } = await request(server) - .delete(`/album/${user2Albums[0].id}/assets`) - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ ids: [user1Asset.id] }); - - expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]); + expect(body).toEqual(errorDto.unauthorized); }); it('should not be able to remove foreign asset from own album', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .delete(`/album/${user2Albums[0].id}/assets`) .set('Authorization', `Bearer ${user2.accessToken}`) - .send({ ids: [user1Asset.id] }); + .send({ ids: [user1Asset1.id] }); expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]); + expect(body).toEqual([ + expect.objectContaining({ + id: user1Asset1.id, + success: false, + error: 'no_permission', + }), + ]); }); it('should not be able to remove foreign asset from foreign album', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .delete(`/album/${user1Albums[0].id}/assets`) .set('Authorization', `Bearer ${user2.accessToken}`) - .send({ ids: [user1Asset.id] }); + .send({ ids: [user1Asset1.id] }); expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]); + expect(body).toEqual([ + expect.objectContaining({ + id: user1Asset1.id, + success: false, + error: 'no_permission', + }), + ]); + }); + + it('should be able to remove own asset from own album', async () => { + const { status, body } = await request(app) + .delete(`/album/${user1Albums[0].id}/assets`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ ids: [user1Asset1.id] }); + + expect(status).toBe(200); + expect(body).toEqual([ + expect.objectContaining({ id: user1Asset1.id, success: true }), + ]); + }); + + it('should be able to remove own asset from shared album', async () => { + const { status, body } = await request(app) + .delete(`/album/${user2Albums[0].id}/assets`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ ids: [user1Asset1.id] }); + + expect(status).toBe(200); + expect(body).toEqual([ + expect.objectContaining({ id: user1Asset1.id, success: true }), + ]); }); }); @@ -398,51 +495,57 @@ describe(`${AlbumController.name} (e2e)`, () => { let album: AlbumResponseDto; beforeEach(async () => { - album = await api.albumApi.create(server, user1.accessToken, { albumName: 'testAlbum' }); + album = await apiUtils.createAlbum(user1.accessToken, { + albumName: 'testAlbum', + }); }); it('should require authentication', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .put(`/album/${user1Albums[0].id}/users`) .send({ sharedUserIds: [] }); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should be able to add user to own album', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .put(`/album/${album.id}/users`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ sharedUserIds: [user2.userId] }); expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ sharedUsers: [expect.objectContaining({ id: user2.userId })] })); + expect(body).toEqual( + expect.objectContaining({ + sharedUsers: [expect.objectContaining({ id: user2.userId })], + }) + ); }); it('should not be able to share album with owner', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .put(`/album/${album.id}/users`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ sharedUserIds: [user1.userId] }); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest('Cannot be shared with owner')); + expect(body).toEqual(errorDto.badRequest('Cannot be shared with owner')); }); it('should not be able to add existing user to shared album', async () => { - await request(server) + await request(app) .put(`/album/${album.id}/users`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ sharedUserIds: [user2.userId] }); - const { status, body } = await request(server) + const { status, body } = await request(app) .put(`/album/${album.id}/users`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ sharedUserIds: [user2.userId] }); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest('User already added')); + expect(body).toEqual(errorDto.badRequest('User already added')); }); }); }); diff --git a/e2e/src/api/specs/audit.e2e-spec.ts b/e2e/src/api/specs/audit.e2e-spec.ts new file mode 100644 index 0000000000..073106e728 --- /dev/null +++ b/e2e/src/api/specs/audit.e2e-spec.ts @@ -0,0 +1,51 @@ +import { + deleteAssets, + getAuditFiles, + updateAsset, + type LoginResponseDto, +} from '@immich/sdk'; +import { apiUtils, asBearerAuth, dbUtils, fileUtils } from 'src/utils'; +import { beforeAll, describe, expect, it } from 'vitest'; + +describe('/audit', () => { + let admin: LoginResponseDto; + + beforeAll(async () => { + apiUtils.setup(); + await dbUtils.reset(); + await fileUtils.reset(); + + admin = await apiUtils.adminSetup(); + }); + + describe('GET :/file-report', () => { + it('excludes assets without issues from report', async () => { + const [trashedAsset, archivedAsset, _] = await Promise.all([ + apiUtils.createAsset(admin.accessToken), + apiUtils.createAsset(admin.accessToken), + apiUtils.createAsset(admin.accessToken), + ]); + + await Promise.all([ + deleteAssets( + { assetBulkDeleteDto: { ids: [trashedAsset.id] } }, + { headers: asBearerAuth(admin.accessToken) } + ), + updateAsset( + { + id: archivedAsset.id, + updateAssetDto: { isArchived: true }, + }, + { headers: asBearerAuth(admin.accessToken) } + ), + ]); + + const body = await getAuditFiles({ + headers: asBearerAuth(admin.accessToken), + }); + + expect(body.orphans).toHaveLength(0); + expect(body.extras).toHaveLength(0); + }); + }); +}); diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts index d384fde2dc..3f17eac220 100644 --- a/e2e/src/api/specs/person.e2e-spec.ts +++ b/e2e/src/api/specs/person.e2e-spec.ts @@ -56,6 +56,7 @@ describe('/activity', () => { expect(status).toBe(200); expect(body).toEqual({ total: 2, + hidden: 1, people: [ expect.objectContaining({ name: 'visible_person' }), expect.objectContaining({ name: 'hidden_person' }), @@ -71,6 +72,7 @@ describe('/activity', () => { expect(status).toBe(200); expect(body).toEqual({ total: 2, + hidden: 1, people: [expect.objectContaining({ name: 'visible_person' })], }); }); diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index df57c57137..e791c447ac 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -15,9 +15,6 @@ import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; -const createSharedLink = (dto: SharedLinkCreateDto, accessToken: string) => - create({ sharedLinkCreateDto: dto }, { headers: asBearerAuth(accessToken) }); - describe('/shared-link', () => { let admin: LoginResponseDto; let asset1: AssetResponseDto; @@ -78,38 +75,33 @@ describe('/shared-link', () => { linkWithMetadata, linkWithoutMetadata, ] = await Promise.all([ - createSharedLink( - { type: SharedLinkType.Album, albumId: deletedAlbum.id }, - user2.accessToken - ), - createSharedLink( - { type: SharedLinkType.Album, albumId: album.id }, - user1.accessToken - ), - createSharedLink( - { type: SharedLinkType.Individual, assetIds: [asset1.id] }, - user1.accessToken - ), - createSharedLink( - { type: SharedLinkType.Album, albumId: album.id, password: 'foo' }, - user1.accessToken - ), - createSharedLink( - { - type: SharedLinkType.Album, - albumId: metadataAlbum.id, - showMetadata: true, - }, - user1.accessToken - ), - createSharedLink( - { - type: SharedLinkType.Album, - albumId: metadataAlbum.id, - showMetadata: false, - }, - user1.accessToken - ), + apiUtils.createSharedLink(user2.accessToken, { + type: SharedLinkType.Album, + albumId: deletedAlbum.id, + }), + apiUtils.createSharedLink(user1.accessToken, { + type: SharedLinkType.Album, + albumId: album.id, + }), + apiUtils.createSharedLink(user1.accessToken, { + type: SharedLinkType.Individual, + assetIds: [asset1.id], + }), + apiUtils.createSharedLink(user1.accessToken, { + type: SharedLinkType.Album, + albumId: album.id, + password: 'foo', + }), + apiUtils.createSharedLink(user1.accessToken, { + type: SharedLinkType.Album, + albumId: metadataAlbum.id, + showMetadata: true, + }), + apiUtils.createSharedLink(user1.accessToken, { + type: SharedLinkType.Album, + albumId: metadataAlbum.id, + showMetadata: false, + }), ]); await deleteUser( diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index 74e1646802..9bfb47284a 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -1,26 +1,31 @@ -import { - LoginResponseDto, - UserResponseDto, - createUser, - deleteUser, - getUserById, -} from '@immich/sdk'; +import { LoginResponseDto, deleteUser, getUserById } from '@immich/sdk'; import { createUserDto, userDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils'; import request from 'supertest'; -import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; describe('/server-info', () => { let admin: LoginResponseDto; + let deletedUser: LoginResponseDto; + let userToDelete: LoginResponseDto; + let nonAdmin: LoginResponseDto; beforeAll(async () => { apiUtils.setup(); - }); - - beforeEach(async () => { await dbUtils.reset(); admin = await apiUtils.adminSetup({ onboarding: false }); + + [deletedUser, nonAdmin, userToDelete] = await Promise.all([ + apiUtils.userSetup(admin.accessToken, createUserDto.user1), + apiUtils.userSetup(admin.accessToken, createUserDto.user2), + apiUtils.userSetup(admin.accessToken, createUserDto.user3), + ]); + + await deleteUser( + { id: deletedUser.userId }, + { headers: asBearerAuth(admin.accessToken) } + ); }); describe('GET /user', () => { @@ -30,60 +35,54 @@ describe('/server-info', () => { expect(body).toEqual(errorDto.unauthorized); }); - it('should start with the admin', async () => { + it('should get users', async () => { const { status, body } = await request(app) .get('/user') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(200); - expect(body).toHaveLength(1); - expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' }); + expect(body).toHaveLength(4); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'admin@immich.cloud' }), + expect.objectContaining({ email: 'user1@immich.cloud' }), + expect.objectContaining({ email: 'user2@immich.cloud' }), + expect.objectContaining({ email: 'user3@immich.cloud' }), + ]) + ); }); it('should hide deleted users', async () => { - const user1 = await apiUtils.userSetup( - admin.accessToken, - createUserDto.user1 - ); - await deleteUser( - { id: user1.userId }, - { headers: asBearerAuth(admin.accessToken) } - ); - const { status, body } = await request(app) .get(`/user`) .query({ isAll: true }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(1); - expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' }); + expect(body).toHaveLength(3); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'admin@immich.cloud' }), + expect.objectContaining({ email: 'user2@immich.cloud' }), + expect.objectContaining({ email: 'user3@immich.cloud' }), + ]) + ); }); it('should include deleted users', async () => { - const user1 = await apiUtils.userSetup( - admin.accessToken, - createUserDto.user1 - ); - await deleteUser( - { id: user1.userId }, - { headers: asBearerAuth(admin.accessToken) } - ); - const { status, body } = await request(app) .get(`/user`) .query({ isAll: false }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(2); - expect(body[0]).toMatchObject({ - id: user1.userId, - email: 'user1@immich.cloud', - deletedAt: expect.any(String), - }); - expect(body[1]).toMatchObject({ - id: admin.userId, - email: 'admin@immich.cloud', - }); + expect(body).toHaveLength(4); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'admin@immich.cloud' }), + expect.objectContaining({ email: 'user1@immich.cloud' }), + expect.objectContaining({ email: 'user2@immich.cloud' }), + expect.objectContaining({ email: 'user3@immich.cloud' }), + ]) + ); }); }); @@ -149,13 +148,13 @@ describe('/server-info', () => { .post(`/user`) .send({ isAdmin: true, - email: 'user1@immich.cloud', - password: 'Password123', + email: 'user4@immich.cloud', + password: 'password123', name: 'Immich', }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toMatchObject({ - email: 'user1@immich.cloud', + email: 'user4@immich.cloud', isAdmin: false, shouldChangePassword: true, }); @@ -181,18 +180,9 @@ describe('/server-info', () => { }); describe('DELETE /user/:id', () => { - let userToDelete: UserResponseDto; - - beforeEach(async () => { - userToDelete = await createUser( - { createUserDto: createUserDto.user1 }, - { headers: asBearerAuth(admin.accessToken) } - ); - }); - it('should require authentication', async () => { const { status, body } = await request(app).delete( - `/user/${userToDelete.id}` + `/user/${userToDelete.userId}` ); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -200,12 +190,12 @@ describe('/server-info', () => { it('should delete user', async () => { const { status, body } = await request(app) - .delete(`/user/${userToDelete.id}`) + .delete(`/user/${userToDelete.userId}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toEqual({ - ...userToDelete, + expect(body).toMatchObject({ + id: userToDelete.userId, updatedAt: expect.any(String), deletedAt: expect.any(String), }); @@ -231,14 +221,9 @@ describe('/server-info', () => { } it('should not allow a non-admin to become an admin', async () => { - const user = await apiUtils.userSetup( - admin.accessToken, - createUserDto.user1 - ); - const { status, body } = await request(app) .put(`/user`) - .send({ isAdmin: true, id: user.userId }) + .send({ isAdmin: true, id: nonAdmin.userId }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 1c7382879d..fbc0b43b31 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -1,10 +1,14 @@ import { AssetResponseDto, + CreateAlbumDto, CreateAssetDto, CreateUserDto, PersonUpdateDto, + SharedLinkCreateDto, + createAlbum, createApiKey, createPerson, + createSharedLink, createUser, defaults, login, @@ -13,14 +17,17 @@ import { updatePerson, } from '@immich/sdk'; import { BrowserContext } from '@playwright/test'; -import { spawn } from 'child_process'; +import { exec, spawn } from 'child_process'; import { randomBytes } from 'node:crypto'; import { access } from 'node:fs/promises'; import path from 'node:path'; +import { promisify } from 'node:util'; import pg from 'pg'; import { loginDto, signupDto } from 'src/fixtures'; import request from 'supertest'; +const execPromise = promisify(exec); + export const app = 'http://127.0.0.1:2283/api'; const directoryExists = (directory: string) => @@ -31,6 +38,9 @@ const directoryExists = (directory: string) => // TODO move test assets into e2e/assets export const testAssetDir = path.resolve(`./../server/test/assets/`); +const serverContainerName = 'immich-e2e-server'; +const uploadMediaDir = '/usr/src/app/upload/upload'; + if (!(await directoryExists(`${testAssetDir}/albums`))) { throw new Error( `Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing` @@ -46,6 +56,14 @@ export const asKeyAuth = (key: string) => ({ 'x-api-key': key }); let client: pg.Client | null = null; +export const fileUtils = { + reset: async () => { + await execPromise( + `docker exec -i "${serverContainerName}" rm -R "${uploadMediaDir}"` + ); + }, +}; + export const dbUtils = { createFace: async ({ assetId, @@ -181,6 +199,11 @@ export const apiUtils = { { headers: asBearerAuth(accessToken) } ); }, + createAlbum: (accessToken: string, dto: CreateAlbumDto) => + createAlbum( + { createAlbumDto: dto }, + { headers: asBearerAuth(accessToken) } + ), createAsset: async ( accessToken: string, dto?: Omit @@ -211,6 +234,11 @@ export const apiUtils = { { headers: asBearerAuth(accessToken) } ); }, + createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) => + createSharedLink( + { sharedLinkCreateDto: dto }, + { headers: asBearerAuth(accessToken) } + ), }; export const cliUtils = { diff --git a/mobile/openapi/doc/PeopleResponseDto.md b/mobile/openapi/doc/PeopleResponseDto.md index 2f87f19993..78f9b2207c 100644 --- a/mobile/openapi/doc/PeopleResponseDto.md +++ b/mobile/openapi/doc/PeopleResponseDto.md @@ -8,6 +8,7 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- +**hidden** | **int** | | **people** | [**List**](PersonResponseDto.md) | | [default to const []] **total** | **int** | | diff --git a/mobile/openapi/lib/model/people_response_dto.dart b/mobile/openapi/lib/model/people_response_dto.dart index 80abedfc72..02a82cadf1 100644 --- a/mobile/openapi/lib/model/people_response_dto.dart +++ b/mobile/openapi/lib/model/people_response_dto.dart @@ -13,30 +13,36 @@ part of openapi.api; class PeopleResponseDto { /// Returns a new [PeopleResponseDto] instance. PeopleResponseDto({ + required this.hidden, this.people = const [], required this.total, }); + int hidden; + List people; int total; @override bool operator ==(Object other) => identical(this, other) || other is PeopleResponseDto && + other.hidden == hidden && _deepEquality.equals(other.people, people) && other.total == total; @override int get hashCode => // ignore: unnecessary_parenthesis + (hidden.hashCode) + (people.hashCode) + (total.hashCode); @override - String toString() => 'PeopleResponseDto[people=$people, total=$total]'; + String toString() => 'PeopleResponseDto[hidden=$hidden, people=$people, total=$total]'; Map toJson() { final json = {}; + json[r'hidden'] = this.hidden; json[r'people'] = this.people; json[r'total'] = this.total; return json; @@ -50,6 +56,7 @@ class PeopleResponseDto { final json = value.cast(); return PeopleResponseDto( + hidden: mapValueOfType(json, r'hidden')!, people: PersonResponseDto.listFromJson(json[r'people']), total: mapValueOfType(json, r'total')!, ); @@ -99,6 +106,7 @@ class PeopleResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'hidden', 'people', 'total', }; diff --git a/mobile/openapi/test/people_response_dto_test.dart b/mobile/openapi/test/people_response_dto_test.dart index ad669eeced..94db6eb86b 100644 --- a/mobile/openapi/test/people_response_dto_test.dart +++ b/mobile/openapi/test/people_response_dto_test.dart @@ -16,6 +16,11 @@ void main() { // final instance = PeopleResponseDto(); group('test PeopleResponseDto', () { + // int hidden + test('to test the property `hidden`', () async { + // TODO + }); + // List people (default value: const []) test('to test the property `people`', () async { // TODO diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 413d815e6b..1b0fea2a5e 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8633,6 +8633,9 @@ }, "PeopleResponseDto": { "properties": { + "hidden": { + "type": "integer" + }, "people": { "items": { "$ref": "#/components/schemas/PersonResponseDto" @@ -8644,6 +8647,7 @@ } }, "required": [ + "hidden", "people", "total" ], diff --git a/open-api/typescript-sdk/axios-client/api.ts b/open-api/typescript-sdk/axios-client/api.ts index a2b67d8554..0c4bbce302 100644 --- a/open-api/typescript-sdk/axios-client/api.ts +++ b/open-api/typescript-sdk/axios-client/api.ts @@ -2795,6 +2795,12 @@ export type PathType = typeof PathType[keyof typeof PathType]; * @interface PeopleResponseDto */ export interface PeopleResponseDto { + /** + * + * @type {number} + * @memberof PeopleResponseDto + */ + 'hidden': number; /** * * @type {Array} diff --git a/open-api/typescript-sdk/fetch-client.ts b/open-api/typescript-sdk/fetch-client.ts index 24acd23f30..811518827e 100644 --- a/open-api/typescript-sdk/fetch-client.ts +++ b/open-api/typescript-sdk/fetch-client.ts @@ -526,6 +526,7 @@ export type UpdatePartnerDto = { inTimeline: boolean; }; export type PeopleResponseDto = { + hidden: number; people: PersonResponseDto[]; total: number; }; diff --git a/server/Dockerfile b/server/Dockerfile index 9a7fc31fa2..7ea2795ea7 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20240213@sha256:16646a37bae065b51e68cb2ba7a63027b29504d43a30644625382afbe326114a as dev +FROM ghcr.io/immich-app/base-server-dev:20240222@sha256:2ff467d6ae5c00a2317eb7b13cb40ba5be0fd33c160175dba621b1bf72bc1cd1 as dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -40,7 +40,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20240213@sha256:61d159d069c5b522f16de9733fb79feb0e82c0b099d16f026196f344d12a1e5e +FROM ghcr.io/immich-app/base-server-prod:20240222@sha256:9ae5eebf95cf7759eec9dcfbd9e48a722701075ac855209f2e0b01c631b76f5c WORKDIR /usr/src/app ENV NODE_ENV=production \ diff --git a/server/e2e/api/specs/asset.e2e-spec.ts b/server/e2e/api/specs/asset.e2e-spec.ts index 7789881206..0e09a68be5 100644 --- a/server/e2e/api/specs/asset.e2e-spec.ts +++ b/server/e2e/api/specs/asset.e2e-spec.ts @@ -531,8 +531,8 @@ describe(`${AssetController.name} (e2e)`, () => { expect(status).toBe(200); expect(body.length).toBe(assets.length); - for (let i = 0; i < assets.length; i++) { - expect(body[i]).toEqual(expect.objectContaining({ id: assets[i].id })); + for (const [i, asset] of assets.entries()) { + expect(body[i]).toEqual(expect.objectContaining({ id: asset.id })); } }); } @@ -699,7 +699,7 @@ describe(`${AssetController.name} (e2e)`, () => { it("should not upload to another user's library", async () => { const content = randomBytes(32); - const library = (await api.libraryApi.getAll(server, user2.accessToken))[0]; + const [library] = await api.libraryApi.getAll(server, user2.accessToken); await api.assetApi.upload(server, user1.accessToken, 'example-image', { content }); const { body, status } = await request(server) diff --git a/server/e2e/api/specs/search.e2e-spec.ts b/server/e2e/api/specs/search.e2e-spec.ts index 74988396d7..0e5cc428cc 100644 --- a/server/e2e/api/specs/search.e2e-spec.ts +++ b/server/e2e/api/specs/search.e2e-spec.ts @@ -44,7 +44,7 @@ describe(`${SearchController.name}`, () => { describe('GET /search (exif)', () => { beforeEach(async () => { - const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id; + const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries)); await assetRepository.upsertExif({ assetId, ...searchStub.exif }); const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true }); @@ -166,7 +166,7 @@ describe(`${SearchController.name}`, () => { describe('GET /search (smart info)', () => { beforeEach(async () => { - const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id; + const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries)); await assetRepository.upsertExif({ assetId, ...searchStub.exif }); await smartInfoRepository.upsert({ assetId, ...searchStub.smartInfo }, Array.from({ length: 512 }, Math.random)); @@ -215,7 +215,7 @@ describe(`${SearchController.name}`, () => { describe('GET /search (file name)', () => { beforeEach(async () => { - const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id; + const { id: assetId } = await assetRepository.create(generateAsset(loginResponse.userId, libraries)); await assetRepository.upsertExif({ assetId, ...searchStub.exif }); const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true }); diff --git a/server/e2e/client/activity-api.ts b/server/e2e/client/activity-api.ts deleted file mode 100644 index f7cac45624..0000000000 --- a/server/e2e/client/activity-api.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ActivityCreateDto, ActivityResponseDto } from '@app/domain'; -import request from 'supertest'; - -export const activityApi = { - create: async (server: any, accessToken: string, dto: ActivityCreateDto) => { - const res = await request(server).post('/activity').set('Authorization', `Bearer ${accessToken}`).send(dto); - expect(res.status === 200 || res.status === 201).toBe(true); - return res.body as ActivityResponseDto; - }, - delete: async (server: any, accessToken: string, id: string) => { - const res = await request(server).delete(`/activity/${id}`).set('Authorization', `Bearer ${accessToken}`); - expect(res.status).toEqual(204); - }, -}; diff --git a/server/e2e/client/album-api.ts b/server/e2e/client/album-api.ts deleted file mode 100644 index 92c75dc64b..0000000000 --- a/server/e2e/client/album-api.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { AddUsersDto, AlbumResponseDto, BulkIdResponseDto, BulkIdsDto, CreateAlbumDto } from '@app/domain'; -import request from 'supertest'; - -export const albumApi = { - create: async (server: any, accessToken: string, dto: CreateAlbumDto) => { - const res = await request(server).post('/album').set('Authorization', `Bearer ${accessToken}`).send(dto); - expect(res.status).toEqual(201); - return res.body as AlbumResponseDto; - }, - addAssets: async (server: any, accessToken: string, id: string, dto: BulkIdsDto) => { - const res = await request(server) - .put(`/album/${id}/assets`) - .set('Authorization', `Bearer ${accessToken}`) - .send(dto); - expect(res.status).toEqual(200); - return res.body as BulkIdResponseDto[]; - }, - addUsers: async (server: any, accessToken: string, id: string, dto: AddUsersDto) => { - const res = await request(server).put(`/album/${id}/users`).set('Authorization', `Bearer ${accessToken}`).send(dto); - expect(res.status).toEqual(200); - return res.body as AlbumResponseDto; - }, - getAllAlbums: async (server: any, accessToken: string) => { - const res = await request(server).get(`/album/`).set('Authorization', `Bearer ${accessToken}`).send(); - expect(res.status).toEqual(200); - return res.body as AlbumResponseDto[]; - }, -}; diff --git a/server/e2e/client/api-key-api.ts b/server/e2e/client/api-key-api.ts deleted file mode 100644 index a35f13f7d9..0000000000 --- a/server/e2e/client/api-key-api.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { APIKeyCreateResponseDto } from '@app/domain'; -import { apiKeyCreateStub } from '@test'; -import request from 'supertest'; - -export const apiKeyApi = { - createApiKey: async (server: any, accessToken: string) => { - const { status, body } = await request(server) - .post('/api-key') - .set('Authorization', `Bearer ${accessToken}`) - .send(apiKeyCreateStub); - - expect(status).toBe(201); - - return body as APIKeyCreateResponseDto; - }, -}; diff --git a/server/e2e/client/auth-api.ts b/server/e2e/client/auth-api.ts index 3043c941f2..f0206d3376 100644 --- a/server/e2e/client/auth-api.ts +++ b/server/e2e/client/auth-api.ts @@ -1,4 +1,4 @@ -import { AuthDeviceResponseDto, LoginCredentialDto, LoginResponseDto, UserResponseDto } from '@app/domain'; +import { LoginCredentialDto, LoginResponseDto, UserResponseDto } from '@app/domain'; import { adminSignupStub, loginResponseStub, loginStub } from '@test'; import request from 'supertest'; @@ -27,19 +27,4 @@ export const authApi = { return body as LoginResponseDto; }, - getAuthDevices: async (server: any, accessToken: string) => { - const { status, body } = await request(server).get('/auth/devices').set('Authorization', `Bearer ${accessToken}`); - - expect(body).toEqual(expect.any(Array)); - expect(status).toBe(200); - - return body as AuthDeviceResponseDto[]; - }, - validateToken: async (server: any, accessToken: string) => { - const { status, body } = await request(server) - .post('/auth/validateToken') - .set('Authorization', `Bearer ${accessToken}`); - expect(body).toEqual({ authStatus: true }); - expect(status).toBe(200); - }, }; diff --git a/server/e2e/client/index.ts b/server/e2e/client/index.ts index b9c0f2ff38..b0464a34d8 100644 --- a/server/e2e/client/index.ts +++ b/server/e2e/client/index.ts @@ -1,25 +1,15 @@ -import { activityApi } from './activity-api'; -import { albumApi } from './album-api'; -import { apiKeyApi } from './api-key-api'; import { assetApi } from './asset-api'; import { authApi } from './auth-api'; import { libraryApi } from './library-api'; -import { partnerApi } from './partner-api'; -import { serverInfoApi } from './server-info-api'; import { sharedLinkApi } from './shared-link-api'; import { trashApi } from './trash-api'; import { userApi } from './user-api'; export const api = { - activityApi, authApi, - apiKeyApi, assetApi, libraryApi, - serverInfoApi, sharedLinkApi, trashApi, - albumApi, userApi, - partnerApi, }; diff --git a/server/e2e/client/partner-api.ts b/server/e2e/client/partner-api.ts deleted file mode 100644 index 97a9558c5f..0000000000 --- a/server/e2e/client/partner-api.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { PartnerResponseDto } from '@app/domain'; -import request from 'supertest'; - -export const partnerApi = { - create: async (server: any, accessToken: string, id: string) => { - const { status, body } = await request(server).post(`/partner/${id}`).set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(201); - return body as PartnerResponseDto; - }, -}; diff --git a/server/e2e/client/server-info-api.ts b/server/e2e/client/server-info-api.ts deleted file mode 100644 index f885bc856f..0000000000 --- a/server/e2e/client/server-info-api.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ServerConfigDto } from '@app/domain'; -import request from 'supertest'; - -export const serverInfoApi = { - getConfig: async (server: any) => { - const res = await request(server).get('/server-info/config'); - expect(res.status).toBe(200); - return res.body as ServerConfigDto; - }, -}; diff --git a/server/e2e/client/shared-link-api.ts b/server/e2e/client/shared-link-api.ts index d6179f6b6f..c34093b0ac 100644 --- a/server/e2e/client/shared-link-api.ts +++ b/server/e2e/client/shared-link-api.ts @@ -10,11 +10,4 @@ export const sharedLinkApi = { expect(status).toBe(201); return body as SharedLinkResponseDto; }, - - getMySharedLink: async (server: any, key: string) => { - const { status, body } = await request(server).get('/shared-link/me').query({ key }); - - expect(status).toBe(200); - return body as SharedLinkResponseDto; - }, }; diff --git a/server/e2e/client/user-api.ts b/server/e2e/client/user-api.ts index 20acf50c38..c538db3a8f 100644 --- a/server/e2e/client/user-api.ts +++ b/server/e2e/client/user-api.ts @@ -18,16 +18,6 @@ export const userApi = { return body as UserResponseDto; }, - get: async (server: any, accessToken: string, id: string) => { - const { status, body } = await request(server) - .get(`/user/info/${id}`) - .set('Authorization', `Bearer ${accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ id }); - - return body as UserResponseDto; - }, update: async (server: any, accessToken: string, dto: UpdateUserDto) => { const { status, body } = await request(server).put('/user').set('Authorization', `Bearer ${accessToken}`).send(dto); diff --git a/server/e2e/jobs/specs/formats.e2e-spec.ts b/server/e2e/jobs/specs/formats.e2e-spec.ts index 5f6ffba311..c8b14d588a 100644 --- a/server/e2e/jobs/specs/formats.e2e-spec.ts +++ b/server/e2e/jobs/specs/formats.e2e-spec.ts @@ -1,7 +1,7 @@ import { LoginResponseDto } from '@app/domain'; import { AssetType } from '@app/infra/entities'; -import { readFile } from 'fs/promises'; -import { basename, join } from 'path'; +import { readFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; import { IMMICH_TEST_ASSET_PATH, testApp } from '../../../src/test-utils/utils'; import { api } from '../../client'; @@ -19,7 +19,7 @@ const JPEG = { iso: 200, fNumber: 11, exposureTime: '1/160', - fileSizeInByte: 53493, + fileSizeInByte: 53_493, make: 'SONY', model: 'DSLR-A550', orientation: null, @@ -42,11 +42,11 @@ const tests = [ exifImageWidth: 4032, exifImageHeight: 3024, latitude: 41.2203, - longitude: -96.071625, + longitude: -96.071_625, make: 'Apple', model: 'iPhone 7', lensModel: 'iPhone 7 back camera 3.99mm f/1.8', - fileSizeInByte: 880703, + fileSizeInByte: 880_703, exposureTime: '1/887', iso: 20, focalLength: 3.99, @@ -66,7 +66,7 @@ const tests = [ exifImageHeight: 800, latitude: null, longitude: null, - fileSizeInByte: 25408, + fileSizeInByte: 25_408, }, }, }, @@ -84,7 +84,7 @@ const tests = [ fNumber: 10, focalLength: 18, iso: 100, - fileSizeInByte: 9057784, + fileSizeInByte: 9_057_784, dateTimeOriginal: '2010-07-20T17:27:12.000Z', latitude: null, longitude: null, @@ -106,7 +106,7 @@ const tests = [ fNumber: 11, focalLength: 85, iso: 200, - fileSizeInByte: 15856335, + fileSizeInByte: 15_856_335, dateTimeOriginal: '2016-09-22T22:10:29.060Z', latitude: null, longitude: null, diff --git a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts index 58e58aaf19..93f7163531 100644 --- a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts +++ b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts @@ -1,7 +1,7 @@ import { LibraryResponseDto, LibraryService, LoginResponseDto } from '@app/domain'; import { AssetType, LibraryType } from '@app/infra/entities'; -import fs from 'fs/promises'; -import path from 'path'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import { IMMICH_TEST_ASSET_PATH, IMMICH_TEST_ASSET_TEMP_PATH, @@ -20,7 +20,8 @@ describe(`Library watcher (e2e)`, () => { beforeAll(async () => { process.env.IMMICH_CONFIG_FILE = path.normalize(`${__dirname}/../config/library-watcher-e2e-config.json`); - server = (await testApp.create()).getHttpServer(); + const app = await testApp.create(); + server = app.getHttpServer(); libraryService = testApp.get(LibraryService); }); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 325bb8ea4c..f328b5dcf6 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -326,7 +326,7 @@ export class AssetService { const stackIdsToCheckForDelete: string[] = []; if (removeParent) { (options as Partial).stack = null; - const assets = await this.assetRepository.getByIds(ids); + const assets = await this.assetRepository.getByIds(ids, { stack: true }); stackIdsToCheckForDelete.push(...new Set(assets.filter((a) => !!a.stackId).map((a) => a.stackId!))); // This updates the updatedAt column of the parents to indicate that one of its children is removed // All the unique parent's -> parent is set to null diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index 94a9f8a42d..4cc0bd6672 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-response.dto.ts @@ -73,23 +73,21 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { const { stripMetadata = false, withStack = false } = options; - const sanitizedAssetResponse: SanitizedAssetResponseDto = { - id: entity.id, - type: entity.type, - thumbhash: entity.thumbhash?.toString('base64') ?? null, - localDateTime: entity.localDateTime, - resized: !!entity.resizePath, - duration: entity.duration ?? '0:00:00.00000', - livePhotoVideoId: entity.livePhotoVideoId, - hasMetadata: false, - }; - if (stripMetadata) { + const sanitizedAssetResponse: SanitizedAssetResponseDto = { + id: entity.id, + type: entity.type, + thumbhash: entity.thumbhash?.toString('base64') ?? null, + localDateTime: entity.localDateTime, + resized: !!entity.resizePath, + duration: entity.duration ?? '0:00:00.00000', + livePhotoVideoId: entity.livePhotoVideoId, + hasMetadata: false, + }; return sanitizedAssetResponse as AssetResponseDto; } return { - ...sanitizedAssetResponse, id: entity.id, deviceAssetId: entity.deviceAssetId, ownerId: entity.ownerId, diff --git a/server/src/domain/audit/audit.service.ts b/server/src/domain/audit/audit.service.ts index 887b72e2cd..a7c003fad6 100644 --- a/server/src/domain/audit/audit.service.ts +++ b/server/src/domain/audit/audit.service.ts @@ -167,7 +167,7 @@ export class AuditService { `Found ${libraryFiles.size} original files, ${thumbFiles.size} thumbnails, ${videoFiles.size} encoded videos, ${profileFiles.size} profile files`, ); const pagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (options) => - this.assetRepository.getAll(options, { withDeleted: true }), + this.assetRepository.getAll(options, { withDeleted: true, withArchived: true }), ); let assetCount = 0; diff --git a/server/src/domain/database/database.service.ts b/server/src/domain/database/database.service.ts index 8cd08acd75..d697d032b3 100644 --- a/server/src/domain/database/database.service.ts +++ b/server/src/domain/database/database.service.ts @@ -72,7 +72,7 @@ export class DatabaseService { In this case, please run 'CREATE EXTENSION IF NOT EXISTS ${this.vectorExt}' manually as a superuser. See https://immich.app/docs/guides/database-queries for how to query the database. - Alternatively, if your Postgres instance has ${extName[otherExt]}, you may use this instead by setting the environment variable 'DB_VECTOR_EXTENSION=${otherExt}'. + Alternatively, if your Postgres instance has ${extName[otherExt]}, you may use this instead by setting the environment variable 'DB_VECTOR_EXTENSION=${extName[otherExt]}'. Note that switching between the two extensions after a successful startup is not supported. The exception is if your version of Immich prior to upgrading was 1.90.2 or earlier. In this case, you may set either extension now, but you will not be able to switch to the other extension following a successful startup. diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts index 360a9b2348..b8ad8f0451 100644 --- a/server/src/domain/person/person.dto.ts +++ b/server/src/domain/person/person.dto.ts @@ -127,7 +127,8 @@ export class PersonStatisticsResponseDto { export class PeopleResponseDto { @ApiProperty({ type: 'integer' }) total!: number; - + @ApiProperty({ type: 'integer' }) + hidden!: number; people!: PersonResponseDto[]; } diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 5da8666016..ffda9034bd 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -114,35 +114,12 @@ describe(PersonService.name, () => { }); describe('getAll', () => { - it('should get all people with thumbnails', async () => { - personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.noThumbnail]); - personMock.getNumberOfPeople.mockResolvedValue(1); - await expect(sut.getAll(authStub.admin, { withHidden: undefined })).resolves.toEqual({ - total: 1, - people: [responseDto], - }); - expect(personMock.getAllForUser).toHaveBeenCalledWith(authStub.admin.user.id, { - minimumFaceCount: 3, - withHidden: false, - }); - }); - it('should get all visible people with thumbnails', async () => { - personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.hidden]); - personMock.getNumberOfPeople.mockResolvedValue(2); - await expect(sut.getAll(authStub.admin, { withHidden: false })).resolves.toEqual({ - total: 2, - people: [responseDto], - }); - expect(personMock.getAllForUser).toHaveBeenCalledWith(authStub.admin.user.id, { - minimumFaceCount: 3, - withHidden: false, - }); - }); it('should get all hidden and visible people with thumbnails', async () => { personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.hidden]); - personMock.getNumberOfPeople.mockResolvedValue(2); + personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); await expect(sut.getAll(authStub.admin, { withHidden: true })).resolves.toEqual({ total: 2, + hidden: 1, people: [ responseDto, { diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 6fbc409bf8..6300cc743c 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -82,15 +82,12 @@ export class PersonService { minimumFaceCount: machineLearning.facialRecognition.minFaces, withHidden: dto.withHidden || false, }); - const total = await this.repository.getNumberOfPeople(auth.user.id); - const persons: PersonResponseDto[] = people - // with thumbnails - .filter((person) => !!person.thumbnailPath) - .map((person) => mapPerson(person)); + const { total, hidden } = await this.repository.getNumberOfPeople(auth.user.id); return { - people: persons.filter((person) => dto.withHidden || !person.isHidden), + people: people.map((person) => mapPerson(person)), total, + hidden, }; } diff --git a/server/src/domain/repositories/person.repository.ts b/server/src/domain/repositories/person.repository.ts index 80240091a9..85c11fe921 100644 --- a/server/src/domain/repositories/person.repository.ts +++ b/server/src/domain/repositories/person.repository.ts @@ -28,6 +28,11 @@ export interface PersonStatistics { assets: number; } +export interface PeopleStatistics { + total: number; + hidden: number; +} + export interface IPersonRepository { getAll(pagination: PaginationOptions, options?: FindManyOptions): Paginated; getAllForUser(userId: string, options: PersonSearchOptions): Promise; @@ -54,7 +59,7 @@ export interface IPersonRepository { getRandomFace(personId: string): Promise; getStatistics(personId: string): Promise; reassignFace(assetFaceId: string, newPersonId: string): Promise; - getNumberOfPeople(userId: string): Promise; + getNumberOfPeople(userId: string): Promise; reassignFaces(data: UpdateFacesData): Promise; update(entity: Partial): Promise; } diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts index 1538958e0f..745f5a38ff 100644 --- a/server/src/infra/infra.utils.ts +++ b/server/src/infra/infra.utils.ts @@ -213,9 +213,9 @@ export function searchAssetBuilder( if (personIds && personIds.length > 0) { builder .leftJoin(`${builder.alias}.faces`, 'faces') - .andWhere('faces.personId IN (:...personIds)', { personIds: personIds }) + .andWhere('faces.personId IN (:...personIds)', { personIds }) .addGroupBy(`${builder.alias}.id`) - .having('COUNT(faces.id) = :personCount', { personCount: personIds.length }); + .having('COUNT(DISTINCT faces.personId) = :personCount', { personCount: personIds.length }); if (withExif) { builder.addGroupBy('exifInfo.assetId'); diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts index 85423b74dd..63b3d570ef 100644 --- a/server/src/infra/repositories/person.repository.ts +++ b/server/src/infra/repositories/person.repository.ts @@ -3,6 +3,7 @@ import { IPersonRepository, Paginated, PaginationOptions, + PeopleStatistics, PersonNameSearchOptions, PersonSearchOptions, PersonStatistics, @@ -69,6 +70,7 @@ export class PersonRepository implements IPersonRepository { .addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC') .addOrderBy('COUNT(face.assetId)', 'DESC') .addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST') + .andWhere("person.thumbnailPath != ''") .having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 }) .groupBy('person.id') .limit(500); @@ -207,15 +209,25 @@ export class PersonRepository implements IPersonRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - async getNumberOfPeople(userId: string): Promise { - return this.personRepository + async getNumberOfPeople(userId: string): Promise { + const items = await this.personRepository .createQueryBuilder('person') .leftJoin('person.faces', 'face') .where('person.ownerId = :userId', { userId }) + .innerJoin('face.asset', 'asset') + .andWhere('asset.isArchived = false') + .andWhere("person.thumbnailPath != ''") + .select('COUNT(DISTINCT(person.id))', 'total') + .addSelect('COUNT(DISTINCT(person.id)) FILTER (WHERE person.isHidden = true)', 'hidden') .having('COUNT(face.assetId) != 0') - .groupBy('person.id') - .withDeleted() - .getCount(); + .getRawOne(); + + const result: PeopleStatistics = { + total: items ? Number.parseInt(items.total) : 0, + hidden: items ? Number.parseInt(items.hidden) : 0, + }; + + return result; } create(entity: Partial): Promise { diff --git a/server/src/infra/sql/person.repository.sql b/server/src/infra/sql/person.repository.sql index bd4a523e86..c2cc45ee88 100644 --- a/server/src/infra/sql/person.repository.sql +++ b/server/src/infra/sql/person.repository.sql @@ -26,6 +26,7 @@ FROM WHERE "person"."ownerId" = $1 AND "asset"."isArchived" = false + AND "person"."thumbnailPath" != '' AND "person"."isHidden" = false GROUP BY "person"."id" @@ -344,12 +345,20 @@ LIMIT -- PersonRepository.getNumberOfPeople SELECT - COUNT(DISTINCT ("person"."id")) AS "cnt" + COUNT(DISTINCT ("person"."id")) AS "total", + COUNT(DISTINCT ("person"."id")) FILTER ( + WHERE + "person"."isHidden" = true + ) AS "hidden" FROM "person" "person" LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" + INNER JOIN "assets" "asset" ON "asset"."id" = "face"."assetId" + AND ("asset"."deletedAt" IS NULL) WHERE "person"."ownerId" = $1 + AND "asset"."isArchived" = false + AND "person"."thumbnailPath" != '' HAVING COUNT("face"."assetId") != 0 diff --git a/web/package-lock.json b/web/package-lock.json index 84bd64d3e9..78e5caf7c5 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -32,7 +32,7 @@ "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/enhanced-img": "^0.1.8", - "@sveltejs/kit": "^2.5.0", + "@sveltejs/kit": "^2.5.1", "@sveltejs/vite-plugin-svelte": "^3.0.2", "@testing-library/jest-dom": "^6.1.5", "@testing-library/svelte": "^4.0.3", @@ -1859,9 +1859,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.0.tgz", - "integrity": "sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.1.tgz", + "integrity": "sha512-TKj08o3mJCoQNLTdRdGkHPePTCPUGTgkew65RDqjVU3MtPVxljsofXQYfXndHfq0P7KoPRO/0/reF6HesU0Djw==", "dev": true, "hasInstallScript": true, "dependencies": { diff --git a/web/package.json b/web/package.json index 1542acc2dc..2b53d06451 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@socket.io/component-emitter": "^3.1.0", "@sveltejs/adapter-static": "^3.0.1", "@sveltejs/enhanced-img": "^0.1.8", - "@sveltejs/kit": "^2.5.0", + "@sveltejs/kit": "^2.5.1", "@sveltejs/vite-plugin-svelte": "^3.0.2", "@testing-library/jest-dom": "^6.1.5", "@testing-library/svelte": "^4.0.3", diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte index 496d579cae..f4bead5b39 100644 --- a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte +++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte @@ -14,12 +14,14 @@ import { createEventDispatcher } from 'svelte'; import { fade } from 'svelte/transition'; import type { SettingsEventType } from '../admin-settings'; - import SettingAccordion from '../setting-accordion.svelte'; - import SettingButtonsRow from '../setting-buttons-row.svelte'; - import SettingCheckboxes from '../setting-checkboxes.svelte'; - import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte'; - import SettingSelect from '../setting-select.svelte'; - import SettingSwitch from '../setting-switch.svelte'; + import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; + import SettingInputField, { + SettingInputFieldType, + } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; + import SettingCheckboxes from '$lib/components/shared-components/settings/setting-checkboxes.svelte'; + import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; diff --git a/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte b/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte index d3315f18a8..3db53f749b 100644 --- a/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte +++ b/web/src/lib/components/admin-page/settings/job-settings/job-settings.svelte @@ -5,8 +5,10 @@ import { createEventDispatcher } from 'svelte'; import { fade } from 'svelte/transition'; import type { SettingsEventType } from '../admin-settings'; - import SettingButtonsRow from '../setting-buttons-row.svelte'; - import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte'; + import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; + import SettingInputField, { + SettingInputFieldType, + } from '$lib/components/shared-components/settings/setting-input-field.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; diff --git a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte index 078f522833..746db2c198 100644 --- a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte +++ b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte @@ -4,10 +4,12 @@ import { createEventDispatcher } from 'svelte'; import { fade } from 'svelte/transition'; import type { SettingsEventType } from '../admin-settings'; - import SettingAccordion from '../setting-accordion.svelte'; - import SettingButtonsRow from '../setting-buttons-row.svelte'; - import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte'; - import SettingSwitch from '../setting-switch.svelte'; + import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; + import SettingInputField, { + SettingInputFieldType, + } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; + import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; diff --git a/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte b/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte index f29e6f9eaf..97ae006816 100644 --- a/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte +++ b/web/src/lib/components/admin-page/settings/logging-settings/logging-settings.svelte @@ -4,9 +4,9 @@ import { createEventDispatcher } from 'svelte'; import { fade } from 'svelte/transition'; import type { SettingsEventType } from '../admin-settings'; - import SettingButtonsRow from '../setting-buttons-row.svelte'; - import SettingSelect from '../setting-select.svelte'; - import SettingSwitch from '../setting-switch.svelte'; + import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; + import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte index 13ee941991..6a542d81d4 100644 --- a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte +++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte @@ -4,11 +4,13 @@ import { createEventDispatcher } from 'svelte'; import { fade } from 'svelte/transition'; import type { SettingsEventType } from '../admin-settings'; - import SettingAccordion from '../setting-accordion.svelte'; - import SettingButtonsRow from '../setting-buttons-row.svelte'; - import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte'; - import SettingSelect from '../setting-select.svelte'; - import SettingSwitch from '../setting-switch.svelte'; + import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; + import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; + import SettingInputField, { + SettingInputFieldType, + } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; diff --git a/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte b/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte index f7c5a9db61..46dc7a6351 100644 --- a/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte +++ b/web/src/lib/components/admin-page/settings/map-settings/map-settings.svelte @@ -4,10 +4,12 @@ import { createEventDispatcher } from 'svelte'; import { fade } from 'svelte/transition'; import type { SettingsEventType } from '../admin-settings'; - import SettingAccordion from '../setting-accordion.svelte'; - import SettingButtonsRow from '../setting-buttons-row.svelte'; - import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte'; - import SettingSwitch from '../setting-switch.svelte'; + import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; + import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; + import SettingInputField, { + SettingInputFieldType, + } from '$lib/components/shared-components/settings/setting-input-field.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; diff --git a/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte b/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte index f62684ebf2..fbda787bcd 100644 --- a/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte +++ b/web/src/lib/components/admin-page/settings/new-version-check-settings/new-version-check-settings.svelte @@ -4,8 +4,8 @@ import { createEventDispatcher } from 'svelte'; import { fade } from 'svelte/transition'; import type { SettingsEventType } from '../admin-settings'; - import SettingButtonsRow from '../setting-buttons-row.svelte'; - import SettingSwitch from '../setting-switch.svelte'; + import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; diff --git a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte index cca08b574e..10716728c5 100644 --- a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte @@ -5,9 +5,11 @@ import { fade } from 'svelte/transition'; import type { SettingsEventType } from '../admin-settings'; import ConfirmDisableLogin from '../confirm-disable-login.svelte'; - import SettingButtonsRow from '../setting-buttons-row.svelte'; - import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte'; - import SettingSwitch from '../setting-switch.svelte'; + import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; + import SettingInputField, { + SettingInputFieldType, + } from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; diff --git a/web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte b/web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte index f67bc04182..8dc7323cf9 100644 --- a/web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte +++ b/web/src/lib/components/admin-page/settings/password-login/password-login-settings.svelte @@ -5,8 +5,8 @@ import { fade } from 'svelte/transition'; import type { SettingsEventType } from '../admin-settings'; import ConfirmDisableLogin from '../confirm-disable-login.svelte'; - import SettingButtonsRow from '../setting-buttons-row.svelte'; - import SettingSwitch from '../setting-switch.svelte'; + import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; diff --git a/web/src/lib/components/admin-page/settings/server/server-settings.svelte b/web/src/lib/components/admin-page/settings/server/server-settings.svelte index 3c70d44205..4f235af1f6 100644 --- a/web/src/lib/components/admin-page/settings/server/server-settings.svelte +++ b/web/src/lib/components/admin-page/settings/server/server-settings.svelte @@ -1,11 +1,13 @@ diff --git a/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte b/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte index bb1f6351be..10c52c1361 100644 --- a/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte +++ b/web/src/lib/components/admin-page/settings/theme/theme-settings.svelte @@ -4,8 +4,8 @@ import { createEventDispatcher } from 'svelte'; import { fade } from 'svelte/transition'; import type { SettingsEventType } from '../admin-settings'; - import SettingButtonsRow from '../setting-buttons-row.svelte'; - import SettingTextarea from '../setting-textarea.svelte'; + import SettingTextarea from '$lib/components/shared-components/settings/setting-textarea.svelte'; + import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; diff --git a/web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte b/web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte index 4c63380fda..8e2936b556 100644 --- a/web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte +++ b/web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte @@ -1,13 +1,16 @@
dispatch('close')} /> - +
+

Show & hide people

+

({countTotalPeople.toLocaleString($locale)})

+
@@ -47,7 +52,7 @@
-
+
diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 197246d385..8222007d57 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -11,7 +11,7 @@ export let scrollbar = true; export let admin = false; - $: scrollbarClass = scrollbar ? 'immich-scrollbar p-4 pb-8' : 'scrollbar-hidden'; + $: scrollbarClass = scrollbar ? 'immich-scrollbar p-2 pb-8' : 'scrollbar-hidden'; $: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full'; diff --git a/web/src/lib/components/map-page/map-settings-modal.svelte b/web/src/lib/components/map-page/map-settings-modal.svelte index d6cbe5884f..17fd558ab3 100644 --- a/web/src/lib/components/map-page/map-settings-modal.svelte +++ b/web/src/lib/components/map-page/map-settings-modal.svelte @@ -4,10 +4,10 @@ import { Duration } from 'luxon'; import { createEventDispatcher } from 'svelte'; import { fly } from 'svelte/transition'; - import SettingSelect from '../admin-page/settings/setting-select.svelte'; - import SettingSwitch from '../admin-page/settings/setting-switch.svelte'; import Button from '../elements/buttons/button.svelte'; import LinkButton from '../elements/buttons/link-button.svelte'; + import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; export let settings: MapSettings; let customDateRange = !!settings.dateAfter || !!settings.dateBefore; diff --git a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte index 786d65395a..e39b473f67 100644 --- a/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte +++ b/web/src/lib/components/onboarding-page/onboarding-storage-template.svelte @@ -26,8 +26,9 @@

STORAGE TEMPLATE

- The storage template is used to determine the folder structure and file name of your media files. You can use - variables to customize the template to your liking. + When enabled, this feature will auto-organize files based on a user-defined template. Due to stability issues the + feature has been turned off by default. For more information, please see the + documentation.

{#if config && $user} diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte index a6818ee1fd..3972c0c665 100644 --- a/web/src/lib/components/shared-components/change-date.svelte +++ b/web/src/lib/components/shared-components/change-date.svelte @@ -3,6 +3,7 @@ import { DateTime } from 'luxon'; import ConfirmDialogue from './confirm-dialogue.svelte'; import Combobox from './combobox.svelte'; + export let initialDate: DateTime = DateTime.now(); type ZoneOption = { @@ -28,7 +29,7 @@ const initialOption = timezones.find((item) => item.value === 'UTC' + initialDate.toFormat('ZZ')); - let selectedOption = { + let selectedOption = initialOption && { label: initialOption?.label || '', value: initialOption?.value || '', }; @@ -36,7 +37,7 @@ let selectedDate = initialDate.toFormat("yyyy-MM-dd'T'HH:mm"); // Keep local time if not it's really confusing - $: date = DateTime.fromISO(selectedDate).setZone(selectedOption.value, { keepLocalTime: true }); + $: date = DateTime.fromISO(selectedDate).setZone(selectedOption?.value, { keepLocalTime: true }); const dispatch = createEventDispatcher<{ cancel: void; @@ -82,7 +83,7 @@
- +
diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index d0ce0e25a9..7c1e1f68dd 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -1,12 +1,7 @@ @@ -15,34 +10,32 @@ import Icon from '$lib/components/elements/icon.svelte'; import { clickOutside } from '$lib/utils/click-outside'; - import { mdiMagnify, mdiUnfoldMoreHorizontal } from '@mdi/js'; + import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js'; import { createEventDispatcher } from 'svelte'; + import IconButton from '../elements/buttons/icon-button.svelte'; - export let type: Type = 'button'; + export let id: string | undefined = undefined; export let options: ComboBoxOption[] = []; - export let selectedOption: ComboBoxOption | undefined = undefined; + export let selectedOption: ComboBoxOption | undefined; export let placeholder = ''; - export const label = ''; - export let noLabel = false; let isOpen = false; - let searchQuery = ''; + let searchQuery = selectedOption?.label || ''; $: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); const dispatch = createEventDispatcher<{ - select: ComboBoxOption; + select: ComboBoxOption | undefined; click: void; }>(); - let handleClick = () => { + const handleClick = () => { searchQuery = ''; - isOpen = !isOpen; + isOpen = true; dispatch('click'); }; let handleOutClick = () => { - searchQuery = ''; isOpen = false; }; @@ -51,49 +44,77 @@ dispatch('select', option); isOpen = false; }; + + const onClear = () => { + selectedOption = undefined; + searchQuery = ''; + dispatch('select', selectedOption); + }; -
- +
{#if isOpen}
-
-
-
- -
-
- - - -
-
- {#each filteredOptions as option (option.label)} - - {/each} -
+ {#if filteredOptions.length === 0} +
No results
+ {/if} + {#each filteredOptions as option (option.label)} + {@const selected = option.label === selectedOption?.label} + + {/each}
{/if} diff --git a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte index 28a11999ab..b641260cb3 100644 --- a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte +++ b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte @@ -1,8 +1,4 @@ -
+
(value = '')} on:submit|preventDefault={onSubmit} @@ -148,9 +144,9 @@ on:selectSearchTerm={({ detail: searchTerm }) => onHistoryTermClick(searchTerm)} /> {/if} - - {#if showFilter} - onSearch(detail)} /> - {/if} + + {#if showFilter} + onSearch(detail)} /> + {/if}
diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte index c433be947f..00ceb6a872 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte @@ -17,7 +17,6 @@ import { fly } from 'svelte/transition'; import Combobox, { type ComboBoxOption } from '../combobox.svelte'; import { DateTime } from 'luxon'; - import { searchQuery } from '$lib/stores/search.store'; enum MediaType { All = 'all', @@ -44,7 +43,7 @@ type SearchFilter = { context?: string; - people: PersonResponseDto[]; + people: (PersonResponseDto | Pick)[]; location: { country?: ComboBoxOption; @@ -69,6 +68,8 @@ mediaType: MediaType; }; + export let searchQuery: MetadataSearchDto | SmartSearchDto; + let suggestions: SearchSuggestion = { people: [], country: [], @@ -112,19 +113,19 @@ populateExistingFilters(); }); - const showSelectedPeopleFirst = () => { - suggestions.people.sort((a, _) => { + function orderBySelectedPeopleFirst>(people: T[]) { + return people.sort((a, _) => { if (filter.people.some((p) => p.id === a.id)) { return -1; } return 1; }); - }; + } const getPeople = async () => { try { const { people } = await getAllPeople({ withHidden: false }); - suggestions.people = people; + suggestions.people = orderBySelectedPeopleFirst(people); } catch (error) { handleError(error, 'Failed to get people'); } @@ -133,14 +134,12 @@ const handlePeopleSelection = (id: string) => { if (filter.people.some((p) => p.id === id)) { filter.people = filter.people.filter((p) => p.id !== id); - showSelectedPeopleFirst(); return; } const person = suggestions.people.find((p) => p.id === id); if (person) { filter.people = [...filter.people, person]; - showSelectedPeopleFirst(); } }; @@ -280,35 +279,36 @@ }; function populateExistingFilters() { - if ($searchQuery) { + if (searchQuery) { + const personIds = 'personIds' in searchQuery && searchQuery.personIds ? searchQuery.personIds : []; + filter = { - context: 'query' in $searchQuery ? $searchQuery.query : '', - people: - 'personIds' in $searchQuery ? ($searchQuery.personIds?.map((id) => ({ id })) as PersonResponseDto[]) : [], + context: 'query' in searchQuery ? searchQuery.query : '', + people: orderBySelectedPeopleFirst(personIds.map((id) => ({ id }))), location: { - country: $searchQuery.country ? { label: $searchQuery.country, value: $searchQuery.country } : undefined, - state: $searchQuery.state ? { label: $searchQuery.state, value: $searchQuery.state } : undefined, - city: $searchQuery.city ? { label: $searchQuery.city, value: $searchQuery.city } : undefined, + country: searchQuery.country ? { label: searchQuery.country, value: searchQuery.country } : undefined, + state: searchQuery.state ? { label: searchQuery.state, value: searchQuery.state } : undefined, + city: searchQuery.city ? { label: searchQuery.city, value: searchQuery.city } : undefined, }, camera: { - make: $searchQuery.make ? { label: $searchQuery.make, value: $searchQuery.make } : undefined, - model: $searchQuery.model ? { label: $searchQuery.model, value: $searchQuery.model } : undefined, + make: searchQuery.make ? { label: searchQuery.make, value: searchQuery.make } : undefined, + model: searchQuery.model ? { label: searchQuery.model, value: searchQuery.model } : undefined, }, date: { - takenAfter: $searchQuery.takenAfter - ? DateTime.fromISO($searchQuery.takenAfter).toUTC().toFormat('yyyy-MM-dd') + takenAfter: searchQuery.takenAfter + ? DateTime.fromISO(searchQuery.takenAfter).toUTC().toFormat('yyyy-MM-dd') : undefined, - takenBefore: $searchQuery.takenBefore - ? DateTime.fromISO($searchQuery.takenBefore).toUTC().toFormat('yyyy-MM-dd') + takenBefore: searchQuery.takenBefore + ? DateTime.fromISO(searchQuery.takenBefore).toUTC().toFormat('yyyy-MM-dd') : undefined, }, - isArchive: $searchQuery.isArchived, - isFavorite: $searchQuery.isFavorite, - isNotInAlbum: 'isNotInAlbum' in $searchQuery ? $searchQuery.isNotInAlbum : undefined, + isArchive: searchQuery.isArchived, + isFavorite: searchQuery.isFavorite, + isNotInAlbum: 'isNotInAlbum' in searchQuery ? searchQuery.isNotInAlbum : undefined, mediaType: - $searchQuery.type === AssetTypeEnum.Image + searchQuery.type === AssetTypeEnum.Image ? MediaType.Image - : $searchQuery.type === AssetTypeEnum.Video + : searchQuery.type === AssetTypeEnum.Video ? MediaType.Video : MediaType.All, }; @@ -344,7 +344,7 @@ {#each peopleList as person (person.id)} {/each}
@@ -404,8 +404,9 @@
-

Country

+
-

State

+
-

City

+
-

Make

+
-

Model

+
-
+

MEDIA TYPE

diff --git a/web/src/lib/components/admin-page/settings/setting-accordion.svelte b/web/src/lib/components/shared-components/settings/setting-accordion.svelte similarity index 100% rename from web/src/lib/components/admin-page/settings/setting-accordion.svelte rename to web/src/lib/components/shared-components/settings/setting-accordion.svelte diff --git a/web/src/lib/components/admin-page/settings/setting-buttons-row.svelte b/web/src/lib/components/shared-components/settings/setting-buttons-row.svelte similarity index 100% rename from web/src/lib/components/admin-page/settings/setting-buttons-row.svelte rename to web/src/lib/components/shared-components/settings/setting-buttons-row.svelte diff --git a/web/src/lib/components/admin-page/settings/setting-checkboxes.svelte b/web/src/lib/components/shared-components/settings/setting-checkboxes.svelte similarity index 100% rename from web/src/lib/components/admin-page/settings/setting-checkboxes.svelte rename to web/src/lib/components/shared-components/settings/setting-checkboxes.svelte diff --git a/web/src/lib/components/shared-components/settings/setting-combobox.svelte b/web/src/lib/components/shared-components/settings/setting-combobox.svelte new file mode 100644 index 0000000000..7f3dc1906e --- /dev/null +++ b/web/src/lib/components/shared-components/settings/setting-combobox.svelte @@ -0,0 +1,42 @@ + + +
+
+
+ + {#if isEdited} +
+ Unsaved change +
+ {/if} +
+ +

{subtitle}

+
+
+ onSelect(detail)} + /> + +
+
diff --git a/web/src/lib/components/admin-page/settings/setting-input-field.svelte b/web/src/lib/components/shared-components/settings/setting-input-field.svelte similarity index 100% rename from web/src/lib/components/admin-page/settings/setting-input-field.svelte rename to web/src/lib/components/shared-components/settings/setting-input-field.svelte diff --git a/web/src/lib/components/admin-page/settings/setting-select.svelte b/web/src/lib/components/shared-components/settings/setting-select.svelte similarity index 100% rename from web/src/lib/components/admin-page/settings/setting-select.svelte rename to web/src/lib/components/shared-components/settings/setting-select.svelte diff --git a/web/src/lib/components/admin-page/settings/setting-switch.svelte b/web/src/lib/components/shared-components/settings/setting-switch.svelte similarity index 99% rename from web/src/lib/components/admin-page/settings/setting-switch.svelte rename to web/src/lib/components/shared-components/settings/setting-switch.svelte index 6797423a55..2a06b272ce 100644 --- a/web/src/lib/components/admin-page/settings/setting-switch.svelte +++ b/web/src/lib/components/shared-components/settings/setting-switch.svelte @@ -30,6 +30,7 @@

{subtitle}

+
+ +
+ +

{selectedDate}

+
+
+ {#if $locale !== undefined} +
+ handleLocaleChange(combobox?.value)} + /> +
+ {/if} + +
+ ($alwaysLoadOriginalFile = !$alwaysLoadOriginalFile)} + /> +
+ +
+ +
+
+
diff --git a/web/src/lib/components/user-settings-page/change-password-settings.svelte b/web/src/lib/components/user-settings-page/change-password-settings.svelte index 6e17f2e7db..9745b15fa7 100644 --- a/web/src/lib/components/user-settings-page/change-password-settings.svelte +++ b/web/src/lib/components/user-settings-page/change-password-settings.svelte @@ -5,9 +5,12 @@ } from '$lib/components/shared-components/notification/notification'; import { changePassword } from '@immich/sdk'; import { fade } from 'svelte/transition'; - import SettingInputField, { SettingInputFieldType } from '../admin-page/settings/setting-input-field.svelte'; - import Button from '../elements/buttons/button.svelte'; + + import Button from '$lib/components/elements/buttons/button.svelte'; import type { HttpError } from '@sveltejs/kit'; + import SettingInputField, { + SettingInputFieldType, + } from '$lib/components/shared-components/settings/setting-input-field.svelte'; let password = ''; let newPassword = ''; diff --git a/web/src/lib/components/user-settings-page/device-card.svelte b/web/src/lib/components/user-settings-page/device-card.svelte index 29067aaca1..5395ae43f4 100644 --- a/web/src/lib/components/user-settings-page/device-card.svelte +++ b/web/src/lib/components/user-settings-page/device-card.svelte @@ -57,7 +57,7 @@
Last seen - {DateTime.fromISO(device.updatedAt).toRelativeCalendar(options)} + {DateTime.fromISO(device.updatedAt, { locale: $locale }).toRelativeCalendar(options)}
{#if !device.current} diff --git a/web/src/lib/components/user-settings-page/memories-settings.svelte b/web/src/lib/components/user-settings-page/memories-settings.svelte index 8dc34f5374..938936b6c5 100644 --- a/web/src/lib/components/user-settings-page/memories-settings.svelte +++ b/web/src/lib/components/user-settings-page/memories-settings.svelte @@ -6,8 +6,9 @@ import { updateUser, type UserResponseDto } from '@immich/sdk'; import { fade } from 'svelte/transition'; import { handleError } from '../../utils/handle-error'; - import SettingSwitch from '../admin-page/settings/setting-switch.svelte'; + import Button from '../elements/buttons/button.svelte'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; export let user: UserResponseDto; diff --git a/web/src/lib/components/user-settings-page/partner-settings.svelte b/web/src/lib/components/user-settings-page/partner-settings.svelte index a485564191..f29cf42961 100644 --- a/web/src/lib/components/user-settings-page/partner-settings.svelte +++ b/web/src/lib/components/user-settings-page/partner-settings.svelte @@ -10,13 +10,13 @@ import { mdiCheck, mdiClose } from '@mdi/js'; import { onMount } from 'svelte'; import { handleError } from '../../utils/handle-error'; - import SettingSwitch from '../admin-page/settings/setting-switch.svelte'; import Button from '../elements/buttons/button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import Icon from '../elements/icon.svelte'; - import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte'; - import UserAvatar from '../shared-components/user-avatar.svelte'; + import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte'; + import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; import PartnerSelectionModal from './partner-selection-modal.svelte'; + import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; interface PartnerSharing { user: UserResponseDto; diff --git a/web/src/lib/components/user-settings-page/quality-settings.svelte b/web/src/lib/components/user-settings-page/quality-settings.svelte deleted file mode 100644 index 9adf77c5f4..0000000000 --- a/web/src/lib/components/user-settings-page/quality-settings.svelte +++ /dev/null @@ -1,24 +0,0 @@ - - -
-
-
-
- -
-
-
-
diff --git a/web/src/lib/components/user-settings-page/sidebar-settings.svelte b/web/src/lib/components/user-settings-page/sidebar-settings.svelte deleted file mode 100644 index 3ac2947f6b..0000000000 --- a/web/src/lib/components/user-settings-page/sidebar-settings.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - -
-
-
-
- -
-
- -
-
-
-
diff --git a/web/src/lib/components/user-settings-page/trash-settings.svelte b/web/src/lib/components/user-settings-page/trash-settings.svelte index 21f00178d4..989f39af4e 100644 --- a/web/src/lib/components/user-settings-page/trash-settings.svelte +++ b/web/src/lib/components/user-settings-page/trash-settings.svelte @@ -1,7 +1,7 @@
diff --git a/web/src/lib/components/user-settings-page/user-profile-settings.svelte b/web/src/lib/components/user-settings-page/user-profile-settings.svelte index 7bac6b988e..053d852dda 100644 --- a/web/src/lib/components/user-settings-page/user-profile-settings.svelte +++ b/web/src/lib/components/user-settings-page/user-profile-settings.svelte @@ -5,11 +5,13 @@ } from '$lib/components/shared-components/notification/notification'; import { fade } from 'svelte/transition'; import { handleError } from '../../utils/handle-error'; - import SettingInputField, { SettingInputFieldType } from '../admin-page/settings/setting-input-field.svelte'; import Button from '../elements/buttons/button.svelte'; import { user } from '$lib/stores/user.store'; import { cloneDeep } from 'lodash-es'; import { updateUser } from '@immich/sdk'; + import SettingInputField, { + SettingInputFieldType, + } from '$lib/components/shared-components/settings/setting-input-field.svelte'; let editedUser = cloneDeep($user); diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index ef686c85ba..394e2802ed 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -6,15 +6,13 @@ import { user } from '$lib/stores/user.store'; import { oauth } from '$lib/utils'; import { type ApiKeyResponseDto, type AuthDeviceResponseDto } from '@immich/sdk'; - import SettingAccordion from '../admin-page/settings/setting-accordion.svelte'; + import SettingAccordion from '../shared-components/settings/setting-accordion.svelte'; import AppearanceSettings from './appearance-settings.svelte'; import ChangePasswordSettings from './change-password-settings.svelte'; import DeviceList from './device-list.svelte'; import MemoriesSettings from './memories-settings.svelte'; import OAuthSettings from './oauth-settings.svelte'; import PartnerSettings from './partner-settings.svelte'; - import QualitySettings from './quality-settings.svelte'; - import SidebarSettings from './sidebar-settings.svelte'; import TrashSettings from './trash-settings.svelte'; import UserAPIKeyList from './user-api-key-list.svelte'; import UserProfileSettings from './user-profile-settings.svelte'; @@ -29,7 +27,7 @@ } - + @@ -69,18 +67,10 @@ - - - - - - - - diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index bda2097bb4..af5558c261 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -70,7 +70,6 @@ export enum QueryParameter { PREVIOUS_ROUTE = 'previousRoute', QUERY = 'query', SEARCHED_PEOPLE = 'searchedPeople', - SEARCH_TERM = 'q', SMART_SEARCH = 'smartSearch', PAGE = 'page', } @@ -97,3 +96,147 @@ export enum Theme { LIGHT = 'light', DARK = 'dark', } + +export const fallbackLocale = { + code: 'en-US', + name: 'English (US)', +}; + +export const locales = [ + { code: 'af-ZA', name: 'Afrikaans (South Africa)' }, + { code: 'sq-AL', name: 'Albanian (Albania)' }, + { code: 'ar-DZ', name: 'Arabic (Algeria)' }, + { code: 'ar-BH', name: 'Arabic (Bahrain)' }, + { code: 'ar-EG', name: 'Arabic (Egypt)' }, + { code: 'ar-IQ', name: 'Arabic (Iraq)' }, + { code: 'ar-JO', name: 'Arabic (Jordan)' }, + { code: 'ar-KW', name: 'Arabic (Kuwait)' }, + { code: 'ar-LB', name: 'Arabic (Lebanon)' }, + { code: 'ar-LY', name: 'Arabic (Libya)' }, + { code: 'ar-MA', name: 'Arabic (Morocco)' }, + { code: 'ar-OM', name: 'Arabic (Oman)' }, + { code: 'ar-QA', name: 'Arabic (Qatar)' }, + { code: 'ar-SA', name: 'Arabic (Saudi Arabia)' }, + { code: 'ar-SY', name: 'Arabic (Syria)' }, + { code: 'ar-TN', name: 'Arabic (Tunisia)' }, + { code: 'ar-AE', name: 'Arabic (United Arab Emirates)' }, + { code: 'ar-YE', name: 'Arabic (Yemen)' }, + { code: 'hy-AM', name: 'Armenian (Armenia)' }, + { code: 'az-AZ', name: 'Azerbaijani (Azerbaijan)' }, + { code: 'eu-ES', name: 'Basque (Spain)' }, + { code: 'be-BY', name: 'Belarusian (Belarus)' }, + { code: 'bn-IN', name: 'Bengali (India)' }, + { code: 'bs-BA', name: 'Bosnian (Bosnia and Herzegovina)' }, + { code: 'bg-BG', name: 'Bulgarian (Bulgaria)' }, + { code: 'ca-ES', name: 'Catalan (Spain)' }, + { code: 'zh-CN', name: 'Chinese (China)' }, + { code: 'zh-HK', name: 'Chinese (Hong Kong SAR China)' }, + { code: 'zh-MO', name: 'Chinese (Macao SAR China)' }, + { code: 'zh-SG', name: 'Chinese (Singapore)' }, + { code: 'zh-TW', name: 'Chinese (Taiwan)' }, + { code: 'hr-HR', name: 'Croatian (Croatia)' }, + { code: 'cs-CZ', name: 'Czech (Czech Republic)' }, + { code: 'da-DK', name: 'Danish (Denmark)' }, + { code: 'nl-BE', name: 'Dutch (Belgium)' }, + { code: 'nl-NL', name: 'Dutch (Netherlands)' }, + { code: 'en-AU', name: 'English (Australia)' }, + { code: 'en-BZ', name: 'English (Belize)' }, + { code: 'en-CA', name: 'English (Canada)' }, + { code: 'en-IE', name: 'English (Ireland)' }, + { code: 'en-JM', name: 'English (Jamaica)' }, + { code: 'en-NZ', name: 'English (New Zealand)' }, + { code: 'en-PH', name: 'English (Philippines)' }, + { code: 'en-ZA', name: 'English (South Africa)' }, + { code: 'en-TT', name: 'English (Trinidad and Tobago)' }, + { code: 'en-VI', name: 'English (U.S. Virgin Islands)' }, + { code: 'en-GB', name: 'English (United Kingdom)' }, + { code: 'en-US', name: 'English (United States)' }, + { code: 'en-ZW', name: 'English (Zimbabwe)' }, + { code: 'et-EE', name: 'Estonian (Estonia)' }, + { code: 'fo-FO', name: 'Faroese (Faroe Islands)' }, + { code: 'fi-FI', name: 'Finnish (Finland)' }, + { code: 'fr-BE', name: 'French (Belgium)' }, + { code: 'fr-CA', name: 'French (Canada)' }, + { code: 'fr-FR', name: 'French (France)' }, + { code: 'fr-LU', name: 'French (Luxembourg)' }, + { code: 'fr-MC', name: 'French (Monaco)' }, + { code: 'fr-CH', name: 'French (Switzerland)' }, + { code: 'gl-ES', name: 'Galician (Spain)' }, + { code: 'ka-GE', name: 'Georgian (Georgia)' }, + { code: 'de-AT', name: 'German (Austria)' }, + { code: 'de-DE', name: 'German (Germany)' }, + { code: 'de-LI', name: 'German (Liechtenstein)' }, + { code: 'de-LU', name: 'German (Luxembourg)' }, + { code: 'de-CH', name: 'German (Switzerland)' }, + { code: 'el-GR', name: 'Greek (Greece)' }, + { code: 'gu-IN', name: 'Gujarati (India)' }, + { code: 'he-IL', name: 'Hebrew (Israel)' }, + { code: 'hi-IN', name: 'Hindi (India)' }, + { code: 'hu-HU', name: 'Hungarian (Hungary)' }, + { code: 'is-IS', name: 'Icelandic (Iceland)' }, + { code: 'id-ID', name: 'Indonesian (Indonesia)' }, + { code: 'it-IT', name: 'Italian (Italy)' }, + { code: 'it-CH', name: 'Italian (Switzerland)' }, + { code: 'ja-JP', name: 'Japanese (Japan)' }, + { code: 'kn-IN', name: 'Kannada (India)' }, + { code: 'kk-KZ', name: 'Kazakh (Kazakhstan)' }, + { code: 'kok-IN', name: 'Konkani (India)' }, + { code: 'ko-KR', name: 'Korean (South Korea)' }, + { code: 'lv-LV', name: 'Latvian (Latvia)' }, + { code: 'lt-LT', name: 'Lithuanian (Lithuania)' }, + { code: 'mk-MK', name: 'Macedonian (Macedonia)' }, + { code: 'ms-BN', name: 'Malay (Brunei)' }, + { code: 'ms-MY', name: 'Malay (Malaysia)' }, + { code: 'ml-IN', name: 'Malayalam (India)' }, + { code: 'mt-MT', name: 'Maltese (Malta)' }, + { code: 'mr-IN', name: 'Marathi (India)' }, + { code: 'mn-MN', name: 'Mongolian (Mongolia)' }, + { code: 'se-NO', name: 'Northern Sami (Norway)' }, + { code: 'nb-NO', name: 'Norwegian Bokmål (Norway)' }, + { code: 'nn-NO', name: 'Norwegian Nynorsk (Norway)' }, + { code: 'fa-IR', name: 'Persian (Iran)' }, + { code: 'pl-PL', name: 'Polish (Poland)' }, + { code: 'pt-BR', name: 'Portuguese (Brazil)' }, + { code: 'pt-PT', name: 'Portuguese (Portugal)' }, + { code: 'pa-IN', name: 'Punjabi (India)' }, + { code: 'ro-RO', name: 'Romanian (Romania)' }, + { code: 'ru-RU', name: 'Russian (Russia)' }, + { code: 'sr-BA', name: 'Serbian (Bosnia and Herzegovina)' }, + { code: 'sr-CS', name: 'Serbian (Serbia And Montenegro)' }, + { code: 'sk-SK', name: 'Slovak (Slovakia)' }, + { code: 'sl-SI', name: 'Slovenian (Slovenia)' }, + { code: 'es-AR', name: 'Spanish (Argentina)' }, + { code: 'es-BO', name: 'Spanish (Bolivia)' }, + { code: 'es-CL', name: 'Spanish (Chile)' }, + { code: 'es-CO', name: 'Spanish (Colombia)' }, + { code: 'es-CR', name: 'Spanish (Costa Rica)' }, + { code: 'es-DO', name: 'Spanish (Dominican Republic)' }, + { code: 'es-EC', name: 'Spanish (Ecuador)' }, + { code: 'es-SV', name: 'Spanish (El Salvador)' }, + { code: 'es-GT', name: 'Spanish (Guatemala)' }, + { code: 'es-HN', name: 'Spanish (Honduras)' }, + { code: 'es-MX', name: 'Spanish (Mexico)' }, + { code: 'es-NI', name: 'Spanish (Nicaragua)' }, + { code: 'es-PA', name: 'Spanish (Panama)' }, + { code: 'es-PY', name: 'Spanish (Paraguay)' }, + { code: 'es-PE', name: 'Spanish (Peru)' }, + { code: 'es-PR', name: 'Spanish (Puerto Rico)' }, + { code: 'es-ES', name: 'Spanish (Spain)' }, + { code: 'es-UY', name: 'Spanish (Uruguay)' }, + { code: 'es-VE', name: 'Spanish (Venezuela)' }, + { code: 'sw-KE', name: 'Swahili (Kenya)' }, + { code: 'sv-FI', name: 'Swedish (Finland)' }, + { code: 'sv-SE', name: 'Swedish (Sweden)' }, + { code: 'syr-SY', name: 'Syriac (Syria)' }, + { code: 'ta-IN', name: 'Tamil (India)' }, + { code: 'te-IN', name: 'Telugu (India)' }, + { code: 'th-TH', name: 'Thai (Thailand)' }, + { code: 'tn-ZA', name: 'Tswana (South Africa)' }, + { code: 'tr-TR', name: 'Turkish (Turkey)' }, + { code: 'uk-UA', name: 'Ukrainian (Ukraine)' }, + { code: 'uz-UZ', name: 'Uzbek (Uzbekistan)' }, + { code: 'vi-VN', name: 'Vietnamese (Vietnam)' }, + { code: 'cy-GB', name: 'Welsh (United Kingdom)' }, + { code: 'xh-ZA', name: 'Xhosa (South Africa)' }, + { code: 'zu-ZA', name: 'Zulu (South Africa)' }, +]; diff --git a/web/src/lib/stores/search.store.ts b/web/src/lib/stores/search.store.ts index ded7dc17ae..41fd287f4c 100644 --- a/web/src/lib/stores/search.store.ts +++ b/web/src/lib/stores/search.store.ts @@ -1,8 +1,6 @@ -import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk'; import { persisted } from 'svelte-local-storage-store'; import { writable } from 'svelte/store'; export const savedSearchTerms = persisted('search-terms', [], {}); export const isSearchEnabled = writable(false); export const preventRaceConditionSearchBar = writable(false); -export const searchQuery = writable(undefined); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 88b0f0650f..644dd7f638 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -1,6 +1,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification'; +import { locales } from '$lib/constants'; import { handleError } from '$lib/utils/handle-error'; import { AssetJobName, @@ -185,3 +186,11 @@ export const oauth = { return unlinkOAuthAccount(); }, }; + +export const findLocale = (code: string | undefined) => { + const language = locales.find((lang) => lang.code === code); + return { + code: language?.code, + name: language?.name, + }; +}; diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index fa1ceb88cb..83756a4064 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -1,8 +1,11 @@ +import { locale } from '$lib/stores/preferences.store'; import type { AssetResponseDto } from '@immich/sdk'; import { groupBy, sortBy } from 'lodash-es'; import { DateTime, Interval } from 'luxon'; +import { get } from 'svelte/store'; -export const fromLocalDateTime = (localDateTime: string) => DateTime.fromISO(localDateTime, { zone: 'UTC' }); +export const fromLocalDateTime = (localDateTime: string) => + DateTime.fromISO(localDateTime, { zone: 'UTC', locale: get(locale) }); export const groupDateFormat: Intl.DateTimeFormatOptions = { weekday: 'short', diff --git a/web/src/routes/(user)/albums/+page.svelte b/web/src/routes/(user)/albums/+page.svelte index fa0115d828..07000d9136 100644 --- a/web/src/routes/(user)/albums/+page.svelte +++ b/web/src/routes/(user)/albums/+page.svelte @@ -43,11 +43,13 @@ import { flip } from 'svelte/animate'; import type { PageData } from './$types'; import { useAlbums } from './albums.bloc'; + import SearchBar from '$lib/components/elements/search-bar.svelte'; export let data: PageData; let shouldShowEditUserForm = false; let selectedAlbum: AlbumResponseDto; + let searchAlbum = ''; let sortByOptions: Record = { albumTitle: { @@ -180,6 +182,8 @@ } } + $: albumsFiltered = $albums.filter((album) => album.albumName.toLowerCase().includes(searchAlbum.toLowerCase())); + const searchSort = (searched: string): Sort => { for (const key in sortByOptions) { if (sortByOptions[key].title === searched) { @@ -243,6 +247,9 @@
+
@@ -285,7 +292,7 @@ {#if $albumViewSettings.view === AlbumViewMode.Cover}
- {#each $albums as album, index (album.id)} + {#each albumsFiltered as album, index (album.id)} {:else if $albumViewSettings.view === AlbumViewMode.List} - +
@@ -310,7 +317,7 @@ - {#each $albums as album (album.id)} + {#each albumsFiltered as album (album.id)} goto(`${AppRoute.ALBUMS}/${album.id}`)} diff --git a/web/src/routes/(user)/albums/[albumId]/+page.svelte b/web/src/routes/(user)/albums/[albumId]/+page.svelte index 52b780acc2..7c9ca39acd 100644 --- a/web/src/routes/(user)/albums/[albumId]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId]/+page.svelte @@ -603,7 +603,7 @@ e.key === 'Enter' && titleInput.blur()} on:blur={handleUpdateName} - class="w-[99%] border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned + class="w-[99%] mb-2 border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned ? 'hover:border-gray-400' : 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray" type="text" @@ -616,7 +616,7 @@ {#if album.assetCount > 0} - +

{getDateRange()}

·

{album.assetCount} items

@@ -625,7 +625,7 @@ {#if album.sharedUsers.length > 0 || (album.hasSharedLink && isOwned)} -
+
{#if album.hasSharedLink && isOwned} {#if isOwned}