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" + > +
+
+
+
+ +

Add a 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; - diff --git a/web/src/lib/components/elements/dropdown.svelte b/web/src/lib/components/elements/dropdown.svelte index 65c551e43..6bf9b55d6 100644 --- a/web/src/lib/components/elements/dropdown.svelte +++ b/web/src/lib/components/elements/dropdown.svelte @@ -29,10 +29,13 @@ icon?: string; }; - let showMenu = false; + export let showMenu = false; + export let controlable = false; const handleClickOutside = () => { - showMenu = false; + if (!controlable) { + showMenu = false; + } }; const handleSelectOption = (option: T) => { @@ -60,7 +63,7 @@