From b8a9cbc6591d5c22e7babfc3640105f9609c2130 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 21 Nov 2023 21:16:40 -0500 Subject: [PATCH] chore: rebase and clean-up --- 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 | 27 ++- server/src/domain/asset/dto/asset.dto.ts | 46 +++- server/src/domain/job/job.constants.ts | 2 + server/src/domain/job/job.interface.ts | 6 + .../domain/metadata/metadata.service.spec.ts | 20 +- .../src/domain/metadata/metadata.service.ts | 39 +++- .../src/domain/repositories/job.repository.ts | 3 +- .../repositories/metadata.repository.ts | 3 +- .../infra/repositories/metadata.repository.ts | 8 +- server/src/microservices/app.service.ts | 1 + .../repositories/metadata.repository.mock.ts | 3 +- web/src/api/open-api/api.ts | 36 +++ .../asset-viewer/detail-panel.svelte | 210 ++++++++++++++++-- .../photos-page/actions/change-date.svelte | 54 +++++ .../actions/change-location.svelte | 53 +++++ .../shared-components/change-date.svelte | 129 +++++++++++ .../shared-components/change-location.svelte | 85 +++++++ .../shared-components/map/map.svelte | 28 ++- .../(user)/albums/[albumId]/+page.svelte | 4 + web/src/routes/(user)/favorites/+page.svelte | 4 + .../(user)/people/[personId]/+page.svelte | 4 + web/src/routes/(user)/photos/+page.svelte | 4 + web/src/routes/(user)/search/+page.svelte | 4 + 31 files changed, 928 insertions(+), 53 deletions(-) create mode 100644 web/src/lib/components/photos-page/actions/change-date.svelte create mode 100644 web/src/lib/components/photos-page/actions/change-location.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 diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index c48c7a8f57..6bbe321aa0 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} @@ -4161,6 +4179,12 @@ export interface UpdateAlbumDto { * @interface UpdateAssetDto */ export interface UpdateAssetDto { + /** + * + * @type {string} + * @memberof UpdateAssetDto + */ + 'dateTimeOriginal'?: string; /** * * @type {string} @@ -4179,6 +4203,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 74fd5ec453..40ebe6a411 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 d214ebd476..cfd8f604d2 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 64c8d1e7e7..60cab8c749 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 d1f3570ef8..d90b365b72 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 06f65de666..d04bdd8091 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 b2966e961a..9d9874beb8 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 131766ba96..23433d933b 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -6404,6 +6404,9 @@ }, "AssetBulkUpdateDto": { "properties": { + "dateTimeOriginal": { + "type": "string" + }, "ids": { "items": { "format": "uuid", @@ -6417,6 +6420,12 @@ "isFavorite": { "type": "boolean" }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + }, "removeParent": { "type": "boolean" }, @@ -9311,6 +9320,9 @@ }, "UpdateAssetDto": { "properties": { + "dateTimeOriginal": { + "type": "string" + }, "description": { "type": "string" }, @@ -9319,6 +9331,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 dbba670e73..e40d8b43d8 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, @@ -389,10 +389,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] } }); @@ -400,7 +398,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) { @@ -420,6 +418,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); @@ -583,4 +585,17 @@ export class AssetService { } } } + + private async updateMetadata(dto: ISidecarWriteJob & { description?: string }) { + const { id, description, dateTimeOriginal, latitude, longitude } = dto; + + if (description !== undefined) { + await this.assetRepository.upsertExif({ assetId: id, description }); + } + + const writes = _.omitBy({ dateTimeOriginal, latitude, longitude }, _.isUndefined); + if (Object.keys(writes).length > 0) { + 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 c7c371706e..ac50f22426 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 c5b4fe235b..a7f4677849 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 033dfdac4a..54b04d5d59 100644 --- a/server/src/domain/job/job.interface.ts +++ b/server/src/domain/job/job.interface.ts @@ -33,3 +33,9 @@ export interface IBulkEntityJob extends IBaseJob { export interface IDeleteFilesJob extends IBaseJob { files: Array; } + +export interface ISidecarWriteJob extends IEntityJob { + dateTimeOriginal?: string; + latitude?: number; + longitude?: number; +} diff --git a/server/src/domain/metadata/metadata.service.spec.ts b/server/src/domain/metadata/metadata.service.spec.ts index bb2d706224..194294f94c 100644 --- a/server/src/domain/metadata/metadata.service.spec.ts +++ b/server/src/domain/metadata/metadata.service.spec.ts @@ -253,11 +253,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() }); @@ -275,7 +275,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]); @@ -292,7 +292,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!, }); @@ -324,7 +324,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, @@ -345,7 +345,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, @@ -402,7 +402,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]); @@ -441,7 +441,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 }); @@ -457,7 +457,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 }); @@ -473,7 +473,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 }); diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts index f600f75a9b..42378b9d67 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, @@ -251,6 +252,38 @@ export class MetadataService { return true; } + async handleSidecarWrite(job: ISidecarWriteJob) { + const { id, dateTimeOriginal, latitude, longitude } = job; + const asset = await this.assetRepository.getById(id); + if (!asset) { + return false; + } + + const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`; + const exif = _.omitBy( + { + 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 }); + } + + await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: id } }); + + return true; + } + private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) { const { latitude, longitude } = exifData; if (!(await this.configCore.hasFeature(FeatureFlag.REVERSE_GEOCODING)) || !longitude || !latitude) { @@ -350,8 +383,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 4b426062f2..49edeccd4b 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,7 +55,7 @@ export type JobItem = | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } | { name: JobName.METADATA_EXTRACTION; data: IEntityJob } | { name: JobName.LINK_LIVE_PHOTOS; data: IEntityJob } - + | { name: JobName.SIDECAR_WRITE; data: ISidecarWriteJob } // Sidecar Scanning | { name: JobName.QUEUE_SIDECAR; data: IBaseJob } | { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob } diff --git a/server/src/domain/repositories/metadata.repository.ts b/server/src/domain/repositories/metadata.repository.ts index 0c3b78462b..03ffe3a354 100644 --- a/server/src/domain/repositories/metadata.repository.ts +++ b/server/src/domain/repositories/metadata.repository.ts @@ -35,5 +35,6 @@ export interface IMetadataRepository { teardown(): Promise; reverseGeocode(point: GeoPoint): Promise; deleteCache(): 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 63bc29dcba..1557a10d76 100644 --- a/server/src/infra/repositories/metadata.repository.ts +++ b/server/src/infra/repositories/metadata.repository.ts @@ -1,7 +1,7 @@ import { GeoPoint, IMetadataRepository, ImmichTags, ReverseGeocodeResult } from '@app/domain'; import { REVERSE_GEOCODING_DUMP_DIRECTORY } from '@app/infra'; import { Injectable, Logger } from '@nestjs/common'; -import { DefaultReadTaskOptions, exiftool } from 'exiftool-vendored'; +import { DefaultReadTaskOptions, Tags, exiftool } from 'exiftool-vendored'; import { readdir, rm } from 'fs/promises'; import * as geotz from 'geo-tz'; import { getName } from 'i18n-iso-countries'; @@ -76,7 +76,7 @@ export class MetadataRepository implements IMetadataRepository { return { country, state, city }; } - getExifTags(path: string): Promise { + readTags(path: string): Promise { return exiftool .read(path, undefined, { ...DefaultReadTaskOptions, @@ -93,4 +93,8 @@ export class MetadataRepository implements IMetadataRepository { return null; }) as Promise; } + + async writeTags(path: string, tags: Partial): Promise { + await exiftool.write(path, tags, ['-overwrite_original']); + } } diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts index 67d995e331..0386d0f078 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/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts index 76c6f777a5..e31ba87a2c 100644 --- a/server/test/repositories/metadata.repository.mock.ts +++ b/server/test/repositories/metadata.repository.mock.ts @@ -3,9 +3,10 @@ import { IMetadataRepository } from '@app/domain'; export const newMetadataRepositoryMock = (): jest.Mocked => { return { deleteCache: jest.fn(), - 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 c48c7a8f57..6bbe321aa0 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} @@ -4161,6 +4179,12 @@ export interface UpdateAlbumDto { * @interface UpdateAssetDto */ export interface UpdateAssetDto { + /** + * + * @type {string} + * @memberof UpdateAssetDto + */ + 'dateTimeOriginal'?: string; /** * * @type {string} @@ -4179,6 +4203,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/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 366c4e9301..f4f6e5e6be 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -10,17 +10,24 @@ 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 { + notificationController, + NotificationType, + } from '$lib/components/shared-components/notification/notification'; import Icon from '$lib/components/elements/icon.svelte'; import Map from '../shared-components/map/map.svelte'; import { AppRoute } from '$lib/constants'; + import ChangeLocation from '../shared-components/change-location.svelte'; export let asset: AssetResponseDto; export let albums: AlbumResponseDto[] = []; @@ -90,6 +97,45 @@ let showAssetPath = false; const toggleAssetPath = () => (showAssetPath = !showAssetPath); + + let isShowChangeDate = false; + + async function handleConfirmChangeDate(event: CustomEvent) { + isShowChangeDate = false; + if (asset.exifInfo) { + asset.exifInfo.dateTimeOriginal = event.detail; + } + try { + await api.assetApi.updateAsset({ + id: asset.id, + updateAssetDto: { + dateTimeOriginal: event.detail, + }, + }); + notificationController.show({ message: 'Metadata updated please reload to apply', type: NotificationType.Info }); + } catch (error) { + console.error(error); + } + } + + let isShowChangeLocation = false; + + async function handleConfirmChangeLocation(event: CustomEvent<{ lng: number; lat: number }>) { + isShowChangeLocation = false; + + try { + await api.assetApi.updateAsset({ + id: asset.id, + updateAssetDto: { + latitude: event.detail.lat, + longitude: event.detail.lng, + }, + }); + notificationController.show({ message: 'Metadata updated please reload to apply', type: NotificationType.Info }); + } catch (error) { + console.error(error); + } + }
@@ -195,37 +241,101 @@ {@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" + > +
+
+ +
-
-

- {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} +
(isShowChangeDate = true)} + on:keydown={(event) => event.key === 'Enter' && (isShowChangeDate = true)} + tabindex="0" + role="button" + > +
+
+ +
+ +
+

No date available for this asset, click to add one.

+
+
+ +
+ {/if} + + {#if isShowChangeDate} + {#if asset.exifInfo?.dateTimeOriginal} + {@const assetDateTimeOriginal = DateTime.fromISO(asset.exifInfo.dateTimeOriginal, { + zone: asset.exifInfo.timeZone ?? undefined, + })} + (isShowChangeDate = false)} + > + +

Please select a new date:

+
+
+ {:else} + (isShowChangeDate = false)} + > + +

Please select a new date:

+
+
+ {/if} + {/if} {#if asset.exifInfo?.fileSizeInByte}
@@ -293,7 +403,13 @@ {/if} {#if asset.exifInfo?.city} -
+
(isShowChangeLocation = true)} + on:keydown={(event) => event.key === 'Enter' && (isShowChangeLocation = true)} + tabindex="0" + role="button" + >
@@ -309,7 +425,57 @@
{/if}
+ +
+ {:else} +
(isShowChangeLocation = true)} + on:keydown={(event) => event.key === 'Enter' && (isShowChangeLocation = true)} + tabindex="0" + role="button" + > +
+
+
+
+ +
+

No location available for this asset, click to add one.

+
+
+ +
+ {/if} + {#if isShowChangeLocation} + {#if latlng} + (isShowChangeLocation = false)} + > + +

Please select a new location:

+
+
+ {:else} + (isShowChangeLocation = false)} + > + +

Please select a new location:

+
+
+ {/if} {/if}
diff --git a/web/src/lib/components/photos-page/actions/change-date.svelte b/web/src/lib/components/photos-page/actions/change-date.svelte new file mode 100644 index 0000000000..1fade93ac0 --- /dev/null +++ b/web/src/lib/components/photos-page/actions/change-date.svelte @@ -0,0 +1,54 @@ + + +{#if menuItem} + (isShowChangeDate = true)} /> +{/if} +{#if isShowChangeDate} + (isShowChangeDate = false)} + > + +

Please select a new date:

+
+
+{/if} diff --git a/web/src/lib/components/photos-page/actions/change-location.svelte b/web/src/lib/components/photos-page/actions/change-location.svelte new file mode 100644 index 0000000000..9470756584 --- /dev/null +++ b/web/src/lib/components/photos-page/actions/change-location.svelte @@ -0,0 +1,53 @@ + + +{#if menuItem} + (isShowChangeLocation = true)} /> +{/if} +{#if isShowChangeLocation} + (isShowChangeLocation = false)} + > + +

Please select a new location:

+
+
+{/if} diff --git a/web/src/lib/components/shared-components/change-date.svelte b/web/src/lib/components/shared-components/change-date.svelte new file mode 100644 index 0000000000..03af7d4593 --- /dev/null +++ b/web/src/lib/components/shared-components/change-date.svelte @@ -0,0 +1,129 @@ + + + handleEscape()}> +
+
+

+ {title} +

+
+
+
+ +

{prompt}

+
+
+
+ + +
+
+ + +
+
+ +
+ {#if !hideCancelButton} + + {/if} + +
+
+
+ diff --git a/web/src/lib/components/shared-components/change-location.svelte b/web/src/lib/components/shared-components/change-location.svelte new file mode 100644 index 0000000000..dae06880c4 --- /dev/null +++ b/web/src/lib/components/shared-components/change-location.svelte @@ -0,0 +1,85 @@ + + + handleEscape()}> +
+
+
+
+ +

{prompt}

+
+
+
+ +
+
+ { + location = e.detail; + }} + /> +
+
+ +
+ {#if !hideCancelButton} + + {/if} + +
+
+
+ diff --git a/web/src/lib/components/shared-components/map/map.svelte b/web/src/lib/components/shared-components/map/map.svelte index 3687d117e1..92107693d6 100644 --- a/web/src/lib/components/shared-components/map/map.svelte +++ b/web/src/lib/components/shared-components/map/map.svelte @@ -28,6 +28,14 @@ export let zoom: number | undefined = undefined; export let center: LngLatLike | undefined = undefined; export let simplified = false; + export let clickable = false; + + let map: maplibregl.Map; + let marker: maplibregl.Marker | null = null; + + $: if (map) { + map.on('click', handleMapClick); + } $: style = (async () => { const { data } = await api.systemConfigApi.getMapStyle({ @@ -36,7 +44,10 @@ return data as StyleSpecification; })(); - const dispatch = createEventDispatcher<{ selected: string[] }>(); + const dispatch = createEventDispatcher<{ + selected: string[]; + clickedPoint: { lat: number; lng: number }; + }>(); function handleAssetClick(assetId: string, map: Map | null) { if (!map) { @@ -63,6 +74,19 @@ }); } + function handleMapClick(event: maplibregl.MapMouseEvent) { + if (clickable) { + const { lng, lat } = event.lngLat; + dispatch('clickedPoint', { lng, lat }); + + if (marker) { + marker.remove(); + } + + marker = new maplibregl.Marker().setLngLat([lng, lat]).addTo(map); + } + } + type FeaturePoint = Feature; const asFeature = (marker: MapMarkerResponseDto): FeaturePoint => { @@ -87,7 +111,7 @@ {#await style then style} - + {#if !simplified} diff --git a/web/src/routes/(user)/albums/[albumId]/+page.svelte b/web/src/routes/(user)/albums/[albumId]/+page.svelte index c7e4ab48e3..646b90335d 100644 --- a/web/src/routes/(user)/albums/[albumId]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId]/+page.svelte @@ -11,6 +11,8 @@ import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; + import ChangeDate from '$lib/components/photos-page/actions/change-date.svelte'; + import ChangeLocation from '$lib/components/photos-page/actions/change-location.svelte'; import RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; @@ -446,6 +448,8 @@ {/if} {#if isAllUserOwned} assetStore.removeAsset(assetId)} /> + + {/if} diff --git a/web/src/routes/(user)/favorites/+page.svelte b/web/src/routes/(user)/favorites/+page.svelte index 28d81e5a67..52da7c8d3b 100644 --- a/web/src/routes/(user)/favorites/+page.svelte +++ b/web/src/routes/(user)/favorites/+page.svelte @@ -2,6 +2,8 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; + import ChangeDate from '$lib/components/photos-page/actions/change-date.svelte'; + import ChangeLocation from '$lib/components/photos-page/actions/change-location.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; @@ -40,6 +42,8 @@ + + {/if} diff --git a/web/src/routes/(user)/people/[personId]/+page.svelte b/web/src/routes/(user)/people/[personId]/+page.svelte index d93cb83fc2..1d0e655b6f 100644 --- a/web/src/routes/(user)/people/[personId]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/+page.svelte @@ -8,6 +8,8 @@ import SetBirthDateModal from '$lib/components/faces-page/set-birth-date-modal.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; + import ChangeDate from '$lib/components/photos-page/actions/change-date.svelte'; + import ChangeLocation from '$lib/components/photos-page/actions/change-location.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; @@ -377,6 +379,8 @@ $assetStore.removeAssets(ids)} /> + + {:else} diff --git a/web/src/routes/(user)/photos/+page.svelte b/web/src/routes/(user)/photos/+page.svelte index 7515d747b4..51905429ed 100644 --- a/web/src/routes/(user)/photos/+page.svelte +++ b/web/src/routes/(user)/photos/+page.svelte @@ -2,6 +2,8 @@ import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; + import ChangeDate from '$lib/components/photos-page/actions/change-date.svelte'; + import ChangeLocation from '$lib/components/photos-page/actions/change-location.svelte'; import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; @@ -70,6 +72,8 @@ {#if $selectedAssets.size > 1} assetStore.removeAssets(ids)} /> {/if} + + diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index 3f932ba513..e5961c8f04 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -3,6 +3,8 @@ import { page } from '$app/stores'; import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; + import ChangeDate from '$lib/components/photos-page/actions/change-date.svelte'; + import ChangeLocation from '$lib/components/photos-page/actions/change-location.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte'; @@ -117,6 +119,8 @@ + + {:else}