From 50bc92aac06a0cd1261be0921ee0b5fd048f4f1c Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 23 Oct 2023 14:37:51 +0200 Subject: [PATCH 01/45] refactor(server): make access core singleton (#4609) --- server/src/domain/access/access.core.ts | 16 +++++++++++++++- server/src/domain/album/album.service.ts | 2 +- server/src/domain/asset/asset.service.ts | 2 +- server/src/domain/audit/audit.service.ts | 2 +- server/src/domain/library/library.service.ts | 2 +- server/src/domain/person/person.service.ts | 2 +- .../domain/shared-link/shared-link.service.ts | 2 +- server/src/immich/api-v1/asset/asset.service.ts | 2 +- .../test/repositories/access.repository.mock.ts | 8 ++++++-- 9 files changed, 28 insertions(+), 10 deletions(-) diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index 7f75a7683058b..7c5a6643085bc 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -37,8 +37,22 @@ export enum Permission { PERSON_MERGE = 'person.merge', } +let instance: AccessCore | null; + export class AccessCore { - constructor(private repository: IAccessRepository) {} + private constructor(private repository: IAccessRepository) {} + + static create(repository: IAccessRepository) { + if (!instance) { + instance = new AccessCore(repository); + } + + return instance; + } + + static reset() { + instance = null; + } requireUploadAccess(authUser: AuthUserDto | null): AuthUserDto { if (!authUser || (authUser.isPublicUser && !authUser.isAllowUpload)) { diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 04b885040af83..e6f1edfa77f01 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -31,7 +31,7 @@ export class AlbumService { @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IUserRepository) private userRepository: IUserRepository, ) { - this.access = new AccessCore(accessRepository); + this.access = AccessCore.create(accessRepository); } async getCount(authUser: AuthUserDto): Promise { diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 57623fa1b28bf..6823c00305127 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -89,7 +89,7 @@ export class AssetService { @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, ) { - this.access = new AccessCore(accessRepository); + this.access = AccessCore.create(accessRepository); this.configCore = SystemConfigCore.create(configRepository); this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository); } diff --git a/server/src/domain/audit/audit.service.ts b/server/src/domain/audit/audit.service.ts index dc3d65b6535d7..1eaa720815553 100644 --- a/server/src/domain/audit/audit.service.ts +++ b/server/src/domain/audit/audit.service.ts @@ -40,7 +40,7 @@ export class AuditService { @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IUserRepository) private userRepository: IUserRepository, ) { - this.access = new AccessCore(accessRepository); + this.access = AccessCore.create(accessRepository); } async handleCleanup(): Promise { diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index a59663bf770d0..d226b22c7cf26 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -43,7 +43,7 @@ export class LibraryService { @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IUserRepository) private userRepository: IUserRepository, ) { - this.access = new AccessCore(accessRepository); + this.access = AccessCore.create(accessRepository); } async getStatistics(authUser: AuthUserDto, id: string): Promise { diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 162ab8fdb42d9..eb0bdc5c6c961 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -56,7 +56,7 @@ export class PersonService { @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, ) { - this.access = new AccessCore(accessRepository); + this.access = AccessCore.create(accessRepository); this.configCore = SystemConfigCore.create(configRepository); this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, repository); } diff --git a/server/src/domain/shared-link/shared-link.service.ts b/server/src/domain/shared-link/shared-link.service.ts index 9e2a0fc8a0be4..2cb87c8ebcb8b 100644 --- a/server/src/domain/shared-link/shared-link.service.ts +++ b/server/src/domain/shared-link/shared-link.service.ts @@ -16,7 +16,7 @@ export class SharedLinkService { @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ISharedLinkRepository) private repository: ISharedLinkRepository, ) { - this.access = new AccessCore(accessRepository); + this.access = AccessCore.create(accessRepository); } getAll(authUser: AuthUserDto): Promise { diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 415fb380de582..1de4edeea6faa 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -68,7 +68,7 @@ export class AssetService { @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) { this.assetCore = new AssetCore(_assetRepository, jobRepository); - this.access = new AccessCore(accessRepository); + this.access = AccessCore.create(accessRepository); } public async uploadFile( diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index c9568283eb3a6..416d5043a44eb 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -1,4 +1,4 @@ -import { IAccessRepository } from '@app/domain'; +import { AccessCore, IAccessRepository } from '@app/domain'; export interface IAccessRepositoryMock { asset: jest.Mocked; @@ -8,7 +8,11 @@ export interface IAccessRepositoryMock { person: jest.Mocked; } -export const newAccessRepositoryMock = (): IAccessRepositoryMock => { +export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => { + if (reset) { + AccessCore.reset(); + } + return { asset: { hasOwnerAccess: jest.fn(), From c6b4bc883b9788498071cc4921a171b2c811c6c2 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 23 Oct 2023 14:38:48 +0200 Subject: [PATCH 02/45] refactor(server): make user core singleton (#4607) --- server/src/domain/auth/auth.service.ts | 2 +- server/src/domain/user/user.core.ts | 24 ++++++++++++++++--- server/src/domain/user/user.service.ts | 2 +- .../test/repositories/user.repository.mock.ts | 8 +++++-- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/server/src/domain/auth/auth.service.ts b/server/src/domain/auth/auth.service.ts index 01cb5adac4202..a7e467c5a3b11 100644 --- a/server/src/domain/auth/auth.service.ts +++ b/server/src/domain/auth/auth.service.ts @@ -75,7 +75,7 @@ export class AuthService { @Inject(IKeyRepository) private keyRepository: IKeyRepository, ) { this.configCore = SystemConfigCore.create(configRepository); - this.userCore = new UserCore(userRepository, libraryRepository, cryptoRepository); + this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository); custom.setHttpOptionsDefaults({ timeout: 30000 }); } diff --git a/server/src/domain/user/user.core.ts b/server/src/domain/user/user.core.ts index 2597a2441eb40..d6cb35b4111e3 100644 --- a/server/src/domain/user/user.core.ts +++ b/server/src/domain/user/user.core.ts @@ -15,13 +15,31 @@ import { ICryptoRepository, ILibraryRepository, IUserRepository, UserListFilter const SALT_ROUNDS = 10; +let instance: UserCore | null; + export class UserCore { - constructor( - private userRepository: IUserRepository, - private libraryRepository: ILibraryRepository, + private constructor( private cryptoRepository: ICryptoRepository, + private libraryRepository: ILibraryRepository, + private userRepository: IUserRepository, ) {} + static create( + cryptoRepository: ICryptoRepository, + libraryRepository: ILibraryRepository, + userRepository: IUserRepository, + ) { + if (!instance) { + instance = new UserCore(cryptoRepository, libraryRepository, userRepository); + } + + return instance; + } + + static reset() { + instance = null; + } + async updateUser(authUser: AuthUserDto, id: string, dto: Partial): Promise { if (!authUser.isAdmin && authUser.id !== id) { throw new ForbiddenException('You are not allowed to update this user'); diff --git a/server/src/domain/user/user.service.ts b/server/src/domain/user/user.service.ts index 8664bca50dce2..796a72aa765c7 100644 --- a/server/src/domain/user/user.service.ts +++ b/server/src/domain/user/user.service.ts @@ -45,7 +45,7 @@ export class UserService { @Inject(IUserRepository) private userRepository: IUserRepository, ) { this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository); - this.userCore = new UserCore(userRepository, libraryRepository, cryptoRepository); + this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository); } async getAll(authUser: AuthUserDto, isAll: boolean): Promise { diff --git a/server/test/repositories/user.repository.mock.ts b/server/test/repositories/user.repository.mock.ts index 30017e758e1ad..d164bbe10f997 100644 --- a/server/test/repositories/user.repository.mock.ts +++ b/server/test/repositories/user.repository.mock.ts @@ -1,6 +1,10 @@ -import { IUserRepository } from '@app/domain'; +import { IUserRepository, UserCore } from '@app/domain'; + +export const newUserRepositoryMock = (reset = true): jest.Mocked => { + if (reset) { + UserCore.reset(); + } -export const newUserRepositoryMock = (): jest.Mocked => { return { get: jest.fn(), getAdmin: jest.fn(), From 64e4ae7e4b1f3d18d89e517a97efc05ac7945e1e Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Mon, 23 Oct 2023 14:44:47 +0200 Subject: [PATCH 03/45] fix(docs): dates and responsive design in milestone page (#4606) * fix: dates and responsive design * fix: overflow * add tags for birthday * use cake for birthday icon * use uppercase for enum --- docs/src/components/timeline.tsx | 14 +++--- docs/src/pages/milestones.tsx | 74 +++++++++++++++++++++++++++----- 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/docs/src/components/timeline.tsx b/docs/src/components/timeline.tsx index 2925736034f44..d902073182bf3 100644 --- a/docs/src/components/timeline.tsx +++ b/docs/src/components/timeline.tsx @@ -10,6 +10,12 @@ export interface Item { release: string; tag?: string; date: Date; + dateType: DateType; +} + +export enum DateType { + RELEASE = 'Release Date', + DATE = 'Date', } interface Props { @@ -50,7 +56,7 @@ export default function Timeline({ items }: Props): JSX.Element {
-
+

@@ -67,14 +73,12 @@ export default function Timeline({ items }: Props): JSX.Element { [{item.release}]{' '} ) : ( - - [{item.release} {isBrowser ? item.date.toLocaleDateString(navigator.language) : ''}] - + [{item.release}] )}

- Release Date - {isBrowser ? item.date.toLocaleDateString(navigator.language) : ''} + {`${item.dateType} - ${isBrowser ? item.date.toLocaleDateString(navigator.language) : ''}`}

{item.description}

