From 644e52b1534692f896e4b6f1c67a5e4f787e5809 Mon Sep 17 00:00:00 2001
From: YFrendo
Date: Thu, 30 Nov 2023 04:52:28 +0100
Subject: [PATCH] feat: Edit metadata (#5066)
* chore: rebase and clean-up
* feat: sync description, add e2e tests
* feat: simplify web code
* chore: unit tests
* fix: linting
* Bug fix with the arrows key
* timezone typeahead filter
timezone typeahead filter
* small stlying
* format fix
* Bug fix in the map selection
Bug fix in the map selection
* Websocket basic
Websocket basic
* Update metadata visualisation through the websocket
* Update timeline
* fix merge
* fix web
* fix web
* maplibre system
* format fix
* format fix
* refactor: clean up
* Fix small bug in the hour/timezone
* Don't diplay modify for readOnly asset
* Add log in case of failure
* Formater + try/catch error
* Remove everything related to websocket
* Revert "Remove everything related to websocket"
This reverts commit 14bcb9e1e4398e8211adfe6c14348ef8f3f5fce4.
* remove notification
* fix test
---------
Co-authored-by: Jason Rasmussen
Co-authored-by: Alex Tran
---
cli/src/api/open-api/api.ts | 36 +++
mobile/openapi/doc/AssetBulkUpdateDto.md | 3 +
mobile/openapi/doc/UpdateAssetDto.md | 3 +
.../lib/model/asset_bulk_update_dto.dart | 57 +++-
.../openapi/lib/model/update_asset_dto.dart | 61 ++++-
.../test/asset_bulk_update_dto_test.dart | 15 +
.../openapi/test/update_asset_dto_test.dart | 15 +
server/immich-openapi-specs.json | 18 ++
server/src/domain/asset/asset.service.ts | 23 +-
server/src/domain/asset/dto/asset.dto.ts | 46 +++-
server/src/domain/job/job.constants.ts | 2 +
server/src/domain/job/job.interface.ts | 9 +-
server/src/domain/job/job.service.ts | 12 +
.../domain/metadata/metadata.service.spec.ts | 57 +++-
.../src/domain/metadata/metadata.service.ts | 39 ++-
.../src/domain/repositories/job.repository.ts | 3 +-
.../repositories/metadata.repository.ts | 3 +-
.../infra/repositories/metadata.repository.ts | 12 +-
server/src/microservices/app.service.ts | 1 +
server/test/e2e/asset.e2e-spec.ts | 48 ++++
.../repositories/metadata.repository.mock.ts | 3 +-
web/src/api/open-api/api.ts | 36 +++
.../components/album-page/album-viewer.svelte | 2 +
.../asset-viewer/detail-panel.svelte | 256 +++++++++++++++---
.../elements/buttons/link-button.svelte | 3 +-
.../lib/components/elements/dropdown.svelte | 15 +-
.../actions/change-date-action.svelte | 39 +++
.../actions/change-location-action.svelte | 42 +++
.../shared-components/change-date.svelte | 128 +++++++++
.../shared-components/change-location.svelte | 60 ++++
.../shared-components/confirm-dialogue.svelte | 5 +-
.../shared-components/map/map.svelte | 24 +-
.../shared-components/update-panel.svelte | 15 +
web/src/lib/stores/websocket.ts | 2 +
.../(user)/albums/[albumId]/+page.svelte | 7 +
web/src/routes/(user)/archive/+page.svelte | 2 +
web/src/routes/(user)/favorites/+page.svelte | 6 +
.../(user)/partners/[userId]/+page.svelte | 2 +
.../(user)/people/[personId]/+page.svelte | 4 +
web/src/routes/(user)/photos/+page.svelte | 6 +
web/src/routes/(user)/search/+page.svelte | 4 +
web/src/routes/(user)/trash/+page.svelte | 2 +
42 files changed, 1045 insertions(+), 81 deletions(-)
create mode 100644 web/src/lib/components/photos-page/actions/change-date-action.svelte
create mode 100644 web/src/lib/components/photos-page/actions/change-location-action.svelte
create mode 100644 web/src/lib/components/shared-components/change-date.svelte
create mode 100644 web/src/lib/components/shared-components/change-location.svelte
create mode 100644 web/src/lib/components/shared-components/update-panel.svelte
diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts
index d0dd30fe9..ac5ea101e 100644
--- a/cli/src/api/open-api/api.ts
+++ b/cli/src/api/open-api/api.ts
@@ -447,6 +447,12 @@ export interface AssetBulkDeleteDto {
* @interface AssetBulkUpdateDto
*/
export interface AssetBulkUpdateDto {
+ /**
+ *
+ * @type {string}
+ * @memberof AssetBulkUpdateDto
+ */
+ 'dateTimeOriginal'?: string;
/**
*
* @type {Array}
@@ -465,6 +471,18 @@ export interface AssetBulkUpdateDto {
* @memberof AssetBulkUpdateDto
*/
'isFavorite'?: boolean;
+ /**
+ *
+ * @type {number}
+ * @memberof AssetBulkUpdateDto
+ */
+ 'latitude'?: number;
+ /**
+ *
+ * @type {number}
+ * @memberof AssetBulkUpdateDto
+ */
+ 'longitude'?: number;
/**
*
* @type {boolean}
@@ -4137,6 +4155,12 @@ export interface UpdateAlbumDto {
* @interface UpdateAssetDto
*/
export interface UpdateAssetDto {
+ /**
+ *
+ * @type {string}
+ * @memberof UpdateAssetDto
+ */
+ 'dateTimeOriginal'?: string;
/**
*
* @type {string}
@@ -4155,6 +4179,18 @@ export interface UpdateAssetDto {
* @memberof UpdateAssetDto
*/
'isFavorite'?: boolean;
+ /**
+ *
+ * @type {number}
+ * @memberof UpdateAssetDto
+ */
+ 'latitude'?: number;
+ /**
+ *
+ * @type {number}
+ * @memberof UpdateAssetDto
+ */
+ 'longitude'?: number;
}
/**
*
diff --git a/mobile/openapi/doc/AssetBulkUpdateDto.md b/mobile/openapi/doc/AssetBulkUpdateDto.md
index 74fd5ec45..40ebe6a41 100644
--- a/mobile/openapi/doc/AssetBulkUpdateDto.md
+++ b/mobile/openapi/doc/AssetBulkUpdateDto.md
@@ -8,9 +8,12 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
+**dateTimeOriginal** | **String** | | [optional]
**ids** | **List** | | [default to const []]
**isArchived** | **bool** | | [optional]
**isFavorite** | **bool** | | [optional]
+**latitude** | **num** | | [optional]
+**longitude** | **num** | | [optional]
**removeParent** | **bool** | | [optional]
**stackParentId** | **String** | | [optional]
diff --git a/mobile/openapi/doc/UpdateAssetDto.md b/mobile/openapi/doc/UpdateAssetDto.md
index d214ebd47..cfd8f604d 100644
--- a/mobile/openapi/doc/UpdateAssetDto.md
+++ b/mobile/openapi/doc/UpdateAssetDto.md
@@ -8,9 +8,12 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
+**dateTimeOriginal** | **String** | | [optional]
**description** | **String** | | [optional]
**isArchived** | **bool** | | [optional]
**isFavorite** | **bool** | | [optional]
+**latitude** | **num** | | [optional]
+**longitude** | **num** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart
index 64c8d1e7e..60cab8c74 100644
--- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart
+++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart
@@ -13,13 +13,24 @@ part of openapi.api;
class AssetBulkUpdateDto {
/// Returns a new [AssetBulkUpdateDto] instance.
AssetBulkUpdateDto({
+ this.dateTimeOriginal,
this.ids = const [],
this.isArchived,
this.isFavorite,
+ this.latitude,
+ this.longitude,
this.removeParent,
this.stackParentId,
});
+ ///
+ /// 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? dateTimeOriginal;
+
List ids;
///
@@ -38,6 +49,22 @@ class AssetBulkUpdateDto {
///
bool? isFavorite;
+ ///
+ /// 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? latitude;
+
+ ///
+ /// 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? longitude;
+
///
/// 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
@@ -56,26 +83,37 @@ class AssetBulkUpdateDto {
@override
bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
+ other.dateTimeOriginal == dateTimeOriginal &&
other.ids == ids &&
other.isArchived == isArchived &&
other.isFavorite == isFavorite &&
+ other.latitude == latitude &&
+ other.longitude == longitude &&
other.removeParent == removeParent &&
other.stackParentId == stackParentId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
+ (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) +
(ids.hashCode) +
(isArchived == null ? 0 : isArchived!.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
+ (latitude == null ? 0 : latitude!.hashCode) +
+ (longitude == null ? 0 : longitude!.hashCode) +
(removeParent == null ? 0 : removeParent!.hashCode) +
(stackParentId == null ? 0 : stackParentId!.hashCode);
@override
- String toString() => 'AssetBulkUpdateDto[ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, removeParent=$removeParent, stackParentId=$stackParentId]';
+ String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, removeParent=$removeParent, stackParentId=$stackParentId]';
Map toJson() {
final json = {};
+ if (this.dateTimeOriginal != null) {
+ json[r'dateTimeOriginal'] = this.dateTimeOriginal;
+ } else {
+ // json[r'dateTimeOriginal'] = null;
+ }
json[r'ids'] = this.ids;
if (this.isArchived != null) {
json[r'isArchived'] = this.isArchived;
@@ -87,6 +125,16 @@ class AssetBulkUpdateDto {
} else {
// json[r'isFavorite'] = null;
}
+ if (this.latitude != null) {
+ json[r'latitude'] = this.latitude;
+ } else {
+ // json[r'latitude'] = null;
+ }
+ if (this.longitude != null) {
+ json[r'longitude'] = this.longitude;
+ } else {
+ // json[r'longitude'] = null;
+ }
if (this.removeParent != null) {
json[r'removeParent'] = this.removeParent;
} else {
@@ -108,11 +156,18 @@ class AssetBulkUpdateDto {
final json = value.cast();
return AssetBulkUpdateDto(
+ dateTimeOriginal: mapValueOfType(json, r'dateTimeOriginal'),
ids: json[r'ids'] is List
? (json[r'ids'] as List).cast()
: const [],
isArchived: mapValueOfType(json, r'isArchived'),
isFavorite: mapValueOfType(json, r'isFavorite'),
+ latitude: json[r'latitude'] == null
+ ? null
+ : num.parse(json[r'latitude'].toString()),
+ longitude: json[r'longitude'] == null
+ ? null
+ : num.parse(json[r'longitude'].toString()),
removeParent: mapValueOfType(json, r'removeParent'),
stackParentId: mapValueOfType(json, r'stackParentId'),
);
diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart
index d1f3570ef..d90b365b7 100644
--- a/mobile/openapi/lib/model/update_asset_dto.dart
+++ b/mobile/openapi/lib/model/update_asset_dto.dart
@@ -13,11 +13,22 @@ part of openapi.api;
class UpdateAssetDto {
/// Returns a new [UpdateAssetDto] instance.
UpdateAssetDto({
+ this.dateTimeOriginal,
this.description,
this.isArchived,
this.isFavorite,
+ this.latitude,
+ this.longitude,
});
+ ///
+ /// 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? 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
@@ -42,24 +53,51 @@ class UpdateAssetDto {
///
bool? isFavorite;
+ ///
+ /// 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? latitude;
+
+ ///
+ /// 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? longitude;
+
@override
bool operator ==(Object other) => identical(this, other) || other is UpdateAssetDto &&
+ other.dateTimeOriginal == dateTimeOriginal &&
other.description == description &&
other.isArchived == isArchived &&
- other.isFavorite == isFavorite;
+ other.isFavorite == isFavorite &&
+ other.latitude == latitude &&
+ other.longitude == longitude;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
+ (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) +
(description == null ? 0 : description!.hashCode) +
(isArchived == null ? 0 : isArchived!.hashCode) +
- (isFavorite == null ? 0 : isFavorite!.hashCode);
+ (isFavorite == null ? 0 : isFavorite!.hashCode) +
+ (latitude == null ? 0 : latitude!.hashCode) +
+ (longitude == null ? 0 : longitude!.hashCode);
@override
- String toString() => 'UpdateAssetDto[description=$description, isArchived=$isArchived, isFavorite=$isFavorite]';
+ String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude]';
Map toJson() {
final json = {};
+ if (this.dateTimeOriginal != null) {
+ json[r'dateTimeOriginal'] = this.dateTimeOriginal;
+ } else {
+ // json[r'dateTimeOriginal'] = null;
+ }
if (this.description != null) {
json[r'description'] = this.description;
} else {
@@ -75,6 +113,16 @@ class UpdateAssetDto {
} else {
// json[r'isFavorite'] = null;
}
+ if (this.latitude != null) {
+ json[r'latitude'] = this.latitude;
+ } else {
+ // json[r'latitude'] = null;
+ }
+ if (this.longitude != null) {
+ json[r'longitude'] = this.longitude;
+ } else {
+ // json[r'longitude'] = null;
+ }
return json;
}
@@ -86,9 +134,16 @@ class UpdateAssetDto {
final json = value.cast();
return UpdateAssetDto(
+ dateTimeOriginal: mapValueOfType(json, r'dateTimeOriginal'),
description: mapValueOfType(json, r'description'),
isArchived: mapValueOfType(json, r'isArchived'),
isFavorite: mapValueOfType(json, r'isFavorite'),
+ latitude: json[r'latitude'] == null
+ ? null
+ : num.parse(json[r'latitude'].toString()),
+ longitude: json[r'longitude'] == null
+ ? null
+ : num.parse(json[r'longitude'].toString()),
);
}
return null;
diff --git a/mobile/openapi/test/asset_bulk_update_dto_test.dart b/mobile/openapi/test/asset_bulk_update_dto_test.dart
index 06f65de66..d04bdd809 100644
--- a/mobile/openapi/test/asset_bulk_update_dto_test.dart
+++ b/mobile/openapi/test/asset_bulk_update_dto_test.dart
@@ -16,6 +16,11 @@ void main() {
// final instance = AssetBulkUpdateDto();
group('test AssetBulkUpdateDto', () {
+ // String dateTimeOriginal
+ test('to test the property `dateTimeOriginal`', () async {
+ // TODO
+ });
+
// List ids (default value: const [])
test('to test the property `ids`', () async {
// TODO
@@ -31,6 +36,16 @@ void main() {
// TODO
});
+ // num latitude
+ test('to test the property `latitude`', () async {
+ // TODO
+ });
+
+ // num longitude
+ test('to test the property `longitude`', () async {
+ // TODO
+ });
+
// bool removeParent
test('to test the property `removeParent`', () async {
// TODO
diff --git a/mobile/openapi/test/update_asset_dto_test.dart b/mobile/openapi/test/update_asset_dto_test.dart
index b2966e961..9d9874beb 100644
--- a/mobile/openapi/test/update_asset_dto_test.dart
+++ b/mobile/openapi/test/update_asset_dto_test.dart
@@ -16,6 +16,11 @@ void main() {
// final instance = UpdateAssetDto();
group('test UpdateAssetDto', () {
+ // String dateTimeOriginal
+ test('to test the property `dateTimeOriginal`', () async {
+ // TODO
+ });
+
// String description
test('to test the property `description`', () async {
// TODO
@@ -31,6 +36,16 @@ void main() {
// TODO
});
+ // num latitude
+ test('to test the property `latitude`', () async {
+ // TODO
+ });
+
+ // num longitude
+ test('to test the property `longitude`', () async {
+ // TODO
+ });
+
});
diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json
index 66ebe1920..356399813 100644
--- a/server/immich-openapi-specs.json
+++ b/server/immich-openapi-specs.json
@@ -6449,6 +6449,9 @@
},
"AssetBulkUpdateDto": {
"properties": {
+ "dateTimeOriginal": {
+ "type": "string"
+ },
"ids": {
"items": {
"format": "uuid",
@@ -6462,6 +6465,12 @@
"isFavorite": {
"type": "boolean"
},
+ "latitude": {
+ "type": "number"
+ },
+ "longitude": {
+ "type": "number"
+ },
"removeParent": {
"type": "boolean"
},
@@ -9343,6 +9352,9 @@
},
"UpdateAssetDto": {
"properties": {
+ "dateTimeOriginal": {
+ "type": "string"
+ },
"description": {
"type": "string"
},
@@ -9351,6 +9363,12 @@
},
"isFavorite": {
"type": "boolean"
+ },
+ "latitude": {
+ "type": "number"
+ },
+ "longitude": {
+ "type": "number"
}
},
"type": "object"
diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts
index 86e480932..c547d6a6d 100644
--- a/server/src/domain/asset/asset.service.ts
+++ b/server/src/domain/asset/asset.service.ts
@@ -8,7 +8,7 @@ import { AccessCore, Permission } from '../access';
import { AuthUserDto } from '../auth';
import { mimeTypes } from '../domain.constant';
import { HumanReadableSize, usePagination } from '../domain.util';
-import { IAssetDeletionJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
+import { IAssetDeletionJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
import {
CommunicationEvent,
IAccessRepository,
@@ -393,10 +393,8 @@ export class AssetService {
async update(authUser: AuthUserDto, id: string, dto: UpdateAssetDto): Promise {
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, id);
- const { description, ...rest } = dto;
- if (description !== undefined) {
- await this.assetRepository.upsertExif({ assetId: id, description });
- }
+ const { description, dateTimeOriginal, latitude, longitude, ...rest } = dto;
+ await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude });
const asset = await this.assetRepository.save({ id, ...rest });
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [id] } });
@@ -404,7 +402,7 @@ export class AssetService {
}
async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto): Promise {
- const { ids, removeParent, ...options } = dto;
+ const { ids, removeParent, dateTimeOriginal, latitude, longitude, ...options } = dto;
await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
if (removeParent) {
@@ -424,6 +422,10 @@ export class AssetService {
await this.assetRepository.updateAll([options.stackParentId], { stackParentId: null });
}
+ for (const id of ids) {
+ await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude });
+ }
+
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
await this.assetRepository.updateAll(ids, options);
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids);
@@ -587,4 +589,13 @@ export class AssetService {
}
}
}
+
+ private async updateMetadata(dto: ISidecarWriteJob) {
+ const { id, description, dateTimeOriginal, latitude, longitude } = dto;
+ const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude }, _.isUndefined);
+ if (Object.keys(writes).length > 0) {
+ await this.assetRepository.upsertExif({ assetId: id, ...writes });
+ await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } });
+ }
+ }
}
diff --git a/server/src/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts
index c7c371706..ac50f2242 100644
--- a/server/src/domain/asset/dto/asset.dto.ts
+++ b/server/src/domain/asset/dto/asset.dto.ts
@@ -1,7 +1,19 @@
import { AssetType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
-import { IsBoolean, IsEnum, IsInt, IsPositive, IsString, Min } from 'class-validator';
+import {
+ IsBoolean,
+ IsDateString,
+ IsEnum,
+ IsInt,
+ IsLatitude,
+ IsLongitude,
+ IsNotEmpty,
+ IsPositive,
+ IsString,
+ Min,
+ ValidateIf,
+} from 'class-validator';
import { Optional, QueryBoolean, QueryDate, ValidateUUID } from '../../domain.util';
import { BulkIdsDto } from '../response-dto';
@@ -10,6 +22,10 @@ export enum AssetOrder {
DESC = 'desc',
}
+const hasGPS = (o: { latitude: undefined; longitude: undefined }) =>
+ o.latitude !== undefined || o.longitude !== undefined;
+const ValidateGPS = () => ValidateIf(hasGPS);
+
export class AssetSearchDto {
@ValidateUUID({ optional: true })
id?: string;
@@ -172,6 +188,20 @@ export class AssetBulkUpdateDto extends BulkIdsDto {
@Optional()
@IsBoolean()
removeParent?: boolean;
+
+ @Optional()
+ @IsDateString()
+ dateTimeOriginal?: string;
+
+ @ValidateGPS()
+ @IsLatitude()
+ @IsNotEmpty()
+ latitude?: number;
+
+ @ValidateGPS()
+ @IsLongitude()
+ @IsNotEmpty()
+ longitude?: number;
}
export class UpdateAssetDto {
@@ -186,6 +216,20 @@ export class UpdateAssetDto {
@Optional()
@IsString()
description?: string;
+
+ @Optional()
+ @IsDateString()
+ dateTimeOriginal?: string;
+
+ @ValidateGPS()
+ @IsLatitude()
+ @IsNotEmpty()
+ latitude?: number;
+
+ @ValidateGPS()
+ @IsLongitude()
+ @IsNotEmpty()
+ longitude?: number;
}
export class RandomAssetsDto {
diff --git a/server/src/domain/job/job.constants.ts b/server/src/domain/job/job.constants.ts
index c5b4fe235..a7f467784 100644
--- a/server/src/domain/job/job.constants.ts
+++ b/server/src/domain/job/job.constants.ts
@@ -96,6 +96,7 @@ export enum JobName {
QUEUE_SIDECAR = 'queue-sidecar',
SIDECAR_DISCOVERY = 'sidecar-discovery',
SIDECAR_SYNC = 'sidecar-sync',
+ SIDECAR_WRITE = 'sidecar-write',
}
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
@@ -168,6 +169,7 @@ export const JOBS_TO_QUEUE: Record = {
[JobName.QUEUE_SIDECAR]: QueueName.SIDECAR,
[JobName.SIDECAR_DISCOVERY]: QueueName.SIDECAR,
[JobName.SIDECAR_SYNC]: QueueName.SIDECAR,
+ [JobName.SIDECAR_WRITE]: QueueName.SIDECAR,
// Library management
[JobName.LIBRARY_SCAN_ASSET]: QueueName.LIBRARY,
diff --git a/server/src/domain/job/job.interface.ts b/server/src/domain/job/job.interface.ts
index 033dfdac4..be76f6645 100644
--- a/server/src/domain/job/job.interface.ts
+++ b/server/src/domain/job/job.interface.ts
@@ -9,7 +9,7 @@ export interface IAssetFaceJob extends IBaseJob {
export interface IEntityJob extends IBaseJob {
id: string;
- source?: 'upload';
+ source?: 'upload' | 'sidecar-write';
}
export interface IAssetDeletionJob extends IEntityJob {
@@ -33,3 +33,10 @@ export interface IBulkEntityJob extends IBaseJob {
export interface IDeleteFilesJob extends IBaseJob {
files: Array;
}
+
+export interface ISidecarWriteJob extends IEntityJob {
+ description?: string;
+ dateTimeOriginal?: string;
+ latitude?: number;
+ longitude?: number;
+}
diff --git a/server/src/domain/job/job.service.ts b/server/src/domain/job/job.service.ts
index 7ebffcc69..4735eb6b5 100644
--- a/server/src/domain/job/job.service.ts
+++ b/server/src/domain/job/job.service.ts
@@ -165,7 +165,19 @@ export class JobService {
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: item.data });
break;
+ case JobName.SIDECAR_WRITE:
+ await this.jobRepository.queue({
+ name: JobName.METADATA_EXTRACTION,
+ data: { id: item.data.id, source: 'sidecar-write' },
+ });
+
case JobName.METADATA_EXTRACTION:
+ if (item.data.source === 'sidecar-write') {
+ const [asset] = await this.assetRepository.getByIds([item.data.id]);
+ if (asset) {
+ this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, asset.ownerId, mapAsset(asset));
+ }
+ }
await this.jobRepository.queue({ name: JobName.LINK_LIVE_PHOTOS, data: item.data });
break;
diff --git a/server/src/domain/metadata/metadata.service.spec.ts b/server/src/domain/metadata/metadata.service.spec.ts
index 7ce7db054..0ef5dfd73 100644
--- a/server/src/domain/metadata/metadata.service.spec.ts
+++ b/server/src/domain/metadata/metadata.service.spec.ts
@@ -218,11 +218,11 @@ describe(MetadataService.name, () => {
const originalDate = new Date('2023-11-21T16:13:17.517Z');
const sidecarDate = new Date('2022-01-01T00:00:00.000Z');
assetMock.getByIds.mockResolvedValue([assetStub.sidecar]);
- when(metadataMock.getExifTags)
+ when(metadataMock.readTags)
.calledWith(assetStub.sidecar.originalPath)
// higher priority tag
.mockResolvedValue({ CreationDate: originalDate.toISOString() });
- when(metadataMock.getExifTags)
+ when(metadataMock.readTags)
.calledWith(assetStub.sidecar.sidecarPath as string)
// lower priority tag, but in sidecar
.mockResolvedValue({ CreateDate: sidecarDate.toISOString() });
@@ -240,7 +240,7 @@ describe(MetadataService.name, () => {
it('should handle lists of numbers', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
- metadataMock.getExifTags.mockResolvedValue({ ISO: [160] as any });
+ metadataMock.readTags.mockResolvedValue({ ISO: [160] as any });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
@@ -257,7 +257,7 @@ describe(MetadataService.name, () => {
assetMock.getByIds.mockResolvedValue([assetStub.withLocation]);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: true }]);
metadataMock.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' });
- metadataMock.getExifTags.mockResolvedValue({
+ metadataMock.readTags.mockResolvedValue({
GPSLatitude: assetStub.withLocation.exifInfo!.latitude!,
GPSLongitude: assetStub.withLocation.exifInfo!.longitude!,
});
@@ -289,7 +289,7 @@ describe(MetadataService.name, () => {
it('should apply motion photos', async () => {
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
- metadataMock.getExifTags.mockResolvedValue({
+ metadataMock.readTags.mockResolvedValue({
Directory: 'foo/bar/',
MotionPhoto: 1,
MicroVideo: 1,
@@ -310,7 +310,7 @@ describe(MetadataService.name, () => {
it('should create new motion asset if not found and link it with the photo', async () => {
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
- metadataMock.getExifTags.mockResolvedValue({
+ metadataMock.readTags.mockResolvedValue({
Directory: 'foo/bar/',
MotionPhoto: 1,
MicroVideo: 1,
@@ -367,7 +367,7 @@ describe(MetadataService.name, () => {
tz: '+02:00',
};
assetMock.getByIds.mockResolvedValue([assetStub.image]);
- metadataMock.getExifTags.mockResolvedValue(tags);
+ metadataMock.readTags.mockResolvedValue(tags);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
@@ -406,7 +406,7 @@ describe(MetadataService.name, () => {
it('should handle duration', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
- metadataMock.getExifTags.mockResolvedValue({ Duration: 6.21 });
+ metadataMock.readTags.mockResolvedValue({ Duration: 6.21 });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
@@ -422,7 +422,7 @@ describe(MetadataService.name, () => {
it('should handle duration as an object without Scale', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
- metadataMock.getExifTags.mockResolvedValue({ Duration: { Value: 6.2 } });
+ metadataMock.readTags.mockResolvedValue({ Duration: { Value: 6.2 } });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
@@ -438,7 +438,7 @@ describe(MetadataService.name, () => {
it('should handle duration with scale', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
- metadataMock.getExifTags.mockResolvedValue({ Duration: { Scale: 1.11111111111111e-5, Value: 558720 } });
+ metadataMock.readTags.mockResolvedValue({ Duration: { Scale: 1.11111111111111e-5, Value: 558720 } });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
@@ -531,4 +531,41 @@ describe(MetadataService.name, () => {
});
});
});
+
+ describe('handleSidecarWrite', () => {
+ it('should skip assets that do not exist anymore', async () => {
+ assetMock.getByIds.mockResolvedValue([]);
+ await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(false);
+ expect(metadataMock.writeTags).not.toHaveBeenCalled();
+ });
+
+ it('should skip jobs with not metadata', async () => {
+ assetMock.getByIds.mockResolvedValue([assetStub.sidecar]);
+ await expect(sut.handleSidecarWrite({ id: assetStub.sidecar.id })).resolves.toBe(true);
+ expect(metadataMock.writeTags).not.toHaveBeenCalled();
+ });
+
+ it('should write tags', async () => {
+ const description = 'this is a description';
+ const gps = 12;
+ const date = '2023-11-22T04:56:12.196Z';
+
+ assetMock.getByIds.mockResolvedValue([assetStub.sidecar]);
+ await expect(
+ sut.handleSidecarWrite({
+ id: assetStub.sidecar.id,
+ description,
+ latitude: gps,
+ longitude: gps,
+ dateTimeOriginal: date,
+ }),
+ ).resolves.toBe(true);
+ expect(metadataMock.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, {
+ ImageDescription: description,
+ CreationDate: date,
+ GPSLatitude: gps,
+ GPSLongitude: gps,
+ });
+ });
+ });
});
diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts
index 77dcfecb0..e9c7ff931 100644
--- a/server/src/domain/metadata/metadata.service.ts
+++ b/server/src/domain/metadata/metadata.service.ts
@@ -3,10 +3,11 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { ExifDateTime, Tags } from 'exiftool-vendored';
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
import { constants } from 'fs/promises';
+import _ from 'lodash';
import { Duration } from 'luxon';
import { Subscription } from 'rxjs';
import { usePagination } from '../domain.util';
-import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
+import { IBaseJob, IEntityJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
import {
ExifDuration,
IAlbumRepository,
@@ -79,7 +80,6 @@ export class MetadataService {
private logger = new Logger(MetadataService.name);
private storageCore: StorageCore;
private configCore: SystemConfigCore;
- private oldCities?: string;
private subscription: Subscription | null = null;
constructor(
@@ -244,6 +244,37 @@ export class MetadataService {
return true;
}
+ async handleSidecarWrite(job: ISidecarWriteJob) {
+ const { id, description, dateTimeOriginal, latitude, longitude } = job;
+ const [asset] = await this.assetRepository.getByIds([id]);
+ if (!asset) {
+ return false;
+ }
+
+ const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
+ const exif = _.omitBy(
+ {
+ ImageDescription: description,
+ CreationDate: dateTimeOriginal,
+ GPSLatitude: latitude,
+ GPSLongitude: longitude,
+ },
+ _.isUndefined,
+ );
+
+ if (Object.keys(exif).length === 0) {
+ return true;
+ }
+
+ await this.repository.writeTags(sidecarPath, exif);
+
+ if (!asset.sidecarPath) {
+ await this.assetRepository.save({ id, sidecarPath });
+ }
+
+ return true;
+ }
+
private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) {
const { latitude, longitude } = exifData;
if (!(await this.configCore.hasFeature(FeatureFlag.REVERSE_GEOCODING)) || !longitude || !latitude) {
@@ -346,8 +377,8 @@ export class MetadataService {
asset: AssetEntity,
): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; tags: ImmichTags }> {
const stats = await this.storageRepository.stat(asset.originalPath);
- const mediaTags = await this.repository.getExifTags(asset.originalPath);
- const sidecarTags = asset.sidecarPath ? await this.repository.getExifTags(asset.sidecarPath) : null;
+ const mediaTags = await this.repository.readTags(asset.originalPath);
+ const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null;
// ensure date from sidecar is used if present
const hasDateOverride = !!this.getDateTimeOriginal(sidecarTags);
diff --git a/server/src/domain/repositories/job.repository.ts b/server/src/domain/repositories/job.repository.ts
index 4b426062f..7b9deabbd 100644
--- a/server/src/domain/repositories/job.repository.ts
+++ b/server/src/domain/repositories/job.repository.ts
@@ -9,6 +9,7 @@ import {
IEntityJob,
ILibraryFileJob,
ILibraryRefreshJob,
+ ISidecarWriteJob,
} from '../job/job.interface';
export interface JobCounts {
@@ -54,11 +55,11 @@ export type JobItem =
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
| { name: JobName.METADATA_EXTRACTION; data: IEntityJob }
| { name: JobName.LINK_LIVE_PHOTOS; data: IEntityJob }
-
// Sidecar Scanning
| { name: JobName.QUEUE_SIDECAR; data: IBaseJob }
| { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob }
| { name: JobName.SIDECAR_SYNC; data: IEntityJob }
+ | { name: JobName.SIDECAR_WRITE; data: ISidecarWriteJob }
// Object Tagging
| { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob }
diff --git a/server/src/domain/repositories/metadata.repository.ts b/server/src/domain/repositories/metadata.repository.ts
index c0a0fef46..e8d4d1e4e 100644
--- a/server/src/domain/repositories/metadata.repository.ts
+++ b/server/src/domain/repositories/metadata.repository.ts
@@ -33,5 +33,6 @@ export interface IMetadataRepository {
init(): Promise;
teardown(): Promise;
reverseGeocode(point: GeoPoint): Promise;
- getExifTags(path: string): Promise;
+ readTags(path: string): Promise;
+ writeTags(path: string, tags: Partial): Promise;
}
diff --git a/server/src/infra/repositories/metadata.repository.ts b/server/src/infra/repositories/metadata.repository.ts
index 8f8d068e5..d8f91dd1a 100644
--- a/server/src/infra/repositories/metadata.repository.ts
+++ b/server/src/infra/repositories/metadata.repository.ts
@@ -9,7 +9,7 @@ import { GeodataAdmin1Entity, GeodataAdmin2Entity, GeodataPlacesEntity, SystemMe
import { DatabaseLock } from '@app/infra/utils/database-locks';
import { Inject, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
-import { DefaultReadTaskOptions, exiftool } from 'exiftool-vendored';
+import { DefaultReadTaskOptions, exiftool, Tags } from 'exiftool-vendored';
import { createReadStream, existsSync } from 'fs';
import { readFile } from 'fs/promises';
import * as geotz from 'geo-tz';
@@ -181,7 +181,7 @@ export class MetadataRepository implements IMetadataRepository {
return { country, state, city };
}
- getExifTags(path: string): Promise {
+ readTags(path: string): Promise {
return exiftool
.read(path, undefined, {
...DefaultReadTaskOptions,
@@ -198,4 +198,12 @@ export class MetadataRepository implements IMetadataRepository {
return null;
}) as Promise;
}
+
+ async writeTags(path: string, tags: Partial): Promise {
+ try {
+ await exiftool.write(path, tags, ['-overwrite_original']);
+ } catch (error) {
+ this.logger.warn(`Error writing exif data (${path}): ${error}`);
+ }
+ }
}
diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts
index 554519114..3f89fa06f 100644
--- a/server/src/microservices/app.service.ts
+++ b/server/src/microservices/app.service.ts
@@ -84,6 +84,7 @@ export class AppService {
[JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data),
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
[JobName.SIDECAR_SYNC]: () => this.metadataService.handleSidecarSync(),
+ [JobName.SIDECAR_WRITE]: (data) => this.metadataService.handleSidecarWrite(data),
[JobName.LIBRARY_SCAN_ASSET]: (data) => this.libraryService.handleAssetRefresh(data),
[JobName.LIBRARY_SCAN]: (data) => this.libraryService.handleQueueAssetRefresh(data),
[JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data),
diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts
index 53468a480..95cc92b6a 100644
--- a/server/test/e2e/asset.e2e-spec.ts
+++ b/server/test/e2e/asset.e2e-spec.ts
@@ -700,6 +700,54 @@ describe(`${AssetController.name} (e2e)`, () => {
expect(status).toEqual(200);
});
+ it('should update date time original', async () => {
+ const { status, body } = await request(server)
+ .put(`/asset/${asset1.id}`)
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
+
+ expect(body).toMatchObject({
+ id: asset1.id,
+ exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00.000Z' }),
+ });
+ expect(status).toEqual(200);
+ });
+
+ it('should reject invalid gps coordinates', async () => {
+ for (const test of [
+ { latitude: 12 },
+ { longitude: 12 },
+ { latitude: 12, longitude: 'abc' },
+ { latitude: 'abc', longitude: 12 },
+ { latitude: null, longitude: 12 },
+ { latitude: 12, longitude: null },
+ { latitude: 91, longitude: 12 },
+ { latitude: -91, longitude: 12 },
+ { latitude: 12, longitude: -181 },
+ { latitude: 12, longitude: 181 },
+ ]) {
+ const { status, body } = await request(server)
+ .put(`/asset/${asset1.id}`)
+ .send(test)
+ .set('Authorization', `Bearer ${user1.accessToken}`);
+ expect(status).toBe(400);
+ expect(body).toEqual(errorStub.badRequest());
+ }
+ });
+
+ it('should update gps data', async () => {
+ const { status, body } = await request(server)
+ .put(`/asset/${asset1.id}`)
+ .set('Authorization', `Bearer ${user1.accessToken}`)
+ .send({ latitude: 12, longitude: 12 });
+
+ expect(body).toMatchObject({
+ id: asset1.id,
+ exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }),
+ });
+ expect(status).toEqual(200);
+ });
+
it('should set the description', async () => {
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts
index c602c54d5..3e97cb327 100644
--- a/server/test/repositories/metadata.repository.mock.ts
+++ b/server/test/repositories/metadata.repository.mock.ts
@@ -2,9 +2,10 @@ import { IMetadataRepository } from '@app/domain';
export const newMetadataRepositoryMock = (): jest.Mocked => {
return {
- getExifTags: jest.fn(),
init: jest.fn(),
teardown: jest.fn(),
reverseGeocode: jest.fn(),
+ readTags: jest.fn(),
+ writeTags: jest.fn(),
};
};
diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts
index d0dd30fe9..ac5ea101e 100644
--- a/web/src/api/open-api/api.ts
+++ b/web/src/api/open-api/api.ts
@@ -447,6 +447,12 @@ export interface AssetBulkDeleteDto {
* @interface AssetBulkUpdateDto
*/
export interface AssetBulkUpdateDto {
+ /**
+ *
+ * @type {string}
+ * @memberof AssetBulkUpdateDto
+ */
+ 'dateTimeOriginal'?: string;
/**
*
* @type {Array}
@@ -465,6 +471,18 @@ export interface AssetBulkUpdateDto {
* @memberof AssetBulkUpdateDto
*/
'isFavorite'?: boolean;
+ /**
+ *
+ * @type {number}
+ * @memberof AssetBulkUpdateDto
+ */
+ 'latitude'?: number;
+ /**
+ *
+ * @type {number}
+ * @memberof AssetBulkUpdateDto
+ */
+ 'longitude'?: number;
/**
*
* @type {boolean}
@@ -4137,6 +4155,12 @@ export interface UpdateAlbumDto {
* @interface UpdateAssetDto
*/
export interface UpdateAssetDto {
+ /**
+ *
+ * @type {string}
+ * @memberof UpdateAssetDto
+ */
+ 'dateTimeOriginal'?: string;
/**
*
* @type {string}
@@ -4155,6 +4179,18 @@ export interface UpdateAssetDto {
* @memberof UpdateAssetDto
*/
'isFavorite'?: boolean;
+ /**
+ *
+ * @type {number}
+ * @memberof UpdateAssetDto
+ */
+ 'latitude'?: number;
+ /**
+ *
+ * @type {number}
+ * @memberof UpdateAssetDto
+ */
+ 'longitude'?: number;
}
/**
*
diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte
index 5b10ab1e8..ba71c396d 100644
--- a/web/src/lib/components/album-page/album-viewer.svelte
+++ b/web/src/lib/components/album-page/album-viewer.svelte
@@ -20,6 +20,7 @@
import ThemeButton from '../shared-components/theme-button.svelte';
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
+ import UpdatePanel from '../shared-components/update-panel.svelte';
export let sharedLink: SharedLinkResponseDto;
export let user: UserResponseDto | undefined = undefined;
@@ -167,4 +168,5 @@
+
diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte
index 366c4e930..29b91a228 100644
--- a/web/src/lib/components/asset-viewer/detail-panel.svelte
+++ b/web/src/lib/components/asset-viewer/detail-panel.svelte
@@ -5,22 +5,27 @@
import { getAssetFilename } from '$lib/utils/asset-utils';
import { AlbumResponseDto, AssetResponseDto, ThumbnailFormat, api } from '@api';
import { DateTime } from 'luxon';
- import { createEventDispatcher } from 'svelte';
+ import { createEventDispatcher, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { asByteUnitString } from '../../utils/byte-units';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import UserAvatar from '../shared-components/user-avatar.svelte';
+ import ChangeDate from '$lib/components/shared-components/change-date.svelte';
import {
mdiCalendar,
mdiCameraIris,
mdiClose,
+ mdiPencil,
mdiImageOutline,
mdiMapMarkerOutline,
mdiInformationOutline,
} from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import Map from '../shared-components/map/map.svelte';
+ import { websocketStore } from '$lib/stores/websocket';
import { AppRoute } from '$lib/constants';
+ import ChangeLocation from '../shared-components/change-location.svelte';
+ import { handleError } from '../../utils/handle-error';
export let asset: AssetResponseDto;
export let albums: AlbumResponseDto[] = [];
@@ -52,6 +57,16 @@
$: people = asset.people || [];
+ const unsubscribe = websocketStore.onAssetUpdate.subscribe((assetUpdate) => {
+ if (assetUpdate && assetUpdate.id === asset.id) {
+ asset = assetUpdate;
+ }
+ });
+
+ onDestroy(() => {
+ unsubscribe();
+ });
+
const dispatch = createEventDispatcher();
const getMegapixel = (width: number, height: number): number | undefined => {
@@ -79,9 +94,7 @@
try {
await api.assetApi.updateAsset({
id: asset.id,
- updateAssetDto: {
- description: description,
- },
+ updateAssetDto: { description },
});
} catch (error) {
console.error(error);
@@ -90,6 +103,35 @@
let showAssetPath = false;
const toggleAssetPath = () => (showAssetPath = !showAssetPath);
+
+ let isShowChangeDate = false;
+
+ async function handleConfirmChangeDate(dateTimeOriginal: string) {
+ isShowChangeDate = false;
+ try {
+ await api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { dateTimeOriginal } });
+ } catch (error) {
+ handleError(error, 'Unable to change date');
+ }
+ }
+
+ let isShowChangeLocation = false;
+
+ async function handleConfirmChangeLocation(gps: { lng: number; lat: number }) {
+ isShowChangeLocation = false;
+
+ try {
+ await api.assetApi.updateAsset({
+ id: asset.id,
+ updateAssetDto: {
+ latitude: gps.lat,
+ longitude: gps.lng,
+ },
+ });
+ } catch (error) {
+ handleError(error, 'Unable to change location');
+ }
+ }
@@ -191,41 +233,115 @@
DETAILS
{/if}
- {#if asset.exifInfo?.dateTimeOriginal}
+ {#if asset.exifInfo?.dateTimeOriginal && !asset.isReadOnly}
{@const assetDateTimeOriginal = DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
zone: asset.exifInfo.timeZone ?? undefined,
})}
-
-
-
-
+
(isShowChangeDate = true)}
+ on:keydown={(event) => event.key === 'Enter' && (isShowChangeDate = true)}
+ tabindex="0"
+ role="button"
+ title="Edit date"
+ >
+
+
+
+
-
-
- {assetDateTimeOriginal.toLocaleString(
- {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- },
- { locale: $locale },
- )}
-
-
+
{assetDateTimeOriginal.toLocaleString(
{
- weekday: 'short',
- hour: 'numeric',
- minute: '2-digit',
- timeZoneName: 'longOffset',
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
},
{ locale: $locale },
)}
+
+
+ {assetDateTimeOriginal.toLocaleString(
+ {
+ weekday: 'short',
+ hour: 'numeric',
+ minute: '2-digit',
+ timeZoneName: 'longOffset',
+ },
+ { locale: $locale },
+ )}
+
+
-
{/if}
+
+
+ {:else if !asset.exifInfo?.dateTimeOriginal && !asset.isReadOnly}
+
+ {:else if asset.exifInfo?.dateTimeOriginal && asset.isReadOnly}
+ {@const assetDateTimeOriginal = DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
+ zone: asset.exifInfo.timeZone ?? undefined,
+ })}
+
+
+
+
+
+
+
+
+ {assetDateTimeOriginal.toLocaleString(
+ {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ },
+ { locale: $locale },
+ )}
+
+
+
+ {assetDateTimeOriginal.toLocaleString(
+ {
+ weekday: 'short',
+ hour: 'numeric',
+ minute: '2-digit',
+ timeZoneName: 'longOffset',
+ },
+ { locale: $locale },
+ )}
+
+
+
+
+
+ {/if}
+
+ {#if isShowChangeDate}
+ {@const assetDateTimeOriginal = asset.exifInfo?.dateTimeOriginal
+ ? DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
+ zone: asset.exifInfo.timeZone ?? undefined,
+ })
+ : DateTime.now()}
+
handleConfirmChangeDate(date)}
+ on:cancel={() => (isShowChangeDate = false)}
+ />
+ {/if}
{#if asset.exifInfo?.fileSizeInByte}
@@ -292,24 +408,84 @@
{/if}
- {#if asset.exifInfo?.city}
-
-
+ {#if asset.exifInfo?.city && !asset.isReadOnly}
+
(isShowChangeLocation = true)}
+ on:keydown={(event) => event.key === 'Enter' && (isShowChangeLocation = true)}
+ tabindex="0"
+ role="button"
+ title="Edit location"
+ >
+
+
+
+
+
{asset.exifInfo.city}
+ {#if asset.exifInfo?.state}
+
+
{asset.exifInfo.state}
+
+ {/if}
+ {#if asset.exifInfo?.country}
+
+
{asset.exifInfo.country}
+
+ {/if}
+
+
-
{asset.exifInfo.city}
- {#if asset.exifInfo?.state}
-
-
{asset.exifInfo.state}
-
- {/if}
- {#if asset.exifInfo?.country}
-
-
{asset.exifInfo.country}
-
- {/if}
+
+ {:else if !asset.exifInfo?.city && !asset.isReadOnly}
+
(isShowChangeLocation = true)}
+ on:keydown={(event) => event.key === 'Enter' && (isShowChangeLocation = true)}
+ tabindex="0"
+ role="button"
+ title="Add location"
+ >
+
+
+
+
+
+ {:else if asset.exifInfo?.city && asset.isReadOnly}
+
+
+
+
+
+
{asset.exifInfo.city}
+ {#if asset.exifInfo?.state}
+
+
{asset.exifInfo.state}
+
+ {/if}
+ {#if asset.exifInfo?.country}
+
+
{asset.exifInfo.country}
+
+ {/if}
+
+
+
+ {/if}
+ {#if isShowChangeLocation}
+
handleConfirmChangeLocation(gps)}
+ on:cancel={() => (isShowChangeLocation = false)}
+ />
{/if}
diff --git a/web/src/lib/components/elements/buttons/link-button.svelte b/web/src/lib/components/elements/buttons/link-button.svelte
index d5fe8b29b..2cb22d41d 100644
--- a/web/src/lib/components/elements/buttons/link-button.svelte
+++ b/web/src/lib/components/elements/buttons/link-button.svelte
@@ -7,8 +7,9 @@
export let color: Color = 'transparent-gray';
export let disabled = false;
+ export let fullwidth = false;
-