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}