This commit is contained in:
Jeremy 2025-10-15 12:29:01 +02:00 committed by GitHub
commit 783bf4d2b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 360 additions and 24 deletions

View file

@ -340,6 +340,7 @@ Class | Method | HTTP request | Description
- [AssetMetadataUpsertDto](doc//AssetMetadataUpsertDto.md) - [AssetMetadataUpsertDto](doc//AssetMetadataUpsertDto.md)
- [AssetMetadataUpsertItemDto](doc//AssetMetadataUpsertItemDto.md) - [AssetMetadataUpsertItemDto](doc//AssetMetadataUpsertItemDto.md)
- [AssetOrder](doc//AssetOrder.md) - [AssetOrder](doc//AssetOrder.md)
- [AssetOrderBy](doc//AssetOrderBy.md)
- [AssetResponseDto](doc//AssetResponseDto.md) - [AssetResponseDto](doc//AssetResponseDto.md)
- [AssetStackResponseDto](doc//AssetStackResponseDto.md) - [AssetStackResponseDto](doc//AssetStackResponseDto.md)
- [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md)

View file

@ -111,6 +111,7 @@ part 'model/asset_metadata_response_dto.dart';
part 'model/asset_metadata_upsert_dto.dart'; part 'model/asset_metadata_upsert_dto.dart';
part 'model/asset_metadata_upsert_item_dto.dart'; part 'model/asset_metadata_upsert_item_dto.dart';
part 'model/asset_order.dart'; part 'model/asset_order.dart';
part 'model/asset_order_by.dart';
part 'model/asset_response_dto.dart'; part 'model/asset_response_dto.dart';
part 'model/asset_stack_response_dto.dart'; part 'model/asset_stack_response_dto.dart';
part 'model/asset_stats_response_dto.dart'; part 'model/asset_stats_response_dto.dart';

View file

@ -39,6 +39,9 @@ class TimelineApi {
/// * [AssetOrder] order: /// * [AssetOrder] order:
/// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first) /// 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: /// * [String] personId:
/// Filter assets containing a specific person (face recognition) /// Filter assets containing a specific person (face recognition)
/// ///
@ -61,7 +64,7 @@ class TimelineApi {
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned. /// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<Response> 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<Response> 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 // ignore: prefer_const_declarations
final apiPath = r'/timeline/bucket'; final apiPath = r'/timeline/bucket';
@ -87,6 +90,9 @@ class TimelineApi {
if (order != null) { if (order != null) {
queryParams.addAll(_queryParams('', 'order', order)); queryParams.addAll(_queryParams('', 'order', order));
} }
if (orderBy != null) {
queryParams.addAll(_queryParams('', 'orderBy', orderBy));
}
if (personId != null) { if (personId != null) {
queryParams.addAll(_queryParams('', 'personId', personId)); queryParams.addAll(_queryParams('', 'personId', personId));
} }
@ -148,6 +154,9 @@ class TimelineApi {
/// * [AssetOrder] order: /// * [AssetOrder] order:
/// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first) /// 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: /// * [String] personId:
/// Filter assets containing a specific person (face recognition) /// Filter assets containing a specific person (face recognition)
/// ///
@ -170,8 +179,8 @@ class TimelineApi {
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned. /// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<TimeBucketAssetResponseDto?> 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 { Future<TimeBucketAssetResponseDto?> 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, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, ); 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) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }
@ -205,6 +214,9 @@ class TimelineApi {
/// * [AssetOrder] order: /// * [AssetOrder] order:
/// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first) /// 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: /// * [String] personId:
/// Filter assets containing a specific person (face recognition) /// Filter assets containing a specific person (face recognition)
/// ///
@ -227,7 +239,7 @@ class TimelineApi {
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned. /// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<Response> 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<Response> 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 // ignore: prefer_const_declarations
final apiPath = r'/timeline/buckets'; final apiPath = r'/timeline/buckets';
@ -253,6 +265,9 @@ class TimelineApi {
if (order != null) { if (order != null) {
queryParams.addAll(_queryParams('', 'order', order)); queryParams.addAll(_queryParams('', 'order', order));
} }
if (orderBy != null) {
queryParams.addAll(_queryParams('', 'orderBy', orderBy));
}
if (personId != null) { if (personId != null) {
queryParams.addAll(_queryParams('', 'personId', personId)); queryParams.addAll(_queryParams('', 'personId', personId));
} }
@ -310,6 +325,9 @@ class TimelineApi {
/// * [AssetOrder] order: /// * [AssetOrder] order:
/// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first) /// 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: /// * [String] personId:
/// Filter assets containing a specific person (face recognition) /// Filter assets containing a specific person (face recognition)
/// ///
@ -332,8 +350,8 @@ class TimelineApi {
/// ///
/// * [bool] withStacked: /// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned. /// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<List<TimeBucketsResponseDto>?> 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 { Future<List<TimeBucketsResponseDto>?> 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, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, ); 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) { if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response)); throw ApiException(response.statusCode, await _decodeBodyBytes(response));
} }

View file

@ -276,6 +276,8 @@ class ApiClient {
return AssetMetadataUpsertItemDto.fromJson(value); return AssetMetadataUpsertItemDto.fromJson(value);
case 'AssetOrder': case 'AssetOrder':
return AssetOrderTypeTransformer().decode(value); return AssetOrderTypeTransformer().decode(value);
case 'AssetOrderBy':
return AssetOrderByTypeTransformer().decode(value);
case 'AssetResponseDto': case 'AssetResponseDto':
return AssetResponseDto.fromJson(value); return AssetResponseDto.fromJson(value);
case 'AssetStackResponseDto': case 'AssetStackResponseDto':

View file

@ -73,6 +73,9 @@ String parameterToString(dynamic value) {
if (value is AssetOrder) { if (value is AssetOrder) {
return AssetOrderTypeTransformer().encode(value).toString(); return AssetOrderTypeTransformer().encode(value).toString();
} }
if (value is AssetOrderBy) {
return AssetOrderByTypeTransformer().encode(value).toString();
}
if (value is AssetTypeEnum) { if (value is AssetTypeEnum) {
return AssetTypeEnumTypeTransformer().encode(value).toString(); return AssetTypeEnumTypeTransformer().encode(value).toString();
} }

View file

@ -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 = <AssetOrderBy>[
ADDED,
DELETED,
TAKEN,
];
static AssetOrderBy? fromJson(dynamic value) => AssetOrderByTypeTransformer().decode(value);
static List<AssetOrderBy> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetOrderBy>[];
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;
}

View file

@ -8841,6 +8841,15 @@
"$ref": "#/components/schemas/AssetOrder" "$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", "name": "personId",
"required": false, "required": false,
@ -9005,6 +9014,15 @@
"$ref": "#/components/schemas/AssetOrder" "$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", "name": "personId",
"required": false, "required": false,
@ -11056,6 +11074,14 @@
], ],
"type": "string" "type": "string"
}, },
"AssetOrderBy": {
"enum": [
"DATE_ADDED",
"DATE_DELETED",
"DATE_TAKEN"
],
"type": "string"
},
"AssetResponseDto": { "AssetResponseDto": {
"properties": { "properties": {
"checksum": { "checksum": {

View file

@ -4306,12 +4306,13 @@ export function tagAssets({ id, bulkIdsDto }: {
/** /**
* This endpoint requires the `asset.read` permission. * 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; albumId?: string;
isFavorite?: boolean; isFavorite?: boolean;
isTrashed?: boolean; isTrashed?: boolean;
key?: string; key?: string;
order?: AssetOrder; order?: AssetOrder;
orderBy?: AssetOrderBy;
personId?: string; personId?: string;
slug?: string; slug?: string;
tagId?: string; tagId?: string;
@ -4331,6 +4332,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
isTrashed, isTrashed,
key, key,
order, order,
orderBy,
personId, personId,
slug, slug,
tagId, tagId,
@ -4347,12 +4349,13 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
/** /**
* This endpoint requires the `asset.read` permission. * 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; albumId?: string;
isFavorite?: boolean; isFavorite?: boolean;
isTrashed?: boolean; isTrashed?: boolean;
key?: string; key?: string;
order?: AssetOrder; order?: AssetOrder;
orderBy?: AssetOrderBy;
personId?: string; personId?: string;
slug?: string; slug?: string;
tagId?: string; tagId?: string;
@ -4371,6 +4374,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per
isTrashed, isTrashed,
key, key,
order, order,
orderBy,
personId, personId,
slug, slug,
tagId, tagId,
@ -5047,3 +5051,8 @@ export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post", ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic" ClientSecretBasic = "client_secret_basic"
} }
export enum AssetOrderBy {
DateAdded = "DATE_ADDED",
DateDeleted = "DATE_DELETED",
DateTaken = "DATE_TAKEN"
}

View file

@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator'; 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'; import { ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
export class TimeBucketDto { export class TimeBucketDto {
@ -46,6 +46,14 @@ export class TimeBucketDto {
}) })
order?: AssetOrder; 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({ @ValidateEnum({
enum: AssetVisibility, enum: AssetVisibility,
name: 'AssetVisibility', name: 'AssetVisibility',

View file

@ -55,6 +55,12 @@ export enum AssetOrder {
Desc = 'desc', Desc = 'desc',
} }
export enum AssetOrderBy {
DateAdded = 'DATE_ADDED',
DateDeleted = 'DATE_DELETED',
DateTaken = 'DATE_TAKEN',
}
export enum DatabaseAction { export enum DatabaseAction {
Create = 'CREATE', Create = 'CREATE',
Update = 'UPDATE', Update = 'UPDATE',

View file

@ -4,7 +4,15 @@ import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { Stack } from 'src/database'; import { Stack } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; 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 { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table';
@ -14,9 +22,11 @@ import { AssetMetadataItem } from 'src/types';
import { import {
anyUuid, anyUuid,
asUuid, asUuid,
getTimeBucket,
getTimeBucketOffset,
getTimeBucketRef,
hasPeople, hasPeople,
removeUndefinedKeys, removeUndefinedKeys,
truncatedDate,
unnest, unnest,
withDefaultVisibility, withDefaultVisibility,
withExif, withExif,
@ -65,6 +75,7 @@ interface AssetBuilderOptions {
export interface TimeBucketOptions extends AssetBuilderOptions { export interface TimeBucketOptions extends AssetBuilderOptions {
order?: AssetOrder; order?: AssetOrder;
orderBy?: AssetOrderBy;
} }
export interface TimeBucketItem { export interface TimeBucketItem {
@ -550,11 +561,13 @@ export class AssetRepository {
@GenerateSql({ params: [{}] }) @GenerateSql({ params: [{}] })
async getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> { async getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
const orderBy = options.orderBy ?? AssetOrderBy.DateTaken;
return this.db return this.db
.with('asset', (qb) => .with('asset', (qb) =>
qb qb
.selectFrom('asset') .selectFrom('asset')
.select(truncatedDate<Date>().as('timeBucket')) .select((eb) => getTimeBucket<Date>(eb, orderBy).as('timeBucket'))
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted)) .$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null) .where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.$if(options.visibility === undefined, withDefaultVisibility) .$if(options.visibility === undefined, withDefaultVisibility)
@ -581,7 +594,7 @@ export class AssetRepository {
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)), .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)),
) )
.selectFrom('asset') .selectFrom('asset')
.select(sql<string>`("timeBucket" AT TIME ZONE 'UTC')::date::text`.as('timeBucket')) .select('timeBucket')
.select((eb) => eb.fn.countAll<number>().as('count')) .select((eb) => eb.fn.countAll<number>().as('count'))
.groupBy('timeBucket') .groupBy('timeBucket')
.orderBy('timeBucket', options.order ?? 'desc') .orderBy('timeBucket', options.order ?? 'desc')
@ -592,6 +605,8 @@ export class AssetRepository {
params: [DummyValue.TIME_BUCKET, { withStacked: true }], params: [DummyValue.TIME_BUCKET, { withStacked: true }],
}) })
getTimeBucket(timeBucket: string, options: TimeBucketOptions) { getTimeBucket(timeBucket: string, options: TimeBucketOptions) {
const orderBy = options.orderBy ?? AssetOrderBy.DateTaken;
const query = this.db const query = this.db
.with('cte', (qb) => .with('cte', (qb) =>
qb qb
@ -605,12 +620,10 @@ export class AssetRepository {
sql`asset.type = 'IMAGE'`.as('isImage'), sql`asset.type = 'IMAGE'`.as('isImage'),
sql`asset."deletedAt" is not null`.as('isTrashed'), sql`asset."deletedAt" is not null`.as('isTrashed'),
'asset.livePhotoVideoId', 'asset.livePhotoVideoId',
sql`extract(epoch from (asset."localDateTime" AT TIME ZONE 'UTC' - asset."fileCreatedAt" at time zone 'UTC'))::real / 3600`.as( getTimeBucketOffset(eb, orderBy).as('localOffsetHours'),
'localOffsetHours',
),
'asset.ownerId', 'asset.ownerId',
'asset.status', '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'), eb.fn('encode', ['asset.thumbhash', sql.lit('base64')]).as('thumbhash'),
'asset_exif.city', 'asset_exif.city',
'asset_exif.country', 'asset_exif.country',
@ -633,7 +646,7 @@ export class AssetRepository {
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null) .where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.$if(options.visibility == undefined, withDefaultVisibility) .$if(options.visibility == undefined, withDefaultVisibility)
.$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!)) .$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!))
.where(truncatedDate(), '=', timeBucket.replace(/^[+-]/, '')) .where((eb) => eb(getTimeBucket<string>(eb, orderBy), '=', timeBucket.replace(/^[+-]/, '')))
.$if(!!options.albumId, (qb) => .$if(!!options.albumId, (qb) =>
qb.where((eb) => qb.where((eb) =>
eb.exists( eb.exists(
@ -679,7 +692,7 @@ export class AssetRepository {
) )
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted)) .$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) .$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) => .with('agg', (qb) =>
qb qb

View file

@ -16,7 +16,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { parse } from 'pg-connection-string'; import { parse } from 'pg-connection-string';
import postgres, { Notice, PostgresError } from 'postgres'; import postgres, { Notice, PostgresError } from 'postgres';
import { columns, Exif, Person } from 'src/database'; 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 { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { DatabaseConnectionParams, VectorExtension } from 'src/types'; import { DatabaseConnectionParams, VectorExtension } from 'src/types';
@ -282,8 +282,30 @@ export function withTags(eb: ExpressionBuilder<DB, 'asset'>) {
).as('tags'); ).as('tags');
} }
export function truncatedDate<O>() { export function getTimeBucketRef(eb: ExpressionBuilder<DB, 'asset'>, orderBy: AssetOrderBy) {
return sql<O>`date_trunc(${sql.lit('MONTH')}, "localDateTime" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`; 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<DB, 'asset'>, 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<O>(
eb: ExpressionBuilder<DB, 'asset'>,
orderBy: AssetOrderBy,
timezone: Expression<string> = sql.lit('UTC'),
) {
return sql<O>`date_trunc(${sql.lit('MONTH')}, ${getTimeBucketRef(eb, orderBy)}, ${timezone})`;
} }
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: string) { export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: string) {

View file

@ -16,6 +16,8 @@
mdiFolderOutline, mdiFolderOutline,
mdiHeart, mdiHeart,
mdiHeartOutline, mdiHeartOutline,
mdiClock,
mdiClockOutline,
mdiImageAlbum, mdiImageAlbum,
mdiImageMultiple, mdiImageMultiple,
mdiImageMultipleOutline, mdiImageMultipleOutline,
@ -40,6 +42,7 @@
let isMapSelected: boolean = $state(false); let isMapSelected: boolean = $state(false);
let isPeopleSelected: boolean = $state(false); let isPeopleSelected: boolean = $state(false);
let isPhotosSelected: boolean = $state(false); let isPhotosSelected: boolean = $state(false);
let isRecentlyAddedSelected: boolean = $state(false);
let isSharingSelected: boolean = $state(false); let isSharingSelected: boolean = $state(false);
let isTrashSelected: boolean = $state(false); let isTrashSelected: boolean = $state(false);
let isUtilitiesSelected: boolean = $state(false); let isUtilitiesSelected: boolean = $state(false);
@ -118,6 +121,13 @@
<SideBarLink title={$t('folders')} href={resolve('/(user)/folders')} icon={mdiFolderOutline} flippedLogo /> <SideBarLink title={$t('folders')} href={resolve('/(user)/folders')} icon={mdiFolderOutline} flippedLogo />
{/if} {/if}
<SideBarLink
title={$t('recently_added_page_title')}
href={resolve('/(user)/recently-added')}
bind:isSelected={isRecentlyAddedSelected}
icon={isRecentlyAddedSelected ? mdiClock : mdiClockOutline}
></SideBarLink>
<SideBarLink <SideBarLink
title={$t('utilities')} title={$t('utilities')}
href={resolve('/(user)/utilities')} href={resolve('/(user)/utilities')}

View file

@ -0,0 +1,112 @@
<script lang="ts">
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();
};
</script>
<UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}>
<Timeline
enableRouting={true}
{timelineManager}
{assetInteraction}
removeAction={AssetAction.UNARCHIVE}
onEscape={handleEscape}
>
{#snippet empty()}
<EmptyPlaceholder text={$t('no_archived_assets_message')} />
{/snippet}
</Timeline>
</UserPageLayout>
{#if assetInteraction.selectionActive}
<AssetSelectControlBar
ownerId={$user.id}
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
></FavoriteAction>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem onArchive={(assetIds) => timelineManager.removeAssets(assetIds)} />
{#if $preferences.tags.enabled}
<TagAction menuItem />
{/if}
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager.addAssets(assets)}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<hr />
<AssetJobActions />
</ButtonContextMenu>
</AssetSelectControlBar>
{/if}

View file

@ -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;

View file

@ -18,7 +18,7 @@
import { featureFlags, serverConfig } from '$lib/stores/server-config.store'; import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; 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 { Button, HStack, modalManager, Text } from '@immich/ui';
import { mdiDeleteForeverOutline, mdiHistory } from '@mdi/js'; import { mdiDeleteForeverOutline, mdiHistory } from '@mdi/js';
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
@ -36,7 +36,7 @@
} }
const timelineManager = new TimelineManager(); const timelineManager = new TimelineManager();
void timelineManager.updateOptions({ isTrashed: true }); void timelineManager.updateOptions({ isTrashed: true, orderBy: AssetOrderBy.DateDeleted });
onDestroy(() => timelineManager.destroy()); onDestroy(() => timelineManager.destroy());
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();