mirror of
https://github.com/immich-app/immich.git
synced 2025-12-23 01:11:36 +03:00
feat: batch change date and time relatively (#17717)
Co-authored-by: marcel.kuehne <> Co-authored-by: Zack Pollard <zackpollard@ymail.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
IsNotEmpty,
|
||||
IsPositive,
|
||||
IsString,
|
||||
IsTimeZone,
|
||||
Max,
|
||||
Min,
|
||||
ValidateIf,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetStats } from 'src/repositories/asset.repository';
|
||||
import { Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class DeviceIdDto {
|
||||
@IsNotEmpty()
|
||||
@@ -65,6 +66,16 @@ export class AssetBulkUpdateDto extends UpdateAssetBase {
|
||||
|
||||
@Optional()
|
||||
duplicateId?: string | null;
|
||||
|
||||
@IsNotSiblingOf(['dateTimeOriginal'])
|
||||
@Optional()
|
||||
@IsInt()
|
||||
dateTimeRelative?: number;
|
||||
|
||||
@IsNotSiblingOf(['dateTimeOriginal'])
|
||||
@IsTimeZone()
|
||||
@Optional()
|
||||
timeZone?: string;
|
||||
}
|
||||
|
||||
export class UpdateAssetDto extends UpdateAssetBase {
|
||||
|
||||
@@ -7,6 +7,18 @@ set
|
||||
where
|
||||
"assetId" in ($2)
|
||||
|
||||
-- AssetRepository.updateDateTimeOriginal
|
||||
update "asset_exif"
|
||||
set
|
||||
"dateTimeOriginal" = "dateTimeOriginal" + $1::interval,
|
||||
"timeZone" = $2
|
||||
where
|
||||
"assetId" in ($3)
|
||||
returning
|
||||
"assetId",
|
||||
"dateTimeOriginal",
|
||||
"timeZone"
|
||||
|
||||
-- AssetRepository.getByDayOfYear
|
||||
with
|
||||
"res" as (
|
||||
|
||||
@@ -169,6 +169,21 @@ export class AssetRepository {
|
||||
await this.db.updateTable('asset_exif').set(options).where('assetId', 'in', ids).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.NUMBER, DummyValue.STRING] })
|
||||
@Chunked()
|
||||
async updateDateTimeOriginal(
|
||||
ids: string[],
|
||||
delta?: number,
|
||||
timeZone?: string,
|
||||
): Promise<{ assetId: string; dateTimeOriginal: Date | null; timeZone: string | null }[]> {
|
||||
return await this.db
|
||||
.updateTable('asset_exif')
|
||||
.set({ dateTimeOriginal: sql`"dateTimeOriginal" + ${(delta ?? 0) + ' minute'}::interval`, timeZone })
|
||||
.where('assetId', 'in', ids)
|
||||
.returning(['assetId', 'dateTimeOriginal', 'timeZone'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
async upsertJobStatus(...jobStatus: Insertable<AssetJobStatusTable>[]): Promise<void> {
|
||||
if (jobStatus.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -468,6 +468,33 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update exif table if dateTimeRelative and timeZone field is provided', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
const dateTimeRelative = 35;
|
||||
const timeZone = 'UTC+2';
|
||||
mocks.asset.updateDateTimeOriginal.mockResolvedValue([
|
||||
{ assetId: 'asset-1', dateTimeOriginal: new Date('2020-02-25T04:41:00'), timeZone },
|
||||
]);
|
||||
await sut.updateAll(authStub.admin, {
|
||||
ids: ['asset-1'],
|
||||
dateTimeRelative,
|
||||
timeZone,
|
||||
});
|
||||
expect(mocks.asset.updateDateTimeOriginal).toHaveBeenCalledWith(['asset-1'], dateTimeRelative, timeZone);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.SidecarWrite,
|
||||
data: {
|
||||
id: 'asset-1',
|
||||
dateTimeOriginal: '2020-02-25T06:41:00.000+02:00',
|
||||
description: undefined,
|
||||
latitude: undefined,
|
||||
longitude: undefined,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAll', () => {
|
||||
|
||||
@@ -113,22 +113,48 @@ export class AssetService extends BaseService {
|
||||
}
|
||||
|
||||
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
|
||||
const { ids, description, dateTimeOriginal, latitude, longitude, ...options } = dto;
|
||||
const { ids, description, dateTimeOriginal, dateTimeRelative, timeZone, latitude, longitude, ...options } = dto;
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids });
|
||||
|
||||
if (
|
||||
description !== undefined ||
|
||||
dateTimeOriginal !== undefined ||
|
||||
latitude !== undefined ||
|
||||
longitude !== undefined
|
||||
) {
|
||||
const staticValuesChanged =
|
||||
description !== undefined || dateTimeOriginal !== undefined || latitude !== undefined || longitude !== undefined;
|
||||
|
||||
if (staticValuesChanged) {
|
||||
await this.assetRepository.updateAllExif(ids, { description, dateTimeOriginal, latitude, longitude });
|
||||
await this.jobRepository.queueAll(
|
||||
ids.map((id) => ({
|
||||
name: JobName.SidecarWrite,
|
||||
data: { id, description, dateTimeOriginal, latitude, longitude },
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const assets =
|
||||
(dateTimeRelative !== undefined && dateTimeRelative !== 0) || timeZone !== undefined
|
||||
? await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone)
|
||||
: null;
|
||||
|
||||
const dateTimesWithTimezone =
|
||||
assets?.map((asset) => {
|
||||
const isoString = asset.dateTimeOriginal?.toISOString();
|
||||
let dateTime = isoString ? DateTime.fromISO(isoString) : null;
|
||||
|
||||
if (dateTime && asset.timeZone) {
|
||||
dateTime = dateTime.setZone(asset.timeZone);
|
||||
}
|
||||
|
||||
return {
|
||||
assetId: asset.assetId,
|
||||
dateTimeOriginal: dateTime?.toISO() ?? null,
|
||||
};
|
||||
}) ?? null;
|
||||
|
||||
if (staticValuesChanged || dateTimesWithTimezone) {
|
||||
const entries: JobItem[] = (dateTimesWithTimezone ?? ids).map((entry: any) => ({
|
||||
name: JobName.SidecarWrite,
|
||||
data: {
|
||||
id: entry.assetId ?? entry,
|
||||
description,
|
||||
dateTimeOriginal: entry.dateTimeOriginal ?? dateTimeOriginal,
|
||||
latitude,
|
||||
longitude,
|
||||
},
|
||||
}));
|
||||
await this.jobRepository.queueAll(entries);
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { DateTime } from 'luxon';
|
||||
import { IsDateStringFormat, MaxDateString } from 'src/validation';
|
||||
import { IsDateStringFormat, IsNotSiblingOf, MaxDateString, Optional } from 'src/validation';
|
||||
import { describe } from 'vitest';
|
||||
|
||||
describe('Validation', () => {
|
||||
describe('MaxDateString', () => {
|
||||
@@ -54,4 +55,38 @@ describe('Validation', () => {
|
||||
await expect(validate(dto)).resolves.toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IsNotSiblingOf', () => {
|
||||
class MyDto {
|
||||
@IsNotSiblingOf(['attribute2'])
|
||||
@Optional()
|
||||
attribute1?: string;
|
||||
|
||||
@IsNotSiblingOf(['attribute1', 'attribute3'])
|
||||
@Optional()
|
||||
attribute2?: string;
|
||||
|
||||
@IsNotSiblingOf(['attribute2'])
|
||||
@Optional()
|
||||
attribute3?: string;
|
||||
|
||||
@Optional()
|
||||
unrelatedAttribute?: string;
|
||||
}
|
||||
|
||||
it('passes when only one attribute is present', async () => {
|
||||
const dto = plainToInstance(MyDto, { attribute1: 'value1', unrelatedAttribute: 'value2' });
|
||||
await expect(validate(dto)).resolves.toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fails when colliding attributes are present', async () => {
|
||||
const dto = plainToInstance(MyDto, { attribute1: 'value1', attribute2: 'value2' });
|
||||
await expect(validate(dto)).resolves.toHaveLength(2);
|
||||
});
|
||||
|
||||
it('passes when no colliding attributes are present', async () => {
|
||||
const dto = plainToInstance(MyDto, { attribute1: 'value1', attribute3: 'value2' });
|
||||
await expect(validate(dto)).resolves.toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,11 +22,13 @@ import {
|
||||
Validate,
|
||||
ValidateBy,
|
||||
ValidateIf,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
buildMessage,
|
||||
isDateString,
|
||||
isDefined,
|
||||
} from 'class-validator';
|
||||
import { CronJob } from 'cron';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -146,6 +148,27 @@ export function Optional({ nullable, emptyToNull, ...validationOptions }: Option
|
||||
return applyDecorators(...decorators);
|
||||
}
|
||||
|
||||
export function IsNotSiblingOf(siblings: string[], validationOptions?: ValidationOptions) {
|
||||
return ValidateBy(
|
||||
{
|
||||
name: 'isNotSiblingOf',
|
||||
constraints: siblings,
|
||||
validator: {
|
||||
validate(value: any, args: ValidationArguments) {
|
||||
if (!isDefined(value)) {
|
||||
return true;
|
||||
}
|
||||
return args.constraints.filter((prop) => isDefined((args.object as any)[prop])).length === 0;
|
||||
},
|
||||
defaultMessage: (args: ValidationArguments) => {
|
||||
return `${args.property} cannot exist alongside any of the following properties: ${args.constraints.join(', ')}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
validationOptions,
|
||||
);
|
||||
}
|
||||
|
||||
export const ValidateHexColor = () => {
|
||||
const decorators = [
|
||||
IsHexColor(),
|
||||
|
||||
Reference in New Issue
Block a user