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}}/{{MMMM}}-{{dd}}/{{filename}}',
'{{y}}/{{MM}}/{{filename}}', '{{y}}/{{MM}}/{{filename}}',
'{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{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}}/{{MMM}}/{{filename}}',
'{{y}}/{{MMMM}}/{{filename}}', '{{y}}/{{MMMM}}/{{filename}}',
'{{y}}/{{MM}}/{{dd}}/{{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 () => { it('should migrate previously failed move from original path when it still exists', async () => {
mocks.user.get.mockResolvedValue(userStub.user1); mocks.user.get.mockResolvedValue(userStub.user1);

View File

@ -28,6 +28,7 @@ const storagePresets = [
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}', '{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
'{{y}}/{{MM}}/{{filename}}', '{{y}}/{{MM}}/{{filename}}',
'{{y}}/{{#if album}}{{album}}{{else}}Other/{{MM}}{{/if}}/{{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}}/{{MMM}}/{{filename}}',
'{{y}}/{{MMMM}}/{{filename}}', '{{y}}/{{MMMM}}/{{filename}}',
'{{y}}/{{MM}}/{{dd}}/{{filename}}', '{{y}}/{{MM}}/{{dd}}/{{filename}}',
@ -54,6 +55,8 @@ interface RenderMetadata {
filename: string; filename: string;
extension: string; extension: string;
albumName: string | null; albumName: string | null;
albumStartDate: Date | null;
albumEndDate: Date | null;
} }
@Injectable() @Injectable()
@ -62,6 +65,7 @@ export class StorageTemplateService extends BaseService {
compiled: HandlebarsTemplateDelegate<any>; compiled: HandlebarsTemplateDelegate<any>;
raw: string; raw: string;
needsAlbum: boolean; needsAlbum: boolean;
needsAlbumMetadata: boolean;
} | null = null; } | null = null;
private get template() { private get template() {
@ -99,6 +103,8 @@ export class StorageTemplateService extends BaseService {
filename: 'IMG_123', filename: 'IMG_123',
extension: 'jpg', extension: 'jpg',
albumName: 'album', albumName: 'album',
albumStartDate: new Date(),
albumEndDate: new Date(),
}); });
} catch (error) { } catch (error) {
this.logger.warn(`Storage template validation failed: ${JSON.stringify(error)}`); this.logger.warn(`Storage template validation failed: ${JSON.stringify(error)}`);
@ -255,9 +261,20 @@ export class StorageTemplateService extends BaseService {
} }
let albumName = null; let albumName = null;
let albumStartDate = null;
let albumEndDate = null;
if (this.template.needsAlbum) { if (this.template.needsAlbum) {
const albums = await this.albumRepository.getByAssetId(asset.ownerId, asset.id); 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, { const storagePath = this.render(this.template.compiled, {
@ -265,6 +282,8 @@ export class StorageTemplateService extends BaseService {
filename: sanitized, filename: sanitized,
extension, extension,
albumName, albumName,
albumStartDate,
albumEndDate,
}); });
const fullPath = path.normalize(path.join(rootPath, storagePath)); const fullPath = path.normalize(path.join(rootPath, storagePath));
let destination = `${fullPath}.${extension}`; let destination = `${fullPath}.${extension}`;
@ -323,12 +342,13 @@ export class StorageTemplateService extends BaseService {
return { return {
raw: template, raw: template,
compiled: handlebar.compile(template, { knownHelpers: undefined, strict: true }), 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) { 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> = { const substitutions: Record<string, string> = {
filename, filename,
ext: extension, ext: extension,
@ -346,6 +366,15 @@ export class StorageTemplateService extends BaseService {
for (const token of Object.values(storageTokens).flat()) { for (const token of Object.values(storageTokens).flat()) {
substitutions[token] = dt.toFormat(token); 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, '/'); 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 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 = [ const dateTokens = [
...templateOptions.yearOptions, ...templateOptions.yearOptions,
@ -91,6 +93,8 @@
for (const token of dateTokens) { for (const token of dateTokens) {
substitutions[token] = dt.toFormat(token); substitutions[token] = dt.toFormat(token);
substitutions['album-startDate-' + token] = albumStartTime.toFormat(token);
substitutions['album-endDate-' + token] = albumEndTime.toFormat(token);
} }
return template(substitutions); return template(substitutions);

View File

@ -29,6 +29,14 @@
<li>{`{{assetId}}`} - Asset ID</li> <li>{`{{assetId}}`} - Asset ID</li>
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li> <li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>
<li>{`{{album}}`} - Album Name</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> </ul>
</div> </div>
</div> </div>