diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 698b9774da..beac561501 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -340,6 +340,7 @@ Class | Method | HTTP request | Description - [AssetMetadataUpsertDto](doc//AssetMetadataUpsertDto.md) - [AssetMetadataUpsertItemDto](doc//AssetMetadataUpsertItemDto.md) - [AssetOrder](doc//AssetOrder.md) + - [AssetOrderBy](doc//AssetOrderBy.md) - [AssetResponseDto](doc//AssetResponseDto.md) - [AssetStackResponseDto](doc//AssetStackResponseDto.md) - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index a3117ede86..07a8da4dd1 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -111,6 +111,7 @@ part 'model/asset_metadata_response_dto.dart'; part 'model/asset_metadata_upsert_dto.dart'; part 'model/asset_metadata_upsert_item_dto.dart'; part 'model/asset_order.dart'; +part 'model/asset_order_by.dart'; part 'model/asset_response_dto.dart'; part 'model/asset_stack_response_dto.dart'; part 'model/asset_stats_response_dto.dart'; diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 70ac076c9d..ed3e719821 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -39,6 +39,9 @@ class TimelineApi { /// * [AssetOrder] order: /// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first) /// + /// * [AssetOrderBy] orderBy: + /// The field to sort time bucket assets by (DATE_TAKEN, DATE_ADDED, DATE_DELETED) + /// /// * [String] personId: /// Filter assets containing a specific person (face recognition) /// @@ -61,7 +64,7 @@ class TimelineApi { /// /// * [bool] withStacked: /// Include stacked assets in the response. When true, only primary assets from stacks are returned. - Future getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, AssetOrderBy? orderBy, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final apiPath = r'/timeline/bucket'; @@ -87,6 +90,9 @@ class TimelineApi { if (order != null) { queryParams.addAll(_queryParams('', 'order', order)); } + if (orderBy != null) { + queryParams.addAll(_queryParams('', 'orderBy', orderBy)); + } if (personId != null) { queryParams.addAll(_queryParams('', 'personId', personId)); } @@ -148,6 +154,9 @@ class TimelineApi { /// * [AssetOrder] order: /// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first) /// + /// * [AssetOrderBy] orderBy: + /// The field to sort time bucket assets by (DATE_TAKEN, DATE_ADDED, DATE_DELETED) + /// /// * [String] personId: /// Filter assets containing a specific person (face recognition) /// @@ -170,8 +179,8 @@ class TimelineApi { /// /// * [bool] withStacked: /// Include stacked assets in the response. When true, only primary assets from stacks are returned. - Future getTimeBucket(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, ); + Future getTimeBucket(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, AssetOrderBy? orderBy, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, orderBy: orderBy, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -205,6 +214,9 @@ class TimelineApi { /// * [AssetOrder] order: /// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first) /// + /// * [AssetOrderBy] orderBy: + /// The field to sort time bucket assets by (DATE_TAKEN, DATE_ADDED, DATE_DELETED) + /// /// * [String] personId: /// Filter assets containing a specific person (face recognition) /// @@ -227,7 +239,7 @@ class TimelineApi { /// /// * [bool] withStacked: /// Include stacked assets in the response. When true, only primary assets from stacks are returned. - Future getTimeBucketsWithHttpInfo({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketsWithHttpInfo({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, AssetOrderBy? orderBy, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final apiPath = r'/timeline/buckets'; @@ -253,6 +265,9 @@ class TimelineApi { if (order != null) { queryParams.addAll(_queryParams('', 'order', order)); } + if (orderBy != null) { + queryParams.addAll(_queryParams('', 'orderBy', orderBy)); + } if (personId != null) { queryParams.addAll(_queryParams('', 'personId', personId)); } @@ -310,6 +325,9 @@ class TimelineApi { /// * [AssetOrder] order: /// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first) /// + /// * [AssetOrderBy] orderBy: + /// The field to sort time bucket assets by (DATE_TAKEN, DATE_ADDED, DATE_DELETED) + /// /// * [String] personId: /// Filter assets containing a specific person (face recognition) /// @@ -332,8 +350,8 @@ class TimelineApi { /// /// * [bool] withStacked: /// Include stacked assets in the response. When true, only primary assets from stacks are returned. - Future?> getTimeBuckets({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, ); + Future?> getTimeBuckets({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, AssetOrderBy? orderBy, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, orderBy: orderBy, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 33056cf14e..78e2afc57c 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -276,6 +276,8 @@ class ApiClient { return AssetMetadataUpsertItemDto.fromJson(value); case 'AssetOrder': return AssetOrderTypeTransformer().decode(value); + case 'AssetOrderBy': + return AssetOrderByTypeTransformer().decode(value); case 'AssetResponseDto': return AssetResponseDto.fromJson(value); case 'AssetStackResponseDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index b34e9210c8..064bb0df41 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -73,6 +73,9 @@ String parameterToString(dynamic value) { if (value is AssetOrder) { return AssetOrderTypeTransformer().encode(value).toString(); } + if (value is AssetOrderBy) { + return AssetOrderByTypeTransformer().encode(value).toString(); + } if (value is AssetTypeEnum) { return AssetTypeEnumTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_order_by.dart b/mobile/openapi/lib/model/asset_order_by.dart new file mode 100644 index 0000000000..1301fc38d6 --- /dev/null +++ b/mobile/openapi/lib/model/asset_order_by.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class AssetOrderBy { + /// Instantiate a new enum with the provided [value]. + const AssetOrderBy._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const ADDED = AssetOrderBy._(r'DATE_ADDED'); + static const DELETED = AssetOrderBy._(r'DATE_DELETED'); + static const TAKEN = AssetOrderBy._(r'DATE_TAKEN'); + + /// List of all possible values in this [enum][AssetOrderBy]. + static const values = [ + ADDED, + DELETED, + TAKEN, + ]; + + static AssetOrderBy? fromJson(dynamic value) => AssetOrderByTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetOrderBy.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AssetOrderBy] to String, +/// and [decode] dynamic data back to [AssetOrderBy]. +class AssetOrderByTypeTransformer { + factory AssetOrderByTypeTransformer() => _instance ??= const AssetOrderByTypeTransformer._(); + + const AssetOrderByTypeTransformer._(); + + String encode(AssetOrderBy data) => data.value; + + /// Decodes a [dynamic value][data] to a AssetOrderBy. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + AssetOrderBy? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'DATE_ADDED': return AssetOrderBy.ADDED; + case r'DATE_DELETED': return AssetOrderBy.DELETED; + case r'DATE_TAKEN': return AssetOrderBy.TAKEN; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AssetOrderByTypeTransformer] instance. + static AssetOrderByTypeTransformer? _instance; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index b9da330ee5..3993cb09f9 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8841,6 +8841,15 @@ "$ref": "#/components/schemas/AssetOrder" } }, + { + "name": "orderBy", + "required": false, + "in": "query", + "description": "The field to sort time bucket assets by (DATE_TAKEN, DATE_ADDED, DATE_DELETED)", + "schema": { + "$ref": "#/components/schemas/AssetOrderBy" + } + }, { "name": "personId", "required": false, @@ -9005,6 +9014,15 @@ "$ref": "#/components/schemas/AssetOrder" } }, + { + "name": "orderBy", + "required": false, + "in": "query", + "description": "The field to sort time bucket assets by (DATE_TAKEN, DATE_ADDED, DATE_DELETED)", + "schema": { + "$ref": "#/components/schemas/AssetOrderBy" + } + }, { "name": "personId", "required": false, @@ -11056,6 +11074,14 @@ ], "type": "string" }, + "AssetOrderBy": { + "enum": [ + "DATE_ADDED", + "DATE_DELETED", + "DATE_TAKEN" + ], + "type": "string" + }, "AssetResponseDto": { "properties": { "checksum": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 60d72fb32b..73ecdacd2f 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -4306,12 +4306,13 @@ export function tagAssets({ id, bulkIdsDto }: { /** * This endpoint requires the `asset.read` permission. */ -export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: { +export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, orderBy, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: { albumId?: string; isFavorite?: boolean; isTrashed?: boolean; key?: string; order?: AssetOrder; + orderBy?: AssetOrderBy; personId?: string; slug?: string; tagId?: string; @@ -4331,6 +4332,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers isTrashed, key, order, + orderBy, personId, slug, tagId, @@ -4347,12 +4349,13 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers /** * This endpoint requires the `asset.read` permission. */ -export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, orderBy, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: { albumId?: string; isFavorite?: boolean; isTrashed?: boolean; key?: string; order?: AssetOrder; + orderBy?: AssetOrderBy; personId?: string; slug?: string; tagId?: string; @@ -4371,6 +4374,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per isTrashed, key, order, + orderBy, personId, slug, tagId, @@ -5047,3 +5051,8 @@ export enum OAuthTokenEndpointAuthMethod { ClientSecretPost = "client_secret_post", ClientSecretBasic = "client_secret_basic" } +export enum AssetOrderBy { + DateAdded = "DATE_ADDED", + DateDeleted = "DATE_DELETED", + DateTaken = "DATE_TAKEN" +} diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index 58772da00b..38c27143e6 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString } from 'class-validator'; -import { AssetOrder, AssetVisibility } from 'src/enum'; +import { AssetOrder, AssetOrderBy, AssetVisibility } from 'src/enum'; import { ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; export class TimeBucketDto { @@ -46,6 +46,14 @@ export class TimeBucketDto { }) order?: AssetOrder; + @ValidateEnum({ + enum: AssetOrderBy, + name: 'AssetOrderBy', + description: 'The field to sort time bucket assets by (DATE_TAKEN, DATE_ADDED, DATE_DELETED)', + optional: true, + }) + orderBy?: AssetOrderBy; + @ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', diff --git a/server/src/enum.ts b/server/src/enum.ts index b8e6e5209f..d32a141b56 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -55,6 +55,12 @@ export enum AssetOrder { Desc = 'desc', } +export enum AssetOrderBy { + DateAdded = 'DATE_ADDED', + DateDeleted = 'DATE_DELETED', + DateTaken = 'DATE_TAKEN', +} + export enum DatabaseAction { Create = 'CREATE', Update = 'UPDATE', diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 5c3bd8996c..58e4f3f9cc 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -4,7 +4,15 @@ import { isEmpty, isUndefined, omitBy } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { Stack } from 'src/database'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; -import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { + AssetFileType, + AssetMetadataKey, + AssetOrder, + AssetOrderBy, + AssetStatus, + AssetType, + AssetVisibility, +} from 'src/enum'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; @@ -14,9 +22,11 @@ import { AssetMetadataItem } from 'src/types'; import { anyUuid, asUuid, + getTimeBucket, + getTimeBucketOffset, + getTimeBucketRef, hasPeople, removeUndefinedKeys, - truncatedDate, unnest, withDefaultVisibility, withExif, @@ -65,6 +75,7 @@ interface AssetBuilderOptions { export interface TimeBucketOptions extends AssetBuilderOptions { order?: AssetOrder; + orderBy?: AssetOrderBy; } export interface TimeBucketItem { @@ -550,11 +561,13 @@ export class AssetRepository { @GenerateSql({ params: [{}] }) async getTimeBuckets(options: TimeBucketOptions): Promise { + const orderBy = options.orderBy ?? AssetOrderBy.DateTaken; + return this.db .with('asset', (qb) => qb .selectFrom('asset') - .select(truncatedDate().as('timeBucket')) + .select((eb) => getTimeBucket(eb, orderBy).as('timeBucket')) .$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted)) .where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null) .$if(options.visibility === undefined, withDefaultVisibility) @@ -581,7 +594,7 @@ export class AssetRepository { .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)), ) .selectFrom('asset') - .select(sql`("timeBucket" AT TIME ZONE 'UTC')::date::text`.as('timeBucket')) + .select('timeBucket') .select((eb) => eb.fn.countAll().as('count')) .groupBy('timeBucket') .orderBy('timeBucket', options.order ?? 'desc') @@ -592,6 +605,8 @@ export class AssetRepository { params: [DummyValue.TIME_BUCKET, { withStacked: true }], }) getTimeBucket(timeBucket: string, options: TimeBucketOptions) { + const orderBy = options.orderBy ?? AssetOrderBy.DateTaken; + const query = this.db .with('cte', (qb) => qb @@ -605,12 +620,10 @@ export class AssetRepository { sql`asset.type = 'IMAGE'`.as('isImage'), sql`asset."deletedAt" is not null`.as('isTrashed'), 'asset.livePhotoVideoId', - sql`extract(epoch from (asset."localDateTime" AT TIME ZONE 'UTC' - asset."fileCreatedAt" at time zone 'UTC'))::real / 3600`.as( - 'localOffsetHours', - ), + getTimeBucketOffset(eb, orderBy).as('localOffsetHours'), 'asset.ownerId', 'asset.status', - sql`asset."fileCreatedAt" at time zone 'utc'`.as('fileCreatedAt'), + sql`${getTimeBucketRef(eb, orderBy)} at time zone 'utc'`.as('fileCreatedAt'), eb.fn('encode', ['asset.thumbhash', sql.lit('base64')]).as('thumbhash'), 'asset_exif.city', 'asset_exif.country', @@ -633,7 +646,7 @@ export class AssetRepository { .where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null) .$if(options.visibility == undefined, withDefaultVisibility) .$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!)) - .where(truncatedDate(), '=', timeBucket.replace(/^[+-]/, '')) + .where((eb) => eb(getTimeBucket(eb, orderBy), '=', timeBucket.replace(/^[+-]/, ''))) .$if(!!options.albumId, (qb) => qb.where((eb) => eb.exists( @@ -679,7 +692,7 @@ export class AssetRepository { ) .$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted)) .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) - .orderBy('asset.fileCreatedAt', options.order ?? 'desc'), + .orderBy((eb) => getTimeBucketRef(eb, orderBy), options.order ?? 'desc'), ) .with('agg', (qb) => qb diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index d9fe6b7897..06f04cf5b1 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -16,7 +16,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { parse } from 'pg-connection-string'; import postgres, { Notice, PostgresError } from 'postgres'; import { columns, Exif, Person } from 'src/database'; -import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum'; +import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { DB } from 'src/schema'; import { DatabaseConnectionParams, VectorExtension } from 'src/types'; @@ -282,8 +282,30 @@ export function withTags(eb: ExpressionBuilder) { ).as('tags'); } -export function truncatedDate() { - return sql`date_trunc(${sql.lit('MONTH')}, "localDateTime" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`; +export function getTimeBucketRef(eb: ExpressionBuilder, orderBy: AssetOrderBy) { + if (orderBy === AssetOrderBy.DateAdded) { + return eb.ref('asset.createdAt'); + } else if (orderBy === AssetOrderBy.DateDeleted) { + return eb.ref('asset.deletedAt'); + } + + return eb.ref('asset.localDateTime'); +} + +export function getTimeBucketOffset(eb: ExpressionBuilder, orderBy: AssetOrderBy) { + if (orderBy === AssetOrderBy.DateTaken) { + return sql`extract(epoch from (asset."localDateTime" AT TIME ZONE 'UTC' - asset."fileCreatedAt" at time zone 'UTC'))::real / 3600`; + } + + return sql.lit(0); +} + +export function getTimeBucket( + eb: ExpressionBuilder, + orderBy: AssetOrderBy, + timezone: Expression = sql.lit('UTC'), +) { + return sql`date_trunc(${sql.lit('MONTH')}, ${getTimeBucketRef(eb, orderBy)}, ${timezone})`; } export function withTagId(qb: SelectQueryBuilder, tagId: string) { diff --git a/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte b/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte index 0f98124504..34add49abf 100644 --- a/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte +++ b/web/src/lib/components/shared-components/side-bar/user-sidebar.svelte @@ -16,6 +16,8 @@ mdiFolderOutline, mdiHeart, mdiHeartOutline, + mdiClock, + mdiClockOutline, mdiImageAlbum, mdiImageMultiple, mdiImageMultipleOutline, @@ -40,6 +42,7 @@ let isMapSelected: boolean = $state(false); let isPeopleSelected: boolean = $state(false); let isPhotosSelected: boolean = $state(false); + let isRecentlyAddedSelected: boolean = $state(false); let isSharingSelected: boolean = $state(false); let isTrashSelected: boolean = $state(false); let isUtilitiesSelected: boolean = $state(false); @@ -118,6 +121,13 @@ {/if} + + + import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; + import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; + import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte'; + import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte'; + import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte'; + import AssetJobActions from '$lib/components/timeline/actions/AssetJobActions.svelte'; + import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte'; + import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte'; + import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte'; + import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte'; + import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte'; + import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte'; + import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte'; + import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte'; + import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte'; + import TagAction from '$lib/components/timeline/actions/TagAction.svelte'; + import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte'; + import Timeline from '$lib/components/timeline/Timeline.svelte'; + import { AssetAction } from '$lib/constants'; + import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; + import { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; + import { preferences, user } from '$lib/stores/user.store'; + import { AssetOrderBy, AssetVisibility } from '@immich/sdk'; + import { mdiDotsVertical, mdiPlus } from '@mdi/js'; + import { onDestroy } from 'svelte'; + import { t } from 'svelte-i18n'; + import type { PageData } from './$types'; + + interface Props { + data: PageData; + } + + let { data }: Props = $props(); + const timelineManager = new TimelineManager(); + void timelineManager.updateOptions({ + visibility: AssetVisibility.Timeline, + withStacked: true, + withPartners: true, + orderBy: AssetOrderBy.DateAdded, + }); + onDestroy(() => timelineManager.destroy()); + + const assetInteraction = new AssetInteraction(); + + const handleEscape = () => { + if (assetInteraction.selectionActive) { + assetInteraction.clearMultiselect(); + return; + } + }; + + const handleSetVisibility = (assetIds: string[]) => { + timelineManager.removeAssets(assetIds); + assetInteraction.clearMultiselect(); + }; + + + + + {#snippet empty()} + + {/snippet} + + + +{#if assetInteraction.selectionActive} + assetInteraction.clearMultiselect()} + > + + + + + + + + timelineManager.updateAssetOperation(ids, (asset) => { + asset.isFavorite = isFavorite; + return { remove: false }; + })} + > + + + + + + timelineManager.removeAssets(assetIds)} /> + {#if $preferences.tags.enabled} + + {/if} + timelineManager.removeAssets(assetIds)} + onUndoDelete={(assets) => timelineManager.addAssets(assets)} + /> + +
+ +
+
+{/if} diff --git a/web/src/routes/(user)/recently-added/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/recently-added/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 0000000000..6c2e4b822d --- /dev/null +++ b/web/src/routes/(user)/recently-added/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,17 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { getAssetInfoFromParam } from '$lib/utils/navigation'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params, url }) => { + await authenticate(url); + const asset = await getAssetInfoFromParam(params); + const $t = await getFormatter(); + + return { + asset, + meta: { + title: $t('recently_added'), + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte index 20a9746a4b..171ec64a92 100644 --- a/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/trash/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -18,7 +18,7 @@ import { featureFlags, serverConfig } from '$lib/stores/server-config.store'; import { handlePromiseError } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; - import { emptyTrash, restoreTrash } from '@immich/sdk'; + import { AssetOrderBy, emptyTrash, restoreTrash } from '@immich/sdk'; import { Button, HStack, modalManager, Text } from '@immich/ui'; import { mdiDeleteForeverOutline, mdiHistory } from '@mdi/js'; import { onDestroy } from 'svelte'; @@ -36,7 +36,7 @@ } const timelineManager = new TimelineManager(); - void timelineManager.updateOptions({ isTrashed: true }); + void timelineManager.updateOptions({ isTrashed: true, orderBy: AssetOrderBy.DateDeleted }); onDestroy(() => timelineManager.destroy()); const assetInteraction = new AssetInteraction();