chore: rebase and clean-up

This commit is contained in:
Jason Rasmussen
2023-11-21 21:16:40 -05:00
parent c1d9ce8679
commit b8a9cbc659
31 changed files with 928 additions and 53 deletions

View File

@@ -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;
}
/**
*

View File

@@ -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]

View File

@@ -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)

View File

@@ -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'),
);

View File

@@ -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;

View File

@@ -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

View File

@@ -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
});
});

View File

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

View File

@@ -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 } });
}
}
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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 });

View File

@@ -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);

View File

@@ -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 }

View File

@@ -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>;
}

View File

@@ -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']);
}
}

View File

@@ -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),

View File

@@ -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(),
};
};

View File

@@ -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;
}
/**
*

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}

View 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>

View File

@@ -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>

View File

@@ -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 }} />

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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}