diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index c1e9f9dfb8..5e9d90ddc6 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 d953f25c8a..4ddadf7348 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 571badf029..d7e75ae365 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -14,6 +14,7 @@ class AssetBulkUpdateDto { /// Returns a new [AssetBulkUpdateDto] instance. AssetBulkUpdateDto({ this.dateTimeOriginal, + this.dateTimeRelative, this.description, this.duplicateId, this.ids = const [], @@ -21,6 +22,7 @@ class AssetBulkUpdateDto { this.latitude, this.longitude, this.rating, + this.timeZone, this.visibility, }); @@ -32,6 +34,14 @@ class AssetBulkUpdateDto { /// String? dateTimeOriginal; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + num? dateTimeRelative; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -78,6 +88,14 @@ class AssetBulkUpdateDto { /// num? rating; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? timeZone; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -89,6 +107,7 @@ class AssetBulkUpdateDto { @override bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto && other.dateTimeOriginal == dateTimeOriginal && + other.dateTimeRelative == dateTimeRelative && other.description == description && other.duplicateId == duplicateId && _deepEquality.equals(other.ids, ids) && @@ -96,12 +115,14 @@ class AssetBulkUpdateDto { other.latitude == latitude && other.longitude == longitude && other.rating == rating && + other.timeZone == timeZone && other.visibility == visibility; @override int get hashCode => // ignore: unnecessary_parenthesis (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) + + (dateTimeRelative == null ? 0 : dateTimeRelative!.hashCode) + (description == null ? 0 : description!.hashCode) + (duplicateId == null ? 0 : duplicateId!.hashCode) + (ids.hashCode) + @@ -109,10 +130,11 @@ class AssetBulkUpdateDto { (latitude == null ? 0 : latitude!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) + (rating == null ? 0 : rating!.hashCode) + + (timeZone == null ? 0 : timeZone!.hashCode) + (visibility == null ? 0 : visibility!.hashCode); @override - String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, description=$description, duplicateId=$duplicateId, ids=$ids, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating, visibility=$visibility]'; + String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, dateTimeRelative=$dateTimeRelative, description=$description, duplicateId=$duplicateId, ids=$ids, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating, timeZone=$timeZone, visibility=$visibility]'; Map toJson() { final json = {}; @@ -121,6 +143,11 @@ class AssetBulkUpdateDto { } else { // json[r'dateTimeOriginal'] = null; } + if (this.dateTimeRelative != null) { + json[r'dateTimeRelative'] = this.dateTimeRelative; + } else { + // json[r'dateTimeRelative'] = null; + } if (this.description != null) { json[r'description'] = this.description; } else { @@ -152,6 +179,11 @@ class AssetBulkUpdateDto { } else { // json[r'rating'] = null; } + if (this.timeZone != null) { + json[r'timeZone'] = this.timeZone; + } else { + // json[r'timeZone'] = null; + } if (this.visibility != null) { json[r'visibility'] = this.visibility; } else { @@ -170,6 +202,7 @@ class AssetBulkUpdateDto { return AssetBulkUpdateDto( dateTimeOriginal: mapValueOfType(json, r'dateTimeOriginal'), + dateTimeRelative: num.parse('${json[r'dateTimeRelative']}'), description: mapValueOfType(json, r'description'), duplicateId: mapValueOfType(json, r'duplicateId'), ids: json[r'ids'] is Iterable @@ -179,6 +212,7 @@ class AssetBulkUpdateDto { latitude: num.parse('${json[r'latitude']}'), longitude: num.parse('${json[r'longitude']}'), rating: num.parse('${json[r'rating']}'), + timeZone: mapValueOfType(json, r'timeZone'), visibility: AssetVisibility.fromJson(json[r'visibility']), ); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 74a1407096..e8b5df9dc1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -9979,6 +9979,9 @@ "dateTimeOriginal": { "type": "string" }, + "dateTimeRelative": { + "type": "number" + }, "description": { "type": "string" }, @@ -10007,6 +10010,9 @@ "minimum": -1, "type": "number" }, + "timeZone": { + "type": "string" + }, "visibility": { "allOf": [ { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index a1a04ea566..5011e065eb 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -456,6 +456,7 @@ export type AssetMediaResponseDto = { }; export type AssetBulkUpdateDto = { dateTimeOriginal?: string; + dateTimeRelative?: number; description?: string; duplicateId?: string | null; ids: string[]; @@ -463,6 +464,7 @@ export type AssetBulkUpdateDto = { latitude?: number; longitude?: number; rating?: number; + timeZone?: string; visibility?: AssetVisibility; }; export type AssetBulkUploadCheckItem = { diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 5728d21646..31e5679e76 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -8,6 +8,7 @@ import { IsNotEmpty, IsPositive, IsString, + IsTimeZone, Max, Min, ValidateIf, @@ -15,7 +16,7 @@ import { import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AssetType, AssetVisibility } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; -import { Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; +import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; export class DeviceIdDto { @IsNotEmpty() @@ -65,6 +66,16 @@ export class AssetBulkUpdateDto extends UpdateAssetBase { @Optional() duplicateId?: string | null; + + @IsNotSiblingOf(['dateTimeOriginal']) + @Optional() + @IsInt() + dateTimeRelative?: number; + + @IsNotSiblingOf(['dateTimeOriginal']) + @IsTimeZone() + @Optional() + timeZone?: string; } export class UpdateAssetDto extends UpdateAssetBase { diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index f7a4d1402d..712fb08a50 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -7,6 +7,18 @@ set where "assetId" in ($2) +-- AssetRepository.updateDateTimeOriginal +update "asset_exif" +set + "dateTimeOriginal" = "dateTimeOriginal" + $1::interval, + "timeZone" = $2 +where + "assetId" in ($3) +returning + "assetId", + "dateTimeOriginal", + "timeZone" + -- AssetRepository.getByDayOfYear with "res" as ( diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 8aa25c4a6a..61ccbf6541 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -169,6 +169,21 @@ export class AssetRepository { await this.db.updateTable('asset_exif').set(options).where('assetId', 'in', ids).execute(); } + @GenerateSql({ params: [[DummyValue.UUID], DummyValue.NUMBER, DummyValue.STRING] }) + @Chunked() + async updateDateTimeOriginal( + ids: string[], + delta?: number, + timeZone?: string, + ): Promise<{ assetId: string; dateTimeOriginal: Date | null; timeZone: string | null }[]> { + 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 6461735976..7b29b8ab96 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 864a9cc512..9a2c580707 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 7cd7826223..631ba60a60 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 3f7e1c6f3b..e583f6a44e 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 6fca29d98e..79e3d506f3 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 0000000000..1aebe17640 --- /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 5f65fdd744..3007798719 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 c160c65922..a1147b708f 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;