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:
mkuehne707
2025-08-07 15:42:33 +02:00
committed by GitHub
parent df2525ee08
commit 011a667314
19 changed files with 574 additions and 52 deletions

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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