chore: rebase and clean-up
This commit is contained in:
36
cli/src/api/open-api/api.ts
generated
36
cli/src/api/open-api/api.ts
generated
@@ -447,6 +447,12 @@ export interface AssetBulkDeleteDto {
|
||||
* @interface AssetBulkUpdateDto
|
||||
*/
|
||||
export interface AssetBulkUpdateDto {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof AssetBulkUpdateDto
|
||||
*/
|
||||
'dateTimeOriginal'?: string;
|
||||
/**
|
||||
*
|
||||
* @type {Array<string>}
|
||||
@@ -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;
|
||||
}
|
||||
/**
|
||||
*
|
||||
|
||||
3
mobile/openapi/doc/AssetBulkUpdateDto.md
generated
3
mobile/openapi/doc/AssetBulkUpdateDto.md
generated
@@ -8,9 +8,12 @@ import 'package:openapi/api.dart';
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**dateTimeOriginal** | **String** | | [optional]
|
||||
**ids** | **List<String>** | | [default to const []]
|
||||
**isArchived** | **bool** | | [optional]
|
||||
**isFavorite** | **bool** | | [optional]
|
||||
**latitude** | **num** | | [optional]
|
||||
**longitude** | **num** | | [optional]
|
||||
**removeParent** | **bool** | | [optional]
|
||||
**stackParentId** | **String** | | [optional]
|
||||
|
||||
|
||||
3
mobile/openapi/doc/UpdateAssetDto.md
generated
3
mobile/openapi/doc/UpdateAssetDto.md
generated
@@ -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)
|
||||
|
||||
|
||||
57
mobile/openapi/lib/model/asset_bulk_update_dto.dart
generated
57
mobile/openapi/lib/model/asset_bulk_update_dto.dart
generated
@@ -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<String> 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<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
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<String, dynamic>();
|
||||
|
||||
return AssetBulkUpdateDto(
|
||||
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
|
||||
ids: json[r'ids'] is List
|
||||
? (json[r'ids'] as List).cast<String>()
|
||||
: const [],
|
||||
isArchived: mapValueOfType<bool>(json, r'isArchived'),
|
||||
isFavorite: mapValueOfType<bool>(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<bool>(json, r'removeParent'),
|
||||
stackParentId: mapValueOfType<String>(json, r'stackParentId'),
|
||||
);
|
||||
|
||||
61
mobile/openapi/lib/model/update_asset_dto.dart
generated
61
mobile/openapi/lib/model/update_asset_dto.dart
generated
@@ -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<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
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<String, dynamic>();
|
||||
|
||||
return UpdateAssetDto(
|
||||
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
isArchived: mapValueOfType<bool>(json, r'isArchived'),
|
||||
isFavorite: mapValueOfType<bool>(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;
|
||||
|
||||
15
mobile/openapi/test/asset_bulk_update_dto_test.dart
generated
15
mobile/openapi/test/asset_bulk_update_dto_test.dart
generated
@@ -16,6 +16,11 @@ void main() {
|
||||
// final instance = AssetBulkUpdateDto();
|
||||
|
||||
group('test AssetBulkUpdateDto', () {
|
||||
// String dateTimeOriginal
|
||||
test('to test the property `dateTimeOriginal`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// List<String> 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
|
||||
|
||||
15
mobile/openapi/test/update_asset_dto_test.dart
generated
15
mobile/openapi/test/update_asset_dto_test.dart
generated
@@ -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
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<AssetResponseDto> {
|
||||
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<void> {
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, QueueName> = {
|
||||
[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,
|
||||
|
||||
@@ -33,3 +33,9 @@ export interface IBulkEntityJob extends IBaseJob {
|
||||
export interface IDeleteFilesJob extends IBaseJob {
|
||||
files: Array<string | null | undefined>;
|
||||
}
|
||||
|
||||
export interface ISidecarWriteJob extends IEntityJob {
|
||||
dateTimeOriginal?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -35,5 +35,6 @@ export interface IMetadataRepository {
|
||||
teardown(): Promise<void>;
|
||||
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>;
|
||||
deleteCache(): Promise<void>;
|
||||
getExifTags(path: string): Promise<ImmichTags | null>;
|
||||
readTags(path: string): Promise<ImmichTags | null>;
|
||||
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -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<ImmichTags | null> {
|
||||
readTags(path: string): Promise<ImmichTags | null> {
|
||||
return exiftool
|
||||
.read(path, undefined, {
|
||||
...DefaultReadTaskOptions,
|
||||
@@ -93,4 +93,8 @@ export class MetadataRepository implements IMetadataRepository {
|
||||
return null;
|
||||
}) as Promise<ImmichTags | null>;
|
||||
}
|
||||
|
||||
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
|
||||
await exiftool.write(path, tags, ['-overwrite_original']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -3,9 +3,10 @@ import { IMetadataRepository } from '@app/domain';
|
||||
export const newMetadataRepositoryMock = (): jest.Mocked<IMetadataRepository> => {
|
||||
return {
|
||||
deleteCache: jest.fn(),
|
||||
getExifTags: jest.fn(),
|
||||
init: jest.fn(),
|
||||
teardown: jest.fn(),
|
||||
reverseGeocode: jest.fn(),
|
||||
readTags: jest.fn(),
|
||||
writeTags: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
36
web/src/api/open-api/api.ts
generated
36
web/src/api/open-api/api.ts
generated
@@ -447,6 +447,12 @@ export interface AssetBulkDeleteDto {
|
||||
* @interface AssetBulkUpdateDto
|
||||
*/
|
||||
export interface AssetBulkUpdateDto {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof AssetBulkUpdateDto
|
||||
*/
|
||||
'dateTimeOriginal'?: string;
|
||||
/**
|
||||
*
|
||||
* @type {Array<string>}
|
||||
@@ -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;
|
||||
}
|
||||
/**
|
||||
*
|
||||
|
||||
@@ -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<string>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
||||
@@ -195,37 +241,101 @@
|
||||
{@const assetDateTimeOriginal = DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
|
||||
zone: asset.exifInfo.timeZone ?? undefined,
|
||||
})}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div>
|
||||
<Icon path={mdiCalendar} size="24" />
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 py-4 hover:bg-gray-200 hover:text-black cursor-pointer"
|
||||
on:click={() => (isShowChangeDate = true)}
|
||||
on:keydown={(event) => event.key === 'Enter' && (isShowChangeDate = true)}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<Icon path={mdiCalendar} size="24" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p>
|
||||
{assetDateTimeOriginal.toLocaleString(
|
||||
{
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
</p>
|
||||
<div class="flex gap-2 text-sm">
|
||||
<div>
|
||||
<p>
|
||||
{assetDateTimeOriginal.toLocaleString(
|
||||
{
|
||||
weekday: 'short',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'longOffset',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
</p>
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>
|
||||
{assetDateTimeOriginal.toLocaleString(
|
||||
{
|
||||
weekday: 'short',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'longOffset',
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>{/if}
|
||||
<button class="focus:outline-none">
|
||||
<Icon path={mdiPencil} size="24" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex justify-between gap-4 py-4 hover:bg-gray-200 hover:text-black cursor-pointer"
|
||||
on:click={() => (isShowChangeDate = true)}
|
||||
on:keydown={(event) => event.key === 'Enter' && (isShowChangeDate = true)}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<Icon path={mdiCalendar} size="24" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p>No date available for this asset, click to add one.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="focus:outline-none">
|
||||
<Icon path={mdiPencil} size="24" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isShowChangeDate}
|
||||
{#if asset.exifInfo?.dateTimeOriginal}
|
||||
{@const assetDateTimeOriginal = DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
|
||||
zone: asset.exifInfo.timeZone ?? undefined,
|
||||
})}
|
||||
<ChangeDate
|
||||
title="Change Date"
|
||||
confirmText="Confirm"
|
||||
initialDate={assetDateTimeOriginal}
|
||||
on:confirm={handleConfirmChangeDate}
|
||||
on:cancel={() => (isShowChangeDate = false)}
|
||||
>
|
||||
<svelte:fragment slot="prompt">
|
||||
<p>Please select a new date:</p>
|
||||
</svelte:fragment>
|
||||
</ChangeDate>
|
||||
{:else}
|
||||
<ChangeDate
|
||||
title="Change Date"
|
||||
confirmText="Confirm"
|
||||
initialDate={DateTime.now()}
|
||||
on:confirm={handleConfirmChangeDate}
|
||||
on:cancel={() => (isShowChangeDate = false)}
|
||||
>
|
||||
<svelte:fragment slot="prompt">
|
||||
<p>Please select a new date:</p>
|
||||
</svelte:fragment>
|
||||
</ChangeDate>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if asset.exifInfo?.fileSizeInByte}
|
||||
<div class="flex gap-4 py-4">
|
||||
@@ -293,7 +403,13 @@
|
||||
{/if}
|
||||
|
||||
{#if asset.exifInfo?.city}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div
|
||||
class="flex justify-between gap-4 py-4 hover:bg-gray-200 hover:text-black cursor-pointer"
|
||||
on:click={() => (isShowChangeLocation = true)}
|
||||
on:keydown={(event) => event.key === 'Enter' && (isShowChangeLocation = true)}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
|
||||
|
||||
<div>
|
||||
@@ -309,7 +425,57 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button class="focus:outline-none">
|
||||
<Icon path={mdiPencil} size="24" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex justify-between gap-4 py-4 hover:bg-gray-200 hover:text-black cursor-pointer"
|
||||
on:click={() => (isShowChangeLocation = true)}
|
||||
on:keydown={(event) => event.key === 'Enter' && (isShowChangeLocation = true)}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p>No location available for this asset, click to add one.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="focus:outline-none">
|
||||
<Icon path={mdiPencil} size="24" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isShowChangeLocation}
|
||||
{#if latlng}
|
||||
<ChangeLocation
|
||||
confirmText="Confirm"
|
||||
location={latlng}
|
||||
id_asset={asset.id}
|
||||
on:confirm={handleConfirmChangeLocation}
|
||||
on:cancel={() => (isShowChangeLocation = false)}
|
||||
>
|
||||
<svelte:fragment slot="prompt">
|
||||
<p>Please select a new location:</p>
|
||||
</svelte:fragment>
|
||||
</ChangeLocation>
|
||||
{:else}
|
||||
<ChangeLocation
|
||||
confirmText="Confirm"
|
||||
on:confirm={handleConfirmChangeLocation}
|
||||
on:cancel={() => (isShowChangeLocation = false)}
|
||||
>
|
||||
<svelte:fragment slot="prompt">
|
||||
<p>Please select a new location:</p>
|
||||
</svelte:fragment>
|
||||
</ChangeLocation>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@api';
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
|
||||
import { DateTime } from 'luxon';
|
||||
export let force = !$featureFlags.trash;
|
||||
export let menuItem = false;
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
let isShowChangeDate = false;
|
||||
|
||||
async function handleConfirmChangeDate(event: CustomEvent<string>) {
|
||||
isShowChangeDate = false;
|
||||
const ids = Array.from(getOwnedAssets())
|
||||
.filter((a) => !a.isExternal)
|
||||
.map((a) => a.id);
|
||||
|
||||
try {
|
||||
await api.assetApi.updateAssets({
|
||||
assetBulkUpdateDto: {
|
||||
ids: ids,
|
||||
dateTimeOriginal: event.detail,
|
||||
},
|
||||
});
|
||||
notificationController.show({ message: 'Metadata updated please reload to apply', type: NotificationType.Info });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
clearSelect();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption text={force ? 'Change date' : 'Change date'} on:click={() => (isShowChangeDate = true)} />
|
||||
{/if}
|
||||
{#if isShowChangeDate}
|
||||
<ChangeDate
|
||||
title="Change Date"
|
||||
confirmText="Confirm"
|
||||
initialDate={DateTime.now()}
|
||||
on:confirm={handleConfirmChangeDate}
|
||||
on:cancel={() => (isShowChangeDate = false)}
|
||||
>
|
||||
<svelte:fragment slot="prompt">
|
||||
<p>Please select a new date:</p>
|
||||
</svelte:fragment>
|
||||
</ChangeDate>
|
||||
{/if}
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@api';
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
|
||||
|
||||
export let force = !$featureFlags.trash;
|
||||
export let menuItem = false;
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
let isShowChangeLocation = false;
|
||||
|
||||
async function handleConfirmChangeLocation(event: CustomEvent<{ lng: number; lat: number }>) {
|
||||
isShowChangeLocation = false;
|
||||
const ids = Array.from(getOwnedAssets())
|
||||
.filter((a) => !a.isExternal)
|
||||
.map((a) => a.id);
|
||||
|
||||
try {
|
||||
await api.assetApi.updateAssets({
|
||||
assetBulkUpdateDto: {
|
||||
ids: ids,
|
||||
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);
|
||||
}
|
||||
clearSelect();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption text={force ? 'Change location' : 'Change location'} on:click={() => (isShowChangeLocation = true)} />
|
||||
{/if}
|
||||
{#if isShowChangeLocation}
|
||||
<ChangeLocation
|
||||
confirmText="Confirm"
|
||||
on:confirm={handleConfirmChangeLocation}
|
||||
on:cancel={() => (isShowChangeLocation = false)}
|
||||
>
|
||||
<svelte:fragment slot="prompt">
|
||||
<p>Please select a new location:</p>
|
||||
</svelte:fragment>
|
||||
</ChangeLocation>
|
||||
{/if}
|
||||
129
web/src/lib/components/shared-components/change-date.svelte
Normal file
129
web/src/lib/components/shared-components/change-date.svelte
Normal file
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import FullScreenModal from './full-screen-modal.svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import type { Color } from '$lib/components/elements/buttons/button.svelte';
|
||||
import { DateTime } from 'luxon';
|
||||
export let title = 'Change Date';
|
||||
export let prompt = 'Please select a new date:';
|
||||
export let confirmText = 'Confirm';
|
||||
export let confirmColor: Color = 'primary';
|
||||
export let cancelText = 'Cancel';
|
||||
export let cancelColor: Color = 'secondary';
|
||||
export let hideCancelButton = false;
|
||||
export let initialDate: DateTime = DateTime.now();
|
||||
|
||||
let selectedDate: string;
|
||||
let selectedTimezone: string | null;
|
||||
|
||||
let timezones = [
|
||||
'',
|
||||
'UTC-12:00',
|
||||
'UTC-11:00',
|
||||
'UTC-10:00',
|
||||
'UTC-09:30',
|
||||
'UTC-09:00',
|
||||
'UTC-08:00',
|
||||
'UTC-07:00',
|
||||
'UTC-06:00',
|
||||
'UTC-05:00',
|
||||
'UTC-04:00',
|
||||
'UTC-03:30',
|
||||
'UTC-03:00',
|
||||
'UTC-02:00',
|
||||
'UTC-01:00',
|
||||
'UTC+00:00',
|
||||
'UTC+01:00',
|
||||
'UTC+02:00',
|
||||
'UTC+03:00',
|
||||
'UTC+03:30',
|
||||
'UTC+04:00',
|
||||
'UTC+04:30',
|
||||
'UTC+05:00',
|
||||
'UTC+05:30',
|
||||
'UTC+05:45',
|
||||
'UTC+06:00',
|
||||
'UTC+06:30',
|
||||
'UTC+07:00',
|
||||
'UTC+08:00',
|
||||
'UTC+08:45',
|
||||
'UTC+09:00',
|
||||
'UTC+09:30',
|
||||
'UTC+10:00',
|
||||
'UTC+10:30',
|
||||
'UTC+11:00',
|
||||
'UTC+12:00',
|
||||
'UTC+12:45',
|
||||
'UTC+13:00',
|
||||
'UTC+14:00',
|
||||
];
|
||||
|
||||
selectedDate = initialDate.toFormat("yyyy-MM-dd'T'HH:mm");
|
||||
selectedTimezone = 'UTC' + initialDate.toFormat('ZZ');
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let isConfirmButtonDisabled = false;
|
||||
|
||||
const handleCancel = () => dispatch('cancel');
|
||||
const handleEscape = () => {
|
||||
if (!isConfirmButtonDisabled) {
|
||||
dispatch('cancel');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
let date = DateTime.fromISO(selectedDate);
|
||||
if (selectedTimezone != null) {
|
||||
date = date.setZone(selectedTimezone);
|
||||
}
|
||||
|
||||
isConfirmButtonDisabled = true;
|
||||
dispatch('confirm', date.toISO());
|
||||
};
|
||||
</script>
|
||||
|
||||
<FullScreenModal on:clickOutside={handleCancel} on:escape={() => handleEscape()}>
|
||||
<div
|
||||
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
|
||||
>
|
||||
<h1 class="pb-2 text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-md px-4 py-5 text-center">
|
||||
<slot name="prompt">
|
||||
<p>{prompt}</p>
|
||||
</slot>
|
||||
<div class="mt-2" />
|
||||
<div class="flex flex-col">
|
||||
<label for="datetime">Date and Time</label>
|
||||
<input id="datetime" type="datetime-local" bind:value={selectedDate} class="mt-2 w-full text-black" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label for="timezone">Timezone</label>
|
||||
<select id="timezone" bind:value={selectedTimezone} class="mt-2 w-full text-black">
|
||||
{#each timezones as timezone}
|
||||
<option value={timezone}>{timezone || 'Choose a timezone'}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex w-full gap-4 px-4">
|
||||
{#if !hideCancelButton}
|
||||
<Button color={cancelColor} fullwidth on:click={handleCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button color={confirmColor} fullwidth on:click={handleConfirm} disabled={isConfirmButtonDisabled}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FullScreenModal>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import FullScreenModal from './full-screen-modal.svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import type { Color } from '$lib/components/elements/buttons/button.svelte';
|
||||
import Map from './map/map.svelte';
|
||||
export const title = 'Change Location';
|
||||
export let prompt = 'Please select a new date:';
|
||||
export let confirmText = 'Confirm';
|
||||
export let confirmColor: Color = 'primary';
|
||||
export let cancelText = 'Cancel';
|
||||
export let cancelColor: Color = 'secondary';
|
||||
export let hideCancelButton = false;
|
||||
export let location: { lng: number; lat: number } = { lng: 0, lat: 0 };
|
||||
export let id_asset: string | null = null;
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let isConfirmButtonDisabled = false;
|
||||
|
||||
const originalLat = location.lat;
|
||||
const originalLng = location.lng;
|
||||
|
||||
const handleCancel = () => dispatch('cancel');
|
||||
const handleEscape = () => {
|
||||
if (!isConfirmButtonDisabled) {
|
||||
dispatch('cancel');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
dispatch('confirm', location);
|
||||
};
|
||||
</script>
|
||||
|
||||
<FullScreenModal on:clickOutside={handleCancel} on:escape={() => handleEscape()}>
|
||||
<div
|
||||
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-md px-4 py-5 text-center">
|
||||
<slot name="prompt">
|
||||
<p>{prompt}</p>
|
||||
</slot>
|
||||
<div class="mt-2" />
|
||||
<div class="flex flex-col">
|
||||
<label for="datetime">Pick a location</label>
|
||||
</div>
|
||||
<div style="height: 500px;">
|
||||
<Map
|
||||
mapMarkers={id_asset
|
||||
? [
|
||||
{
|
||||
id: id_asset,
|
||||
lat: originalLat,
|
||||
lon: originalLng,
|
||||
},
|
||||
]
|
||||
: []}
|
||||
zoom={id_asset ? 15 : 1}
|
||||
center={location}
|
||||
simplified={true}
|
||||
clickable={true}
|
||||
on:clickedPoint={(e) => {
|
||||
location = e.detail;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex w-full gap-4 px-4">
|
||||
{#if !hideCancelButton}
|
||||
<Button color={cancelColor} fullwidth on:click={handleCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button color={confirmColor} fullwidth on:click={handleConfirm} disabled={isConfirmButtonDisabled}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FullScreenModal>
|
||||
@@ -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<Point, { id: string }>;
|
||||
|
||||
const asFeature = (marker: MapMarkerResponseDto): FeaturePoint => {
|
||||
@@ -87,7 +111,7 @@
|
||||
</script>
|
||||
|
||||
{#await style then style}
|
||||
<MapLibre {style} class="h-full" {center} {zoom} attributionControl={false} diffStyleUpdates={true} let:map>
|
||||
<MapLibre {style} class="h-full" {center} {zoom} attributionControl={false} diffStyleUpdates={true} let:map bind:map>
|
||||
<NavigationControl position="top-left" showCompass={!simplified} />
|
||||
{#if !simplified}
|
||||
<GeolocateControl position="top-left" fitBoundsOptions={{ maxZoom: 12 }} />
|
||||
|
||||
@@ -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}
|
||||
<DeleteAssets menuItem onAssetDelete={(assetId) => assetStore.removeAsset(assetId)} />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
{/if}
|
||||
</AssetSelectContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
|
||||
@@ -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 @@
|
||||
<AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
|
||||
<DownloadAction menuItem />
|
||||
<ArchiveAction menuItem unarchive={isAllArchive} />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
</AssetSelectContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{/if}
|
||||
|
||||
@@ -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 @@
|
||||
<DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
|
||||
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
|
||||
<ArchiveAction menuItem unarchive={isAllArchive} onArchive={(ids) => $assetStore.removeAssets(ids)} />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
</AssetSelectContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
|
||||
@@ -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}
|
||||
<StackAction onStack={(ids) => assetStore.removeAssets(ids)} />
|
||||
{/if}
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<AssetJobActions />
|
||||
</AssetSelectContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
|
||||
@@ -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 @@
|
||||
<DownloadAction menuItem />
|
||||
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
|
||||
<ArchiveAction menuItem unarchive={isAllArchived} />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
</AssetSelectContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user