feat: add album start and end dates for storage template (#17188)

This commit is contained in:
Bonne Eggleston 2025-04-21 16:54:33 -07:00 committed by GitHub
parent c70140e707
commit fe8c5e8107
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 102 additions and 3 deletions

View File

@ -69,6 +69,7 @@ describe(StorageTemplateService.name, () => {
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
'{{y}}/{{MM}}/{{filename}}',
'{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}}',
'{{#if album}}{{album-startDate-y}}/{{album}}{{else}}{{y}}/Other/{{MM}}{{/if}}/{{filename}}',
'{{y}}/{{MMM}}/{{filename}}',
'{{y}}/{{MMMM}}/{{filename}}',
'{{y}}/{{MM}}/{{dd}}/{{filename}}',
@ -182,6 +183,63 @@ describe(StorageTemplateService.name, () => {
});
});
it('should handle album startDate', async () => {
const asset = assetStub.storageAsset();
const user = userStub.user1;
const album = albumStub.oneAsset;
const config = structuredClone(defaults);
config.storageTemplate.template =
'{{#if album}}{{album-startDate-y}}/{{album-startDate-MM}} - {{album}}{{else}}{{y}}/{{MM}}/{{/if}}/{{filename}}';
sut.onConfigInit({ newConfig: config });
mocks.user.get.mockResolvedValue(user);
mocks.asset.getStorageTemplateAsset.mockResolvedValueOnce(asset);
mocks.album.getByAssetId.mockResolvedValueOnce([album]);
mocks.album.getMetadataForIds.mockResolvedValueOnce([
{
startDate: asset.fileCreatedAt,
endDate: asset.fileCreatedAt,
albumId: album.id,
assetCount: 1,
lastModifiedAssetTimestamp: null,
},
]);
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS);
const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0');
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: asset.id,
newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month} - ${album.albumName}/${asset.originalFileName}`,
oldPath: asset.originalPath,
pathType: AssetPathType.ORIGINAL,
});
});
it('should handle else condition from album startDate', async () => {
const asset = assetStub.storageAsset();
const user = userStub.user1;
const config = structuredClone(defaults);
config.storageTemplate.template =
'{{#if album}}{{album-startDate-y}}/{{album-startDate-MM}} - {{album}}{{else}}{{y}}/{{MM}}/{{/if}}/{{filename}}';
sut.onConfigInit({ newConfig: config });
mocks.user.get.mockResolvedValue(user);
mocks.asset.getStorageTemplateAsset.mockResolvedValueOnce(asset);
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.SUCCESS);
const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0');
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: asset.id,
newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month}/${asset.originalFileName}`,
oldPath: asset.originalPath,
pathType: AssetPathType.ORIGINAL,
});
});
it('should migrate previously failed move from original path when it still exists', async () => {
mocks.user.get.mockResolvedValue(userStub.user1);

View File

@ -28,6 +28,7 @@ const storagePresets = [
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
'{{y}}/{{MM}}/{{filename}}',
'{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{filename}}',
'{{#if album}}{{album-startDate-y}}/{{album}}{{else}}{{y}}/Other/{{MM}}{{/if}}/{{filename}}',
'{{y}}/{{MMM}}/{{filename}}',
'{{y}}/{{MMMM}}/{{filename}}',
'{{y}}/{{MM}}/{{dd}}/{{filename}}',
@ -54,6 +55,8 @@ interface RenderMetadata {
filename: string;
extension: string;
albumName: string | null;
albumStartDate: Date | null;
albumEndDate: Date | null;
}
@Injectable()
@ -62,6 +65,7 @@ export class StorageTemplateService extends BaseService {
compiled: HandlebarsTemplateDelegate<any>;
raw: string;
needsAlbum: boolean;
needsAlbumMetadata: boolean;
} | null = null;
private get template() {
@ -99,6 +103,8 @@ export class StorageTemplateService extends BaseService {
filename: 'IMG_123',
extension: 'jpg',
albumName: 'album',
albumStartDate: new Date(),
albumEndDate: new Date(),
});
} catch (error) {
this.logger.warn(`Storage template validation failed: ${JSON.stringify(error)}`);
@ -255,9 +261,20 @@ export class StorageTemplateService extends BaseService {
}
let albumName = null;
let albumStartDate = null;
let albumEndDate = null;
if (this.template.needsAlbum) {
const albums = await this.albumRepository.getByAssetId(asset.ownerId, asset.id);
albumName = albums?.[0]?.albumName || null;
const album = albums?.[0];
if (album) {
albumName = album.albumName || null;
if (this.template.needsAlbumMetadata) {
const [metadata] = await this.albumRepository.getMetadataForIds([album.id]);
albumStartDate = metadata?.startDate || null;
albumEndDate = metadata?.endDate || null;
}
}
}
const storagePath = this.render(this.template.compiled, {
@ -265,6 +282,8 @@ export class StorageTemplateService extends BaseService {
filename: sanitized,
extension,
albumName,
albumStartDate,
albumEndDate,
});
const fullPath = path.normalize(path.join(rootPath, storagePath));
let destination = `${fullPath}.${extension}`;
@ -323,12 +342,13 @@ export class StorageTemplateService extends BaseService {
return {
raw: template,
compiled: handlebar.compile(template, { knownHelpers: undefined, strict: true }),
needsAlbum: template.includes('{{album}}'),
needsAlbum: template.includes('album'),
needsAlbumMetadata: template.includes('album-startDate') || template.includes('album-endDate'),
};
}
private render(template: HandlebarsTemplateDelegate<any>, options: RenderMetadata) {
const { filename, extension, asset, albumName } = options;
const { filename, extension, asset, albumName, albumStartDate, albumEndDate } = options;
const substitutions: Record<string, string> = {
filename,
ext: extension,
@ -346,6 +366,15 @@ export class StorageTemplateService extends BaseService {
for (const token of Object.values(storageTokens).flat()) {
substitutions[token] = dt.toFormat(token);
if (albumName) {
// Use system time zone for album dates to ensure all assets get the exact same date.
substitutions['album-startDate-' + token] = albumStartDate
? DateTime.fromJSDate(albumStartDate, { zone: systemTimeZone }).toFormat(token)
: '';
substitutions['album-endDate-' + token] = albumEndDate
? DateTime.fromJSDate(albumEndDate, { zone: systemTimeZone }).toFormat(token)
: '';
}
}
return template(substitutions).replaceAll(/\/{2,}/gm, '/');

View File

@ -78,6 +78,8 @@
};
const dt = luxon.DateTime.fromISO(new Date('2022-02-03T04:56:05.250').toISOString());
const albumStartTime = luxon.DateTime.fromISO(new Date('2021-12-31T05:32:41.750').toISOString());
const albumEndTime = luxon.DateTime.fromISO(new Date('2023-05-06T09:15:17.100').toISOString());
const dateTokens = [
...templateOptions.yearOptions,
@ -91,6 +93,8 @@
for (const token of dateTokens) {
substitutions[token] = dt.toFormat(token);
substitutions['album-startDate-' + token] = albumStartTime.toFormat(token);
substitutions['album-endDate-' + token] = albumEndTime.toFormat(token);
}
return template(substitutions);

View File

@ -29,6 +29,14 @@
<li>{`{{assetId}}`} - Asset ID</li>
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>
<li>{`{{album}}`} - Album Name</li>
<li>
{`{{album-startDate-x}}`} - Album Start Date and Time (e.g. album-startDate-yy).
{$t('admin.storage_template_date_time_sample', { values: { date: '2021-12-31T05:32:41.750' } })}
</li>
<li>
{`{{album-endDate-x}}`} - Album End Date and Time (e.g. album-endDate-MM).
{$t('admin.storage_template_date_time_sample', { values: { date: '2023-05-06T09:15:17.100' } })}
</li>
</ul>
</div>
</div>