From fe8c5e8107a9131fff825177e3d699d9fad6db15 Mon Sep 17 00:00:00 2001 From: Bonne Eggleston Date: Mon, 21 Apr 2025 16:54:33 -0700 Subject: [PATCH] feat: add album start and end dates for storage template (#17188) --- .../services/storage-template.service.spec.ts | 58 +++++++++++++++++++ .../src/services/storage-template.service.ts | 35 ++++++++++- .../storage-template-settings.svelte | 4 ++ .../supported-variables-panel.svelte | 8 +++ 4 files changed, 102 insertions(+), 3 deletions(-) diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 971a9e8302..d9ac89952e 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -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); diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 71a0160ee2..542633a03f 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -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; 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, options: RenderMetadata) { - const { filename, extension, asset, albumName } = options; + const { filename, extension, asset, albumName, albumStartDate, albumEndDate } = options; const substitutions: Record = { 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, '/'); diff --git a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte index 9b4aa5e934..67299d8f6b 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/storage-template-settings.svelte @@ -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); diff --git a/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte b/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte index fc8f913281..c1255c252d 100644 --- a/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte +++ b/web/src/lib/components/admin-page/settings/storage-template/supported-variables-panel.svelte @@ -29,6 +29,14 @@
  • {`{{assetId}}`} - Asset ID
  • {`{{assetIdShort}}`} - Asset ID (last 12 characters)
  • {`{{album}}`} - Album Name
  • +
  • + {`{{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' } })} +
  • +
  • + {`{{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' } })} +