diff --git a/i18n/en.json b/i18n/en.json index 5903d7850e..ff1c206a68 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -309,6 +309,10 @@ "storage_template_enable_description": "Enable storage template engine", "storage_template_hash_verification_enabled": "Hash verification enabled", "storage_template_hash_verification_enabled_description": "Enables hash verification, don't disable this unless you're certain of the implications", + "folder_content_order": "Folder content order", + "folder_content_order_description": "Set the order in which assets are displayed in the folder view", + "folder_content_order_name": "File name", + "folder_content_order_date": "Creation date", "storage_template_migration": "Storage template migration", "storage_template_migration_description": "Apply the current {template} to previously uploaded assets", "storage_template_migration_info": "The storage template will convert all extensions to lowercase. Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the {job}.", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c052e41a49..6f8d4630cc 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16554,6 +16554,13 @@ ], "type": "object" }, + "FolderContentOrder": { + "enum": [ + "name", + "date" + ], + "type": "string" + }, "FoldersResponse": { "properties": { "enabled": { @@ -21972,6 +21979,13 @@ "enabled": { "type": "boolean" }, + "folderContentOrder": { + "allOf": [ + { + "$ref": "#/components/schemas/FolderContentOrder" + } + ] + }, "hashVerificationEnabled": { "type": "boolean" }, @@ -21981,6 +21995,7 @@ }, "required": [ "enabled", + "folderContentOrder", "hashVerificationEnabled", "template" ], diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 537427ff03..26f4bb6a13 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1581,6 +1581,7 @@ export type SystemConfigServerDto = { }; export type SystemConfigStorageTemplateDto = { enabled: boolean; + folderContentOrder: FolderContentOrder; hashVerificationEnabled: boolean; template: string; }; @@ -5639,6 +5640,10 @@ export enum OAuthTokenEndpointAuthMethod { ClientSecretPost = "client_secret_post", ClientSecretBasic = "client_secret_basic" } +export enum FolderContentOrder { + Name = "name", + Date = "date" +} export enum TriggerType { AssetCreate = "AssetCreate", PersonRecognized = "PersonRecognized" diff --git a/server/src/config.ts b/server/src/config.ts index c18acd79f8..33ae98aa49 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -3,6 +3,7 @@ import { AudioCodec, Colorspace, CQMode, + FolderContentOrder, ImageFormat, LogLevel, OAuthTokenEndpointAuthMethod, @@ -121,6 +122,7 @@ export interface SystemConfig { storageTemplate: { enabled: boolean; hashVerificationEnabled: boolean; + folderContentOrder: FolderContentOrder; template: string; }; image: { @@ -311,6 +313,7 @@ export const defaults = Object.freeze({ storageTemplate: { enabled: false, hashVerificationEnabled: true, + folderContentOrder: FolderContentOrder.Name, template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', }, image: { diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index c835073c31..cf0fcd209c 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -20,6 +20,7 @@ import { AudioCodec, CQMode, Colorspace, + FolderContentOrder, ImageFormat, LogLevel, OAuthTokenEndpointAuthMethod, @@ -545,6 +546,9 @@ class SystemConfigStorageTemplateDto { @IsNotEmpty() @IsString() template!: string; + + @ValidateEnum({ enum: FolderContentOrder, name: 'FolderContentOrder' }) + folderContentOrder!: FolderContentOrder; } export class SystemConfigTemplateStorageOptionDto { diff --git a/server/src/enum.ts b/server/src/enum.ts index 9d0a2c0426..a527954fb6 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -873,3 +873,8 @@ export enum PluginTriggerType { AssetCreate = 'AssetCreate', PersonRecognized = 'PersonRecognized', } + +export enum FolderContentOrder { + Name = 'name', + Date = 'date', +} diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts index ceab79f6eb..2e63144f13 100644 --- a/server/src/repositories/view-repository.ts +++ b/server/src/repositories/view-repository.ts @@ -1,7 +1,7 @@ import { Kysely } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { AssetVisibility } from 'src/enum'; +import { AssetVisibility, FolderContentOrder } from 'src/enum'; import { DB } from 'src/schema'; import { asUuid, withExif } from 'src/utils/database'; @@ -26,8 +26,16 @@ export class ViewRepository { return results.map((row) => row.directoryPath.replaceAll(/\/$/g, '')); } - @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) - async getAssetsByOriginalPath(userId: string, partialPath: string) { + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, FolderContentOrder.Name] }) + @GenerateSql({ + params: [DummyValue.UUID, DummyValue.STRING, FolderContentOrder.Date], + name: 'getAssetsByOriginalPath (date order)', + }) + async getAssetsByOriginalPath( + userId: string, + partialPath: string, + order: FolderContentOrder = FolderContentOrder.Name, + ) { const normalizedPath = partialPath.replaceAll(/\/$/g, ''); return this.db @@ -42,10 +50,13 @@ export class ViewRepository { .where('localDateTime', 'is not', null) .where('originalPath', 'like', `%${normalizedPath}/%`) .where('originalPath', 'not like', `%${normalizedPath}/%/%`) - .orderBy( - (eb) => eb.fn('regexp_replace', ['asset.originalPath', eb.val('.*/(.+)'), eb.val(String.raw`\1`)]), - 'asc', + .$if(order === FolderContentOrder.Name, (qb) => + qb.orderBy( + (eb) => eb.fn('regexp_replace', ['asset.originalPath', eb.val('.*/(.+)'), eb.val(String.raw`\1`)]), + 'asc', + ), ) + .$if(order === FolderContentOrder.Date, (qb) => qb.orderBy('fileCreatedAt', 'asc')) .execute(); } } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index fbdd655bbc..db2e1e7340 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -4,6 +4,7 @@ import { AudioCodec, Colorspace, CQMode, + FolderContentOrder, ImageFormat, LogLevel, OAuthTokenEndpointAuthMethod, @@ -159,6 +160,7 @@ const updatedConfig = Object.freeze({ storageTemplate: { enabled: false, hashVerificationEnabled: true, + folderContentOrder: FolderContentOrder.Name, template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', }, image: { diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts index 86bfcef734..750d2641fa 100644 --- a/server/src/services/view.service.spec.ts +++ b/server/src/services/view.service.spec.ts @@ -1,4 +1,5 @@ import { mapAsset } from 'src/dtos/asset-response.dto'; +import { FolderContentOrder } from 'src/enum'; import { ViewService } from 'src/services/view.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; @@ -44,6 +45,11 @@ describe(ViewService.name, () => { const result = await sut.getAssetsByOriginalPath(authStub.admin, path); expect(result).toEqual(mockAssetReponseDto); await expect(mocks.view.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets); + expect(mocks.view.getAssetsByOriginalPath).toHaveBeenCalledWith( + authStub.admin.user.id, + path, + FolderContentOrder.Name, + ); }); }); }); diff --git a/server/src/services/view.service.ts b/server/src/services/view.service.ts index 9d1ee3cf89..2ec53da2b0 100644 --- a/server/src/services/view.service.ts +++ b/server/src/services/view.service.ts @@ -10,7 +10,12 @@ export class ViewService extends BaseService { } async getAssetsByOriginalPath(auth: AuthDto, path: string): Promise { - const assets = await this.viewRepository.getAssetsByOriginalPath(auth.user.id, path); + const { storageTemplate } = await this.getConfig({ withCache: true }); + const assets = await this.viewRepository.getAssetsByOriginalPath( + auth.user.id, + path, + storageTemplate.folderContentOrder, + ); return assets.map((asset) => mapAsset(asset, { auth })); } } diff --git a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte index e119e8d8b0..b5a8593e3c 100644 --- a/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte +++ b/web/src/lib/components/admin-settings/StorageTemplateSettings.svelte @@ -4,6 +4,7 @@ import SupportedVariablesPanel from '$lib/components/admin-settings/SupportedVariablesPanel.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte'; import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte'; + import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte'; import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte'; import { AppRoute, SettingInputFieldType } from '$lib/constants'; import FormatMessage from '$lib/elements/FormatMessage.svelte'; @@ -11,7 +12,7 @@ import { systemConfigManager } from '$lib/managers/system-config-manager.svelte'; import { handleSystemConfigSave } from '$lib/services/system-config.service'; import { user } from '$lib/stores/user.store'; - import { getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk'; + import { FolderContentOrder, getStorageTemplateOptions, type SystemConfigTemplateStorageOptionDto } from '@immich/sdk'; import { LoadingSpinner } from '@immich/ui'; import handlebar from 'handlebars'; import * as luxon from 'luxon'; @@ -150,6 +151,19 @@ configToEdit.storageTemplate.hashVerificationEnabled === config.storageTemplate.hashVerificationEnabled )} /> + + {/if} {#if configToEdit.storageTemplate.enabled}