diff --git a/docs/src/pages/milestones.tsx b/docs/src/pages/milestones.tsx index 673c7251d1cd3..87004f33963ba 100644 --- a/docs/src/pages/milestones.tsx +++ b/docs/src/pages/milestones.tsx @@ -4,6 +4,7 @@ import { mdiAppleIos, mdiArchiveOutline, mdiBookSearchOutline, + mdiCakeVariant, mdiCheckAll, mdiCheckboxMarked, mdiCollage, @@ -43,7 +44,7 @@ import { } from '@mdi/js'; import Layout from '@theme/Layout'; import React from 'react'; -import Timeline, { Item } from '../components/timeline'; +import Timeline, { DateType, Item } from '../components/timeline'; const items: Item[] = [ { @@ -53,6 +54,7 @@ const items: Item[] = [ release: 'v1.82.0', tag: 'v1.82.0', date: new Date(2023, 9, 17), + dateType: DateType.RELEASE, }, { icon: mdiBookSearchOutline, @@ -61,6 +63,7 @@ const items: Item[] = [ release: 'v1.79.0', tag: 'v1.79.0', date: new Date(2023, 8, 21), + dateType: DateType.RELEASE, }, { icon: mdiMap, @@ -69,6 +72,7 @@ const items: Item[] = [ release: 'v1.76.0', tag: 'v1.76.0', date: new Date(2023, 7, 29), + dateType: DateType.RELEASE, }, { icon: mdiFile, @@ -77,6 +81,7 @@ const items: Item[] = [ release: 'v1.75.0', tag: 'v1.75.0', date: new Date(2023, 7, 26), + dateType: DateType.RELEASE, }, { icon: mdiMonitor, @@ -85,6 +90,7 @@ const items: Item[] = [ release: 'v1.75.0', tag: 'v1.75.0', date: new Date(2023, 7, 26), + dateType: DateType.RELEASE, }, { icon: mdiServer, @@ -93,6 +99,7 @@ const items: Item[] = [ release: 'v1.72.0', tag: 'v1.72.0', date: new Date(2023, 7, 6), + dateType: DateType.RELEASE, }, { icon: mdiImageAlbum, @@ -101,6 +108,7 @@ const items: Item[] = [ release: 'v1.72.0', tag: 'v1.72.0', date: new Date(2023, 7, 6), + dateType: DateType.RELEASE, }, { icon: mdiImageAlbum, @@ -109,6 +117,7 @@ const items: Item[] = [ release: 'v1.72.0', tag: 'v1.72.0', date: new Date(2023, 7, 6), + dateType: DateType.RELEASE, }, { icon: mdiRotate360, @@ -117,6 +126,7 @@ const items: Item[] = [ release: 'v1.71.0', tag: 'v1.71.0', date: new Date(2023, 6, 29), + dateType: DateType.RELEASE, }, { icon: mdiMotionPlayOutline, @@ -125,6 +135,7 @@ const items: Item[] = [ release: 'v1.69.0', tag: 'v1.69.0', date: new Date(2023, 6, 23), + dateType: DateType.RELEASE, }, { icon: mdiFaceManOutline, @@ -133,6 +144,7 @@ const items: Item[] = [ release: 'v1.68.0', tag: 'v1.68.0', date: new Date(2023, 6, 20), + dateType: DateType.RELEASE, }, { icon: mdiMerge, @@ -141,6 +153,7 @@ const items: Item[] = [ release: 'v1.67.0', tag: 'v1.67.0', date: new Date(2023, 6, 14), + dateType: DateType.RELEASE, }, { icon: mdiImage, @@ -149,6 +162,7 @@ const items: Item[] = [ release: 'v1.66.0', tag: 'v1.66.0', date: new Date(2023, 6, 4), + dateType: DateType.RELEASE, }, { icon: mdiKeyboardSettingsOutline, @@ -157,6 +171,7 @@ const items: Item[] = [ release: 'v1.66.0', tag: 'v1.66.0', date: new Date(2023, 6, 4), + dateType: DateType.RELEASE, }, { icon: mdiImageMultipleOutline, @@ -165,6 +180,7 @@ const items: Item[] = [ release: 'v1.65.0', tag: 'v1.65.0', date: new Date(2023, 5, 30), + dateType: DateType.RELEASE, }, { icon: mdiFaceMan, @@ -173,6 +189,7 @@ const items: Item[] = [ release: 'v1.63.0', tag: 'v1.63.0', date: new Date(2023, 5, 24), + dateType: DateType.RELEASE, }, { icon: mdiImageMultipleOutline, @@ -181,6 +198,7 @@ const items: Item[] = [ release: 'v1.61.0', tag: 'v1.61.0', date: new Date(2023, 5, 16), + dateType: DateType.RELEASE, }, { icon: mdiCollage, @@ -189,6 +207,7 @@ const items: Item[] = [ release: 'v1.61.0', tag: 'v1.61.0', date: new Date(2023, 5, 16), + dateType: DateType.RELEASE, }, { icon: mdiRaw, @@ -197,6 +216,7 @@ const items: Item[] = [ release: 'v1.61.0', tag: 'v1.61.0', date: new Date(2023, 5, 16), + dateType: DateType.RELEASE, }, { icon: mdiShareAll, @@ -205,6 +225,7 @@ const items: Item[] = [ release: 'v1.58.0', tag: 'v1.58.0', date: new Date(2023, 4, 28), + dateType: DateType.RELEASE, }, { icon: mdiFile, @@ -213,6 +234,7 @@ const items: Item[] = [ release: 'v1.58.0', tag: 'v1.58.0', date: new Date(2023, 4, 28), + dateType: DateType.RELEASE, }, { icon: mdiFolder, @@ -221,6 +243,7 @@ const items: Item[] = [ release: 'v1.57.0', tag: 'v1.57.0', date: new Date(2023, 4, 23), + dateType: DateType.RELEASE, }, { icon: mdiShareCircle, @@ -229,6 +252,7 @@ const items: Item[] = [ release: 'v1.56.0', tag: 'v1.56.0', date: new Date(2023, 4, 18), + dateType: DateType.RELEASE, }, { icon: mdiFaceMan, @@ -237,6 +261,7 @@ const items: Item[] = [ release: 'v1.56.0', tag: 'v1.56.0', date: new Date(2023, 4, 18), + dateType: DateType.RELEASE, }, { icon: mdiMap, @@ -245,6 +270,7 @@ const items: Item[] = [ release: 'v1.55.0', tag: 'v1.55.0', date: new Date(2023, 4, 9), + dateType: DateType.RELEASE, }, { icon: mdiDevices, @@ -253,6 +279,7 @@ const items: Item[] = [ release: 'v1.55.0', tag: 'v1.55.0', date: new Date(2023, 4, 9), + dateType: DateType.RELEASE, }, { icon: mdiStar, @@ -261,6 +288,7 @@ const items: Item[] = [ release: 'v1.54.0', tag: 'v1.54.0', date: new Date(2023, 3, 18), + dateType: DateType.RELEASE, }, { icon: mdiText, @@ -269,6 +297,7 @@ const items: Item[] = [ release: 'v1.54.0', tag: 'v1.54.0', date: new Date(2023, 3, 18), + dateType: DateType.RELEASE, }, { icon: mdiArchiveOutline, @@ -277,6 +306,7 @@ const items: Item[] = [ release: 'v1.54.0', tag: 'v1.54.0', date: new Date(2023, 3, 18), + dateType: DateType.RELEASE, }, { icon: mdiDevices, @@ -285,6 +315,7 @@ const items: Item[] = [ release: 'v1.54.0', tag: 'v1.54.0', date: new Date(2023, 3, 18), + dateType: DateType.RELEASE, }, { icon: mdiFileSearch, @@ -293,6 +324,7 @@ const items: Item[] = [ release: 'v1.52.0', tag: 'v1.52.0', date: new Date(2023, 2, 29), + dateType: DateType.RELEASE, }, { icon: mdiImageSearch, @@ -301,6 +333,7 @@ const items: Item[] = [ release: 'v1.51.0', tag: 'v1.51.0', date: new Date(2023, 2, 20), + dateType: DateType.RELEASE, }, { icon: mdiMagnify, @@ -309,6 +342,7 @@ const items: Item[] = [ release: 'v1.51.0', tag: 'v1.51.0', date: new Date(2023, 2, 20), + dateType: DateType.RELEASE, }, { icon: mdiAppleIos, @@ -317,6 +351,7 @@ const items: Item[] = [ release: 'v1.48.0', tag: 'v1.48.0', date: new Date(2023, 1, 21), + dateType: DateType.RELEASE, }, { icon: mdiMotionPlayOutline, @@ -325,6 +360,7 @@ const items: Item[] = [ release: 'v1.48.0', tag: 'v1.48.0', date: new Date(2023, 2, 21), + dateType: DateType.RELEASE, }, { icon: mdiMaterialDesign, @@ -333,6 +369,7 @@ const items: Item[] = [ release: 'v1.47.0', tag: 'v1.47.0', date: new Date(2023, 1, 13), + dateType: DateType.RELEASE, }, { icon: mdiHeart, @@ -341,14 +378,16 @@ const items: Item[] = [ release: 'v1.46.0', tag: 'v1.46.0', date: new Date(2023, 1, 9), + dateType: DateType.RELEASE, }, { - icon: mdiPartyPopper, + icon: mdiCakeVariant, title: 'Immich Turns 1', description: 'Immich is officially one year old.', release: 'v1.43.0', tag: 'v1.43.0', - date: new Date(2023, 0, 27), + date: new Date(2023, 1, 3), + dateType: DateType.DATE, }, { icon: mdiHeart, @@ -357,6 +396,7 @@ const items: Item[] = [ release: 'v1.43.0', tag: 'v1.43.0', date: new Date(2023, 0, 27), + dateType: DateType.RELEASE, }, { icon: mdiShareCircle, @@ -365,6 +405,7 @@ const items: Item[] = [ release: 'v1.41.0', tag: 'v1.41.1_64-dev', date: new Date(2023, 0, 10), + dateType: DateType.RELEASE, }, { icon: mdiFolder, @@ -373,6 +414,7 @@ const items: Item[] = [ release: 'v1.39.0', tag: 'v1.39.0_61-dev', date: new Date(2022, 11, 19), + dateType: DateType.RELEASE, }, { icon: mdiMotionPlayOutline, @@ -381,6 +423,7 @@ const items: Item[] = [ release: 'v1.36.0', tag: 'v1.36.0_55-dev', date: new Date(2022, 10, 20), + dateType: DateType.RELEASE, }, { icon: mdiSecurity, @@ -389,6 +432,7 @@ const items: Item[] = [ release: 'v1.36.0', tag: 'v1.36.0_55-dev', date: new Date(2022, 10, 20), + dateType: DateType.RELEASE, }, { icon: mdiWeb, @@ -397,6 +441,7 @@ const items: Item[] = [ release: 'v1.33.1', tag: 'v1.33.0_52-dev', date: new Date(2022, 9, 26), + dateType: DateType.RELEASE, }, { icon: mdiThemeLightDark, @@ -405,6 +450,7 @@ const items: Item[] = [ release: 'v1.32.0', tag: ' v1.32.0_50-dev', date: new Date(2022, 9, 14), + dateType: DateType.RELEASE, }, { icon: mdiPanVertical, @@ -413,6 +459,7 @@ const items: Item[] = [ release: 'v1.27.0', tag: 'v1.27.0_37-dev', date: new Date(2022, 8, 6), + dateType: DateType.RELEASE, }, { icon: mdiCheckAll, @@ -421,6 +468,7 @@ const items: Item[] = [ release: 'v1.27.0', tag: 'v1.27.0_37-dev', date: new Date(2022, 8, 6), + dateType: DateType.RELEASE, }, { icon: mdiAndroid, @@ -429,6 +477,7 @@ const items: Item[] = [ release: 'v1.24.0', tag: 'v1.24.0_34-dev', date: new Date(2022, 7, 19), + dateType: DateType.RELEASE, }, { icon: mdiAccountGroup, @@ -437,6 +486,7 @@ const items: Item[] = [ release: 'v1.10.0', tag: 'v1.10.0_15-dev', date: new Date(2022, 4, 29), + dateType: DateType.RELEASE, }, { icon: mdiShareCircle, @@ -445,6 +495,7 @@ const items: Item[] = [ release: 'v1.7.0', tag: 'v1.7.0_11-dev ', date: new Date(2022, 3, 24), + dateType: DateType.RELEASE, }, { icon: mdiTag, @@ -453,6 +504,7 @@ const items: Item[] = [ release: 'v1.7.0', tag: 'v1.7.0_11-dev ', date: new Date(2022, 3, 24), + dateType: DateType.RELEASE, }, { icon: mdiImage, @@ -461,6 +513,7 @@ const items: Item[] = [ release: 'v1.3.0', tag: 'V1.3.0-dev ', date: new Date(2022, 2, 22), + dateType: DateType.RELEASE, }, { icon: mdiCheckboxMarked, @@ -469,6 +522,7 @@ const items: Item[] = [ release: 'v1.2.0', tag: 'V0.2-dev ', date: new Date(2022, 1, 8), + dateType: DateType.RELEASE, }, { icon: mdiVideo, @@ -477,13 +531,15 @@ const items: Item[] = [ release: 'v1.2.0', tag: 'v0.2-dev ', date: new Date(2022, 1, 8), + dateType: DateType.RELEASE, }, { icon: mdiPartyPopper, title: 'First Commit', description: 'First commit on GitHub, Immich is born.', release: 'v1.0.0', - date: new Date(2022, 2, 3), + date: new Date(2022, 1, 3), + dateType: DateType.DATE, }, ]; @@ -491,17 +547,15 @@ export default function MilestonePage(): JSX.Element { return (
-

+

Major Milestones

-

+

A list of project achievements and milestones,
by release date.

-
-
- -
+
+
From 2288b022bcba1b7b7ab1aa39980df718acca16dc Mon Sep 17 00:00:00 2001 From: Michael Manganiello Date: Mon, 23 Oct 2023 09:02:27 -0400 Subject: [PATCH 04/45] fix(server): Check album asset membership in bulk (#4603) Add `AlbumRepository` method to retrieve an album's asset ids, with an optional parameter to only filter by the provided asset ids. With this, we can now check asset membership using a single query. When adding or removing assets to an album, checking whether each asset is already present in the album now requires a single query, instead of one query per asset. Related to #4539 performance improvements. Before: ``` // Asset membership and permissions check (2 queries per asset) immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "albums" "AlbumEntity" LEFT JOIN "albums_assets_assets" "AlbumEntity_AlbumEntity__AlbumEntity_assets" ON "AlbumEntity_AlbumEntity__AlbumEntity_assets"."albumsId"="AlbumEntity"."id" LEFT JOIN "assets" "AlbumEntity__AlbumEntity_assets" ON "AlbumEntity__AlbumEntity_assets"."id"="AlbumEntity_AlbumEntity__AlbumEntity_assets"."assetsId" AND ("AlbumEntity__AlbumEntity_assets"."deletedAt" IS NULL) WHERE ( ("AlbumEntity"."id" = $1 AND "AlbumEntity__AlbumEntity_assets"."id" = $2) ) AND ( "AlbumEntity"."deletedAt" IS NULL )) LIMIT 1 -- PARAMETERS: ["3fdf0e58-a1c7-4efe-8288-06e4c3f38df9","b666ae6c-afa8-4d6f-a1ad-7091a0659320"] immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["b666ae6c-afa8-4d6f-a1ad-7091a0659320","6bc60cf1-bd18-4501-a1c2-120b51276fda"] immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "albums" "AlbumEntity" LEFT JOIN "albums_assets_assets" "AlbumEntity_AlbumEntity__AlbumEntity_assets" ON "AlbumEntity_AlbumEntity__AlbumEntity_assets"."albumsId"="AlbumEntity"."id" LEFT JOIN "assets" "AlbumEntity__AlbumEntity_assets" ON "AlbumEntity__AlbumEntity_assets"."id"="AlbumEntity_AlbumEntity__AlbumEntity_assets"."assetsId" AND ("AlbumEntity__AlbumEntity_assets"."deletedAt" IS NULL) WHERE ( ("AlbumEntity"."id" = $1 AND "AlbumEntity__AlbumEntity_assets"."id" = $2) ) AND ( "AlbumEntity"."deletedAt" IS NULL )) LIMIT 1 -- PARAMETERS: ["3fdf0e58-a1c7-4efe-8288-06e4c3f38df9","c656ab1c-7775-4ff7-b56f-01308c072a76"] immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["c656ab1c-7775-4ff7-b56f-01308c072a76","6bc60cf1-bd18-4501-a1c2-120b51276fda"] immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "albums" "AlbumEntity" LEFT JOIN "albums_assets_assets" "AlbumEntity_AlbumEntity__AlbumEntity_assets" ON "AlbumEntity_AlbumEntity__AlbumEntity_assets"."albumsId"="AlbumEntity"."id" LEFT JOIN "assets" "AlbumEntity__AlbumEntity_assets" ON "AlbumEntity__AlbumEntity_assets"."id"="AlbumEntity_AlbumEntity__AlbumEntity_assets"."assetsId" AND ("AlbumEntity__AlbumEntity_assets"."deletedAt" IS NULL) WHERE ( ("AlbumEntity"."id" = $1 AND "AlbumEntity__AlbumEntity_assets"."id" = $2) ) AND ( "AlbumEntity"."deletedAt" IS NULL )) LIMIT 1 -- PARAMETERS: ["3fdf0e58-a1c7-4efe-8288-06e4c3f38df9","cf82adb2-1fcc-4f9e-9013-8fc03cc8d3a9"] immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["cf82adb2-1fcc-4f9e-9013-8fc03cc8d3a9","6bc60cf1-bd18-4501-a1c2-120b51276fda"] ``` After: ``` // Asset membership check (1 query for all assets) immich_server | query: SELECT "albums_assets"."assetsId" AS "assetId" FROM "albums_assets_assets" "albums_assets" WHERE "albums_assets"."albumsId" = $1 AND "albums_assets"."assetsId" IN ($2, $3, $4) -- PARAMETERS: ["ca870d76-6311-4e89-bf9a-f5b51ea2452c","b666ae6c-afa8-4d6f-a1ad-7091a0659320","c656ab1c-7775-4ff7-b56f-01308c072a76","cf82adb2-1fcc-4f9e-9013-8fc03cc8d3a9"] // Permissions check (1 query per asset) immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["b666ae6c-afa8-4d6f-a1ad-7091a0659320","6bc60cf1-bd18-4501-a1c2-120b51276fda"] immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["c656ab1c-7775-4ff7-b56f-01308c072a76","6bc60cf1-bd18-4501-a1c2-120b51276fda"] immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["cf82adb2-1fcc-4f9e-9013-8fc03cc8d3a9","6bc60cf1-bd18-4501-a1c2-120b51276fda"] ``` --- server/src/domain/album/album.service.spec.ts | 18 +++++++++------ server/src/domain/album/album.service.ts | 8 +++++-- .../domain/repositories/album.repository.ts | 1 + .../infra/repositories/album.repository.ts | 22 +++++++++++++++++++ .../repositories/album.repository.mock.ts | 1 + 5 files changed, 41 insertions(+), 9 deletions(-) diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index 453539129d2b9..25b616ad6d693 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -460,7 +460,7 @@ describe(AlbumService.name, () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.asset.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.hasAsset.mockResolvedValue(false); + albumMock.getAssetIds.mockResolvedValueOnce(new Set()); await expect( sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), @@ -485,6 +485,7 @@ describe(AlbumService.name, () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.asset.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })); + albumMock.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ { success: true, id: 'asset-1' }, @@ -503,6 +504,7 @@ describe(AlbumService.name, () => { accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true); accessMock.asset.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); + albumMock.getAssetIds.mockResolvedValueOnce(new Set()); await expect( sut.addAssets(authStub.user1, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), @@ -529,7 +531,7 @@ describe(AlbumService.name, () => { accessMock.album.hasSharedLinkAccess.mockResolvedValue(true); accessMock.asset.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.hasAsset.mockResolvedValue(false); + albumMock.getAssetIds.mockResolvedValueOnce(new Set()); await expect( sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), @@ -560,7 +562,7 @@ describe(AlbumService.name, () => { accessMock.asset.hasOwnerAccess.mockResolvedValue(false); accessMock.asset.hasPartnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.hasAsset.mockResolvedValue(false); + albumMock.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ { success: true, id: 'asset-1' }, @@ -578,7 +580,7 @@ describe(AlbumService.name, () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); accessMock.asset.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.hasAsset.mockResolvedValue(true); + albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE }, @@ -592,6 +594,7 @@ describe(AlbumService.name, () => { accessMock.asset.hasOwnerAccess.mockResolvedValue(false); accessMock.asset.hasPartnerAccess.mockResolvedValue(false); albumMock.getById.mockResolvedValue(albumStub.oneAsset); + albumMock.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ { success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION }, @@ -630,7 +633,7 @@ describe(AlbumService.name, () => { it('should allow the owner to remove assets', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.hasAsset.mockResolvedValue(true); + albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: true, id: 'asset-id' }, @@ -643,6 +646,7 @@ describe(AlbumService.name, () => { it('should skip assets not in the album', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.empty)); + albumMock.getAssetIds.mockResolvedValueOnce(new Set()); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: false, id: 'asset-id', error: BulkIdErrorReason.NOT_FOUND }, @@ -654,7 +658,7 @@ describe(AlbumService.name, () => { it('should skip assets without user permission to remove', async () => { accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); - albumMock.hasAsset.mockResolvedValue(true); + albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { @@ -670,7 +674,7 @@ describe(AlbumService.name, () => { it('should reset the thumbnail if it is removed', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets)); - albumMock.hasAsset.mockResolvedValue(true); + albumMock.getAssetIds.mockResolvedValueOnce(new Set(['asset-id'])); await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ { success: true, id: 'asset-id' }, diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index e6f1edfa77f01..ecff9f49d730d 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -152,9 +152,11 @@ export class AlbumService { await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); + const existingAssetIds = await this.albumRepository.getAssetIds(id, dto.ids); + const results: BulkIdResponseDto[] = []; for (const assetId of dto.ids) { - const hasAsset = await this.albumRepository.hasAsset({ albumId: id, assetId }); + const hasAsset = existingAssetIds.has(assetId); if (hasAsset) { results.push({ id: assetId, success: false, error: BulkIdErrorReason.DUPLICATE }); continue; @@ -187,9 +189,11 @@ export class AlbumService { await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); + const existingAssetIds = await this.albumRepository.getAssetIds(id, dto.ids); + const results: BulkIdResponseDto[] = []; for (const assetId of dto.ids) { - const hasAsset = await this.albumRepository.hasAsset({ albumId: id, assetId }); + const hasAsset = existingAssetIds.has(assetId); if (!hasAsset) { results.push({ id: assetId, success: false, error: BulkIdErrorReason.NOT_FOUND }); continue; diff --git a/server/src/domain/repositories/album.repository.ts b/server/src/domain/repositories/album.repository.ts index 276ab796bd1d6..d3ca62da12487 100644 --- a/server/src/domain/repositories/album.repository.ts +++ b/server/src/domain/repositories/album.repository.ts @@ -26,6 +26,7 @@ export interface IAlbumRepository { getByIds(ids: string[]): Promise; getByAssetId(ownerId: string, assetId: string): Promise; addAssets(assets: AlbumAssets): Promise; + getAssetIds(albumId: string, assetIds?: string[]): Promise>; hasAsset(asset: AlbumAsset): Promise; removeAsset(assetId: string): Promise; removeAssets(assets: AlbumAssets): Promise; diff --git a/server/src/infra/repositories/album.repository.ts b/server/src/infra/repositories/album.repository.ts index a8cd504146140..69df2268593b7 100644 --- a/server/src/infra/repositories/album.repository.ts +++ b/server/src/infra/repositories/album.repository.ts @@ -183,6 +183,28 @@ export class AlbumRepository implements IAlbumRepository { .execute(); } + /** + * Get asset IDs for the given album ID. + * + * @param albumId Album ID to get asset IDs for. + * @param assetIds Optional list of asset IDs to filter on. + * @returns Set of Asset IDs for the given album ID. + */ + async getAssetIds(albumId: string, assetIds?: string[]): Promise> { + const query = this.dataSource + .createQueryBuilder() + .select('albums_assets.assetsId', 'assetId') + .from('albums_assets_assets', 'albums_assets') + .where('"albums_assets"."albumsId" = :albumId', { albumId }); + + if (assetIds?.length) { + query.andWhere('"albums_assets"."assetsId" IN (:...assetIds)', { assetIds }); + } + + const result = await query.getRawMany(); + return new Set(result.map((row) => row['assetId'])); + } + hasAsset(asset: AlbumAsset): Promise { return this.repository.exist({ where: { diff --git a/server/test/repositories/album.repository.mock.ts b/server/test/repositories/album.repository.mock.ts index 20c3552699946..7cd0a846b3c68 100644 --- a/server/test/repositories/album.repository.mock.ts +++ b/server/test/repositories/album.repository.mock.ts @@ -17,6 +17,7 @@ export const newAlbumRepositoryMock = (): jest.Mocked => { addAssets: jest.fn(), removeAsset: jest.fn(), removeAssets: jest.fn(), + getAssetIds: jest.fn(), hasAsset: jest.fn(), create: jest.fn(), update: jest.fn(), From 6b25435b4f4861a5d1cd8ad7950b3dc4d690b547 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 23 Oct 2023 17:52:21 +0200 Subject: [PATCH 05/45] refactor(server): make storage core singleton (#4608) --- server/src/domain/asset/asset.service.spec.ts | 20 +---- server/src/domain/asset/asset.service.ts | 10 +-- server/src/domain/media/media.service.ts | 6 +- .../src/domain/metadata/metadata.service.ts | 4 +- server/src/domain/person/person.service.ts | 4 +- .../storage-template.service.ts | 6 +- server/src/domain/storage/storage.core.ts | 76 ++++++++++++++----- server/src/domain/user/user.service.spec.ts | 20 +---- server/src/domain/user/user.service.ts | 16 ++-- .../repositories/storage.repository.mock.ts | 8 +- 10 files changed, 80 insertions(+), 90 deletions(-) diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 763256d0db130..663769d9c1a63 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -10,8 +10,6 @@ import { newCommunicationRepositoryMock, newCryptoRepositoryMock, newJobRepositoryMock, - newMoveRepositoryMock, - newPersonRepositoryMock, newStorageRepositoryMock, newSystemConfigRepositoryMock, } from '@test'; @@ -25,8 +23,6 @@ import { ICommunicationRepository, ICryptoRepository, IJobRepository, - IMoveRepository, - IPersonRepository, IStorageRepository, ISystemConfigRepository, JobItem, @@ -165,8 +161,6 @@ describe(AssetService.name, () => { let assetMock: jest.Mocked; let cryptoMock: jest.Mocked; let jobMock: jest.Mocked; - let moveMock: jest.Mocked; - let personMock: jest.Mocked; let storageMock: jest.Mocked; let communicationMock: jest.Mocked; let configMock: jest.Mocked; @@ -181,21 +175,9 @@ describe(AssetService.name, () => { communicationMock = newCommunicationRepositoryMock(); cryptoMock = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); - moveMock = newMoveRepositoryMock(); - personMock = newPersonRepositoryMock(); storageMock = newStorageRepositoryMock(); configMock = newSystemConfigRepositoryMock(); - sut = new AssetService( - accessMock, - assetMock, - cryptoMock, - jobMock, - configMock, - moveMock, - personMock, - storageMock, - communicationMock, - ); + sut = new AssetService(accessMock, assetMock, cryptoMock, jobMock, configMock, storageMock, communicationMock); when(assetMock.getById) .calledWith(assetStub.livePhotoStillAsset.id) diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 6823c00305127..8979df1299c36 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -16,8 +16,6 @@ import { ICommunicationRepository, ICryptoRepository, IJobRepository, - IMoveRepository, - IPersonRepository, IStorageRepository, ISystemConfigRepository, ImmichReadStream, @@ -76,7 +74,6 @@ export class AssetService { private logger = new Logger(AssetService.name); private access: AccessCore; private configCore: SystemConfigCore; - private storageCore: StorageCore; constructor( @Inject(IAccessRepository) accessRepository: IAccessRepository, @@ -84,14 +81,11 @@ export class AssetService { @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, - @Inject(IMoveRepository) moveRepository: IMoveRepository, - @Inject(IPersonRepository) personRepository: IPersonRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, ) { this.access = AccessCore.create(accessRepository); this.configCore = SystemConfigCore.create(configRepository); - this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository); } canUploadFile({ authUser, fieldName, file }: UploadRequest): true { @@ -147,9 +141,9 @@ export class AssetService { getUploadFolder({ authUser, fieldName }: UploadRequest): string { authUser = this.access.requireUploadAccess(authUser); - let folder = this.storageCore.getFolderLocation(StorageFolder.UPLOAD, authUser.id); + let folder = StorageCore.getFolderLocation(StorageFolder.UPLOAD, authUser.id); if (fieldName === UploadFieldName.PROFILE_DATA) { - folder = this.storageCore.getFolderLocation(StorageFolder.PROFILE, authUser.id); + folder = StorageCore.getFolderLocation(StorageFolder.PROFILE, authUser.id); } this.storageRepository.mkdirSync(folder); diff --git a/server/src/domain/media/media.service.ts b/server/src/domain/media/media.service.ts index 09ac53a7f5261..1c53752e8c449 100644 --- a/server/src/domain/media/media.service.ts +++ b/server/src/domain/media/media.service.ts @@ -44,7 +44,7 @@ export class MediaService { @Inject(IMoveRepository) moveRepository: IMoveRepository, ) { this.configCore = SystemConfigCore.create(configRepository); - this.storageCore = new StorageCore(this.storageRepository, assetRepository, moveRepository, personRepository); + this.storageCore = StorageCore.create(assetRepository, moveRepository, personRepository, storageRepository); } async handleQueueGenerateThumbnails({ force }: IBaseJob) { @@ -140,7 +140,7 @@ export class MediaService { const { thumbnail, ffmpeg } = await this.configCore.getConfig(); const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize; const path = - format === 'jpeg' ? this.storageCore.getLargeThumbnailPath(asset) : this.storageCore.getSmallThumbnailPath(asset); + format === 'jpeg' ? StorageCore.getLargeThumbnailPath(asset) : StorageCore.getSmallThumbnailPath(asset); this.storageCore.ensureFolders(path); switch (asset.type) { @@ -220,7 +220,7 @@ export class MediaService { } const input = asset.originalPath; - const output = this.storageCore.getEncodedVideoPath(asset); + const output = StorageCore.getEncodedVideoPath(asset); this.storageCore.ensureFolders(output); const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input); diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts index 0b6855ddf65c7..8829f6c6f4617 100644 --- a/server/src/domain/metadata/metadata.service.ts +++ b/server/src/domain/metadata/metadata.service.ts @@ -80,7 +80,7 @@ export class MetadataService { @Inject(IPersonRepository) personRepository: IPersonRepository, ) { this.configCore = SystemConfigCore.create(configRepository); - this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository); + this.storageCore = StorageCore.create(assetRepository, moveRepository, personRepository, storageRepository); this.configCore.config$.subscribe(() => this.init()); } @@ -294,7 +294,7 @@ export class MetadataService { }); const checksum = this.cryptoRepository.hashSha1(video); - const motionPath = this.storageCore.getAndroidMotionPath(asset); + const motionPath = StorageCore.getAndroidMotionPath(asset); this.storageCore.ensureFolders(motionPath); let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index eb0bdc5c6c961..89f8515105cbf 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -58,7 +58,7 @@ export class PersonService { ) { this.access = AccessCore.create(accessRepository); this.configCore = SystemConfigCore.create(configRepository); - this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, repository); + this.storageCore = StorageCore.create(assetRepository, moveRepository, repository, storageRepository); } async getAll(authUser: AuthUserDto, dto: PersonSearchDto): Promise { @@ -309,7 +309,7 @@ export class PersonService { } this.logger.verbose(`Cropping face for person: ${personId}`); - const thumbnailPath = this.storageCore.getPersonThumbnailPath(person); + const thumbnailPath = StorageCore.getPersonThumbnailPath(person); this.storageCore.ensureFolders(thumbnailPath); const halfWidth = (x2 - x1) / 2; diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts index 9aa3a0e5e16d6..bf0c5d8f78e03 100644 --- a/server/src/domain/storage-template/storage-template.service.ts +++ b/server/src/domain/storage-template/storage-template.service.ts @@ -52,7 +52,7 @@ export class StorageTemplateService { this.configCore = SystemConfigCore.create(configRepository); this.configCore.addValidator((config) => this.validate(config)); this.configCore.config$.subscribe((config) => this.onConfig(config)); - this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository); + this.storageCore = StorageCore.create(assetRepository, moveRepository, personRepository, storageRepository); } async handleMigrationSingle({ id }: IEntityJob) { @@ -99,7 +99,7 @@ export class StorageTemplateService { } async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) { - if (asset.isReadOnly || asset.isExternal || this.storageCore.isAndroidMotionPath(asset.originalPath)) { + if (asset.isReadOnly || asset.isExternal || StorageCore.isAndroidMotionPath(asset.originalPath)) { // External assets are not affected by storage template // TODO: shouldn't this only apply to external assets? return; @@ -131,7 +131,7 @@ export class StorageTemplateService { const source = asset.originalPath; const ext = path.extname(source).split('.').pop() as string; const sanitized = sanitize(path.basename(filename, `.${ext}`)); - const rootPath = this.storageCore.getLibraryFolder({ id: asset.ownerId, storageLabel }); + const rootPath = StorageCore.getLibraryFolder({ id: asset.ownerId, storageLabel }); const storagePath = this.render(this.storageTemplate, asset, sanitized, ext); const fullPath = path.normalize(path.join(rootPath, storagePath)); let destination = `${fullPath}.${ext}`; diff --git a/server/src/domain/storage/storage.core.ts b/server/src/domain/storage/storage.core.ts index 69e2bd7995d87..c78e3b0424461 100644 --- a/server/src/domain/storage/storage.core.ts +++ b/server/src/domain/storage/storage.core.ts @@ -21,21 +21,40 @@ export interface MoveRequest { type GeneratedAssetPath = AssetPathType.JPEG_THUMBNAIL | AssetPathType.WEBP_THUMBNAIL | AssetPathType.ENCODED_VIDEO; +let instance: StorageCore | null; + export class StorageCore { private logger = new Logger(StorageCore.name); - constructor( - private repository: IStorageRepository, + private constructor( private assetRepository: IAssetRepository, private moveRepository: IMoveRepository, private personRepository: IPersonRepository, + private repository: IStorageRepository, ) {} - getFolderLocation(folder: StorageFolder, userId: string) { + static create( + assetRepository: IAssetRepository, + moveRepository: IMoveRepository, + personRepository: IPersonRepository, + repository: IStorageRepository, + ) { + if (!instance) { + instance = new StorageCore(assetRepository, moveRepository, personRepository, repository); + } + + return instance; + } + + static reset() { + instance = null; + } + + static getFolderLocation(folder: StorageFolder, userId: string) { return join(StorageCore.getBaseFolder(folder), userId); } - getLibraryFolder(user: { storageLabel: string | null; id: string }) { + static getLibraryFolder(user: { storageLabel: string | null; id: string }) { return join(StorageCore.getBaseFolder(StorageFolder.LIBRARY), user.storageLabel || user.id); } @@ -43,27 +62,27 @@ export class StorageCore { return join(APP_MEDIA_LOCATION, folder); } - getPersonThumbnailPath(person: PersonEntity) { - return this.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`); + static getPersonThumbnailPath(person: PersonEntity) { + return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`); } - getLargeThumbnailPath(asset: AssetEntity) { - return this.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.jpeg`); + static getLargeThumbnailPath(asset: AssetEntity) { + return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.jpeg`); } - getSmallThumbnailPath(asset: AssetEntity) { - return this.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.webp`); + static getSmallThumbnailPath(asset: AssetEntity) { + return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.webp`); } - getEncodedVideoPath(asset: AssetEntity) { - return this.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.mp4`); + static getEncodedVideoPath(asset: AssetEntity) { + return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.mp4`); } - getAndroidMotionPath(asset: AssetEntity) { - return this.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}-MP.mp4`); + static getAndroidMotionPath(asset: AssetEntity) { + return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}-MP.mp4`); } - isAndroidMotionPath(originalPath: string) { + static isAndroidMotionPath(originalPath: string) { return originalPath.startsWith(StorageCore.getBaseFolder(StorageFolder.ENCODED_VIDEO)); } @@ -75,15 +94,25 @@ export class StorageCore { const { id: entityId, resizePath, webpPath, encodedVideoPath } = asset; switch (pathType) { case AssetPathType.JPEG_THUMBNAIL: - return this.moveFile({ entityId, pathType, oldPath: resizePath, newPath: this.getLargeThumbnailPath(asset) }); + return this.moveFile({ + entityId, + pathType, + oldPath: resizePath, + newPath: StorageCore.getLargeThumbnailPath(asset), + }); case AssetPathType.WEBP_THUMBNAIL: - return this.moveFile({ entityId, pathType, oldPath: webpPath, newPath: this.getSmallThumbnailPath(asset) }); + return this.moveFile({ + entityId, + pathType, + oldPath: webpPath, + newPath: StorageCore.getSmallThumbnailPath(asset), + }); case AssetPathType.ENCODED_VIDEO: return this.moveFile({ entityId, pathType, oldPath: encodedVideoPath, - newPath: this.getEncodedVideoPath(asset), + newPath: StorageCore.getEncodedVideoPath(asset), }); } } @@ -96,7 +125,7 @@ export class StorageCore { entityId, pathType, oldPath: thumbnailPath, - newPath: this.getPersonThumbnailPath(person), + newPath: StorageCore.getPersonThumbnailPath(person), }); } } @@ -159,7 +188,12 @@ export class StorageCore { } } - private getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string { - return join(this.getFolderLocation(folder, ownerId), filename.substring(0, 2), filename.substring(2, 4), filename); + private static getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string { + return join( + StorageCore.getFolderLocation(folder, ownerId), + filename.substring(0, 2), + filename.substring(2, 4), + filename, + ); } } diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts index f54ee603d9823..308a2856caea9 100644 --- a/server/src/domain/user/user.service.spec.ts +++ b/server/src/domain/user/user.service.spec.ts @@ -11,8 +11,6 @@ import { newCryptoRepositoryMock, newJobRepositoryMock, newLibraryRepositoryMock, - newMoveRepositoryMock, - newPersonRepositoryMock, newStorageRepositoryMock, newUserRepositoryMock, userStub, @@ -26,8 +24,6 @@ import { ICryptoRepository, IJobRepository, ILibraryRepository, - IMoveRepository, - IPersonRepository, IStorageRepository, IUserRepository, } from '../repositories'; @@ -139,8 +135,6 @@ describe(UserService.name, () => { let assetMock: jest.Mocked; let jobMock: jest.Mocked; let libraryMock: jest.Mocked; - let moveMock: jest.Mocked; - let personMock: jest.Mocked; let storageMock: jest.Mocked; beforeEach(async () => { @@ -149,22 +143,10 @@ describe(UserService.name, () => { cryptoRepositoryMock = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); libraryMock = newLibraryRepositoryMock(); - moveMock = newMoveRepositoryMock(); - personMock = newPersonRepositoryMock(); storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); - sut = new UserService( - albumMock, - assetMock, - cryptoRepositoryMock, - jobMock, - libraryMock, - moveMock, - personMock, - storageMock, - userMock, - ); + sut = new UserService(albumMock, assetMock, cryptoRepositoryMock, jobMock, libraryMock, storageMock, userMock); when(userMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser); when(userMock.get).calledWith(adminUser.id, undefined).mockResolvedValue(adminUser); diff --git a/server/src/domain/user/user.service.ts b/server/src/domain/user/user.service.ts index 796a72aa765c7..8e4e6cac95ce2 100644 --- a/server/src/domain/user/user.service.ts +++ b/server/src/domain/user/user.service.ts @@ -10,8 +10,6 @@ import { ICryptoRepository, IJobRepository, ILibraryRepository, - IMoveRepository, - IPersonRepository, IStorageRepository, IUserRepository, } from '../repositories'; @@ -30,7 +28,6 @@ import { UserCore } from './user.core'; @Injectable() export class UserService { private logger = new Logger(UserService.name); - private storageCore: StorageCore; private userCore: UserCore; constructor( @@ -39,12 +36,9 @@ export class UserService { @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ILibraryRepository) libraryRepository: ILibraryRepository, - @Inject(IMoveRepository) moveRepository: IMoveRepository, - @Inject(IPersonRepository) personRepository: IPersonRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IUserRepository) private userRepository: IUserRepository, ) { - this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository); this.userCore = UserCore.create(cryptoRepository, libraryRepository, userRepository); } @@ -171,11 +165,11 @@ export class UserService { this.logger.log(`Deleting user: ${user.id}`); const folders = [ - this.storageCore.getLibraryFolder(user), - this.storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id), - this.storageCore.getFolderLocation(StorageFolder.PROFILE, user.id), - this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id), - this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, user.id), + StorageCore.getLibraryFolder(user), + StorageCore.getFolderLocation(StorageFolder.UPLOAD, user.id), + StorageCore.getFolderLocation(StorageFolder.PROFILE, user.id), + StorageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id), + StorageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, user.id), ]; for (const folder of folders) { diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index f0c49f6922594..2a485442d6330 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -1,6 +1,10 @@ -import { IStorageRepository } from '@app/domain'; +import { IStorageRepository, StorageCore } from '@app/domain'; + +export const newStorageRepositoryMock = (reset = true): jest.Mocked => { + if (reset) { + StorageCore.reset(); + } -export const newStorageRepositoryMock = (): jest.Mocked => { return { createZipStream: jest.fn(), createReadStream: jest.fn(), From 755649a3c8469dbb02bc55a68ee117b1768c7f5f Mon Sep 17 00:00:00 2001 From: Bogdan Cerovac Date: Mon, 23 Oct 2023 18:24:58 +0200 Subject: [PATCH 06/45] fix(web): trash link wrongly wrapped (#4586) * Added missing alt text on logo in documentation * Better to use CSS to do the uppercases Some assistive technology can spell each letter instead of read the word, depends a lot on internals but it's better to prevent... * Semantic fix - single paragraph instead of many + uppercase via CSS Single sentence in single paragraph, using css instead * React requires className instead of class... * Unnest Trash link from Archive --- .../side-bar/side-bar.svelte | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index 740513a704a6c..873de19f2f1ac 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -141,25 +141,25 @@ {/await} - - {#if $featureFlags.trash} - - - - {#await getStats({ isTrashed: true })} - - {:then data} -
-

{data.videos.toLocaleString($locale)} Videos

-

{data.images.toLocaleString($locale)} Photos

-
- {/await} -
-
-
- {/if} + {#if $featureFlags.trash} + + + + {#await getStats({ isTrashed: true })} + + {:then data} +
+

{data.videos.toLocaleString($locale)} Videos

+

{data.images.toLocaleString($locale)} Photos

+
+ {/await} +
+
+
+ {/if} +
From 093347c7ab4cdcf61386ad582a58bbac5492a53e Mon Sep 17 00:00:00 2001 From: Sergey Kondrikov Date: Mon, 23 Oct 2023 19:35:17 +0300 Subject: [PATCH 07/45] fix(web): timeline scrolling up (#4612) --- web/src/lib/components/photos-page/asset-date-group.svelte | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index 48dd58f762f05..9207cd9248264 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -13,7 +13,6 @@ import type { AssetStore } from '$lib/stores/assets.store'; import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store'; import type { Viewport } from '$lib/stores/assets.store'; - import { flip } from 'svelte/animate'; export let assets: AssetResponseDto[]; export let bucketDate: string; @@ -177,7 +176,6 @@
Date: Mon, 23 Oct 2023 20:00:31 +0200 Subject: [PATCH 08/45] feat(server): "{album}" in storage template (#2973) * feat(server): add to storage template * feat: add album preset --------- Co-authored-by: Jason Rasmussen --- .../storage-template.service.spec.ts | 15 +++- .../storage-template.service.ts | 77 +++++++++++++------ .../system-config/system-config.constants.ts | 1 + .../system-config.service.spec.ts | 1 + .../storage-template-settings.svelte | 28 +++++-- .../supported-variables-panel.svelte | 18 ++--- 6 files changed, 98 insertions(+), 42 deletions(-) diff --git a/server/src/domain/storage-template/storage-template.service.spec.ts b/server/src/domain/storage-template/storage-template.service.spec.ts index 8f6833699d68c..825a5cb25effe 100644 --- a/server/src/domain/storage-template/storage-template.service.spec.ts +++ b/server/src/domain/storage-template/storage-template.service.spec.ts @@ -1,6 +1,7 @@ import { AssetPathType } from '@app/infra/entities'; import { assetStub, + newAlbumRepositoryMock, newAssetRepositoryMock, newMoveRepositoryMock, newPersonRepositoryMock, @@ -11,6 +12,7 @@ import { } from '@test'; import { when } from 'jest-when'; import { + IAlbumRepository, IAssetRepository, IMoveRepository, IPersonRepository, @@ -23,6 +25,7 @@ import { StorageTemplateService } from './storage-template.service'; describe(StorageTemplateService.name, () => { let sut: StorageTemplateService; + let albumMock: jest.Mocked; let assetMock: jest.Mocked; let configMock: jest.Mocked; let moveMock: jest.Mocked; @@ -36,13 +39,23 @@ describe(StorageTemplateService.name, () => { beforeEach(async () => { assetMock = newAssetRepositoryMock(); + albumMock = newAlbumRepositoryMock(); configMock = newSystemConfigRepositoryMock(); moveMock = newMoveRepositoryMock(); personMock = newPersonRepositoryMock(); storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); - sut = new StorageTemplateService(assetMock, configMock, defaults, moveMock, personMock, storageMock, userMock); + sut = new StorageTemplateService( + albumMock, + assetMock, + configMock, + defaults, + moveMock, + personMock, + storageMock, + userMock, + ); }); describe('handleMigrationSingle', () => { diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts index bf0c5d8f78e03..b14d21bcf946d 100644 --- a/server/src/domain/storage-template/storage-template.service.ts +++ b/server/src/domain/storage-template/storage-template.service.ts @@ -7,6 +7,7 @@ import sanitize from 'sanitize-filename'; import { getLivePhotoMotionFilename, usePagination } from '../domain.util'; import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE } from '../job'; import { + IAlbumRepository, IAssetRepository, IMoveRepository, IPersonRepository, @@ -32,14 +33,26 @@ export interface MoveAssetMetadata { filename: string; } +interface RenderMetadata { + asset: AssetEntity; + filename: string; + extension: string; + albumName: string | null; +} + @Injectable() export class StorageTemplateService { private logger = new Logger(StorageTemplateService.name); private configCore: SystemConfigCore; private storageCore: StorageCore; - private storageTemplate: HandlebarsTemplateDelegate; + private template: { + compiled: HandlebarsTemplateDelegate; + raw: string; + needsAlbum: boolean; + }; constructor( + @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig, @@ -48,10 +61,14 @@ export class StorageTemplateService { @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IUserRepository) private userRepository: IUserRepository, ) { - this.storageTemplate = this.compile(config.storageTemplate.template); + this.template = this.compile(config.storageTemplate.template); this.configCore = SystemConfigCore.create(configRepository); this.configCore.addValidator((config) => this.validate(config)); - this.configCore.config$.subscribe((config) => this.onConfig(config)); + this.configCore.config$.subscribe((config) => { + const template = config.storageTemplate.template; + this.logger.debug(`Received config, compiling storage template: ${template}`); + this.template = this.compile(template); + }); this.storageCore = StorageCore.create(assetRepository, moveRepository, personRepository, storageRepository); } @@ -132,7 +149,19 @@ export class StorageTemplateService { const ext = path.extname(source).split('.').pop() as string; const sanitized = sanitize(path.basename(filename, `.${ext}`)); const rootPath = StorageCore.getLibraryFolder({ id: asset.ownerId, storageLabel }); - const storagePath = this.render(this.storageTemplate, asset, sanitized, ext); + + let albumName = null; + if (this.template.needsAlbum) { + const albums = await this.albumRepository.getByAssetId(asset.ownerId, asset.id); + albumName = albums?.[0]?.albumName || null; + } + + const storagePath = this.render(this.template.compiled, { + asset, + filename: sanitized, + extension: ext, + albumName, + }); const fullPath = path.normalize(path.join(rootPath, storagePath)); let destination = `${fullPath}.${ext}`; @@ -187,39 +216,43 @@ export class StorageTemplateService { } private validate(config: SystemConfig) { - const testAsset = { - fileCreatedAt: new Date(), - originalPath: '/upload/test/IMG_123.jpg', - type: AssetType.IMAGE, - id: 'd587e44b-f8c0-4832-9ba3-43268bbf5d4e', - } as AssetEntity; try { - this.render(this.compile(config.storageTemplate.template), testAsset, 'IMG_123', 'jpg'); + const { compiled } = this.compile(config.storageTemplate.template); + this.render(compiled, { + asset: { + fileCreatedAt: new Date(), + originalPath: '/upload/test/IMG_123.jpg', + type: AssetType.IMAGE, + id: 'd587e44b-f8c0-4832-9ba3-43268bbf5d4e', + } as AssetEntity, + filename: 'IMG_123', + extension: 'jpg', + albumName: 'album', + }); } catch (e) { this.logger.warn(`Storage template validation failed: ${JSON.stringify(e)}`); throw new Error(`Invalid storage template: ${e}`); } } - private onConfig(config: SystemConfig) { - this.logger.debug(`Received new config, recompiling storage template: ${config.storageTemplate.template}`); - this.storageTemplate = this.compile(config.storageTemplate.template); - } - private compile(template: string) { - return handlebar.compile(template, { - knownHelpers: undefined, - strict: true, - }); + return { + raw: template, + compiled: handlebar.compile(template, { knownHelpers: undefined, strict: true }), + needsAlbum: template.indexOf('{{album}}') !== -1, + }; } - private render(template: HandlebarsTemplateDelegate, asset: AssetEntity, filename: string, ext: string) { + private render(template: HandlebarsTemplateDelegate, options: RenderMetadata) { + const { filename, extension, asset, albumName } = options; const substitutions: Record = { filename, - ext, + ext: extension, filetype: asset.type == AssetType.IMAGE ? 'IMG' : 'VID', filetypefull: asset.type == AssetType.IMAGE ? 'IMAGE' : 'VIDEO', assetId: asset.id, + //just throw into the root if it doesn't belong to an album + album: (albumName && sanitize(albumName.replace(/\.+/g, ''))) || '.', }; const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; diff --git a/server/src/domain/system-config/system-config.constants.ts b/server/src/domain/system-config/system-config.constants.ts index a1bb03a4c895c..65808f6df28e8 100644 --- a/server/src/domain/system-config/system-config.constants.ts +++ b/server/src/domain/system-config/system-config.constants.ts @@ -23,6 +23,7 @@ export const supportedPresetTokens = [ '{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}', '{{y}}/{{y}}-{{MM}}/{{assetId}}', '{{y}}/{{y}}-{{WW}}/{{assetId}}', + '{{album}}/{{filename}}', ]; export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG'; diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index b094b328ede36..0444217c097ea 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -242,6 +242,7 @@ describe(SystemConfigService.name, () => { '{{y}}/{{y}}-{{MM}}-{{dd}}/{{assetId}}', '{{y}}/{{y}}-{{MM}}/{{assetId}}', '{{y}}/{{y}}-{{WW}}/{{assetId}}', + '{{album}}/{{filename}}', ], secondOptions: ['s', 'ss'], weekOptions: ['W', 'WW'], diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte index e744b182d701a..fc95e6d7b2df5 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte @@ -57,6 +57,7 @@ filetype: 'IMG', filetypefull: 'IMAGE', assetId: 'a8312960-e277-447d-b4ea-56717ccba856', + album: 'Album Name', }; const dt = luxon.DateTime.fromISO(new Date('2022-02-03T04:56:05.250').toISOString()); @@ -208,13 +209,26 @@
-
-

- Template changes will only apply to new assets. To retroactively apply the template to previously uploaded - assets, run the Storage Migration Job -

+
+

Notes

+
+

+ Template changes will only apply to new assets. To retroactively apply the template to previously + uploaded assets, run the + Storage Migration Job. +

+

+ The template variable {`{{album}}`} will always be empty for new assets, + so manually running the + + Storage Migration Job + is required in order to successfully use the variable. +

+
-

FILE NAME

+

FILENAME

    -
  • {`{{filename}}`}
  • +
  • {`{{filename}}`} - IMG_123
  • +
  • {`{{ext}}`} - jpg
-

FILE EXTENSION

-
    -
  • {`{{ext}}`}
  • -
-
- -
-

FILE TYPE

+

FILETYPE

  • {`{{filetype}}`} - VID or IMG
  • {`{{filetypefull}}`} - VIDEO or IMAGE
-
-

FILE TYPE

+

OTHER

  • {`{{assetId}}`} - Asset ID
  • +
  • {`{{album}}`} - Album Name
From 28d35bf04e1225404aa67d480fa42c7dbd728f13 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 23 Oct 2023 18:28:12 +0000 Subject: [PATCH 09/45] fix(mobile): unique hero tag for assets from api response (#4600) * fix(mobile): render error on switching asset while video playing * fix(mobile): generate proper hero tags for assets from DTOs --------- Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/main.dart | 3 --- .../asset_viewer/views/gallery_viewer.dart | 16 +++++++++++++--- .../home/ui/asset_grid/thumbnail_image.dart | 7 ++++++- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 6b5a9b9ec17c2..3ad2f4b629356 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -131,17 +131,14 @@ class ImmichAppState extends ConsumerState debugPrint("[APP STATE] resumed"); ref.read(appStateProvider.notifier).handleAppResume(); break; - case AppLifecycleState.inactive: debugPrint("[APP STATE] inactive"); ref.read(appStateProvider.notifier).handleAppInactivity(); break; - case AppLifecycleState.paused: debugPrint("[APP STATE] paused"); ref.read(appStateProvider.notifier).handleAppPause(); break; - case AppLifecycleState.detached: debugPrint("[APP STATE] detached"); ref.read(appStateProvider.notifier).handleAppDetached(); diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index cdc07a2a7947e..b546aa76dae54 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -38,6 +38,7 @@ import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:isar/isar.dart'; import 'package:openapi/api.dart' show ThumbnailFormat; // ignore: must_be_immutable @@ -86,6 +87,8 @@ class GalleryViewerPage extends HookConsumerWidget { ? ref.watch(assetStackStateProvider(currentAsset)) : []; final stackElements = showStack ? [currentAsset, ...stack] : []; + // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id + final isFromResponse = currentAsset.id == Isar.autoIncrement; Asset asset() => stackIndex.value == -1 ? currentAsset @@ -752,7 +755,9 @@ class GalleryViewerPage extends HookConsumerWidget { }, imageProvider: provider, heroAttributes: PhotoViewHeroAttributes( - tag: a.id + heroOffset, + tag: isFromResponse + ? '${a.remoteId}-$heroOffset' + : a.id + heroOffset, ), filterQuality: FilterQuality.high, tightMode: true, @@ -769,7 +774,9 @@ class GalleryViewerPage extends HookConsumerWidget { onDragUpdate: (_, details, __) => handleSwipeUpDown(details), heroAttributes: PhotoViewHeroAttributes( - tag: a.id + heroOffset, + tag: isFromResponse + ? '${a.remoteId}-$heroOffset' + : a.id + heroOffset, ), filterQuality: FilterQuality.high, maxScale: 1.0, @@ -777,7 +784,10 @@ class GalleryViewerPage extends HookConsumerWidget { basePosition: Alignment.center, child: VideoViewerPage( onPlaying: () => isPlayingVideo.value = true, - onPaused: () => isPlayingVideo.value = false, + onPaused: () => + WidgetsBinding.instance.addPostFrameCallback( + (_) => isPlayingVideo.value = false, + ), asset: a, isMotionVideo: isPlayingMotionVideo.value, placeholder: Image( diff --git a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart index 5b925c86b330f..c9831888732cd 100644 --- a/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart +++ b/mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/utils/storage_indicator.dart'; +import 'package:isar/isar.dart'; class ThumbnailImage extends StatelessWidget { final Asset asset; @@ -41,6 +42,8 @@ class ThumbnailImage extends StatelessWidget { final isDarkTheme = Theme.of(context).brightness == Brightness.dark; final assetContainerColor = isDarkTheme ? Colors.blueGrey : Theme.of(context).primaryColorLight; + // Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id + final isFromResponse = asset.id == Isar.autoIncrement; Widget buildSelectionIcon(Asset asset) { if (isSelected) { @@ -129,7 +132,9 @@ class ThumbnailImage extends StatelessWidget { width: 300, height: 300, child: Hero( - tag: asset.id + heroOffset, + tag: isFromResponse + ? '${asset.remoteId}-$heroOffset' + : asset.id + heroOffset, child: ImmichImage( asset, useGrayBoxPlaceholder: useGrayBoxPlaceholder, From 62a11283afb188db8e0db1bc2fb43cc395c0a547 Mon Sep 17 00:00:00 2001 From: Wingy Date: Mon, 23 Oct 2023 11:38:41 -0700 Subject: [PATCH 10/45] feat(web): custom stylesheets (#4602) * add initial ui and api definitions for stylesheets * proper saving * make custom css work * add textarea * rebuild api * run prettier * add typecast * update typings * move css accordion to be sorted alphabetically * set content-type properly * rename stylesheets to theme * fix server test --- cli/src/api/open-api/api.ts | 19 ++++ mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | 1 + mobile/openapi/doc/SystemConfigDto.md | 1 + mobile/openapi/doc/SystemConfigThemeDto.md | 15 +++ mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api_client.dart | 2 + .../openapi/lib/model/system_config_dto.dart | 10 +- .../lib/model/system_config_theme_dto.dart | 98 +++++++++++++++++++ .../openapi/test/system_config_dto_test.dart | 5 + .../test/system_config_theme_dto_test.dart | 27 +++++ server/immich-openapi-specs.json | 17 +++- .../dto/system-config-theme.dto.ts | 6 ++ .../system-config/dto/system-config.dto.ts | 6 ++ .../system-config/system-config.core.ts | 3 + .../system-config.service.spec.ts | 3 + .../infra/entities/system-config.entity.ts | 5 + web/src/api/open-api/api.ts | 19 ++++ .../settings/setting-textarea.svelte | 53 ++++++++++ .../settings/theme/theme-settings.svelte | 98 +++++++++++++++++++ web/src/routes/+layout.svelte | 1 + .../routes/admin/system-settings/+page.svelte | 5 + web/src/routes/custom.css/+server.ts | 9 ++ 23 files changed, 405 insertions(+), 2 deletions(-) create mode 100644 mobile/openapi/doc/SystemConfigThemeDto.md create mode 100644 mobile/openapi/lib/model/system_config_theme_dto.dart create mode 100644 mobile/openapi/test/system_config_theme_dto_test.dart create mode 100644 server/src/domain/system-config/dto/system-config-theme.dto.ts create mode 100644 web/src/lib/components/admin-page/settings/setting-textarea.svelte create mode 100644 web/src/lib/components/admin-page/settings/theme/theme-settings.svelte create mode 100644 web/src/routes/custom.css/+server.ts diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 8d278b44cebf6..2133d0411346f 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -3307,6 +3307,12 @@ export interface SystemConfigDto { * @memberof SystemConfigDto */ 'storageTemplate': SystemConfigStorageTemplateDto; + /** + * + * @type {SystemConfigThemeDto} + * @memberof SystemConfigDto + */ + 'theme': SystemConfigThemeDto; /** * * @type {SystemConfigThumbnailDto} @@ -3741,6 +3747,19 @@ export interface SystemConfigTemplateStorageOptionDto { */ 'yearOptions': Array; } +/** + * + * @export + * @interface SystemConfigThemeDto + */ +export interface SystemConfigThemeDto { + /** + * + * @type {string} + * @memberof SystemConfigThemeDto + */ + 'customCss': string; +} /** * * @export diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 85b96e64735f0..747c435dd33a9 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -135,6 +135,7 @@ doc/SystemConfigPasswordLoginDto.md doc/SystemConfigReverseGeocodingDto.md doc/SystemConfigStorageTemplateDto.md doc/SystemConfigTemplateStorageOptionDto.md +doc/SystemConfigThemeDto.md doc/SystemConfigThumbnailDto.md doc/SystemConfigTrashDto.md doc/TagApi.md @@ -302,6 +303,7 @@ lib/model/system_config_password_login_dto.dart lib/model/system_config_reverse_geocoding_dto.dart lib/model/system_config_storage_template_dto.dart lib/model/system_config_template_storage_option_dto.dart +lib/model/system_config_theme_dto.dart lib/model/system_config_thumbnail_dto.dart lib/model/system_config_trash_dto.dart lib/model/tag_response_dto.dart @@ -456,6 +458,7 @@ test/system_config_password_login_dto_test.dart test/system_config_reverse_geocoding_dto_test.dart test/system_config_storage_template_dto_test.dart test/system_config_template_storage_option_dto_test.dart +test/system_config_theme_dto_test.dart test/system_config_thumbnail_dto_test.dart test/system_config_trash_dto_test.dart test/tag_api_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 47d04b9bd2d6f..07d2cf402fe02 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -318,6 +318,7 @@ Class | Method | HTTP request | Description - [SystemConfigReverseGeocodingDto](doc//SystemConfigReverseGeocodingDto.md) - [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md) - [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md) + - [SystemConfigThemeDto](doc//SystemConfigThemeDto.md) - [SystemConfigThumbnailDto](doc//SystemConfigThumbnailDto.md) - [SystemConfigTrashDto](doc//SystemConfigTrashDto.md) - [TagResponseDto](doc//TagResponseDto.md) diff --git a/mobile/openapi/doc/SystemConfigDto.md b/mobile/openapi/doc/SystemConfigDto.md index a5b8db773c5b6..d426bef34564b 100644 --- a/mobile/openapi/doc/SystemConfigDto.md +++ b/mobile/openapi/doc/SystemConfigDto.md @@ -16,6 +16,7 @@ Name | Type | Description | Notes **passwordLogin** | [**SystemConfigPasswordLoginDto**](SystemConfigPasswordLoginDto.md) | | **reverseGeocoding** | [**SystemConfigReverseGeocodingDto**](SystemConfigReverseGeocodingDto.md) | | **storageTemplate** | [**SystemConfigStorageTemplateDto**](SystemConfigStorageTemplateDto.md) | | +**theme** | [**SystemConfigThemeDto**](SystemConfigThemeDto.md) | | **thumbnail** | [**SystemConfigThumbnailDto**](SystemConfigThumbnailDto.md) | | **trash** | [**SystemConfigTrashDto**](SystemConfigTrashDto.md) | | diff --git a/mobile/openapi/doc/SystemConfigThemeDto.md b/mobile/openapi/doc/SystemConfigThemeDto.md new file mode 100644 index 0000000000000..bcdbb690cb991 --- /dev/null +++ b/mobile/openapi/doc/SystemConfigThemeDto.md @@ -0,0 +1,15 @@ +# openapi.model.SystemConfigThemeDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**customCss** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e72c1da168fb5..091a38e36d25d 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -163,6 +163,7 @@ part 'model/system_config_password_login_dto.dart'; part 'model/system_config_reverse_geocoding_dto.dart'; part 'model/system_config_storage_template_dto.dart'; part 'model/system_config_template_storage_option_dto.dart'; +part 'model/system_config_theme_dto.dart'; part 'model/system_config_thumbnail_dto.dart'; part 'model/system_config_trash_dto.dart'; part 'model/tag_response_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 34b9a431d44e1..33586a7e13d57 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -417,6 +417,8 @@ class ApiClient { return SystemConfigStorageTemplateDto.fromJson(value); case 'SystemConfigTemplateStorageOptionDto': return SystemConfigTemplateStorageOptionDto.fromJson(value); + case 'SystemConfigThemeDto': + return SystemConfigThemeDto.fromJson(value); case 'SystemConfigThumbnailDto': return SystemConfigThumbnailDto.fromJson(value); case 'SystemConfigTrashDto': diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index 932e68466b7d7..8e15d7e219965 100644 --- a/mobile/openapi/lib/model/system_config_dto.dart +++ b/mobile/openapi/lib/model/system_config_dto.dart @@ -21,6 +21,7 @@ class SystemConfigDto { required this.passwordLogin, required this.reverseGeocoding, required this.storageTemplate, + required this.theme, required this.thumbnail, required this.trash, }); @@ -41,6 +42,8 @@ class SystemConfigDto { SystemConfigStorageTemplateDto storageTemplate; + SystemConfigThemeDto theme; + SystemConfigThumbnailDto thumbnail; SystemConfigTrashDto trash; @@ -55,6 +58,7 @@ class SystemConfigDto { other.passwordLogin == passwordLogin && other.reverseGeocoding == reverseGeocoding && other.storageTemplate == storageTemplate && + other.theme == theme && other.thumbnail == thumbnail && other.trash == trash; @@ -69,11 +73,12 @@ class SystemConfigDto { (passwordLogin.hashCode) + (reverseGeocoding.hashCode) + (storageTemplate.hashCode) + + (theme.hashCode) + (thumbnail.hashCode) + (trash.hashCode); @override - String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, machineLearning=$machineLearning, map=$map, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, storageTemplate=$storageTemplate, thumbnail=$thumbnail, trash=$trash]'; + String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, machineLearning=$machineLearning, map=$map, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash]'; Map toJson() { final json = {}; @@ -85,6 +90,7 @@ class SystemConfigDto { json[r'passwordLogin'] = this.passwordLogin; json[r'reverseGeocoding'] = this.reverseGeocoding; json[r'storageTemplate'] = this.storageTemplate; + json[r'theme'] = this.theme; json[r'thumbnail'] = this.thumbnail; json[r'trash'] = this.trash; return json; @@ -106,6 +112,7 @@ class SystemConfigDto { passwordLogin: SystemConfigPasswordLoginDto.fromJson(json[r'passwordLogin'])!, reverseGeocoding: SystemConfigReverseGeocodingDto.fromJson(json[r'reverseGeocoding'])!, storageTemplate: SystemConfigStorageTemplateDto.fromJson(json[r'storageTemplate'])!, + theme: SystemConfigThemeDto.fromJson(json[r'theme'])!, thumbnail: SystemConfigThumbnailDto.fromJson(json[r'thumbnail'])!, trash: SystemConfigTrashDto.fromJson(json[r'trash'])!, ); @@ -163,6 +170,7 @@ class SystemConfigDto { 'passwordLogin', 'reverseGeocoding', 'storageTemplate', + 'theme', 'thumbnail', 'trash', }; diff --git a/mobile/openapi/lib/model/system_config_theme_dto.dart b/mobile/openapi/lib/model/system_config_theme_dto.dart new file mode 100644 index 0000000000000..f34234952deb4 --- /dev/null +++ b/mobile/openapi/lib/model/system_config_theme_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SystemConfigThemeDto { + /// Returns a new [SystemConfigThemeDto] instance. + SystemConfigThemeDto({ + required this.customCss, + }); + + String customCss; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigThemeDto && + other.customCss == customCss; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (customCss.hashCode); + + @override + String toString() => 'SystemConfigThemeDto[customCss=$customCss]'; + + Map toJson() { + final json = {}; + json[r'customCss'] = this.customCss; + return json; + } + + /// Returns a new [SystemConfigThemeDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigThemeDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return SystemConfigThemeDto( + customCss: mapValueOfType(json, r'customCss')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SystemConfigThemeDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigThemeDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigThemeDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SystemConfigThemeDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'customCss', + }; +} + diff --git a/mobile/openapi/test/system_config_dto_test.dart b/mobile/openapi/test/system_config_dto_test.dart index 1a3e38a9e9d4a..30dbe6860b028 100644 --- a/mobile/openapi/test/system_config_dto_test.dart +++ b/mobile/openapi/test/system_config_dto_test.dart @@ -56,6 +56,11 @@ void main() { // TODO }); + // SystemConfigThemeDto theme + test('to test the property `theme`', () async { + // TODO + }); + // SystemConfigThumbnailDto thumbnail test('to test the property `thumbnail`', () async { // TODO diff --git a/mobile/openapi/test/system_config_theme_dto_test.dart b/mobile/openapi/test/system_config_theme_dto_test.dart new file mode 100644 index 0000000000000..98e283559e42b --- /dev/null +++ b/mobile/openapi/test/system_config_theme_dto_test.dart @@ -0,0 +1,27 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for SystemConfigThemeDto +void main() { + // final instance = SystemConfigThemeDto(); + + group('test SystemConfigThemeDto', () { + // String customCss + test('to test the property `customCss`', () async { + // TODO + }); + + + }); + +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index e6230fcc32465..09ff41e24fd34 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -8060,6 +8060,9 @@ "storageTemplate": { "$ref": "#/components/schemas/SystemConfigStorageTemplateDto" }, + "theme": { + "$ref": "#/components/schemas/SystemConfigThemeDto" + }, "thumbnail": { "$ref": "#/components/schemas/SystemConfigThumbnailDto" }, @@ -8077,7 +8080,8 @@ "storageTemplate", "job", "thumbnail", - "trash" + "trash", + "theme" ], "type": "object" }, @@ -8404,6 +8408,17 @@ ], "type": "object" }, + "SystemConfigThemeDto": { + "properties": { + "customCss": { + "type": "string" + } + }, + "required": [ + "customCss" + ], + "type": "object" + }, "SystemConfigThumbnailDto": { "properties": { "colorspace": { diff --git a/server/src/domain/system-config/dto/system-config-theme.dto.ts b/server/src/domain/system-config/dto/system-config-theme.dto.ts new file mode 100644 index 0000000000000..f47b51e0e177a --- /dev/null +++ b/server/src/domain/system-config/dto/system-config-theme.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class SystemConfigThemeDto { + @IsString() + customCss!: string; +} diff --git a/server/src/domain/system-config/dto/system-config.dto.ts b/server/src/domain/system-config/dto/system-config.dto.ts index 1522683826921..6a88e758c68a0 100644 --- a/server/src/domain/system-config/dto/system-config.dto.ts +++ b/server/src/domain/system-config/dto/system-config.dto.ts @@ -9,6 +9,7 @@ import { SystemConfigOAuthDto } from './system-config-oauth.dto'; import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto'; import { SystemConfigReverseGeocodingDto } from './system-config-reverse-geocoding.dto'; import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto'; +import { SystemConfigThemeDto } from './system-config-theme.dto'; import { SystemConfigThumbnailDto } from './system-config-thumbnail.dto'; import { SystemConfigTrashDto } from './system-config-trash.dto'; @@ -62,6 +63,11 @@ export class SystemConfigDto implements SystemConfig { @ValidateNested() @IsObject() trash!: SystemConfigTrashDto; + + @Type(() => SystemConfigThemeDto) + @ValidateNested() + @IsObject() + theme!: SystemConfigThemeDto; } export function mapConfig(config: SystemConfig): SystemConfigDto { diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index 66c72fc92ef01..4fd2faa295c0b 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -114,6 +114,9 @@ export const defaults = Object.freeze({ enabled: true, days: 30, }, + theme: { + customCss: '', + }, }); export enum FeatureFlag { diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index 0444217c097ea..36cdb6543eb4c 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -115,6 +115,9 @@ const updatedConfig = Object.freeze({ enabled: true, days: 10, }, + theme: { + customCss: '', + }, }); describe(SystemConfigService.name, () => { diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts index 47b1f69fd9b34..6bd552111a4b0 100644 --- a/server/src/infra/entities/system-config.entity.ts +++ b/server/src/infra/entities/system-config.entity.ts @@ -90,6 +90,8 @@ export enum SystemConfigKey { TRASH_ENABLED = 'trash.enabled', TRASH_DAYS = 'trash.days', + + THEME_CUSTOM_CSS = 'theme.customCss', } export enum TranscodePolicy { @@ -221,4 +223,7 @@ export interface SystemConfig { enabled: boolean; days: number; }; + theme: { + customCss: string; + }; } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 8d278b44cebf6..2133d0411346f 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -3307,6 +3307,12 @@ export interface SystemConfigDto { * @memberof SystemConfigDto */ 'storageTemplate': SystemConfigStorageTemplateDto; + /** + * + * @type {SystemConfigThemeDto} + * @memberof SystemConfigDto + */ + 'theme': SystemConfigThemeDto; /** * * @type {SystemConfigThumbnailDto} @@ -3741,6 +3747,19 @@ export interface SystemConfigTemplateStorageOptionDto { */ 'yearOptions': Array; } +/** + * + * @export + * @interface SystemConfigThemeDto + */ +export interface SystemConfigThemeDto { + /** + * + * @type {string} + * @memberof SystemConfigThemeDto + */ + 'customCss': string; +} /** * * @export diff --git a/web/src/lib/components/admin-page/settings/setting-textarea.svelte b/web/src/lib/components/admin-page/settings/setting-textarea.svelte new file mode 100644 index 0000000000000..99a672c472d67 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/setting-textarea.svelte @@ -0,0 +1,53 @@ + + +
+
+ + {#if required} +
*
+ {/if} + + {#if isEdited} +
+ Unsaved change +
+ {/if} +
+ + {#if desc} +

+ {desc} +

+ {:else} + + {/if} + +