From 28d8357cc50b01b01a1533baf903a8c5c7d2b383 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 16 May 2025 11:56:25 -0400 Subject: [PATCH 01/11] feat(web): clear person birthdate (#18330) --- e2e/src/api/specs/person.e2e-spec.ts | 94 ---------- open-api/immich-openapi-specs.json | 2 + .../src/controllers/person.controller.spec.ts | 172 ++++++++++++++++++ server/src/controllers/person.controller.ts | 4 +- server/src/dtos/person.dto.ts | 5 +- .../modals/PersonEditBirthDateModal.svelte | 13 +- .../[[assetId=id]]/+page.svelte | 1 + 7 files changed, 190 insertions(+), 101 deletions(-) create mode 100644 server/src/controllers/person.controller.spec.ts diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts index 6e7eba74ba..1826002af6 100644 --- a/e2e/src/api/specs/person.e2e-spec.ts +++ b/e2e/src/api/specs/person.e2e-spec.ts @@ -5,22 +5,6 @@ import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; -const invalidBirthday = [ - { - birthDate: 'false', - response: ['birthDate must be a string in the format yyyy-MM-dd', 'Birth date cannot be in the future'], - }, - { - birthDate: '123567', - response: ['birthDate must be a string in the format yyyy-MM-dd', 'Birth date cannot be in the future'], - }, - { - birthDate: 123_567, - response: ['birthDate must be a string in the format yyyy-MM-dd', 'Birth date cannot be in the future'], - }, - { birthDate: '9999-01-01', response: ['Birth date cannot be in the future'] }, -]; - describe('/people', () => { let admin: LoginResponseDto; let visiblePerson: PersonResponseDto; @@ -58,14 +42,6 @@ describe('/people', () => { describe('GET /people', () => { beforeEach(async () => {}); - - it('should require authentication', async () => { - const { status, body } = await request(app).get('/people'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should return all people (including hidden)', async () => { const { status, body } = await request(app) .get('/people') @@ -117,13 +93,6 @@ describe('/people', () => { }); describe('GET /people/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get(`/people/${uuidDto.notFound}`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should throw error if person with id does not exist', async () => { const { status, body } = await request(app) .get(`/people/${uuidDto.notFound}`) @@ -144,13 +113,6 @@ describe('/people', () => { }); describe('GET /people/:id/statistics', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get(`/people/${multipleAssetsPerson.id}/statistics`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should throw error if person with id does not exist', async () => { const { status, body } = await request(app) .get(`/people/${uuidDto.notFound}/statistics`) @@ -171,23 +133,6 @@ describe('/people', () => { }); describe('POST /people', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/people`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - for (const { birthDate, response } of invalidBirthday) { - it(`should not accept an invalid birth date [${birthDate}]`, async () => { - const { status, body } = await request(app) - .post(`/people`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ birthDate }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(response)); - }); - } - it('should create a person', async () => { const { status, body } = await request(app) .post(`/people`) @@ -223,39 +168,6 @@ describe('/people', () => { }); describe('PUT /people/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put(`/people/${uuidDto.notFound}`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - for (const { key, type } of [ - { key: 'name', type: 'string' }, - { key: 'featureFaceAssetId', type: 'string' }, - { key: 'isHidden', type: 'boolean value' }, - { key: 'isFavorite', type: 'boolean value' }, - ]) { - it(`should not allow null ${key}`, async () => { - const { status, body } = await request(app) - .put(`/people/${visiblePerson.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ [key]: null }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest([`${key} must be a ${type}`])); - }); - } - - for (const { birthDate, response } of invalidBirthday) { - it(`should not accept an invalid birth date [${birthDate}]`, async () => { - const { status, body } = await request(app) - .put(`/people/${visiblePerson.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ birthDate }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(response)); - }); - } - it('should update a date of birth', async () => { const { status, body } = await request(app) .put(`/people/${visiblePerson.id}`) @@ -312,12 +224,6 @@ describe('/people', () => { }); describe('POST /people/:id/merge', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/people/${uuidDto.notFound}/merge`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should not supporting merging a person into themselves', async () => { const { status, body } = await request(app) .post(`/people/${visiblePerson.id}/merge`) diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 89bdfef45e..e7bf81ce3e 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11075,6 +11075,7 @@ }, "featureFaceAssetId": { "description": "Asset is used to get the feature face thumbnail.", + "format": "uuid", "type": "string" }, "id": { @@ -11280,6 +11281,7 @@ }, "featureFaceAssetId": { "description": "Asset is used to get the feature face thumbnail.", + "format": "uuid", "type": "string" }, "isFavorite": { diff --git a/server/src/controllers/person.controller.spec.ts b/server/src/controllers/person.controller.spec.ts new file mode 100644 index 0000000000..0366829336 --- /dev/null +++ b/server/src/controllers/person.controller.spec.ts @@ -0,0 +1,172 @@ +import { PersonController } from 'src/controllers/person.controller'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PersonService } from 'src/services/person.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { factory } from 'test/small.factory'; +import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(PersonController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(PersonService); + + beforeAll(async () => { + ctx = await controllerSetup(PersonController, [ + { provide: PersonService, useValue: service }, + { provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) }, + ]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('GET /people', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/people'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require closestPersonId to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get(`/people`) + .query({ closestPersonId: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')])); + }); + + it(`should require closestAssetId to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get(`/people`) + .query({ closestAssetId: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')])); + }); + }); + + describe('POST /people', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/people'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should map an empty birthDate to null', async () => { + await request(ctx.getHttpServer()).post('/people').send({ birthDate: '' }); + expect(service.create).toHaveBeenCalledWith(undefined, { birthDate: null }); + }); + }); + + describe('GET /people/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('PUT /people/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).put(`/people/123`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('id must be a UUID')])); + }); + + it(`should not allow a null name`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post(`/people`) + .send({ name: null }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['name must be a string'])); + }); + + it(`should require featureFaceAssetId to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/people/${factory.uuid()}`) + .send({ featureFaceAssetId: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['featureFaceAssetId must be a UUID'])); + }); + + it(`should require isFavorite to be a boolean`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/people/${factory.uuid()}`) + .send({ isFavorite: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['isFavorite must be a boolean value'])); + }); + + it(`should require isHidden to be a boolean`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/people/${factory.uuid()}`) + .send({ isHidden: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['isHidden must be a boolean value'])); + }); + + it('should map an empty birthDate to null', async () => { + const id = factory.uuid(); + await request(ctx.getHttpServer()).put(`/people/${id}`).send({ birthDate: '' }); + expect(service.update).toHaveBeenCalledWith(undefined, id, { birthDate: null }); + }); + + it('should not accept an invalid birth date (false)', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/people/${factory.uuid()}`) + .send({ birthDate: false }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.badRequest([ + 'birthDate must be a string in the format yyyy-MM-dd', + 'Birth date cannot be in the future', + ]), + ); + }); + + it('should not accept an invalid birth date (number)', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/people/${factory.uuid()}`) + .send({ birthDate: 123_456 }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.badRequest([ + 'birthDate must be a string in the format yyyy-MM-dd', + 'Birth date cannot be in the future', + ]), + ); + }); + + it('should not accept a birth date in the future)', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/people/${factory.uuid()}`) + .send({ birthDate: '9999-01-01' }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['Birth date cannot be in the future'])); + }); + }); + + describe('POST /people/:id/merge', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post(`/people/${factory.uuid()}/merge`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('GET /people/:id/statistics', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}/statistics`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index e98dd6a002..3440042eda 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -27,7 +27,9 @@ export class PersonController { constructor( private service: PersonService, private logger: LoggingRepository, - ) {} + ) { + this.logger.setContext(PersonController.name); + } @Get() @Authenticated({ permission: Permission.PERSON_READ }) diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 90490715ef..c59ab905bd 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -33,7 +33,7 @@ export class PersonCreateDto { @ApiProperty({ format: 'date' }) @MaxDateString(() => DateTime.now(), { message: 'Birth date cannot be in the future' }) @IsDateStringFormat('yyyy-MM-dd') - @Optional({ nullable: true }) + @Optional({ nullable: true, emptyToNull: true }) birthDate?: Date | null; /** @@ -54,8 +54,7 @@ export class PersonUpdateDto extends PersonCreateDto { /** * Asset is used to get the feature face thumbnail. */ - @Optional() - @IsString() + @ValidateUUID({ optional: true }) featureFaceAssetId?: string; } diff --git a/web/src/lib/modals/PersonEditBirthDateModal.svelte b/web/src/lib/modals/PersonEditBirthDateModal.svelte index 52d23f4075..d79b716364 100644 --- a/web/src/lib/modals/PersonEditBirthDateModal.svelte +++ b/web/src/lib/modals/PersonEditBirthDateModal.svelte @@ -24,7 +24,7 @@ try { const updatedPerson = await updatePerson({ id: person.id, - personUpdateDto: { birthDate: birthDate.length > 0 ? birthDate : null }, + personUpdateDto: { birthDate }, }); notificationController.show({ message: $t('date_of_birth_saved'), type: NotificationType.Info }); @@ -53,6 +53,13 @@ bind:value={birthDate} max={todayFormatted} /> + {#if person.birthDate} +
+ +
+ {/if} @@ -62,8 +69,8 @@ - diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 50dc8f8166..1dc213729d 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -328,6 +328,7 @@ return; } + person = updatedPerson; people = people.map((person: PersonResponseDto) => { if (person.id === updatedPerson.id) { return updatedPerson; From 1219fd82a038016240e92e73aab2acdb3274f713 Mon Sep 17 00:00:00 2001 From: Sebastian Schneider Date: Fri, 16 May 2025 18:03:54 +0200 Subject: [PATCH 02/11] fix(web): format dates with the locale preference (#18259) fix: Format dates in settings according to user setting --- .../user-settings-page/user-api-key-list.svelte | 9 ++------- .../user-settings-page/user-purchase-settings.svelte | 10 ++++++++-- web/src/lib/constants.ts | 5 +++++ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/web/src/lib/components/user-settings-page/user-api-key-list.svelte b/web/src/lib/components/user-settings-page/user-api-key-list.svelte index 6aebab282c..ccc1bdfe92 100644 --- a/web/src/lib/components/user-settings-page/user-api-key-list.svelte +++ b/web/src/lib/components/user-settings-page/user-api-key-list.svelte @@ -18,6 +18,7 @@ import { fade } from 'svelte/transition'; import { handleError } from '../../utils/handle-error'; import { notificationController, NotificationType } from '../shared-components/notification/notification'; + import { dateFormats } from '$lib/constants'; interface Props { keys: ApiKeyResponseDto[]; @@ -25,12 +26,6 @@ let { keys = $bindable() }: Props = $props(); - const format: Intl.DateTimeFormatOptions = { - month: 'short', - day: 'numeric', - year: 'numeric', - }; - async function refreshKeys() { keys = await getApiKeys(); } @@ -130,7 +125,7 @@ > {key.name} {new Date(key.createdAt).toLocaleDateString($locale, format)} + >{new Date(key.createdAt).toLocaleDateString($locale, dateFormats.settings)} {$t('purchase_activated_time', { - values: { date: new Date(serverPurchaseInfo.activatedAt) }, + values: { + date: new Date(serverPurchaseInfo.activatedAt).toLocaleString($locale, dateFormats.settings), + }, })}

{:else} @@ -161,7 +165,9 @@ {#if $user.license?.activatedAt}

{$t('purchase_activated_time', { - values: { date: new Date($user.license?.activatedAt) }, + values: { + date: new Date($user.license?.activatedAt).toLocaleString($locale, dateFormats.settings), + }, })}

{/if} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 167c976eeb..fdb18b3978 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -72,6 +72,11 @@ export const dateFormats = { day: 'numeric', year: 'numeric', }, + settings: { + month: 'short', + day: 'numeric', + year: 'numeric', + }, }; export enum QueryParameter { From 8ab50403510ddf914319e29983664ce3753925c8 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 16 May 2025 12:58:17 -0400 Subject: [PATCH 03/11] fix(web): modal colors (#18332) * feat(web): clear person birthdate * fix(web): modal colors --- web/src/lib/modals/ConfirmModal.svelte | 2 +- web/src/lib/modals/PasswordResetSuccessModal.svelte | 8 +------- web/src/lib/modals/UserCreateModal.svelte | 2 +- web/src/lib/modals/UserEditModal.svelte | 2 +- web/src/lib/modals/UserRestoreConfirmModal.svelte | 2 +- 5 files changed, 5 insertions(+), 11 deletions(-) diff --git a/web/src/lib/modals/ConfirmModal.svelte b/web/src/lib/modals/ConfirmModal.svelte index 9726a1d9cf..327d13c355 100644 --- a/web/src/lib/modals/ConfirmModal.svelte +++ b/web/src/lib/modals/ConfirmModal.svelte @@ -30,7 +30,7 @@ }; - onClose(false)} {size} class="bg-light text-dark"> + onClose(false)} {size}> {#if promptSnippet}{@render promptSnippet()}{:else}

{prompt}

diff --git a/web/src/lib/modals/PasswordResetSuccessModal.svelte b/web/src/lib/modals/PasswordResetSuccessModal.svelte index 74e035b93b..9f8dc9d668 100644 --- a/web/src/lib/modals/PasswordResetSuccessModal.svelte +++ b/web/src/lib/modals/PasswordResetSuccessModal.svelte @@ -12,13 +12,7 @@ const { onClose, newPassword }: Props = $props(); - onClose()} - size="small" - class="bg-light text-dark" -> + onClose()} size="small">
{$t('admin.user_password_has_been_reset')} diff --git a/web/src/lib/modals/UserCreateModal.svelte b/web/src/lib/modals/UserCreateModal.svelte index 34e498ce1c..f40a709215 100644 --- a/web/src/lib/modals/UserCreateModal.svelte +++ b/web/src/lib/modals/UserCreateModal.svelte @@ -81,7 +81,7 @@ }; - +
{#if error} diff --git a/web/src/lib/modals/UserEditModal.svelte b/web/src/lib/modals/UserEditModal.svelte index a54dd90590..0bb018721b 100644 --- a/web/src/lib/modals/UserEditModal.svelte +++ b/web/src/lib/modals/UserEditModal.svelte @@ -51,7 +51,7 @@ }; - +
diff --git a/web/src/lib/modals/UserRestoreConfirmModal.svelte b/web/src/lib/modals/UserRestoreConfirmModal.svelte index c8fde89f36..5cf9c1c91b 100644 --- a/web/src/lib/modals/UserRestoreConfirmModal.svelte +++ b/web/src/lib/modals/UserRestoreConfirmModal.svelte @@ -23,7 +23,7 @@ }; - +

From 48d746d9d55d9675238e07668015f7a9fcfed812 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Fri, 16 May 2025 13:16:27 -0400 Subject: [PATCH 04/11] refactor(server): "on this day" memory creation (#18333) * refactor memory creation * always update system metadata * maybe fix medium tests --- server/src/enum.ts | 1 + server/src/services/memory.service.ts | 99 ++++++++++--------- .../specs/services/memory.service.spec.ts | 1 + 3 files changed, 54 insertions(+), 47 deletions(-) diff --git a/server/src/enum.ts b/server/src/enum.ts index a4d2d21274..e49f1636a0 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -567,6 +567,7 @@ export enum DatabaseLock { Library = 1337, GetSystemConfig = 69, BackupDatabase = 42, + MemoryCreation = 777, } export enum SyncRequestType { diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 3d3d10540b..1ccd311790 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -4,9 +4,8 @@ import { OnJob } from 'src/decorators'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; -import { JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum'; +import { DatabaseLock, JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { OnThisDayData } from 'src/types'; import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util'; const DAYS = 3; @@ -16,55 +15,61 @@ export class MemoryService extends BaseService { @OnJob({ name: JobName.MEMORIES_CREATE, queue: QueueName.BACKGROUND_TASK }) async onMemoriesCreate() { const users = await this.userRepository.getList({ withDeleted: false }); - const userMap: Record = {}; - for (const user of users) { - const partnerIds = await getMyPartnerIds({ - userId: user.id, - repository: this.partnerRepository, - timelineEnabled: true, - }); - userMap[user.id] = [user.id, ...partnerIds]; - } + const usersIds = await Promise.all( + users.map((user) => + getMyPartnerIds({ + userId: user.id, + repository: this.partnerRepository, + timelineEnabled: true, + }), + ), + ); - const start = DateTime.utc().startOf('day').minus({ days: DAYS }); + await this.databaseRepository.withLock(DatabaseLock.MemoryCreation, async () => { + const state = await this.systemMetadataRepository.get(SystemMetadataKey.MEMORIES_STATE); + const start = DateTime.utc().startOf('day').minus({ days: DAYS }); + const lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state.lastOnThisDayDate) : start; - const state = await this.systemMetadataRepository.get(SystemMetadataKey.MEMORIES_STATE); - const lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state.lastOnThisDayDate) : start; - - // generate a memory +/- X days from today - for (let i = 0; i <= DAYS * 2; i++) { - const target = start.plus({ days: i }); - if (lastOnThisDayDate >= target) { - continue; - } - - const showAt = target.startOf('day').toISO(); - const hideAt = target.endOf('day').toISO(); - - for (const [userId, userIds] of Object.entries(userMap)) { - const memories = await this.assetRepository.getByDayOfYear(userIds, target); - - for (const { year, assets } of memories) { - const data: OnThisDayData = { year }; - await this.memoryRepository.create( - { - ownerId: userId, - type: MemoryType.ON_THIS_DAY, - data, - memoryAt: target.set({ year }).toISO(), - showAt, - hideAt, - }, - new Set(assets.map(({ id }) => id)), - ); + // generate a memory +/- X days from today + for (let i = 0; i <= DAYS * 2; i++) { + const target = start.plus({ days: i }); + if (lastOnThisDayDate >= target) { + continue; } - } - await this.systemMetadataRepository.set(SystemMetadataKey.MEMORIES_STATE, { - ...state, - lastOnThisDayDate: target.toISO(), - }); - } + try { + await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target))); + } catch (error) { + this.logger.error(`Failed to create memories for ${target.toISO()}`, error); + } + // update system metadata even when there is an error to minimize the chance of duplicates + await this.systemMetadataRepository.set(SystemMetadataKey.MEMORIES_STATE, { + ...state, + lastOnThisDayDate: target.toISO(), + }); + } + }); + } + + private async createOnThisDayMemories(ownerId: string, userIds: string[], target: DateTime) { + const showAt = target.startOf('day').toISO(); + const hideAt = target.endOf('day').toISO(); + const memories = await this.assetRepository.getByDayOfYear([ownerId, ...userIds], target); + await Promise.all( + memories.map(({ year, assets }) => + this.memoryRepository.create( + { + ownerId, + type: MemoryType.ON_THIS_DAY, + data: { year }, + memoryAt: target.set({ year }).toISO()!, + showAt, + hideAt, + }, + new Set(assets.map(({ id }) => id)), + ), + ), + ); } @OnJob({ name: JobName.MEMORIES_CLEANUP, queue: QueueName.BACKGROUND_TASK }) diff --git a/server/test/medium/specs/services/memory.service.spec.ts b/server/test/medium/specs/services/memory.service.spec.ts index 445434d60a..8489e6bcc9 100644 --- a/server/test/medium/specs/services/memory.service.spec.ts +++ b/server/test/medium/specs/services/memory.service.spec.ts @@ -15,6 +15,7 @@ describe(MemoryService.name, () => { database: db || defaultDatabase, repos: { asset: 'real', + database: 'real', memory: 'real', user: 'real', systemMetadata: 'real', From 21880aec14270c54b26e930c016b7dd991679d90 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 16 May 2025 19:54:37 +0200 Subject: [PATCH 05/11] fix: z-index issues on search page (#18336) --- .../[[assetId=id]]/+page.svelte | 102 +++++++++--------- 1 file changed, 51 insertions(+), 51 deletions(-) diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index d35e2697c1..5f995b9a7a 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -249,57 +249,6 @@ -

- {#if assetInteraction.selectionActive} -
- cancelMultiselect(assetInteraction)} - > - - - - - - - { - for (const id of ids) { - const asset = searchResultAssets.find((asset) => asset.id === id); - if (asset) { - asset.isFavorite = isFavorite; - } - } - }} - /> - - - - - - - {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} - - {/if} - -
- -
-
-
- {:else} -
- goto(previousRoute)} backIcon={mdiArrowLeft}> -
-
- -
-
-
- {/if} -
- {#if terms}
{/if}
+ +
+ {#if assetInteraction.selectionActive} +
+ cancelMultiselect(assetInteraction)} + > + + + + + + + { + for (const id of ids) { + const asset = searchResultAssets.find((asset) => asset.id === id); + if (asset) { + asset.isFavorite = isFavorite; + } + } + }} + /> + + + + + + + {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} + + {/if} + +
+ +
+
+
+ {:else} +
+ goto(previousRoute)} backIcon={mdiArrowLeft}> +
+
+ +
+
+
+ {/if} +
From 53536581143dd41c345c922f2ec2d8d8a5b74986 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 16 May 2025 13:59:47 -0400 Subject: [PATCH 06/11] refactor: convert slider to switch (#18334) --- web/src/lib/components/elements/slider.svelte | 94 ------------------- .../settings/setting-switch.svelte | 10 +- 2 files changed, 5 insertions(+), 99 deletions(-) delete mode 100644 web/src/lib/components/elements/slider.svelte diff --git a/web/src/lib/components/elements/slider.svelte b/web/src/lib/components/elements/slider.svelte deleted file mode 100644 index 5c80eb2a9e..0000000000 --- a/web/src/lib/components/elements/slider.svelte +++ /dev/null @@ -1,94 +0,0 @@ - - - - - diff --git a/web/src/lib/components/shared-components/settings/setting-switch.svelte b/web/src/lib/components/shared-components/settings/setting-switch.svelte index aa165cfaaa..58e1649bb8 100644 --- a/web/src/lib/components/shared-components/settings/setting-switch.svelte +++ b/web/src/lib/components/shared-components/settings/setting-switch.svelte @@ -1,10 +1,10 @@ - -
- - -
diff --git a/web/src/lib/components/photos-page/delete-asset-dialog.svelte b/web/src/lib/components/photos-page/delete-asset-dialog.svelte index 336a0fd78a..4862e072b8 100644 --- a/web/src/lib/components/photos-page/delete-asset-dialog.svelte +++ b/web/src/lib/components/photos-page/delete-asset-dialog.svelte @@ -1,8 +1,8 @@ + +{#if menuItem} + handleUpdateDescription()} /> +{/if} diff --git a/web/src/lib/modals/AssetUpdateDecriptionConfirmModal.svelte b/web/src/lib/modals/AssetUpdateDecriptionConfirmModal.svelte new file mode 100644 index 0000000000..4d5a81f5fa --- /dev/null +++ b/web/src/lib/modals/AssetUpdateDecriptionConfirmModal.svelte @@ -0,0 +1,29 @@ + + + (confirmed ? onClose(description) : onClose())} +> + {#snippet promptSnippet()} +
+
+ + +
+
+ {/snippet} +
diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 088d3dae97..e46ad0fc77 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -13,6 +13,7 @@ import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; + import ChangeDescription from '$lib/components/photos-page/actions/change-description-action.svelte'; import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; @@ -478,6 +479,7 @@ {#if assetInteraction.isAllUserOwned} + {#if assetInteraction.selectedAssets.length === 1} + + {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 1dc213729d..ea726d783a 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -11,6 +11,7 @@ import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; + import ChangeDescription from '$lib/components/photos-page/actions/change-description-action.svelte'; import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; @@ -515,6 +516,7 @@ onClick={handleReassignAssets} /> + {/if} + assetStore.removeAssets(assetIds)} /> {#if $preferences.tags.enabled} diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 5f995b9a7a..813683244e 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -9,6 +9,7 @@ import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte'; import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; + import ChangeDescription from '$lib/components/photos-page/actions/change-description-action.svelte'; import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; @@ -358,6 +359,7 @@ + {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} From 61d784f4e79af7c413f602c6a300407aa88f44ab Mon Sep 17 00:00:00 2001 From: Snowknight26 Date: Sat, 17 May 2025 08:05:23 -0500 Subject: [PATCH 10/11] fix(web): Make QR code colors solid (#18340) --- web/src/lib/components/shared-components/qrcode.svelte | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/web/src/lib/components/shared-components/qrcode.svelte b/web/src/lib/components/shared-components/qrcode.svelte index 3940975fba..5fa83e880c 100644 --- a/web/src/lib/components/shared-components/qrcode.svelte +++ b/web/src/lib/components/shared-components/qrcode.svelte @@ -1,6 +1,4 @@
From a65c905621603fbfb8148df7ae3f9d923a587a41 Mon Sep 17 00:00:00 2001 From: Dhaval Javia <31767853+dj0024javia@users.noreply.github.com> Date: Sun, 18 May 2025 02:39:15 +0530 Subject: [PATCH 11/11] fix: delay settings apply for slideshow popup (#18028) * fix: fixed slideshow values to apply on done. * chore: linting error fixes * feat: added cancel button and changed text from done to confirm --- .../lib/components/slideshow-settings.svelte | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/web/src/lib/components/slideshow-settings.svelte b/web/src/lib/components/slideshow-settings.svelte index 4af6cdc5e7..c30d2cfb09 100644 --- a/web/src/lib/components/slideshow-settings.svelte +++ b/web/src/lib/components/slideshow-settings.svelte @@ -25,6 +25,13 @@ let { onClose = () => {} }: Props = $props(); + // Temporary variables to hold the settings - marked as reactive with $state() but initialized with store values + let tempSlideshowDelay = $state($slideshowDelay); + let tempShowProgressBar = $state($showProgressBar); + let tempSlideshowNavigation = $state($slideshowNavigation); + let tempSlideshowLook = $state($slideshowLook); + let tempSlideshowTransition = $state($slideshowTransition); + const navigationOptions: Record = { [SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') }, [SlideshowNavigation.AscendingOrder]: { icon: mdiArrowUpThin, title: $t('backward') }, @@ -47,6 +54,15 @@ } } }; + + const applyChanges = () => { + $slideshowDelay = tempSlideshowDelay; + $showProgressBar = tempShowProgressBar; + $slideshowNavigation = tempSlideshowNavigation; + $slideshowLook = tempSlideshowLook; + $slideshowTransition = tempSlideshowTransition; + onClose(); + }; onClose()}> @@ -54,31 +70,32 @@ { - $slideshowNavigation = handleToggle(option, navigationOptions) || $slideshowNavigation; + tempSlideshowNavigation = handleToggle(option, navigationOptions) || tempSlideshowNavigation; }} /> { - $slideshowLook = handleToggle(option, lookOptions) || $slideshowLook; + tempSlideshowLook = handleToggle(option, lookOptions) || tempSlideshowLook; }} /> - - + +
{#snippet stickyBottom()} - + + {/snippet}