From 011a66731448ef67355743a878ae79cc0c786fd8 Mon Sep 17 00:00:00 2001 From: mkuehne707 <59650037+mkuehne707@users.noreply.github.com> Date: Thu, 7 Aug 2025 15:42:33 +0200 Subject: [PATCH] feat: batch change date and time relatively (#17717) Co-authored-by: marcel.kuehne <> Co-authored-by: Zack Pollard --- e2e/src/api/specs/asset.e2e-spec.ts | 24 ++++ i18n/en.json | 6 + .../lib/model/asset_bulk_update_dto.dart | Bin 8310 -> 9773 bytes open-api/immich-openapi-specs.json | 6 + open-api/typescript-sdk/src/fetch-client.ts | 2 + server/src/dtos/asset.dto.ts | 13 +- server/src/queries/asset.repository.sql | 12 ++ server/src/repositories/asset.repository.ts | 15 ++ server/src/services/asset.service.spec.ts | 27 ++++ server/src/services/asset.service.ts | 52 +++++-- server/src/validation.spec.ts | 37 ++++- server/src/validation.ts | 23 +++ .../repositories/asset.repository.mock.ts | 1 + .../asset-viewer/detail-panel.svelte | 12 +- .../components/elements/duration-input.svelte | 52 +++++++ .../actions/change-date-action.svelte | 56 +++++++- .../shared-components/change-date.spec.ts | 115 ++++++++++++++- .../shared-components/change-date.svelte | 131 ++++++++++++++---- web/src/lib/utils/timeline-util.ts | 6 + 19 files changed, 539 insertions(+), 51 deletions(-) create mode 100644 web/src/lib/components/elements/duration-input.svelte diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index c1e9f9dfb..5e9d90ddc 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -854,6 +854,30 @@ describe('/asset', () => { }); }); + describe('PUT /assets', () => { + it('should update date time original relatively', async () => { + const { status, body } = await request(app) + .put(`/assets/`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ ids: [user1Assets[0].id], dateTimeRelative: -1441 }); + + expect(body).toEqual({}); + expect(status).toEqual(204); + + const result = await request(app) + .get(`/assets/${user1Assets[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send(); + + expect(result.body).toMatchObject({ + id: user1Assets[0].id, + exifInfo: expect.objectContaining({ + dateTimeOriginal: '2023-11-19T01:10:00+00:00', + }), + }); + }); + }); + describe('POST /assets', () => { beforeAll(setupTests, 30_000); diff --git a/i18n/en.json b/i18n/en.json index d953f25c8..4ddadf734 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -749,6 +749,7 @@ "date_of_birth_saved": "Date of birth saved successfully", "date_range": "Date range", "day": "Day", + "days": "Days", "deduplicate_all": "Deduplicate All", "deduplication_criteria_1": "Image size in bytes", "deduplication_criteria_2": "Count of EXIF data", @@ -837,6 +838,8 @@ "edit_date": "Edit date", "edit_date_and_time": "Edit date and time", "edit_date_and_time_action_prompt": "{count} date and time edited", + "edit_date_and_time_by_offset": "Change date by offset", + "edit_date_and_time_by_offset_interval": "New date range: {from} - {to}", "edit_description": "Edit description", "edit_description_prompt": "Please select a new description:", "edit_exclusion_pattern": "Edit exclusion pattern", @@ -1107,6 +1110,7 @@ "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", "host": "Host", "hour": "Hour", + "hours": "Hours", "id": "ID", "idle": "Idle", "ignore_icloud_photos": "Ignore iCloud photos", @@ -1295,6 +1299,7 @@ "merged_people_count": "Merged {count, plural, one {# person} other {# people}}", "minimize": "Minimize", "minute": "Minute", + "minutes": "Minutes", "missing": "Missing", "model": "Model", "month": "Month", @@ -1368,6 +1373,7 @@ "oauth": "OAuth", "official_immich_resources": "Official Immich Resources", "offline": "Offline", + "offset": "Offset", "ok": "Ok", "oldest_first": "Oldest first", "on_this_device": "On this device", diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index 571badf02918d41d6447cbfaaa7a08377aee31a6..d7e75ae365860084ccea9371790559fe9fba237b 100644 GIT binary patch delta 533 zcmez7u-0e8YDWIVlGKpQ+|;1doWzpMvee1*8Rv4800pA*^HMh(GJR(f%`45dS4crq zx7mj&n{l%tYa9FIb<7+H9X?D+jFY$X98pG7uV8D7X+{pO2%`whGLQ-wdvX!qE+DA+3)C|F?_HaUP_WU~&xAEP|X5RidL8eqbc(*+hNqM2>0g6_Y`EP|7SU|MWd zV64qk1cfleY=e;0WCI~~MOYxJ$0`8*l95@ghi2?#Sy6d@bg_dX@|%|mnKHuzaq?htq8Utls2-YpQBqtO&7M^C*xJpe5`P#G_Vi0A!a18&r2^Ri D2)4e* delta 72 zcmV-O0Js0GO!h#qs{xb70g { + return await this.db + .updateTable('asset_exif') + .set({ dateTimeOriginal: sql`"dateTimeOriginal" + ${(delta ?? 0) + ' minute'}::interval`, timeZone }) + .where('assetId', 'in', ids) + .returning(['assetId', 'dateTimeOriginal', 'timeZone']) + .execute(); + } + async upsertJobStatus(...jobStatus: Insertable[]): Promise { if (jobStatus.length === 0) { return; diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 646173597..7b29b8ab9 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -468,6 +468,33 @@ describe(AssetService.name, () => { }); expect(mocks.asset.updateAll).toHaveBeenCalled(); }); + + it('should update exif table if dateTimeRelative and timeZone field is provided', async () => { + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + const dateTimeRelative = 35; + const timeZone = 'UTC+2'; + mocks.asset.updateDateTimeOriginal.mockResolvedValue([ + { assetId: 'asset-1', dateTimeOriginal: new Date('2020-02-25T04:41:00'), timeZone }, + ]); + await sut.updateAll(authStub.admin, { + ids: ['asset-1'], + dateTimeRelative, + timeZone, + }); + expect(mocks.asset.updateDateTimeOriginal).toHaveBeenCalledWith(['asset-1'], dateTimeRelative, timeZone); + expect(mocks.job.queueAll).toHaveBeenCalledWith([ + { + name: JobName.SidecarWrite, + data: { + id: 'asset-1', + dateTimeOriginal: '2020-02-25T06:41:00.000+02:00', + description: undefined, + latitude: undefined, + longitude: undefined, + }, + }, + ]); + }); }); describe('deleteAll', () => { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 864a9cc51..9a2c58070 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -113,22 +113,48 @@ export class AssetService extends BaseService { } async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise { - const { ids, description, dateTimeOriginal, latitude, longitude, ...options } = dto; + const { ids, description, dateTimeOriginal, dateTimeRelative, timeZone, latitude, longitude, ...options } = dto; await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids }); - if ( - description !== undefined || - dateTimeOriginal !== undefined || - latitude !== undefined || - longitude !== undefined - ) { + const staticValuesChanged = + description !== undefined || dateTimeOriginal !== undefined || latitude !== undefined || longitude !== undefined; + + if (staticValuesChanged) { await this.assetRepository.updateAllExif(ids, { description, dateTimeOriginal, latitude, longitude }); - await this.jobRepository.queueAll( - ids.map((id) => ({ - name: JobName.SidecarWrite, - data: { id, description, dateTimeOriginal, latitude, longitude }, - })), - ); + } + + const assets = + (dateTimeRelative !== undefined && dateTimeRelative !== 0) || timeZone !== undefined + ? await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone) + : null; + + const dateTimesWithTimezone = + assets?.map((asset) => { + const isoString = asset.dateTimeOriginal?.toISOString(); + let dateTime = isoString ? DateTime.fromISO(isoString) : null; + + if (dateTime && asset.timeZone) { + dateTime = dateTime.setZone(asset.timeZone); + } + + return { + assetId: asset.assetId, + dateTimeOriginal: dateTime?.toISO() ?? null, + }; + }) ?? null; + + if (staticValuesChanged || dateTimesWithTimezone) { + const entries: JobItem[] = (dateTimesWithTimezone ?? ids).map((entry: any) => ({ + name: JobName.SidecarWrite, + data: { + id: entry.assetId ?? entry, + description, + dateTimeOriginal: entry.dateTimeOriginal ?? dateTimeOriginal, + latitude, + longitude, + }, + })); + await this.jobRepository.queueAll(entries); } if ( diff --git a/server/src/validation.spec.ts b/server/src/validation.spec.ts index 7cd782622..631ba60a6 100644 --- a/server/src/validation.spec.ts +++ b/server/src/validation.spec.ts @@ -1,7 +1,8 @@ import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; import { DateTime } from 'luxon'; -import { IsDateStringFormat, MaxDateString } from 'src/validation'; +import { IsDateStringFormat, IsNotSiblingOf, MaxDateString, Optional } from 'src/validation'; +import { describe } from 'vitest'; describe('Validation', () => { describe('MaxDateString', () => { @@ -54,4 +55,38 @@ describe('Validation', () => { await expect(validate(dto)).resolves.toHaveLength(1); }); }); + + describe('IsNotSiblingOf', () => { + class MyDto { + @IsNotSiblingOf(['attribute2']) + @Optional() + attribute1?: string; + + @IsNotSiblingOf(['attribute1', 'attribute3']) + @Optional() + attribute2?: string; + + @IsNotSiblingOf(['attribute2']) + @Optional() + attribute3?: string; + + @Optional() + unrelatedAttribute?: string; + } + + it('passes when only one attribute is present', async () => { + const dto = plainToInstance(MyDto, { attribute1: 'value1', unrelatedAttribute: 'value2' }); + await expect(validate(dto)).resolves.toHaveLength(0); + }); + + it('fails when colliding attributes are present', async () => { + const dto = plainToInstance(MyDto, { attribute1: 'value1', attribute2: 'value2' }); + await expect(validate(dto)).resolves.toHaveLength(2); + }); + + it('passes when no colliding attributes are present', async () => { + const dto = plainToInstance(MyDto, { attribute1: 'value1', attribute3: 'value2' }); + await expect(validate(dto)).resolves.toHaveLength(0); + }); + }); }); diff --git a/server/src/validation.ts b/server/src/validation.ts index 3f7e1c6f3..e583f6a44 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -22,11 +22,13 @@ import { Validate, ValidateBy, ValidateIf, + ValidationArguments, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface, buildMessage, isDateString, + isDefined, } from 'class-validator'; import { CronJob } from 'cron'; import { DateTime } from 'luxon'; @@ -146,6 +148,27 @@ export function Optional({ nullable, emptyToNull, ...validationOptions }: Option return applyDecorators(...decorators); } +export function IsNotSiblingOf(siblings: string[], validationOptions?: ValidationOptions) { + return ValidateBy( + { + name: 'isNotSiblingOf', + constraints: siblings, + validator: { + validate(value: any, args: ValidationArguments) { + if (!isDefined(value)) { + return true; + } + return args.constraints.filter((prop) => isDefined((args.object as any)[prop])).length === 0; + }, + defaultMessage: (args: ValidationArguments) => { + return `${args.property} cannot exist alongside any of the following properties: ${args.constraints.join(', ')}`; + }, + }, + }, + validationOptions, + ); +} + export const ValidateHexColor = () => { const decorators = [ IsHexColor(), diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 6fca29d98..79e3d506f 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -8,6 +8,7 @@ export const newAssetRepositoryMock = (): Mocked (isShowChangeDate = false)} /> diff --git a/web/src/lib/components/elements/duration-input.svelte b/web/src/lib/components/elements/duration-input.svelte new file mode 100644 index 000000000..1aebe1764 --- /dev/null +++ b/web/src/lib/components/elements/duration-input.svelte @@ -0,0 +1,52 @@ + + +
+ + + + +
diff --git a/web/src/lib/components/photos-page/actions/change-date-action.svelte b/web/src/lib/components/photos-page/actions/change-date-action.svelte index 5f65fdd74..300779871 100644 --- a/web/src/lib/components/photos-page/actions/change-date-action.svelte +++ b/web/src/lib/components/photos-page/actions/change-date-action.svelte @@ -1,14 +1,18 @@ (confirmed ? handleConfirm() : onCancel())} > {#snippet promptSnippet()} -
-
- - + {#if withDuration} +
+ + +
- {#if timezoneInput} -
- handleOnSelect(option)} - /> + {/if} +
+
+
+ +
- {/if} +
+
+ + +
+
+ {#if timezoneInput} +
+ handleOnSelect(option)} + /> +
+ {/if} +
+ {$t('edit_date_and_time_by_offset_interval', { values: { from: intervalFrom, to: intervalTo } })} +
+
{/snippet} diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index c160c6592..a1147b708 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -151,6 +151,12 @@ export function formatGroupTitle(_date: DateTime): string { export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string => date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts); +export const getDateTimeOffsetLocaleString = (date: DateTime, opts?: LocaleOptions): string => + date.toLocaleString( + { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'longOffset' }, + opts, + ); + export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): TimelineAsset => { if (isTimelineAsset(unknownAsset)) { return unknownAsset;