mirror of
https://github.com/immich-app/immich.git
synced 2025-08-11 09:16:31 -04:00
feat: batch change date and time relatively (#17717)
Co-authored-by: marcel.kuehne <> Co-authored-by: Zack Pollard <zackpollard@ymail.com>
This commit is contained in:
parent
df2525ee08
commit
011a667314
@ -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', () => {
|
describe('POST /assets', () => {
|
||||||
beforeAll(setupTests, 30_000);
|
beforeAll(setupTests, 30_000);
|
||||||
|
|
||||||
|
@ -749,6 +749,7 @@
|
|||||||
"date_of_birth_saved": "Date of birth saved successfully",
|
"date_of_birth_saved": "Date of birth saved successfully",
|
||||||
"date_range": "Date range",
|
"date_range": "Date range",
|
||||||
"day": "Day",
|
"day": "Day",
|
||||||
|
"days": "Days",
|
||||||
"deduplicate_all": "Deduplicate All",
|
"deduplicate_all": "Deduplicate All",
|
||||||
"deduplication_criteria_1": "Image size in bytes",
|
"deduplication_criteria_1": "Image size in bytes",
|
||||||
"deduplication_criteria_2": "Count of EXIF data",
|
"deduplication_criteria_2": "Count of EXIF data",
|
||||||
@ -837,6 +838,8 @@
|
|||||||
"edit_date": "Edit date",
|
"edit_date": "Edit date",
|
||||||
"edit_date_and_time": "Edit date and time",
|
"edit_date_and_time": "Edit date and time",
|
||||||
"edit_date_and_time_action_prompt": "{count} date and time edited",
|
"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": "Edit description",
|
||||||
"edit_description_prompt": "Please select a new description:",
|
"edit_description_prompt": "Please select a new description:",
|
||||||
"edit_exclusion_pattern": "Edit exclusion pattern",
|
"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",
|
"home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping",
|
||||||
"host": "Host",
|
"host": "Host",
|
||||||
"hour": "Hour",
|
"hour": "Hour",
|
||||||
|
"hours": "Hours",
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
"idle": "Idle",
|
"idle": "Idle",
|
||||||
"ignore_icloud_photos": "Ignore iCloud photos",
|
"ignore_icloud_photos": "Ignore iCloud photos",
|
||||||
@ -1295,6 +1299,7 @@
|
|||||||
"merged_people_count": "Merged {count, plural, one {# person} other {# people}}",
|
"merged_people_count": "Merged {count, plural, one {# person} other {# people}}",
|
||||||
"minimize": "Minimize",
|
"minimize": "Minimize",
|
||||||
"minute": "Minute",
|
"minute": "Minute",
|
||||||
|
"minutes": "Minutes",
|
||||||
"missing": "Missing",
|
"missing": "Missing",
|
||||||
"model": "Model",
|
"model": "Model",
|
||||||
"month": "Month",
|
"month": "Month",
|
||||||
@ -1368,6 +1373,7 @@
|
|||||||
"oauth": "OAuth",
|
"oauth": "OAuth",
|
||||||
"official_immich_resources": "Official Immich Resources",
|
"official_immich_resources": "Official Immich Resources",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
|
"offset": "Offset",
|
||||||
"ok": "Ok",
|
"ok": "Ok",
|
||||||
"oldest_first": "Oldest first",
|
"oldest_first": "Oldest first",
|
||||||
"on_this_device": "On this device",
|
"on_this_device": "On this device",
|
||||||
|
36
mobile/openapi/lib/model/asset_bulk_update_dto.dart
generated
36
mobile/openapi/lib/model/asset_bulk_update_dto.dart
generated
@ -14,6 +14,7 @@ class AssetBulkUpdateDto {
|
|||||||
/// Returns a new [AssetBulkUpdateDto] instance.
|
/// Returns a new [AssetBulkUpdateDto] instance.
|
||||||
AssetBulkUpdateDto({
|
AssetBulkUpdateDto({
|
||||||
this.dateTimeOriginal,
|
this.dateTimeOriginal,
|
||||||
|
this.dateTimeRelative,
|
||||||
this.description,
|
this.description,
|
||||||
this.duplicateId,
|
this.duplicateId,
|
||||||
this.ids = const [],
|
this.ids = const [],
|
||||||
@ -21,6 +22,7 @@ class AssetBulkUpdateDto {
|
|||||||
this.latitude,
|
this.latitude,
|
||||||
this.longitude,
|
this.longitude,
|
||||||
this.rating,
|
this.rating,
|
||||||
|
this.timeZone,
|
||||||
this.visibility,
|
this.visibility,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -32,6 +34,14 @@ class AssetBulkUpdateDto {
|
|||||||
///
|
///
|
||||||
String? dateTimeOriginal;
|
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
|
/// 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
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
@ -78,6 +88,14 @@ class AssetBulkUpdateDto {
|
|||||||
///
|
///
|
||||||
num? rating;
|
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
|
/// 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
|
/// does not include a default value (using the "default:" property), however, the generated
|
||||||
@ -89,6 +107,7 @@ class AssetBulkUpdateDto {
|
|||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
|
bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
|
||||||
other.dateTimeOriginal == dateTimeOriginal &&
|
other.dateTimeOriginal == dateTimeOriginal &&
|
||||||
|
other.dateTimeRelative == dateTimeRelative &&
|
||||||
other.description == description &&
|
other.description == description &&
|
||||||
other.duplicateId == duplicateId &&
|
other.duplicateId == duplicateId &&
|
||||||
_deepEquality.equals(other.ids, ids) &&
|
_deepEquality.equals(other.ids, ids) &&
|
||||||
@ -96,12 +115,14 @@ class AssetBulkUpdateDto {
|
|||||||
other.latitude == latitude &&
|
other.latitude == latitude &&
|
||||||
other.longitude == longitude &&
|
other.longitude == longitude &&
|
||||||
other.rating == rating &&
|
other.rating == rating &&
|
||||||
|
other.timeZone == timeZone &&
|
||||||
other.visibility == visibility;
|
other.visibility == visibility;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) +
|
(dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) +
|
||||||
|
(dateTimeRelative == null ? 0 : dateTimeRelative!.hashCode) +
|
||||||
(description == null ? 0 : description!.hashCode) +
|
(description == null ? 0 : description!.hashCode) +
|
||||||
(duplicateId == null ? 0 : duplicateId!.hashCode) +
|
(duplicateId == null ? 0 : duplicateId!.hashCode) +
|
||||||
(ids.hashCode) +
|
(ids.hashCode) +
|
||||||
@ -109,10 +130,11 @@ class AssetBulkUpdateDto {
|
|||||||
(latitude == null ? 0 : latitude!.hashCode) +
|
(latitude == null ? 0 : latitude!.hashCode) +
|
||||||
(longitude == null ? 0 : longitude!.hashCode) +
|
(longitude == null ? 0 : longitude!.hashCode) +
|
||||||
(rating == null ? 0 : rating!.hashCode) +
|
(rating == null ? 0 : rating!.hashCode) +
|
||||||
|
(timeZone == null ? 0 : timeZone!.hashCode) +
|
||||||
(visibility == null ? 0 : visibility!.hashCode);
|
(visibility == null ? 0 : visibility!.hashCode);
|
||||||
|
|
||||||
@override
|
@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<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@ -121,6 +143,11 @@ class AssetBulkUpdateDto {
|
|||||||
} else {
|
} else {
|
||||||
// json[r'dateTimeOriginal'] = null;
|
// json[r'dateTimeOriginal'] = null;
|
||||||
}
|
}
|
||||||
|
if (this.dateTimeRelative != null) {
|
||||||
|
json[r'dateTimeRelative'] = this.dateTimeRelative;
|
||||||
|
} else {
|
||||||
|
// json[r'dateTimeRelative'] = null;
|
||||||
|
}
|
||||||
if (this.description != null) {
|
if (this.description != null) {
|
||||||
json[r'description'] = this.description;
|
json[r'description'] = this.description;
|
||||||
} else {
|
} else {
|
||||||
@ -152,6 +179,11 @@ class AssetBulkUpdateDto {
|
|||||||
} else {
|
} else {
|
||||||
// json[r'rating'] = null;
|
// json[r'rating'] = null;
|
||||||
}
|
}
|
||||||
|
if (this.timeZone != null) {
|
||||||
|
json[r'timeZone'] = this.timeZone;
|
||||||
|
} else {
|
||||||
|
// json[r'timeZone'] = null;
|
||||||
|
}
|
||||||
if (this.visibility != null) {
|
if (this.visibility != null) {
|
||||||
json[r'visibility'] = this.visibility;
|
json[r'visibility'] = this.visibility;
|
||||||
} else {
|
} else {
|
||||||
@ -170,6 +202,7 @@ class AssetBulkUpdateDto {
|
|||||||
|
|
||||||
return AssetBulkUpdateDto(
|
return AssetBulkUpdateDto(
|
||||||
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
|
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
|
||||||
|
dateTimeRelative: num.parse('${json[r'dateTimeRelative']}'),
|
||||||
description: mapValueOfType<String>(json, r'description'),
|
description: mapValueOfType<String>(json, r'description'),
|
||||||
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
|
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
|
||||||
ids: json[r'ids'] is Iterable
|
ids: json[r'ids'] is Iterable
|
||||||
@ -179,6 +212,7 @@ class AssetBulkUpdateDto {
|
|||||||
latitude: num.parse('${json[r'latitude']}'),
|
latitude: num.parse('${json[r'latitude']}'),
|
||||||
longitude: num.parse('${json[r'longitude']}'),
|
longitude: num.parse('${json[r'longitude']}'),
|
||||||
rating: num.parse('${json[r'rating']}'),
|
rating: num.parse('${json[r'rating']}'),
|
||||||
|
timeZone: mapValueOfType<String>(json, r'timeZone'),
|
||||||
visibility: AssetVisibility.fromJson(json[r'visibility']),
|
visibility: AssetVisibility.fromJson(json[r'visibility']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -9979,6 +9979,9 @@
|
|||||||
"dateTimeOriginal": {
|
"dateTimeOriginal": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"dateTimeRelative": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@ -10007,6 +10010,9 @@
|
|||||||
"minimum": -1,
|
"minimum": -1,
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
|
"timeZone": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"visibility": {
|
"visibility": {
|
||||||
"allOf": [
|
"allOf": [
|
||||||
{
|
{
|
||||||
|
@ -456,6 +456,7 @@ export type AssetMediaResponseDto = {
|
|||||||
};
|
};
|
||||||
export type AssetBulkUpdateDto = {
|
export type AssetBulkUpdateDto = {
|
||||||
dateTimeOriginal?: string;
|
dateTimeOriginal?: string;
|
||||||
|
dateTimeRelative?: number;
|
||||||
description?: string;
|
description?: string;
|
||||||
duplicateId?: string | null;
|
duplicateId?: string | null;
|
||||||
ids: string[];
|
ids: string[];
|
||||||
@ -463,6 +464,7 @@ export type AssetBulkUpdateDto = {
|
|||||||
latitude?: number;
|
latitude?: number;
|
||||||
longitude?: number;
|
longitude?: number;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
|
timeZone?: string;
|
||||||
visibility?: AssetVisibility;
|
visibility?: AssetVisibility;
|
||||||
};
|
};
|
||||||
export type AssetBulkUploadCheckItem = {
|
export type AssetBulkUploadCheckItem = {
|
||||||
|
@ -8,6 +8,7 @@ import {
|
|||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsPositive,
|
IsPositive,
|
||||||
IsString,
|
IsString,
|
||||||
|
IsTimeZone,
|
||||||
Max,
|
Max,
|
||||||
Min,
|
Min,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
@ -15,7 +16,7 @@ import {
|
|||||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { AssetType, AssetVisibility } from 'src/enum';
|
import { AssetType, AssetVisibility } from 'src/enum';
|
||||||
import { AssetStats } from 'src/repositories/asset.repository';
|
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 {
|
export class DeviceIdDto {
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ -65,6 +66,16 @@ export class AssetBulkUpdateDto extends UpdateAssetBase {
|
|||||||
|
|
||||||
@Optional()
|
@Optional()
|
||||||
duplicateId?: string | null;
|
duplicateId?: string | null;
|
||||||
|
|
||||||
|
@IsNotSiblingOf(['dateTimeOriginal'])
|
||||||
|
@Optional()
|
||||||
|
@IsInt()
|
||||||
|
dateTimeRelative?: number;
|
||||||
|
|
||||||
|
@IsNotSiblingOf(['dateTimeOriginal'])
|
||||||
|
@IsTimeZone()
|
||||||
|
@Optional()
|
||||||
|
timeZone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateAssetDto extends UpdateAssetBase {
|
export class UpdateAssetDto extends UpdateAssetBase {
|
||||||
|
@ -7,6 +7,18 @@ set
|
|||||||
where
|
where
|
||||||
"assetId" in ($2)
|
"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
|
-- AssetRepository.getByDayOfYear
|
||||||
with
|
with
|
||||||
"res" as (
|
"res" as (
|
||||||
|
@ -169,6 +169,21 @@ export class AssetRepository {
|
|||||||
await this.db.updateTable('asset_exif').set(options).where('assetId', 'in', ids).execute();
|
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<AssetJobStatusTable>[]): Promise<void> {
|
async upsertJobStatus(...jobStatus: Insertable<AssetJobStatusTable>[]): Promise<void> {
|
||||||
if (jobStatus.length === 0) {
|
if (jobStatus.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
@ -468,6 +468,33 @@ describe(AssetService.name, () => {
|
|||||||
});
|
});
|
||||||
expect(mocks.asset.updateAll).toHaveBeenCalled();
|
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', () => {
|
describe('deleteAll', () => {
|
||||||
|
@ -113,22 +113,48 @@ export class AssetService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
|
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
|
||||||
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 });
|
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids });
|
||||||
|
|
||||||
if (
|
const staticValuesChanged =
|
||||||
description !== undefined ||
|
description !== undefined || dateTimeOriginal !== undefined || latitude !== undefined || longitude !== undefined;
|
||||||
dateTimeOriginal !== undefined ||
|
|
||||||
latitude !== undefined ||
|
if (staticValuesChanged) {
|
||||||
longitude !== undefined
|
|
||||||
) {
|
|
||||||
await this.assetRepository.updateAllExif(ids, { description, dateTimeOriginal, latitude, longitude });
|
await this.assetRepository.updateAllExif(ids, { description, dateTimeOriginal, latitude, longitude });
|
||||||
await this.jobRepository.queueAll(
|
}
|
||||||
ids.map((id) => ({
|
|
||||||
name: JobName.SidecarWrite,
|
const assets =
|
||||||
data: { id, description, dateTimeOriginal, latitude, longitude },
|
(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 (
|
if (
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { plainToInstance } from 'class-transformer';
|
import { plainToInstance } from 'class-transformer';
|
||||||
import { validate } from 'class-validator';
|
import { validate } from 'class-validator';
|
||||||
import { DateTime } from 'luxon';
|
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('Validation', () => {
|
||||||
describe('MaxDateString', () => {
|
describe('MaxDateString', () => {
|
||||||
@ -54,4 +55,38 @@ describe('Validation', () => {
|
|||||||
await expect(validate(dto)).resolves.toHaveLength(1);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -22,11 +22,13 @@ import {
|
|||||||
Validate,
|
Validate,
|
||||||
ValidateBy,
|
ValidateBy,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
|
ValidationArguments,
|
||||||
ValidationOptions,
|
ValidationOptions,
|
||||||
ValidatorConstraint,
|
ValidatorConstraint,
|
||||||
ValidatorConstraintInterface,
|
ValidatorConstraintInterface,
|
||||||
buildMessage,
|
buildMessage,
|
||||||
isDateString,
|
isDateString,
|
||||||
|
isDefined,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { CronJob } from 'cron';
|
import { CronJob } from 'cron';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
@ -146,6 +148,27 @@ export function Optional({ nullable, emptyToNull, ...validationOptions }: Option
|
|||||||
return applyDecorators(...decorators);
|
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 = () => {
|
export const ValidateHexColor = () => {
|
||||||
const decorators = [
|
const decorators = [
|
||||||
IsHexColor(),
|
IsHexColor(),
|
||||||
|
@ -8,6 +8,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
|||||||
createAll: vitest.fn(),
|
createAll: vitest.fn(),
|
||||||
upsertExif: vitest.fn(),
|
upsertExif: vitest.fn(),
|
||||||
updateAllExif: vitest.fn(),
|
updateAllExif: vitest.fn(),
|
||||||
|
updateDateTimeOriginal: vitest.fn().mockResolvedValue([]),
|
||||||
upsertJobStatus: vitest.fn(),
|
upsertJobStatus: vitest.fn(),
|
||||||
getByDayOfYear: vitest.fn(),
|
getByDayOfYear: vitest.fn(),
|
||||||
getByIds: vitest.fn().mockResolvedValue([]),
|
getByIds: vitest.fn().mockResolvedValue([]),
|
||||||
|
@ -5,7 +5,10 @@
|
|||||||
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
|
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
|
||||||
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
|
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
|
import ChangeDate, {
|
||||||
|
type AbsoluteResult,
|
||||||
|
type RelativeResult,
|
||||||
|
} from '$lib/components/shared-components/change-date.svelte';
|
||||||
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
|
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
@ -147,10 +150,12 @@
|
|||||||
|
|
||||||
let isShowChangeDate = $state(false);
|
let isShowChangeDate = $state(false);
|
||||||
|
|
||||||
async function handleConfirmChangeDate(dateTimeOriginal: string) {
|
async function handleConfirmChangeDate(result: AbsoluteResult | RelativeResult) {
|
||||||
isShowChangeDate = false;
|
isShowChangeDate = false;
|
||||||
try {
|
try {
|
||||||
await updateAsset({ id: asset.id, updateAssetDto: { dateTimeOriginal } });
|
if (result.mode === 'absolute') {
|
||||||
|
await updateAsset({ id: asset.id, updateAssetDto: { dateTimeOriginal: result.date } });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_change_date'));
|
handleError(error, $t('errors.unable_to_change_date'));
|
||||||
}
|
}
|
||||||
@ -369,6 +374,7 @@
|
|||||||
<ChangeDate
|
<ChangeDate
|
||||||
initialDate={dateTime}
|
initialDate={dateTime}
|
||||||
initialTimeZone={timeZone ?? ''}
|
initialTimeZone={timeZone ?? ''}
|
||||||
|
withDuration={false}
|
||||||
onConfirm={handleConfirmChangeDate}
|
onConfirm={handleConfirmChangeDate}
|
||||||
onCancel={() => (isShowChangeDate = false)}
|
onCancel={() => (isShowChangeDate = false)}
|
||||||
/>
|
/>
|
||||||
|
52
web/src/lib/components/elements/duration-input.svelte
Normal file
52
web/src/lib/components/elements/duration-input.svelte
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
|
import { Duration } from 'luxon';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: number;
|
||||||
|
class?: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { value = $bindable(), class: className = '', ...rest }: Props = $props();
|
||||||
|
|
||||||
|
function minToParts(minutes: number) {
|
||||||
|
const duration = Duration.fromObject({ minutes: Math.abs(minutes) }).shiftTo('days', 'hours', 'minutes');
|
||||||
|
return {
|
||||||
|
sign: minutes < 0 ? -1 : 1,
|
||||||
|
days: duration.days === 0 ? null : duration.days,
|
||||||
|
hours: duration.hours === 0 ? null : duration.hours,
|
||||||
|
minutes: duration.minutes === 0 ? null : duration.minutes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function partsToMin(sign: number, days: number | null, hours: number | null, minutes: number | null) {
|
||||||
|
return (
|
||||||
|
sign *
|
||||||
|
Duration.fromObject({ days: days ?? 0, hours: hours ?? 0, minutes: minutes ?? 0 }).shiftTo('minutes').minutes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initial = minToParts(value);
|
||||||
|
let sign = $state(initial.sign);
|
||||||
|
let days = $state(initial.days);
|
||||||
|
let hours = $state(initial.hours);
|
||||||
|
let minutes = $state(initial.minutes);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
value = partsToMin(sign, days, hours, minutes);
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleSign() {
|
||||||
|
sign = -sign;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={`flex gap-2 ${className}`} {...rest}>
|
||||||
|
<button type="button" class="w-8 text-xl font-bold leading-none" onclick={toggleSign} title="Toggle sign">
|
||||||
|
{sign >= 0 ? '+' : '-'}
|
||||||
|
</button>
|
||||||
|
<input type="number" min="0" placeholder={$t('days')} class="w-1/3" bind:value={days} />
|
||||||
|
<input type="number" min="0" max="23" placeholder={$t('hours')} class="w-1/3" bind:value={hours} />
|
||||||
|
<input type="number" min="0" max="59" placeholder={$t('minutes')} class="w-1/3" bind:value={minutes} />
|
||||||
|
</div>
|
@ -1,14 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
|
import ChangeDate, {
|
||||||
|
type AbsoluteResult,
|
||||||
|
type RelativeResult,
|
||||||
|
} from '$lib/components/shared-components/change-date.svelte';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { getSelectedAssets } from '$lib/utils/asset-utils';
|
import { getSelectedAssets } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { updateAssets } from '@immich/sdk';
|
import { updateAssets } from '@immich/sdk';
|
||||||
import { mdiCalendarEditOutline } from '@mdi/js';
|
import { mdiCalendarEditOutline } from '@mdi/js';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime, Duration } from 'luxon';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||||
|
import { fromTimelinePlainDateTime } from '$lib/utils/timeline-util.js';
|
||||||
interface Props {
|
interface Props {
|
||||||
menuItem?: boolean;
|
menuItem?: boolean;
|
||||||
}
|
}
|
||||||
@ -18,12 +22,49 @@
|
|||||||
|
|
||||||
let isShowChangeDate = $state(false);
|
let isShowChangeDate = $state(false);
|
||||||
|
|
||||||
const handleConfirm = async (dateTimeOriginal: string) => {
|
let currentInterval = $derived.by(() => {
|
||||||
|
if (isShowChangeDate) {
|
||||||
|
const ids = getSelectedAssets(getOwnedAssets(), $user);
|
||||||
|
const assets = getOwnedAssets().filter((asset) => ids.includes(asset.id));
|
||||||
|
const imageTimestamps = assets.map((asset) => {
|
||||||
|
let localDateTime = fromTimelinePlainDateTime(asset.localDateTime);
|
||||||
|
let fileCreatedAt = fromTimelinePlainDateTime(asset.fileCreatedAt);
|
||||||
|
let offsetMinutes = localDateTime.diff(fileCreatedAt, 'minutes').shiftTo('minutes').minutes;
|
||||||
|
const timeZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`;
|
||||||
|
return fileCreatedAt.setZone('utc', { keepLocalTime: true }).setZone(timeZone);
|
||||||
|
});
|
||||||
|
let minTimestamp = imageTimestamps[0];
|
||||||
|
let maxTimestamp = imageTimestamps[0];
|
||||||
|
for (let current of imageTimestamps) {
|
||||||
|
if (current < minTimestamp) {
|
||||||
|
minTimestamp = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current > maxTimestamp) {
|
||||||
|
maxTimestamp = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { start: minTimestamp, end: maxTimestamp };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleConfirm = async (result: AbsoluteResult | RelativeResult) => {
|
||||||
isShowChangeDate = false;
|
isShowChangeDate = false;
|
||||||
const ids = getSelectedAssets(getOwnedAssets(), $user);
|
const ids = getSelectedAssets(getOwnedAssets(), $user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateAssets({ assetBulkUpdateDto: { ids, dateTimeOriginal } });
|
if (result.mode === 'absolute') {
|
||||||
|
await updateAssets({ assetBulkUpdateDto: { ids, dateTimeOriginal: result.date } });
|
||||||
|
} else if (result.mode === 'relative') {
|
||||||
|
await updateAssets({
|
||||||
|
assetBulkUpdateDto: {
|
||||||
|
ids,
|
||||||
|
dateTimeRelative: result.duration,
|
||||||
|
timeZone: result.timeZone,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_change_date'));
|
handleError(error, $t('errors.unable_to_change_date'));
|
||||||
}
|
}
|
||||||
@ -35,5 +76,10 @@
|
|||||||
<MenuOption text={$t('change_date')} icon={mdiCalendarEditOutline} onClick={() => (isShowChangeDate = true)} />
|
<MenuOption text={$t('change_date')} icon={mdiCalendarEditOutline} onClick={() => (isShowChangeDate = true)} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if isShowChangeDate}
|
{#if isShowChangeDate}
|
||||||
<ChangeDate initialDate={DateTime.now()} onConfirm={handleConfirm} onCancel={() => (isShowChangeDate = false)} />
|
<ChangeDate
|
||||||
|
initialDate={DateTime.now()}
|
||||||
|
{currentInterval}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
onCancel={() => (isShowChangeDate = false)}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -1,15 +1,21 @@
|
|||||||
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
|
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
|
||||||
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
|
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
|
||||||
import { fireEvent, render, screen } from '@testing-library/svelte';
|
import { fireEvent, render, screen } from '@testing-library/svelte';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import ChangeDate from './change-date.svelte';
|
import ChangeDate from './change-date.svelte';
|
||||||
|
|
||||||
describe('ChangeDate component', () => {
|
describe('ChangeDate component', () => {
|
||||||
const initialDate = DateTime.fromISO('2024-01-01');
|
const initialDate = DateTime.fromISO('2024-01-01');
|
||||||
const initialTimeZone = 'Europe/Berlin';
|
const initialTimeZone = 'Europe/Berlin';
|
||||||
|
const currentInterval = {
|
||||||
|
start: DateTime.fromISO('2000-02-01T14:00:00+01:00'),
|
||||||
|
end: DateTime.fromISO('2001-02-01T14:00:00+01:00'),
|
||||||
|
};
|
||||||
const onCancel = vi.fn();
|
const onCancel = vi.fn();
|
||||||
const onConfirm = vi.fn();
|
const onConfirm = vi.fn();
|
||||||
|
|
||||||
|
const getRelativeInputToggle = () => screen.getByTestId('edit-by-offset-switch');
|
||||||
const getDateInput = () => screen.getByLabelText('date_and_time') as HTMLInputElement;
|
const getDateInput = () => screen.getByLabelText('date_and_time') as HTMLInputElement;
|
||||||
const getTimeZoneInput = () => screen.getByLabelText('timezone') as HTMLInputElement;
|
const getTimeZoneInput = () => screen.getByLabelText('timezone') as HTMLInputElement;
|
||||||
const getCancelButton = () => screen.getByText('Cancel');
|
const getCancelButton = () => screen.getByText('Cancel');
|
||||||
@ -37,7 +43,7 @@ describe('ChangeDate component', () => {
|
|||||||
|
|
||||||
await fireEvent.click(getConfirmButton());
|
await fireEvent.click(getConfirmButton());
|
||||||
|
|
||||||
expect(onConfirm).toHaveBeenCalledWith('2024-01-01T00:00:00.000+01:00');
|
expect(onConfirm).toHaveBeenCalledWith({ mode: 'absolute', date: '2024-01-01T00:00:00.000+01:00' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('calls onCancel on cancel', async () => {
|
test('calls onCancel on cancel', async () => {
|
||||||
@ -66,7 +72,112 @@ describe('ChangeDate component', () => {
|
|||||||
|
|
||||||
await fireEvent.click(getConfirmButton());
|
await fireEvent.click(getConfirmButton());
|
||||||
|
|
||||||
expect(onConfirm).toHaveBeenCalledWith('2024-07-01T00:00:00.000+02:00');
|
expect(onConfirm).toHaveBeenCalledWith({ mode: 'absolute', date: '2024-07-01T00:00:00.000+02:00' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('calls onConfirm with correct offset in relative mode', async () => {
|
||||||
|
render(ChangeDate, {
|
||||||
|
props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm },
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(getRelativeInputToggle());
|
||||||
|
|
||||||
|
const dayInput = screen.getByPlaceholderText('days');
|
||||||
|
const hoursInput = screen.getByPlaceholderText('hours');
|
||||||
|
const minutesInput = screen.getByPlaceholderText('minutes');
|
||||||
|
|
||||||
|
const days = 5;
|
||||||
|
const hours = 4;
|
||||||
|
const minutes = 3;
|
||||||
|
|
||||||
|
await fireEvent.input(dayInput, { target: { value: days } });
|
||||||
|
await fireEvent.input(hoursInput, { target: { value: hours } });
|
||||||
|
await fireEvent.input(minutesInput, { target: { value: minutes } });
|
||||||
|
|
||||||
|
await fireEvent.click(getConfirmButton());
|
||||||
|
|
||||||
|
expect(onConfirm).toHaveBeenCalledWith({
|
||||||
|
mode: 'relative',
|
||||||
|
duration: days * 60 * 24 + hours * 60 + minutes,
|
||||||
|
timeZone: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls onConfirm with correct timeZone in relative mode', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(ChangeDate, {
|
||||||
|
props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm },
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.click(getRelativeInputToggle());
|
||||||
|
await user.type(getTimeZoneInput(), initialTimeZone);
|
||||||
|
await user.keyboard('{ArrowDown}');
|
||||||
|
await user.keyboard('{Enter}');
|
||||||
|
|
||||||
|
await user.click(getConfirmButton());
|
||||||
|
expect(onConfirm).toHaveBeenCalledWith({
|
||||||
|
mode: 'relative',
|
||||||
|
duration: 0,
|
||||||
|
timeZone: initialTimeZone,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('correctly handles date preview', () => {
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }),
|
||||||
|
duration: 0,
|
||||||
|
timezone: undefined,
|
||||||
|
expectedResult: 'Jan 1, 2024, 12:00 AM GMT+01:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: DateTime.fromISO('2024-01-01T04:00:00.000+05:00', { setZone: true }),
|
||||||
|
duration: 0,
|
||||||
|
timezone: undefined,
|
||||||
|
expectedResult: 'Jan 1, 2024, 4:00 AM GMT+05:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+00:00', { setZone: true }),
|
||||||
|
duration: 0,
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
expectedResult: 'Jan 1, 2024, 1:00 AM GMT+01:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: DateTime.fromISO('2024-07-01T00:00:00.000+00:00', { setZone: true }),
|
||||||
|
duration: 0,
|
||||||
|
timezone: 'Europe/Berlin',
|
||||||
|
expectedResult: 'Jul 1, 2024, 2:00 AM GMT+02:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }),
|
||||||
|
duration: 1440,
|
||||||
|
timezone: undefined,
|
||||||
|
expectedResult: 'Jan 2, 2024, 12:00 AM GMT+01:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000+01:00', { setZone: true }),
|
||||||
|
duration: -1440,
|
||||||
|
timezone: undefined,
|
||||||
|
expectedResult: 'Dec 31, 2023, 12:00 AM GMT+01:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: DateTime.fromISO('2024-01-01T00:00:00.000-01:00', { setZone: true }),
|
||||||
|
duration: -1440,
|
||||||
|
timezone: 'America/Anchorage',
|
||||||
|
expectedResult: 'Dec 30, 2023, 4:00 PM GMT-09:00',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const component = render(ChangeDate, {
|
||||||
|
props: { initialDate, initialTimeZone, currentInterval, onCancel, onConfirm },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
expect(
|
||||||
|
component.component.calcNewDate(testCase.timestamp, testCase.duration, testCase.timezone),
|
||||||
|
JSON.stringify(testCase),
|
||||||
|
).toBe(testCase.expectedResult);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -5,14 +5,21 @@
|
|||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import DateInput from '../elements/date-input.svelte';
|
import DateInput from '../elements/date-input.svelte';
|
||||||
import Combobox, { type ComboBoxOption } from './combobox.svelte';
|
import Combobox, { type ComboBoxOption } from './combobox.svelte';
|
||||||
|
import DurationInput from '../elements/duration-input.svelte';
|
||||||
|
import { Field, Switch } from '@immich/ui';
|
||||||
|
import { getDateTimeOffsetLocaleString } from '$lib/utils/timeline-util.js';
|
||||||
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
initialDate?: DateTime;
|
initialDate?: DateTime;
|
||||||
initialTimeZone?: string;
|
initialTimeZone?: string;
|
||||||
timezoneInput?: boolean;
|
timezoneInput?: boolean;
|
||||||
|
withDuration?: boolean;
|
||||||
|
currentInterval?: { start: DateTime; end: DateTime };
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onConfirm: (date: string) => void;
|
onConfirm: (result: AbsoluteResult | RelativeResult) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@ -20,10 +27,23 @@
|
|||||||
initialTimeZone = '',
|
initialTimeZone = '',
|
||||||
title = $t('edit_date_and_time'),
|
title = $t('edit_date_and_time'),
|
||||||
timezoneInput = true,
|
timezoneInput = true,
|
||||||
|
withDuration = true,
|
||||||
|
currentInterval = undefined,
|
||||||
onCancel,
|
onCancel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
export type AbsoluteResult = {
|
||||||
|
mode: 'absolute';
|
||||||
|
date: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RelativeResult = {
|
||||||
|
mode: 'relative';
|
||||||
|
duration?: number;
|
||||||
|
timeZone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ZoneOption = {
|
type ZoneOption = {
|
||||||
/**
|
/**
|
||||||
* Timezone name with offset
|
* Timezone name with offset
|
||||||
@ -61,6 +81,10 @@
|
|||||||
valid: boolean;
|
valid: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let showRelative = $state(false);
|
||||||
|
|
||||||
|
let selectedDuration = $state(0);
|
||||||
|
|
||||||
const knownTimezones = Intl.supportedValuesOf('timeZone');
|
const knownTimezones = Intl.supportedValuesOf('timeZone');
|
||||||
|
|
||||||
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
@ -74,7 +98,10 @@
|
|||||||
.filter((zone) => zone.valid)
|
.filter((zone) => zone.valid)
|
||||||
.sort((zoneA, zoneB) => sortTwoZones(zoneA, zoneB));
|
.sort((zoneA, zoneB) => sortTwoZones(zoneA, zoneB));
|
||||||
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
|
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
|
||||||
let selectedOption: ZoneOption | undefined = $state(getPreferredTimeZone(initialDate, userTimeZone, timezones));
|
let selectedAbsoluteOption: ZoneOption | undefined = $state(
|
||||||
|
getPreferredTimeZone(userTimeZone, timezones, initialDate),
|
||||||
|
);
|
||||||
|
let selectedRelativeOption: ZoneOption | undefined = $state(undefined);
|
||||||
|
|
||||||
function zoneOptionForDate(zone: string, date: string) {
|
function zoneOptionForDate(zone: string, date: string) {
|
||||||
const { offsetMinutes, offsetFormat: zoneOffsetAtDate } = getModernOffsetForZoneAndDate(zone, date);
|
const { offsetMinutes, offsetFormat: zoneOffsetAtDate } = getModernOffsetForZoneAndDate(zone, date);
|
||||||
@ -104,16 +131,20 @@
|
|||||||
* to show "Europe/Berlin" instead of the lexicographically first entry "Africa/Blantyre".
|
* to show "Europe/Berlin" instead of the lexicographically first entry "Africa/Blantyre".
|
||||||
*/
|
*/
|
||||||
function getPreferredTimeZone(
|
function getPreferredTimeZone(
|
||||||
date: DateTime,
|
|
||||||
userTimeZone: string,
|
userTimeZone: string,
|
||||||
timezones: ZoneOption[],
|
timezones: ZoneOption[],
|
||||||
|
date?: DateTime,
|
||||||
selectedOption?: ZoneOption,
|
selectedOption?: ZoneOption,
|
||||||
) {
|
) {
|
||||||
const offset = date.offset;
|
const offset = date?.offset;
|
||||||
const previousSelection = timezones.find((item) => item.value === selectedOption?.value);
|
const previousSelection = timezones.find((item) => item.value === selectedOption?.value);
|
||||||
const fromInitialTimeZone = timezones.find((item) => item.value === initialTimeZone);
|
const fromInitialTimeZone = timezones.find((item) => item.value === initialTimeZone);
|
||||||
const sameAsUserTimeZone = timezones.find((item) => item.offsetMinutes === offset && item.value === userTimeZone);
|
let sameAsUserTimeZone;
|
||||||
const firstWithSameOffset = timezones.find((item) => item.offsetMinutes === offset);
|
let firstWithSameOffset;
|
||||||
|
if (offset !== undefined) {
|
||||||
|
sameAsUserTimeZone = timezones.find((item) => item.offsetMinutes === offset && item.value === userTimeZone);
|
||||||
|
firstWithSameOffset = timezones.find((item) => item.offsetMinutes === offset);
|
||||||
|
}
|
||||||
const utcFallback = {
|
const utcFallback = {
|
||||||
label: 'UTC (+00:00)',
|
label: 'UTC (+00:00)',
|
||||||
offsetMinutes: 0,
|
offsetMinutes: 0,
|
||||||
@ -150,12 +181,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (date.isValid && selectedOption) {
|
if (!showRelative && date.isValid && selectedAbsoluteOption) {
|
||||||
// Get the local date/time components from the selected string using neutral timezone
|
// Get the local date/time components from the selected string using neutral timezone
|
||||||
const dtComponents = DateTime.fromISO(selectedDate, { zone: 'utc' });
|
const dtComponents = DateTime.fromISO(selectedDate, { zone: 'utc' });
|
||||||
|
|
||||||
// Determine the modern, DST-aware offset for the selected IANA zone
|
// Determine the modern, DST-aware offset for the selected IANA zone
|
||||||
const { offsetMinutes } = getModernOffsetForZoneAndDate(selectedOption.value, selectedDate);
|
const { offsetMinutes } = getModernOffsetForZoneAndDate(selectedAbsoluteOption.value, selectedDate);
|
||||||
|
|
||||||
// Construct the final ISO string with a fixed-offset zone.
|
// Construct the final ISO string with a fixed-offset zone.
|
||||||
const fixedOffsetZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`;
|
const fixedOffsetZone = `UTC${offsetMinutes >= 0 ? '+' : ''}${Duration.fromObject({ minutes: offsetMinutes }).toFormat('hh:mm')}`;
|
||||||
@ -163,17 +194,45 @@
|
|||||||
// Create a DateTime object in this fixed-offset zone, preserving the local time.
|
// Create a DateTime object in this fixed-offset zone, preserving the local time.
|
||||||
const finalDateTime = DateTime.fromObject(dtComponents.toObject(), { zone: fixedOffsetZone });
|
const finalDateTime = DateTime.fromObject(dtComponents.toObject(), { zone: fixedOffsetZone });
|
||||||
|
|
||||||
onConfirm(finalDateTime.toISO({ includeOffset: true })!);
|
onConfirm({ mode: 'absolute', date: finalDateTime.toISO({ includeOffset: true })! });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showRelative && (selectedDuration || selectedRelativeOption)) {
|
||||||
|
onConfirm({ mode: 'relative', duration: selectedDuration, timeZone: selectedRelativeOption?.value });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOnSelect = (option?: ComboBoxOption) => {
|
const handleOnSelect = (option?: ComboBoxOption) => {
|
||||||
if (option) {
|
if (showRelative) {
|
||||||
selectedOption = getPreferredTimeZone(initialDate, userTimeZone, timezones, option as ZoneOption);
|
selectedRelativeOption = option
|
||||||
|
? getPreferredTimeZone(userTimeZone, timezones, undefined, option as ZoneOption)
|
||||||
|
: undefined;
|
||||||
|
} else {
|
||||||
|
if (option) {
|
||||||
|
selectedAbsoluteOption = getPreferredTimeZone(userTimeZone, timezones, initialDate, option as ZoneOption);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let selectedOption = $derived(showRelative ? selectedRelativeOption : selectedAbsoluteOption);
|
||||||
|
|
||||||
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
|
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
|
||||||
let date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
|
let date = $derived(DateTime.fromISO(selectedDate, { zone: selectedAbsoluteOption?.value, setZone: true }));
|
||||||
|
|
||||||
|
export function calcNewDate(timestamp: DateTime, selectedDuration: number, timezone?: string) {
|
||||||
|
timestamp = timestamp.plus({ minutes: selectedDuration });
|
||||||
|
if (timezone) {
|
||||||
|
timestamp = timestamp.setZone(timezone);
|
||||||
|
}
|
||||||
|
return getDateTimeOffsetLocaleString(timestamp, { locale: get(locale) });
|
||||||
|
}
|
||||||
|
|
||||||
|
let intervalFrom = $derived.by(() =>
|
||||||
|
currentInterval ? calcNewDate(currentInterval.start, selectedDuration, selectedRelativeOption?.value) : undefined,
|
||||||
|
);
|
||||||
|
let intervalTo = $derived.by(() =>
|
||||||
|
currentInterval ? calcNewDate(currentInterval.end, selectedDuration, selectedRelativeOption?.value) : undefined,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
@ -185,22 +244,42 @@
|
|||||||
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
||||||
>
|
>
|
||||||
{#snippet promptSnippet()}
|
{#snippet promptSnippet()}
|
||||||
<div class="flex flex-col text-start gap-2">
|
{#if withDuration}
|
||||||
<div class="flex flex-col">
|
<div class="mb-5">
|
||||||
<label for="datetime">{$t('date_and_time')}</label>
|
<Field label={$t('edit_date_and_time_by_offset')}>
|
||||||
<DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
|
<Switch data-testid="edit-by-offset-switch" bind:checked={showRelative} />
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
{#if timezoneInput}
|
{/if}
|
||||||
<div>
|
<div class="flex flex-col text-start min-h-[140px]">
|
||||||
<Combobox
|
<div>
|
||||||
bind:selectedOption
|
<div class="flex flex-col" style="display: {showRelative ? 'none' : 'flex'}">
|
||||||
label={$t('timezone')}
|
<label for="datetime">{$t('date_and_time')}</label>
|
||||||
options={timezones}
|
<DateInput class="immich-form-input" id="datetime" type="datetime-local" bind:value={selectedDate} />
|
||||||
placeholder={$t('search_timezone')}
|
|
||||||
onSelect={(option) => handleOnSelect(option)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div class="flex flex-col" style="display: {showRelative ? 'flex' : 'none'}">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<label for="relativedatetime">{$t('offset')}</label>
|
||||||
|
<DurationInput class="immich-form-input" id="relativedatetime" bind:value={selectedDuration} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if timezoneInput}
|
||||||
|
<div>
|
||||||
|
<Combobox
|
||||||
|
bind:selectedOption
|
||||||
|
label={$t('timezone')}
|
||||||
|
options={timezones}
|
||||||
|
placeholder={$t('search_timezone')}
|
||||||
|
onSelect={(option) => handleOnSelect(option)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex flex-col" style="display: {showRelative && currentInterval ? 'flex' : 'none'}">
|
||||||
|
<span data-testid="interval-preview"
|
||||||
|
>{$t('edit_date_and_time_by_offset_interval', { values: { from: intervalFrom, to: intervalTo } })}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ConfirmModal>
|
</ConfirmModal>
|
||||||
|
@ -151,6 +151,12 @@ export function formatGroupTitle(_date: DateTime): string {
|
|||||||
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
|
export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): string =>
|
||||||
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
|
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 => {
|
export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): TimelineAsset => {
|
||||||
if (isTimelineAsset(unknownAsset)) {
|
if (isTimelineAsset(unknownAsset)) {
|
||||||
return unknownAsset;
|
return unknownAsset;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user