mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00:00
feat: Edit metadata (#5066)
* chore: rebase and clean-up
* feat: sync description, add e2e tests
* feat: simplify web code
* chore: unit tests
* fix: linting
* Bug fix with the arrows key
* timezone typeahead filter
timezone typeahead filter
* small stlying
* format fix
* Bug fix in the map selection
Bug fix in the map selection
* Websocket basic
Websocket basic
* Update metadata visualisation through the websocket
* Update timeline
* fix merge
* fix web
* fix web
* maplibre system
* format fix
* format fix
* refactor: clean up
* Fix small bug in the hour/timezone
* Don't diplay modify for readOnly asset
* Add log in case of failure
* Formater + try/catch error
* Remove everything related to websocket
* Revert "Remove everything related to websocket"
This reverts commit 14bcb9e1e4
.
* remove notification
* fix test
---------
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
b396e0eee3
commit
644e52b153
42 changed files with 895 additions and 77 deletions
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}
|
||||
|
@ -4137,6 +4155,12 @@ export interface UpdateAlbumDto {
|
|||
* @interface UpdateAssetDto
|
||||
*/
|
||||
export interface UpdateAssetDto {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof UpdateAssetDto
|
||||
*/
|
||||
'dateTimeOriginal'?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
|
@ -4155,6 +4179,18 @@ export interface UpdateAssetDto {
|
|||
* @memberof UpdateAssetDto
|
||||
*/
|
||||
'isFavorite'?: boolean;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof UpdateAssetDto
|
||||
*/
|
||||
'latitude'?: number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof UpdateAssetDto
|
||||
*/
|
||||
'longitude'?: number;
|
||||
}
|
||||
/**
|
||||
*
|
||||
|
|
BIN
mobile/openapi/doc/AssetBulkUpdateDto.md
generated
BIN
mobile/openapi/doc/AssetBulkUpdateDto.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/UpdateAssetDto.md
generated
BIN
mobile/openapi/doc/UpdateAssetDto.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/asset_bulk_update_dto.dart
generated
BIN
mobile/openapi/lib/model/asset_bulk_update_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/update_asset_dto.dart
generated
BIN
mobile/openapi/lib/model/update_asset_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/asset_bulk_update_dto_test.dart
generated
BIN
mobile/openapi/test/asset_bulk_update_dto_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/update_asset_dto_test.dart
generated
BIN
mobile/openapi/test/update_asset_dto_test.dart
generated
Binary file not shown.
|
@ -6449,6 +6449,9 @@
|
|||
},
|
||||
"AssetBulkUpdateDto": {
|
||||
"properties": {
|
||||
"dateTimeOriginal": {
|
||||
"type": "string"
|
||||
},
|
||||
"ids": {
|
||||
"items": {
|
||||
"format": "uuid",
|
||||
|
@ -6462,6 +6465,12 @@
|
|||
"isFavorite": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"latitude": {
|
||||
"type": "number"
|
||||
},
|
||||
"longitude": {
|
||||
"type": "number"
|
||||
},
|
||||
"removeParent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
@ -9343,6 +9352,9 @@
|
|||
},
|
||||
"UpdateAssetDto": {
|
||||
"properties": {
|
||||
"dateTimeOriginal": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
|
@ -9351,6 +9363,12 @@
|
|||
},
|
||||
"isFavorite": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"latitude": {
|
||||
"type": "number"
|
||||
},
|
||||
"longitude": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
|
|
@ -8,7 +8,7 @@ import { AccessCore, Permission } from '../access';
|
|||
import { AuthUserDto } from '../auth';
|
||||
import { mimeTypes } from '../domain.constant';
|
||||
import { HumanReadableSize, usePagination } from '../domain.util';
|
||||
import { IAssetDeletionJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
|
||||
import { IAssetDeletionJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
|
||||
import {
|
||||
CommunicationEvent,
|
||||
IAccessRepository,
|
||||
|
@ -393,10 +393,8 @@ export class AssetService {
|
|||
async update(authUser: AuthUserDto, id: string, dto: UpdateAssetDto): Promise<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] } });
|
||||
|
@ -404,7 +402,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) {
|
||||
|
@ -424,6 +422,10 @@ export class AssetService {
|
|||
await this.assetRepository.updateAll([options.stackParentId], { stackParentId: null });
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude });
|
||||
}
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
|
||||
await this.assetRepository.updateAll(ids, options);
|
||||
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids);
|
||||
|
@ -587,4 +589,13 @@ export class AssetService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async updateMetadata(dto: ISidecarWriteJob) {
|
||||
const { id, description, dateTimeOriginal, latitude, longitude } = dto;
|
||||
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude }, _.isUndefined);
|
||||
if (Object.keys(writes).length > 0) {
|
||||
await this.assetRepository.upsertExif({ assetId: id, ...writes });
|
||||
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -9,7 +9,7 @@ export interface IAssetFaceJob extends IBaseJob {
|
|||
|
||||
export interface IEntityJob extends IBaseJob {
|
||||
id: string;
|
||||
source?: 'upload';
|
||||
source?: 'upload' | 'sidecar-write';
|
||||
}
|
||||
|
||||
export interface IAssetDeletionJob extends IEntityJob {
|
||||
|
@ -33,3 +33,10 @@ export interface IBulkEntityJob extends IBaseJob {
|
|||
export interface IDeleteFilesJob extends IBaseJob {
|
||||
files: Array<string | null | undefined>;
|
||||
}
|
||||
|
||||
export interface ISidecarWriteJob extends IEntityJob {
|
||||
description?: string;
|
||||
dateTimeOriginal?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
|
|
|
@ -165,7 +165,19 @@ export class JobService {
|
|||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: item.data });
|
||||
break;
|
||||
|
||||
case JobName.SIDECAR_WRITE:
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.METADATA_EXTRACTION,
|
||||
data: { id: item.data.id, source: 'sidecar-write' },
|
||||
});
|
||||
|
||||
case JobName.METADATA_EXTRACTION:
|
||||
if (item.data.source === 'sidecar-write') {
|
||||
const [asset] = await this.assetRepository.getByIds([item.data.id]);
|
||||
if (asset) {
|
||||
this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, asset.ownerId, mapAsset(asset));
|
||||
}
|
||||
}
|
||||
await this.jobRepository.queue({ name: JobName.LINK_LIVE_PHOTOS, data: item.data });
|
||||
break;
|
||||
|
||||
|
|
|
@ -218,11 +218,11 @@ describe(MetadataService.name, () => {
|
|||
const originalDate = new Date('2023-11-21T16:13:17.517Z');
|
||||
const sidecarDate = new Date('2022-01-01T00:00:00.000Z');
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.sidecar]);
|
||||
when(metadataMock.getExifTags)
|
||||
when(metadataMock.readTags)
|
||||
.calledWith(assetStub.sidecar.originalPath)
|
||||
// higher priority tag
|
||||
.mockResolvedValue({ CreationDate: originalDate.toISOString() });
|
||||
when(metadataMock.getExifTags)
|
||||
when(metadataMock.readTags)
|
||||
.calledWith(assetStub.sidecar.sidecarPath as string)
|
||||
// lower priority tag, but in sidecar
|
||||
.mockResolvedValue({ CreateDate: sidecarDate.toISOString() });
|
||||
|
@ -240,7 +240,7 @@ describe(MetadataService.name, () => {
|
|||
|
||||
it('should handle lists of numbers', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
metadataMock.getExifTags.mockResolvedValue({ ISO: [160] as any });
|
||||
metadataMock.readTags.mockResolvedValue({ ISO: [160] as any });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
|
||||
|
@ -257,7 +257,7 @@ describe(MetadataService.name, () => {
|
|||
assetMock.getByIds.mockResolvedValue([assetStub.withLocation]);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.REVERSE_GEOCODING_ENABLED, value: true }]);
|
||||
metadataMock.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' });
|
||||
metadataMock.getExifTags.mockResolvedValue({
|
||||
metadataMock.readTags.mockResolvedValue({
|
||||
GPSLatitude: assetStub.withLocation.exifInfo!.latitude!,
|
||||
GPSLongitude: assetStub.withLocation.exifInfo!.longitude!,
|
||||
});
|
||||
|
@ -289,7 +289,7 @@ describe(MetadataService.name, () => {
|
|||
|
||||
it('should apply motion photos', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
|
||||
metadataMock.getExifTags.mockResolvedValue({
|
||||
metadataMock.readTags.mockResolvedValue({
|
||||
Directory: 'foo/bar/',
|
||||
MotionPhoto: 1,
|
||||
MicroVideo: 1,
|
||||
|
@ -310,7 +310,7 @@ describe(MetadataService.name, () => {
|
|||
|
||||
it('should create new motion asset if not found and link it with the photo', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
|
||||
metadataMock.getExifTags.mockResolvedValue({
|
||||
metadataMock.readTags.mockResolvedValue({
|
||||
Directory: 'foo/bar/',
|
||||
MotionPhoto: 1,
|
||||
MicroVideo: 1,
|
||||
|
@ -367,7 +367,7 @@ describe(MetadataService.name, () => {
|
|||
tz: '+02:00',
|
||||
};
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
metadataMock.getExifTags.mockResolvedValue(tags);
|
||||
metadataMock.readTags.mockResolvedValue(tags);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
|
||||
|
@ -406,7 +406,7 @@ describe(MetadataService.name, () => {
|
|||
|
||||
it('should handle duration', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
metadataMock.getExifTags.mockResolvedValue({ Duration: 6.21 });
|
||||
metadataMock.readTags.mockResolvedValue({ Duration: 6.21 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
|
||||
|
@ -422,7 +422,7 @@ describe(MetadataService.name, () => {
|
|||
|
||||
it('should handle duration as an object without Scale', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
metadataMock.getExifTags.mockResolvedValue({ Duration: { Value: 6.2 } });
|
||||
metadataMock.readTags.mockResolvedValue({ Duration: { Value: 6.2 } });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
|
||||
|
@ -438,7 +438,7 @@ describe(MetadataService.name, () => {
|
|||
|
||||
it('should handle duration with scale', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
metadataMock.getExifTags.mockResolvedValue({ Duration: { Scale: 1.11111111111111e-5, Value: 558720 } });
|
||||
metadataMock.readTags.mockResolvedValue({ Duration: { Scale: 1.11111111111111e-5, Value: 558720 } });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
|
||||
|
@ -531,4 +531,41 @@ describe(MetadataService.name, () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSidecarWrite', () => {
|
||||
it('should skip assets that do not exist anymore', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([]);
|
||||
await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(false);
|
||||
expect(metadataMock.writeTags).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip jobs with not metadata', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.sidecar]);
|
||||
await expect(sut.handleSidecarWrite({ id: assetStub.sidecar.id })).resolves.toBe(true);
|
||||
expect(metadataMock.writeTags).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should write tags', async () => {
|
||||
const description = 'this is a description';
|
||||
const gps = 12;
|
||||
const date = '2023-11-22T04:56:12.196Z';
|
||||
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.sidecar]);
|
||||
await expect(
|
||||
sut.handleSidecarWrite({
|
||||
id: assetStub.sidecar.id,
|
||||
description,
|
||||
latitude: gps,
|
||||
longitude: gps,
|
||||
dateTimeOriginal: date,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(metadataMock.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, {
|
||||
ImageDescription: description,
|
||||
CreationDate: date,
|
||||
GPSLatitude: gps,
|
||||
GPSLongitude: gps,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,10 +3,11 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
|
|||
import { ExifDateTime, Tags } from 'exiftool-vendored';
|
||||
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
|
||||
import { constants } from 'fs/promises';
|
||||
import _ from 'lodash';
|
||||
import { Duration } from 'luxon';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { usePagination } from '../domain.util';
|
||||
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
|
||||
import { IBaseJob, IEntityJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
|
||||
import {
|
||||
ExifDuration,
|
||||
IAlbumRepository,
|
||||
|
@ -79,7 +80,6 @@ export class MetadataService {
|
|||
private logger = new Logger(MetadataService.name);
|
||||
private storageCore: StorageCore;
|
||||
private configCore: SystemConfigCore;
|
||||
private oldCities?: string;
|
||||
private subscription: Subscription | null = null;
|
||||
|
||||
constructor(
|
||||
|
@ -244,6 +244,37 @@ export class MetadataService {
|
|||
return true;
|
||||
}
|
||||
|
||||
async handleSidecarWrite(job: ISidecarWriteJob) {
|
||||
const { id, description, dateTimeOriginal, latitude, longitude } = job;
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
|
||||
const exif = _.omitBy<Tags>(
|
||||
{
|
||||
ImageDescription: description,
|
||||
CreationDate: dateTimeOriginal,
|
||||
GPSLatitude: latitude,
|
||||
GPSLongitude: longitude,
|
||||
},
|
||||
_.isUndefined,
|
||||
);
|
||||
|
||||
if (Object.keys(exif).length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.repository.writeTags(sidecarPath, exif);
|
||||
|
||||
if (!asset.sidecarPath) {
|
||||
await this.assetRepository.save({ id, sidecarPath });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) {
|
||||
const { latitude, longitude } = exifData;
|
||||
if (!(await this.configCore.hasFeature(FeatureFlag.REVERSE_GEOCODING)) || !longitude || !latitude) {
|
||||
|
@ -346,8 +377,8 @@ export class MetadataService {
|
|||
asset: AssetEntity,
|
||||
): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; tags: ImmichTags }> {
|
||||
const stats = await this.storageRepository.stat(asset.originalPath);
|
||||
const mediaTags = await this.repository.getExifTags(asset.originalPath);
|
||||
const sidecarTags = asset.sidecarPath ? await this.repository.getExifTags(asset.sidecarPath) : null;
|
||||
const mediaTags = await this.repository.readTags(asset.originalPath);
|
||||
const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null;
|
||||
|
||||
// ensure date from sidecar is used if present
|
||||
const hasDateOverride = !!this.getDateTimeOriginal(sidecarTags);
|
||||
|
|
|
@ -9,6 +9,7 @@ import {
|
|||
IEntityJob,
|
||||
ILibraryFileJob,
|
||||
ILibraryRefreshJob,
|
||||
ISidecarWriteJob,
|
||||
} from '../job/job.interface';
|
||||
|
||||
export interface JobCounts {
|
||||
|
@ -54,11 +55,11 @@ export type JobItem =
|
|||
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
|
||||
| { name: JobName.METADATA_EXTRACTION; data: IEntityJob }
|
||||
| { name: JobName.LINK_LIVE_PHOTOS; data: IEntityJob }
|
||||
|
||||
// Sidecar Scanning
|
||||
| { name: JobName.QUEUE_SIDECAR; data: IBaseJob }
|
||||
| { name: JobName.SIDECAR_DISCOVERY; data: IEntityJob }
|
||||
| { name: JobName.SIDECAR_SYNC; data: IEntityJob }
|
||||
| { name: JobName.SIDECAR_WRITE; data: ISidecarWriteJob }
|
||||
|
||||
// Object Tagging
|
||||
| { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob }
|
||||
|
|
|
@ -33,5 +33,6 @@ export interface IMetadataRepository {
|
|||
init(): Promise<void>;
|
||||
teardown(): Promise<void>;
|
||||
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult | null>;
|
||||
getExifTags(path: string): Promise<ImmichTags | null>;
|
||||
readTags(path: string): Promise<ImmichTags | null>;
|
||||
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import { GeodataAdmin1Entity, GeodataAdmin2Entity, GeodataPlacesEntity, SystemMe
|
|||
import { DatabaseLock } from '@app/infra/utils/database-locks';
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { DefaultReadTaskOptions, exiftool } from 'exiftool-vendored';
|
||||
import { DefaultReadTaskOptions, exiftool, Tags } from 'exiftool-vendored';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { readFile } from 'fs/promises';
|
||||
import * as geotz from 'geo-tz';
|
||||
|
@ -181,7 +181,7 @@ export class MetadataRepository implements IMetadataRepository {
|
|||
return { country, state, city };
|
||||
}
|
||||
|
||||
getExifTags(path: string): Promise<ImmichTags | null> {
|
||||
readTags(path: string): Promise<ImmichTags | null> {
|
||||
return exiftool
|
||||
.read(path, undefined, {
|
||||
...DefaultReadTaskOptions,
|
||||
|
@ -198,4 +198,12 @@ export class MetadataRepository implements IMetadataRepository {
|
|||
return null;
|
||||
}) as Promise<ImmichTags | null>;
|
||||
}
|
||||
|
||||
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
|
||||
try {
|
||||
await exiftool.write(path, tags, ['-overwrite_original']);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -700,6 +700,54 @@ describe(`${AssetController.name} (e2e)`, () => {
|
|||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should update date time original', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.put(`/asset/${asset1.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
||||
|
||||
expect(body).toMatchObject({
|
||||
id: asset1.id,
|
||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00.000Z' }),
|
||||
});
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should reject invalid gps coordinates', async () => {
|
||||
for (const test of [
|
||||
{ latitude: 12 },
|
||||
{ longitude: 12 },
|
||||
{ latitude: 12, longitude: 'abc' },
|
||||
{ latitude: 'abc', longitude: 12 },
|
||||
{ latitude: null, longitude: 12 },
|
||||
{ latitude: 12, longitude: null },
|
||||
{ latitude: 91, longitude: 12 },
|
||||
{ latitude: -91, longitude: 12 },
|
||||
{ latitude: 12, longitude: -181 },
|
||||
{ latitude: 12, longitude: 181 },
|
||||
]) {
|
||||
const { status, body } = await request(server)
|
||||
.put(`/asset/${asset1.id}`)
|
||||
.send(test)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest());
|
||||
}
|
||||
});
|
||||
|
||||
it('should update gps data', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.put(`/asset/${asset1.id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ latitude: 12, longitude: 12 });
|
||||
|
||||
expect(body).toMatchObject({
|
||||
id: asset1.id,
|
||||
exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }),
|
||||
});
|
||||
expect(status).toEqual(200);
|
||||
});
|
||||
|
||||
it('should set the description', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.put(`/asset/${asset1.id}`)
|
||||
|
|
|
@ -2,9 +2,10 @@ import { IMetadataRepository } from '@app/domain';
|
|||
|
||||
export const newMetadataRepositoryMock = (): jest.Mocked<IMetadataRepository> => {
|
||||
return {
|
||||
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}
|
||||
|
@ -4137,6 +4155,12 @@ export interface UpdateAlbumDto {
|
|||
* @interface UpdateAssetDto
|
||||
*/
|
||||
export interface UpdateAssetDto {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof UpdateAssetDto
|
||||
*/
|
||||
'dateTimeOriginal'?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
|
@ -4155,6 +4179,18 @@ export interface UpdateAssetDto {
|
|||
* @memberof UpdateAssetDto
|
||||
*/
|
||||
'isFavorite'?: boolean;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof UpdateAssetDto
|
||||
*/
|
||||
'latitude'?: number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof UpdateAssetDto
|
||||
*/
|
||||
'longitude'?: number;
|
||||
}
|
||||
/**
|
||||
*
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
import ThemeButton from '../shared-components/theme-button.svelte';
|
||||
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
|
||||
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
|
||||
import UpdatePanel from '../shared-components/update-panel.svelte';
|
||||
|
||||
export let sharedLink: SharedLinkResponseDto;
|
||||
export let user: UserResponseDto | undefined = undefined;
|
||||
|
@ -167,4 +168,5 @@
|
|||
</p>
|
||||
</section>
|
||||
</AssetGrid>
|
||||
<UpdatePanel {assetStore} />
|
||||
</main>
|
||||
|
|
|
@ -5,22 +5,27 @@
|
|||
import { getAssetFilename } from '$lib/utils/asset-utils';
|
||||
import { AlbumResponseDto, AssetResponseDto, ThumbnailFormat, api } from '@api';
|
||||
import { DateTime } from 'luxon';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { createEventDispatcher, onDestroy } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { asByteUnitString } from '../../utils/byte-units';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
|
||||
import {
|
||||
mdiCalendar,
|
||||
mdiCameraIris,
|
||||
mdiClose,
|
||||
mdiPencil,
|
||||
mdiImageOutline,
|
||||
mdiMapMarkerOutline,
|
||||
mdiInformationOutline,
|
||||
} from '@mdi/js';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import Map from '../shared-components/map/map.svelte';
|
||||
import { websocketStore } from '$lib/stores/websocket';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import ChangeLocation from '../shared-components/change-location.svelte';
|
||||
import { handleError } from '../../utils/handle-error';
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let albums: AlbumResponseDto[] = [];
|
||||
|
@ -52,6 +57,16 @@
|
|||
|
||||
$: people = asset.people || [];
|
||||
|
||||
const unsubscribe = websocketStore.onAssetUpdate.subscribe((assetUpdate) => {
|
||||
if (assetUpdate && assetUpdate.id === asset.id) {
|
||||
asset = assetUpdate;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const getMegapixel = (width: number, height: number): number | undefined => {
|
||||
|
@ -79,9 +94,7 @@
|
|||
try {
|
||||
await api.assetApi.updateAsset({
|
||||
id: asset.id,
|
||||
updateAssetDto: {
|
||||
description: description,
|
||||
},
|
||||
updateAssetDto: { description },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
@ -90,6 +103,35 @@
|
|||
|
||||
let showAssetPath = false;
|
||||
const toggleAssetPath = () => (showAssetPath = !showAssetPath);
|
||||
|
||||
let isShowChangeDate = false;
|
||||
|
||||
async function handleConfirmChangeDate(dateTimeOriginal: string) {
|
||||
isShowChangeDate = false;
|
||||
try {
|
||||
await api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { dateTimeOriginal } });
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to change date');
|
||||
}
|
||||
}
|
||||
|
||||
let isShowChangeLocation = false;
|
||||
|
||||
async function handleConfirmChangeLocation(gps: { lng: number; lat: number }) {
|
||||
isShowChangeLocation = false;
|
||||
|
||||
try {
|
||||
await api.assetApi.updateAsset({
|
||||
id: asset.id,
|
||||
updateAssetDto: {
|
||||
latitude: gps.lat,
|
||||
longitude: gps.lng,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to change location');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
||||
|
@ -191,41 +233,115 @@
|
|||
<p class="text-sm">DETAILS</p>
|
||||
{/if}
|
||||
|
||||
{#if asset.exifInfo?.dateTimeOriginal}
|
||||
{#if asset.exifInfo?.dateTimeOriginal && !asset.isReadOnly}
|
||||
{@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 place-items-start gap-4 py-4 hover:dark:text-immich-dark-primary hover:text-immich-primary cursor-pointer"
|
||||
on:click={() => (isShowChangeDate = true)}
|
||||
on:keydown={(event) => event.key === 'Enter' && (isShowChangeDate = true)}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
title="Edit date"
|
||||
>
|
||||
<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="20" />
|
||||
</button>
|
||||
</div>
|
||||
{:else if !asset.exifInfo?.dateTimeOriginal && !asset.isReadOnly}
|
||||
<div class="flex justify-between place-items-start gap-4 py-4">
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<Icon path={mdiCalendar} size="24" />
|
||||
</div>
|
||||
</div>
|
||||
<button class="focus:outline-none">
|
||||
<Icon path={mdiPencil} size="20" />
|
||||
</button>
|
||||
</div>
|
||||
{:else if asset.exifInfo?.dateTimeOriginal && asset.isReadOnly}
|
||||
{@const assetDateTimeOriginal = DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
|
||||
zone: asset.exifInfo.timeZone ?? undefined,
|
||||
})}
|
||||
<div class="flex justify-between place-items-start gap-4 py-4">
|
||||
<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">
|
||||
<p>
|
||||
{assetDateTimeOriginal.toLocaleString(
|
||||
{
|
||||
weekday: 'short',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'longOffset',
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isShowChangeDate}
|
||||
{@const assetDateTimeOriginal = asset.exifInfo?.dateTimeOriginal
|
||||
? DateTime.fromISO(asset.exifInfo.dateTimeOriginal, {
|
||||
zone: asset.exifInfo.timeZone ?? undefined,
|
||||
})
|
||||
: DateTime.now()}
|
||||
<ChangeDate
|
||||
initialDate={assetDateTimeOriginal}
|
||||
on:confirm={({ detail: date }) => handleConfirmChangeDate(date)}
|
||||
on:cancel={() => (isShowChangeDate = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if asset.exifInfo?.fileSizeInByte}
|
||||
<div class="flex gap-4 py-4">
|
||||
|
@ -292,24 +408,84 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if asset.exifInfo?.city}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
|
||||
{#if asset.exifInfo?.city && !asset.isReadOnly}
|
||||
<div
|
||||
class="flex justify-between place-items-start gap-4 py-4 hover:dark:text-immich-dark-primary hover:text-immich-primary cursor-pointer"
|
||||
on:click={() => (isShowChangeLocation = true)}
|
||||
on:keydown={(event) => event.key === 'Enter' && (isShowChangeLocation = true)}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
title="Edit location"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
|
||||
|
||||
<div>
|
||||
<p>{asset.exifInfo.city}</p>
|
||||
{#if asset.exifInfo?.state}
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>{asset.exifInfo.state}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if asset.exifInfo?.country}
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>{asset.exifInfo.country}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p>{asset.exifInfo.city}</p>
|
||||
{#if asset.exifInfo?.state}
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>{asset.exifInfo.state}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if asset.exifInfo?.country}
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>{asset.exifInfo.country}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<Icon path={mdiPencil} size="20" />
|
||||
</div>
|
||||
</div>
|
||||
{:else if !asset.exifInfo?.city && !asset.isReadOnly}
|
||||
<div
|
||||
class="flex justify-between place-items-start gap-4 py-4 rounded-lg pr-2 hover:dark:text-immich-dark-primary hover:text-immich-primary"
|
||||
on:click={() => (isShowChangeLocation = true)}
|
||||
on:keydown={(event) => event.key === 'Enter' && (isShowChangeLocation = true)}
|
||||
tabindex="0"
|
||||
role="button"
|
||||
title="Add location"
|
||||
>
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
|
||||
</div>
|
||||
|
||||
<p>Add a location</p>
|
||||
</div>
|
||||
<div class="focus:outline-none">
|
||||
<Icon path={mdiPencil} size="20" />
|
||||
</div>
|
||||
</div>
|
||||
{:else if asset.exifInfo?.city && asset.isReadOnly}
|
||||
<div class="flex justify-between place-items-start gap-4 py-4">
|
||||
<div class="flex gap-4">
|
||||
<div><Icon path={mdiMapMarkerOutline} size="24" /></div>
|
||||
|
||||
<div>
|
||||
<p>{asset.exifInfo.city}</p>
|
||||
{#if asset.exifInfo?.state}
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>{asset.exifInfo.state}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if asset.exifInfo?.country}
|
||||
<div class="flex gap-2 text-sm">
|
||||
<p>{asset.exifInfo.country}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isShowChangeLocation}
|
||||
<ChangeLocation
|
||||
{asset}
|
||||
on:confirm={({ detail: gps }) => handleConfirmChangeLocation(gps)}
|
||||
on:cancel={() => (isShowChangeLocation = false)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
@ -7,8 +7,9 @@
|
|||
|
||||
export let color: Color = 'transparent-gray';
|
||||
export let disabled = false;
|
||||
export let fullwidth = false;
|
||||
</script>
|
||||
|
||||
<Button size="link" {color} shadow={false} rounded="lg" {disabled} on:click>
|
||||
<Button size="link" {color} shadow={false} rounded="lg" {disabled} on:click {fullwidth}>
|
||||
<slot />
|
||||
</Button>
|
||||
|
|
|
@ -29,10 +29,13 @@
|
|||
icon?: string;
|
||||
};
|
||||
|
||||
let showMenu = false;
|
||||
export let showMenu = false;
|
||||
export let controlable = false;
|
||||
|
||||
const handleClickOutside = () => {
|
||||
showMenu = false;
|
||||
if (!controlable) {
|
||||
showMenu = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectOption = (option: T) => {
|
||||
|
@ -60,7 +63,7 @@
|
|||
|
||||
<div id="dropdown-button" use:clickOutside on:outclick={handleClickOutside} on:escape={handleClickOutside}>
|
||||
<!-- BUTTON TITLE -->
|
||||
<LinkButton on:click={() => (showMenu = true)}>
|
||||
<LinkButton on:click={() => (showMenu = true)} fullwidth>
|
||||
<div class="flex place-items-center gap-2 text-sm">
|
||||
{#if renderedSelectedOption?.icon}
|
||||
<Icon path={renderedSelectedOption.icon} size="18" />
|
||||
|
@ -72,13 +75,13 @@
|
|||
<!-- DROP DOWN MENU -->
|
||||
{#if showMenu}
|
||||
<div
|
||||
transition:fly={{ y: -30, x: 30, duration: 200 }}
|
||||
class="text-md absolute right-0 top-5 z-50 flex min-w-[250px] flex-col rounded-2xl bg-gray-100 py-4 text-black shadow-lg dark:bg-gray-700 dark:text-white"
|
||||
transition:fly={{ y: -30, x: 30, duration: 100 }}
|
||||
class="text-md fixed z-50 flex min-w-[250px] max-h-[70vh] overflow-y-scroll immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
{#each options as option (option)}
|
||||
{@const renderedOption = renderOption(option)}
|
||||
<button
|
||||
class="grid grid-cols-[20px,1fr] place-items-center gap-2 p-4 transition-all hover:bg-gray-300 dark:hover:bg-gray-800"
|
||||
class="grid grid-cols-[20px,1fr] place-items-center p-2 transition-all hover:bg-gray-300 dark:hover:bg-gray-800"
|
||||
on:click={() => handleSelectOption(option)}
|
||||
>
|
||||
{#if _.isEqual(selectedOption, option)}
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts">
|
||||
import ChangeDate from '$lib/components/shared-components/change-date.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { api } from '@api';
|
||||
import { DateTime } from 'luxon';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
export let menuItem = false;
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
let isShowChangeDate = false;
|
||||
|
||||
const handleConfirm = async (dateTimeOriginal: string) => {
|
||||
isShowChangeDate = false;
|
||||
const ids = Array.from(getOwnedAssets())
|
||||
.filter((a) => !a.isExternal)
|
||||
.map((a) => a.id);
|
||||
|
||||
try {
|
||||
await api.assetApi.updateAssets({
|
||||
assetBulkUpdateDto: { ids, dateTimeOriginal },
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to change date');
|
||||
}
|
||||
clearSelect();
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption text="Change date" on:click={() => (isShowChangeDate = true)} />
|
||||
{/if}
|
||||
{#if isShowChangeDate}
|
||||
<ChangeDate
|
||||
initialDate={DateTime.now()}
|
||||
on:confirm={({ detail: date }) => handleConfirm(date)}
|
||||
on:cancel={() => (isShowChangeDate = false)}
|
||||
/>
|
||||
{/if}
|
|
@ -0,0 +1,42 @@
|
|||
<script lang="ts">
|
||||
import { api } from '@api';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
|
||||
import { handleError } from '../../../utils/handle-error';
|
||||
|
||||
export let menuItem = false;
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
let isShowChangeLocation = false;
|
||||
|
||||
async function handleConfirm(point: { 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,
|
||||
latitude: point.lat,
|
||||
longitude: point.lng,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to update location');
|
||||
}
|
||||
clearSelect();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption text="Change location" on:click={() => (isShowChangeLocation = true)} />
|
||||
{/if}
|
||||
{#if isShowChangeLocation}
|
||||
<ChangeLocation
|
||||
on:confirm={({ detail: point }) => handleConfirm(point)}
|
||||
on:cancel={() => (isShowChangeLocation = false)}
|
||||
/>
|
||||
{/if}
|
128
web/src/lib/components/shared-components/change-date.svelte
Normal file
128
web/src/lib/components/shared-components/change-date.svelte
Normal file
|
@ -0,0 +1,128 @@
|
|||
<script lang="ts" context="module">
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
declare namespace Intl {
|
||||
type Key = 'calendar' | 'collation' | 'currency' | 'numberingSystem' | 'timeZone' | 'unit';
|
||||
function supportedValuesOf(input: Key): string[];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { DateTime } from 'luxon';
|
||||
import ConfirmDialogue from './confirm-dialogue.svelte';
|
||||
import Dropdown from '../elements/dropdown.svelte';
|
||||
export let initialDate: DateTime = DateTime.now();
|
||||
|
||||
interface ZoneOption {
|
||||
zone: string;
|
||||
offset: string;
|
||||
}
|
||||
|
||||
const timezones: ZoneOption[] = Intl.supportedValuesOf('timeZone').map((zone: string) => ({
|
||||
zone,
|
||||
offset: 'UTC' + DateTime.local({ zone }).toFormat('ZZ'),
|
||||
}));
|
||||
|
||||
const initialOption = timezones.find((item) => item.offset === 'UTC' + initialDate.toFormat('ZZ'));
|
||||
|
||||
let selectedDate = initialDate.toFormat("yyyy-MM-dd'T'HH:mm");
|
||||
let selectedTimezone = initialOption?.offset || null;
|
||||
let disabled = false;
|
||||
|
||||
let searchQuery = '';
|
||||
let filteredTimezones: ZoneOption[] = timezones;
|
||||
|
||||
const updateSearchQuery = (event: Event) => {
|
||||
searchQuery = (event.target as HTMLInputElement).value;
|
||||
filterTimezones();
|
||||
};
|
||||
|
||||
const filterTimezones = () => {
|
||||
filteredTimezones = timezones.filter((timezone) => timezone.zone.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
};
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
cancel: void;
|
||||
confirm: string;
|
||||
}>();
|
||||
|
||||
const handleCancel = () => dispatch('cancel');
|
||||
const handleConfirm = () => {
|
||||
let date = DateTime.fromISO(selectedDate);
|
||||
if (selectedTimezone != null) {
|
||||
date = date.setZone(selectedTimezone, { keepLocalTime: true }); // Keep local time if not it's really confusing
|
||||
}
|
||||
|
||||
const value = date.toISO();
|
||||
if (value) {
|
||||
disabled = true;
|
||||
dispatch('confirm', value);
|
||||
}
|
||||
};
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
let isDropdownOpen = false;
|
||||
|
||||
const openDropdown = () => {
|
||||
isDropdownOpen = true;
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
isDropdownOpen = false;
|
||||
};
|
||||
|
||||
const handleSelectTz = (item: ZoneOption) => {
|
||||
selectedTimezone = item.offset;
|
||||
closeDropdown();
|
||||
};
|
||||
</script>
|
||||
|
||||
<div role="presentation" on:keydown={handleKeydown}>
|
||||
<ConfirmDialogue
|
||||
confirmColor="primary"
|
||||
cancelColor="secondary"
|
||||
title="Change Date"
|
||||
prompt="Please select a new date:"
|
||||
{disabled}
|
||||
on:confirm={handleConfirm}
|
||||
on:cancel={handleCancel}
|
||||
>
|
||||
<div class="flex flex-col text-md px-4 py-5 text-center gap-2" slot="prompt">
|
||||
<div class="mt-2" />
|
||||
<div class="flex flex-col">
|
||||
<label for="datetime">Date and Time</label>
|
||||
<input
|
||||
class="immich-form-label text-sm mt-2 w-full text-black"
|
||||
id="datetime"
|
||||
type="datetime-local"
|
||||
bind:value={selectedDate}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col w-full">
|
||||
<label for="timezone">Timezone</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
class="immich-form-label text-sm mt-2 w-full text-black"
|
||||
id="timezoneSearch"
|
||||
type="text"
|
||||
placeholder="Search timezone..."
|
||||
bind:value={searchQuery}
|
||||
on:input={updateSearchQuery}
|
||||
on:focus={openDropdown}
|
||||
/>
|
||||
<Dropdown
|
||||
selectedOption={initialOption}
|
||||
options={filteredTimezones}
|
||||
render={(item) => (item ? `${item.zone} (${item.offset})` : '(not selected)')}
|
||||
on:select={({ detail: item }) => handleSelectTz(item)}
|
||||
controlable={true}
|
||||
bind:showMenu={isDropdownOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmDialogue>
|
||||
</div>
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
import type { AssetResponseDto } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import ConfirmDialogue from './confirm-dialogue.svelte';
|
||||
import Map from './map/map.svelte';
|
||||
export const title = 'Change Location';
|
||||
export let asset: AssetResponseDto | undefined = undefined;
|
||||
|
||||
interface Point {
|
||||
lng: number;
|
||||
lat: number;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
cancel: void;
|
||||
confirm: Point;
|
||||
}>();
|
||||
|
||||
$: lat = asset?.exifInfo?.latitude || 0;
|
||||
$: lng = asset?.exifInfo?.longitude || 0;
|
||||
$: zoom = lat && lng ? 15 : 1;
|
||||
|
||||
let point: Point | null = null;
|
||||
|
||||
const handleCancel = () => dispatch('cancel');
|
||||
|
||||
const handleSelect = (selected: Point) => {
|
||||
point = selected;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!point) {
|
||||
dispatch('cancel');
|
||||
} else {
|
||||
dispatch('confirm', point);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<ConfirmDialogue
|
||||
confirmColor="primary"
|
||||
cancelColor="secondary"
|
||||
title="Change Location"
|
||||
on:confirm={handleConfirm}
|
||||
on:cancel={handleCancel}
|
||||
>
|
||||
<div slot="prompt" class="flex flex-col w-full h-full gap-2">
|
||||
<label for="datetime">Pick a location</label>
|
||||
<div class="h-[350px] min-h-[300px] w-full">
|
||||
<Map
|
||||
mapMarkers={lat && lng && asset ? [{ id: asset.id, lat, lon: lng }] : []}
|
||||
{zoom}
|
||||
center={lat && lng ? { lat, lng } : undefined}
|
||||
simplified={true}
|
||||
clickable={true}
|
||||
on:clickedPoint={({ detail: point }) => handleSelect(point)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmDialogue>
|
|
@ -11,8 +11,9 @@
|
|||
export let cancelText = 'Cancel';
|
||||
export let cancelColor: Color = 'primary';
|
||||
export let hideCancelButton = false;
|
||||
export let disabled = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
const dispatch = createEventDispatcher<{ cancel: void; confirm: void }>();
|
||||
|
||||
let isConfirmButtonDisabled = false;
|
||||
|
||||
|
@ -53,7 +54,7 @@
|
|||
{cancelText}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button color={confirmColor} fullwidth on:click={handleConfirm} disabled={isConfirmButtonDisabled}>
|
||||
<Button color={confirmColor} fullwidth on:click={handleConfirm} disabled={disabled || isConfirmButtonDisabled}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -28,6 +28,10 @@
|
|||
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;
|
||||
|
||||
$: style = (async () => {
|
||||
const { data } = await api.systemConfigApi.getMapStyle({
|
||||
|
@ -36,7 +40,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 +70,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 => {
|
||||
|
@ -96,6 +116,8 @@
|
|||
diffStyleUpdates={true}
|
||||
let:map
|
||||
on:load={(event) => event.detail.setMaxZoom(14)}
|
||||
on:load={(event) => event.detail.on('click', handleMapClick)}
|
||||
bind:map
|
||||
>
|
||||
<NavigationControl position="top-left" showCompass={!simplified} />
|
||||
{#if !simplified}
|
||||
|
|
15
web/src/lib/components/shared-components/update-panel.svelte
Normal file
15
web/src/lib/components/shared-components/update-panel.svelte
Normal file
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { websocketStore } from '$lib/stores/websocket';
|
||||
import type { AssetStore } from '$lib/stores/assets.store';
|
||||
|
||||
export let assetStore: AssetStore | null;
|
||||
|
||||
websocketStore.onAssetUpdate.subscribe((asset) => {
|
||||
if (asset && asset.originalFileName && assetStore) {
|
||||
assetStore.updateAsset(asset, true);
|
||||
|
||||
assetStore.removeAsset(asset.id); // Update timeline
|
||||
assetStore.addAsset(asset);
|
||||
}
|
||||
});
|
||||
</script>
|
|
@ -14,6 +14,7 @@ export const websocketStore = {
|
|||
onUploadSuccess: writable<AssetResponseDto>(),
|
||||
onAssetDelete: writable<string>(),
|
||||
onAssetTrash: writable<string[]>(),
|
||||
onAssetUpdate: writable<AssetResponseDto>(),
|
||||
onPersonThumbnail: writable<string>(),
|
||||
serverVersion: writable<ServerVersionResponseDto>(),
|
||||
connected: writable<boolean>(false),
|
||||
|
@ -41,6 +42,7 @@ export const openWebsocketConnection = () => {
|
|||
// .on('on_upload_success', (data) => websocketStore.onUploadSuccess.set(data))
|
||||
.on('on_asset_delete', (data) => websocketStore.onAssetDelete.set(data))
|
||||
.on('on_asset_trash', (data) => websocketStore.onAssetTrash.set(data))
|
||||
.on('on_asset_update', (data) => websocketStore.onAssetUpdate.set(data))
|
||||
.on('on_person_thumbnail', (data) => websocketStore.onPersonThumbnail.set(data))
|
||||
.on('on_server_version', (data) => websocketStore.serverVersion.set(data))
|
||||
.on('on_config_update', () => loadConfig())
|
||||
|
|
|
@ -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-action.svelte';
|
||||
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.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';
|
||||
|
@ -56,6 +58,7 @@
|
|||
import ActivityStatus from '$lib/components/asset-viewer/activity-status.svelte';
|
||||
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
|
||||
import AlbumOptions from '$lib/components/album-page/album-options.svelte';
|
||||
import UpdatePanel from '$lib/components/shared-components/update-panel.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
|
@ -446,6 +449,8 @@
|
|||
{/if}
|
||||
{#if isAllUserOwned}
|
||||
<DeleteAssets menuItem onAssetDelete={(assetId) => assetStore.removeAsset(assetId)} />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
{/if}
|
||||
</AssetSelectContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
|
@ -750,3 +755,5 @@
|
|||
on:save={({ detail: description }) => handleUpdateDescription(description)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<UpdatePanel {assetStore} />
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
import { AssetStore } from '$lib/stores/assets.store';
|
||||
import type { PageData } from './$types';
|
||||
import { mdiPlus, mdiDotsVertical } from '@mdi/js';
|
||||
import UpdatePanel from '$lib/components/shared-components/update-panel.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
|
@ -52,3 +53,4 @@
|
|||
/>
|
||||
</AssetGrid>
|
||||
</UserPageLayout>
|
||||
<UpdatePanel {assetStore} />
|
||||
|
|
|
@ -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-action.svelte';
|
||||
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.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';
|
||||
|
@ -16,6 +18,7 @@
|
|||
import { AssetStore } from '$lib/stores/assets.store';
|
||||
import type { PageData } from './$types';
|
||||
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||
import UpdatePanel from '$lib/components/shared-components/update-panel.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
|
@ -40,6 +43,8 @@
|
|||
<AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
|
||||
<DownloadAction menuItem />
|
||||
<ArchiveAction menuItem unarchive={isAllArchive} />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
</AssetSelectContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
{/if}
|
||||
|
@ -53,3 +58,4 @@
|
|||
/>
|
||||
</AssetGrid>
|
||||
</UserPageLayout>
|
||||
<UpdatePanel {assetStore} />
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
import { onDestroy } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
import { mdiPlus, mdiArrowLeft } from '@mdi/js';
|
||||
import UpdatePanel from '$lib/components/shared-components/update-panel.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
|
@ -45,4 +46,5 @@
|
|||
</ControlAppBar>
|
||||
{/if}
|
||||
<AssetGrid {assetStore} {assetInteractionStore} />
|
||||
<UpdatePanel {assetStore} />
|
||||
</main>
|
||||
|
|
|
@ -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-action.svelte';
|
||||
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.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';
|
||||
|
@ -368,6 +370,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-action.svelte';
|
||||
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.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';
|
||||
|
@ -21,6 +23,7 @@
|
|||
import type { PageData } from './$types';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||
import UpdatePanel from '$lib/components/shared-components/update-panel.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
|
@ -70,6 +73,8 @@
|
|||
{#if $selectedAssets.size > 1}
|
||||
<StackAction onStack={(ids) => assetStore.removeAssets(ids)} />
|
||||
{/if}
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<AssetJobActions />
|
||||
</AssetSelectContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
|
@ -93,3 +98,4 @@
|
|||
/>
|
||||
</AssetGrid>
|
||||
</UserPageLayout>
|
||||
<UpdatePanel {assetStore} />
|
||||
|
|
|
@ -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-action.svelte';
|
||||
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.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}
|
||||
|
|
|
@ -23,6 +23,7 @@
|
|||
import empty3Url from '$lib/assets/empty-3.svg';
|
||||
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
|
||||
import { mdiDeleteOutline, mdiHistory } from '@mdi/js';
|
||||
import UpdatePanel from '$lib/components/shared-components/update-panel.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
|
@ -113,3 +114,4 @@
|
|||
</svelte:fragment>
|
||||
</ConfirmDialogue>
|
||||
{/if}
|
||||
<UpdatePanel {assetStore} />
|
||||
|
|
Loading…
Reference in a new issue