feat: recently added assets page (#28272)

* feat(server): add ordering date option to time buckets

* feat(web): add recently added page

* feat(server): recently created assets in explore data

* feat(web): recently added in explore tab

* fix: recently added assets ordering

* fix(server): failing bucket test

* feat(web): improve recently added preview

* chore: update e2e explore/timeline tests

* chore: rename and refactor timeline ordering dates

* fix(web): invalid timeline option

* feat(mobile): recently added page

* fix(server): sync tests

* fix(mobile): resync assets to get uploadedAt column

* chore: rename assetorderby enum

* chore(mobile): formatting

* minor fixes

* stylings

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Ben Beckford 2026-05-11 14:35:10 -07:00 committed by GitHub
parent 38438c8d9a
commit e142e3aca7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 14174 additions and 56 deletions

View file

@ -441,7 +441,18 @@ describe('/search', () => {
.get('/search/explore') .get('/search/explore')
.set('Authorization', `Bearer ${admin.accessToken}`); .set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toEqual([{ fieldName: 'exifInfo.city', items: [] }]); expect(Array.isArray(body)).toBe(true);
expect(body).toEqual(expect.arrayContaining([{ fieldName: 'exifInfo.city', items: [] }]));
expect(body).toEqual(
expect.arrayContaining([
{
fieldName: 'createdAt',
items: expect.arrayContaining([
expect.objectContaining({ data: expect.objectContaining({ id: assetLast.id }) }),
]),
},
]),
);
}); });
}); });

View file

@ -28,6 +28,7 @@ export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetRe
ownerId: [], ownerId: [],
ratio: [], ratio: [],
thumbhash: [], thumbhash: [],
createdAt: [],
fileCreatedAt: [], fileCreatedAt: [],
localOffsetHours: [], localOffsetHours: [],
isFavorite: [], isFavorite: [],

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@ class RemoteAsset extends BaseAsset {
final AssetVisibility visibility; final AssetVisibility visibility;
final String ownerId; final String ownerId;
final String? stackId; final String? stackId;
final DateTime? uploadedAt;
const RemoteAsset({ const RemoteAsset({
required this.id, required this.id,
@ -20,6 +21,7 @@ class RemoteAsset extends BaseAsset {
required super.type, required super.type,
required super.createdAt, required super.createdAt,
required super.updatedAt, required super.updatedAt,
this.uploadedAt,
super.width, super.width,
super.height, super.height,
super.durationMs, super.durationMs,
@ -55,6 +57,7 @@ class RemoteAsset extends BaseAsset {
type: $type, type: $type,
createdAt: $createdAt, createdAt: $createdAt,
updatedAt: $updatedAt, updatedAt: $updatedAt,
uploadedAt: ${uploadedAt ?? "<NA>"},
width: ${width ?? "<NA>"}, width: ${width ?? "<NA>"},
height: ${height ?? "<NA>"}, height: ${height ?? "<NA>"},
durationMs: ${durationMs ?? "<NA>"}, durationMs: ${durationMs ?? "<NA>"},
@ -82,7 +85,8 @@ class RemoteAsset extends BaseAsset {
ownerId == other.ownerId && ownerId == other.ownerId &&
thumbHash == other.thumbHash && thumbHash == other.thumbHash &&
visibility == other.visibility && visibility == other.visibility &&
stackId == other.stackId; stackId == other.stackId &&
uploadedAt == other.uploadedAt;
} }
@override @override
@ -93,7 +97,8 @@ class RemoteAsset extends BaseAsset {
localId.hashCode ^ localId.hashCode ^
thumbHash.hashCode ^ thumbHash.hashCode ^
visibility.hashCode ^ visibility.hashCode ^
stackId.hashCode; stackId.hashCode ^
uploadedAt.hashCode;
RemoteAsset copyWith({ RemoteAsset copyWith({
String? id, String? id,
@ -104,6 +109,7 @@ class RemoteAsset extends BaseAsset {
AssetType? type, AssetType? type,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
DateTime? uploadedAt,
int? width, int? width,
int? height, int? height,
int? durationMs, int? durationMs,
@ -123,6 +129,7 @@ class RemoteAsset extends BaseAsset {
type: type ?? this.type, type: type ?? this.type,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
uploadedAt: uploadedAt ?? this.uploadedAt,
width: width ?? this.width, width: width ?? this.width,
height: height ?? this.height, height: height ?? this.height,
durationMs: durationMs ?? this.durationMs, durationMs: durationMs ?? this.durationMs,
@ -148,6 +155,7 @@ class RemoteAssetExif extends RemoteAsset {
required super.type, required super.type,
required super.createdAt, required super.createdAt,
required super.updatedAt, required super.updatedAt,
super.uploadedAt,
super.width, super.width,
super.height, super.height,
super.durationMs, super.durationMs,
@ -184,6 +192,7 @@ class RemoteAssetExif extends RemoteAsset {
AssetType? type, AssetType? type,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
DateTime? uploadedAt,
int? width, int? width,
int? height, int? height,
int? durationMs, int? durationMs,
@ -204,6 +213,7 @@ class RemoteAssetExif extends RemoteAsset {
type: type ?? this.type, type: type ?? this.type,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
uploadedAt: uploadedAt ?? this.uploadedAt,
width: width ?? this.width, width: width ?? this.width,
height: height ?? this.height, height: height ?? this.height,
durationMs: durationMs ?? this.durationMs, durationMs: durationMs ?? this.durationMs,

View file

@ -2,6 +2,8 @@ enum GroupAssetsBy { day, month, auto, none }
enum HeaderType { none, month, day, monthAndDay } enum HeaderType { none, month, day, monthAndDay }
enum SortAssetsBy { taken, uploaded }
class Bucket { class Bucket {
final int assetCount; final int assetCount;

View file

@ -24,6 +24,7 @@ enum SyncMigrationTask {
v20260128_ResetExifV1, // EXIF table has incorrect width and height information. v20260128_ResetExifV1, // EXIF table has incorrect width and height information.
v20260128_CopyExifWidthHeightToAsset, // Asset table has incorrect width and height for video ratio calculations. v20260128_CopyExifWidthHeightToAsset, // Asset table has incorrect width and height for video ratio calculations.
v20260128_ResetAssetV1, // Asset v2.5.0 has width and height information that were edited assets. v20260128_ResetAssetV1, // Asset v2.5.0 has width and height information that were edited assets.
v20260597_ResetAssetV1AssetV2, // Assets didn't include the uploadedAt column.
} }
class SyncStreamService { class SyncStreamService {
@ -132,6 +133,13 @@ class SyncStreamService {
migrations.add(SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name); migrations.add(SyncMigrationTask.v20260128_CopyExifWidthHeightToAsset.name);
} }
} }
if (!migrations.contains(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name) &&
semVer > const SemVer(major: 2, minor: 7, patch: 5)) {
_logger.info("Running pre-sync task: v20260597_ResetAssetV1AssetV2");
await _syncApiRepository.deleteSyncAck([SyncEntityType.assetV1, SyncEntityType.assetV2]);
migrations.add(SyncMigrationTask.v20260597_ResetAssetV1AssetV2.name);
}
} }
Future<void> _runPostSyncTasks(List<String> migrations) async { Future<void> _runPostSyncTasks(List<String> migrations) async {

View file

@ -35,6 +35,7 @@ enum TimelineOrigin {
deepLink, deepLink,
albumActivities, albumActivities,
folder, folder,
recentlyAdded,
} }
class TimelineFactory { class TimelineFactory {
@ -61,6 +62,8 @@ class TimelineFactory {
TimelineService remoteAssets(String userId) => TimelineService(_timelineRepository.remote(userId, groupBy)); TimelineService remoteAssets(String userId) => TimelineService(_timelineRepository.remote(userId, groupBy));
TimelineService recentlyAdded(String userId) => TimelineService(_timelineRepository.recentlyAdded(userId, groupBy));
TimelineService favorite(String userId) => TimelineService(_timelineRepository.favorite(userId, groupBy)); TimelineService favorite(String userId) => TimelineService(_timelineRepository.favorite(userId, groupBy));
TimelineService trash(String userId) => TimelineService(_timelineRepository.trash(userId, groupBy)); TimelineService trash(String userId) => TimelineService(_timelineRepository.trash(userId, groupBy));

View file

@ -11,6 +11,7 @@ extension DTOToAsset on api.AssetResponseDto {
checksum: checksum, checksum: checksum,
createdAt: fileCreatedAt, createdAt: fileCreatedAt,
updatedAt: updatedAt, updatedAt: updatedAt,
uploadedAt: createdAt,
ownerId: ownerId, ownerId: ownerId,
visibility: visibility.toAssetVisibility(), visibility: visibility.toAssetVisibility(),
durationMs: duration, durationMs: duration,
@ -33,6 +34,7 @@ extension DTOToAsset on api.AssetResponseDto {
checksum: checksum, checksum: checksum,
createdAt: fileCreatedAt, createdAt: fileCreatedAt,
updatedAt: updatedAt, updatedAt: updatedAt,
uploadedAt: createdAt,
ownerId: ownerId, ownerId: ownerId,
visibility: visibility.toAssetVisibility(), visibility: visibility.toAssetVisibility(),
durationMs: duration, durationMs: duration,

View file

@ -27,7 +27,8 @@ SELECT
NULL as longitude, NULL as longitude,
NULL as adjustmentTime, NULL as adjustmentTime,
rae.is_edited, rae.is_edited,
0 as playback_style 0 as playback_style,
rae.uploaded_at
FROM FROM
remote_asset_entity rae remote_asset_entity rae
LEFT JOIN LEFT JOIN
@ -65,7 +66,8 @@ SELECT
lae.longitude, lae.longitude,
lae.adjustment_time, lae.adjustment_time,
0 as is_edited, 0 as is_edited,
lae.playback_style lae.playback_style,
NULL as uploaded_at
FROM FROM
local_asset_entity lae local_asset_entity lae
WHERE NOT EXISTS ( WHERE NOT EXISTS (

View file

@ -29,7 +29,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
); );
$arrayStartIndex += generatedlimit.amountOfVariables; $arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect( return customSelect(
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}', 'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_ms, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, NULL AS i_cloud_id, NULL AS latitude, NULL AS longitude, NULL AS adjustmentTime, rae.is_edited, 0 AS playback_style, rae.uploaded_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_ms, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, lae.i_cloud_id, lae.latitude, lae.longitude, lae.adjustment_time, 0 AS is_edited, lae.playback_style, NULL AS uploaded_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
variables: [ variables: [
for (var $ in userIds) i0.Variable<String>($), for (var $ in userIds) i0.Variable<String>($),
...generatedlimit.introducedVariables, ...generatedlimit.introducedVariables,
@ -68,6 +68,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
adjustmentTime: row.readNullable<DateTime>('adjustmentTime'), adjustmentTime: row.readNullable<DateTime>('adjustmentTime'),
isEdited: row.read<bool>('is_edited'), isEdited: row.read<bool>('is_edited'),
playbackStyle: row.read<int>('playback_style'), playbackStyle: row.read<int>('playback_style'),
uploadedAt: row.readNullable<DateTime>('uploaded_at'),
), ),
); );
} }
@ -141,6 +142,7 @@ class MergedAssetResult {
final DateTime? adjustmentTime; final DateTime? adjustmentTime;
final bool isEdited; final bool isEdited;
final int playbackStyle; final int playbackStyle;
final DateTime? uploadedAt;
MergedAssetResult({ MergedAssetResult({
this.remoteId, this.remoteId,
this.localId, this.localId,
@ -164,6 +166,7 @@ class MergedAssetResult {
this.adjustmentTime, this.adjustmentTime,
required this.isEdited, required this.isEdited,
required this.playbackStyle, required this.playbackStyle,
this.uploadedAt,
}); });
} }

View file

@ -43,6 +43,8 @@ class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin
DateTimeColumn get deletedAt => dateTime().nullable()(); DateTimeColumn get deletedAt => dateTime().nullable()();
DateTimeColumn get uploadedAt => dateTime().nullable()();
TextColumn get livePhotoVideoId => text().nullable()(); TextColumn get livePhotoVideoId => text().nullable()();
IntColumn get visibility => intEnum<AssetVisibility>()(); IntColumn get visibility => intEnum<AssetVisibility>()();
@ -66,6 +68,7 @@ extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData {
type: type, type: type,
createdAt: createdAt, createdAt: createdAt,
updatedAt: updatedAt, updatedAt: updatedAt,
uploadedAt: uploadedAt,
durationMs: durationMs, durationMs: durationMs,
isFavorite: isFavorite, isFavorite: isFavorite,
height: height, height: height,

View file

@ -27,6 +27,7 @@ typedef $$RemoteAssetEntityTableCreateCompanionBuilder =
i0.Value<DateTime?> localDateTime, i0.Value<DateTime?> localDateTime,
i0.Value<String?> thumbHash, i0.Value<String?> thumbHash,
i0.Value<DateTime?> deletedAt, i0.Value<DateTime?> deletedAt,
i0.Value<DateTime?> uploadedAt,
i0.Value<String?> livePhotoVideoId, i0.Value<String?> livePhotoVideoId,
required i2.AssetVisibility visibility, required i2.AssetVisibility visibility,
i0.Value<String?> stackId, i0.Value<String?> stackId,
@ -49,6 +50,7 @@ typedef $$RemoteAssetEntityTableUpdateCompanionBuilder =
i0.Value<DateTime?> localDateTime, i0.Value<DateTime?> localDateTime,
i0.Value<String?> thumbHash, i0.Value<String?> thumbHash,
i0.Value<DateTime?> deletedAt, i0.Value<DateTime?> deletedAt,
i0.Value<DateTime?> uploadedAt,
i0.Value<String?> livePhotoVideoId, i0.Value<String?> livePhotoVideoId,
i0.Value<i2.AssetVisibility> visibility, i0.Value<i2.AssetVisibility> visibility,
i0.Value<String?> stackId, i0.Value<String?> stackId,
@ -177,6 +179,11 @@ class $$RemoteAssetEntityTableFilterComposer
builder: (column) => i0.ColumnFilters(column), builder: (column) => i0.ColumnFilters(column),
); );
i0.ColumnFilters<DateTime> get uploadedAt => $composableBuilder(
column: $table.uploadedAt,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<String> get livePhotoVideoId => $composableBuilder( i0.ColumnFilters<String> get livePhotoVideoId => $composableBuilder(
column: $table.livePhotoVideoId, column: $table.livePhotoVideoId,
builder: (column) => i0.ColumnFilters(column), builder: (column) => i0.ColumnFilters(column),
@ -305,6 +312,11 @@ class $$RemoteAssetEntityTableOrderingComposer
builder: (column) => i0.ColumnOrderings(column), builder: (column) => i0.ColumnOrderings(column),
); );
i0.ColumnOrderings<DateTime> get uploadedAt => $composableBuilder(
column: $table.uploadedAt,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<String> get livePhotoVideoId => $composableBuilder( i0.ColumnOrderings<String> get livePhotoVideoId => $composableBuilder(
column: $table.livePhotoVideoId, column: $table.livePhotoVideoId,
builder: (column) => i0.ColumnOrderings(column), builder: (column) => i0.ColumnOrderings(column),
@ -412,6 +424,11 @@ class $$RemoteAssetEntityTableAnnotationComposer
i0.GeneratedColumn<DateTime> get deletedAt => i0.GeneratedColumn<DateTime> get deletedAt =>
$composableBuilder(column: $table.deletedAt, builder: (column) => column); $composableBuilder(column: $table.deletedAt, builder: (column) => column);
i0.GeneratedColumn<DateTime> get uploadedAt => $composableBuilder(
column: $table.uploadedAt,
builder: (column) => column,
);
i0.GeneratedColumn<String> get livePhotoVideoId => $composableBuilder( i0.GeneratedColumn<String> get livePhotoVideoId => $composableBuilder(
column: $table.livePhotoVideoId, column: $table.livePhotoVideoId,
builder: (column) => column, builder: (column) => column,
@ -507,6 +524,7 @@ class $$RemoteAssetEntityTableTableManager
i0.Value<DateTime?> localDateTime = const i0.Value.absent(), i0.Value<DateTime?> localDateTime = const i0.Value.absent(),
i0.Value<String?> thumbHash = const i0.Value.absent(), i0.Value<String?> thumbHash = const i0.Value.absent(),
i0.Value<DateTime?> deletedAt = const i0.Value.absent(), i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
i0.Value<DateTime?> uploadedAt = const i0.Value.absent(),
i0.Value<String?> livePhotoVideoId = const i0.Value.absent(), i0.Value<String?> livePhotoVideoId = const i0.Value.absent(),
i0.Value<i2.AssetVisibility> visibility = i0.Value<i2.AssetVisibility> visibility =
const i0.Value.absent(), const i0.Value.absent(),
@ -528,6 +546,7 @@ class $$RemoteAssetEntityTableTableManager
localDateTime: localDateTime, localDateTime: localDateTime,
thumbHash: thumbHash, thumbHash: thumbHash,
deletedAt: deletedAt, deletedAt: deletedAt,
uploadedAt: uploadedAt,
livePhotoVideoId: livePhotoVideoId, livePhotoVideoId: livePhotoVideoId,
visibility: visibility, visibility: visibility,
stackId: stackId, stackId: stackId,
@ -550,6 +569,7 @@ class $$RemoteAssetEntityTableTableManager
i0.Value<DateTime?> localDateTime = const i0.Value.absent(), i0.Value<DateTime?> localDateTime = const i0.Value.absent(),
i0.Value<String?> thumbHash = const i0.Value.absent(), i0.Value<String?> thumbHash = const i0.Value.absent(),
i0.Value<DateTime?> deletedAt = const i0.Value.absent(), i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
i0.Value<DateTime?> uploadedAt = const i0.Value.absent(),
i0.Value<String?> livePhotoVideoId = const i0.Value.absent(), i0.Value<String?> livePhotoVideoId = const i0.Value.absent(),
required i2.AssetVisibility visibility, required i2.AssetVisibility visibility,
i0.Value<String?> stackId = const i0.Value.absent(), i0.Value<String?> stackId = const i0.Value.absent(),
@ -570,6 +590,7 @@ class $$RemoteAssetEntityTableTableManager
localDateTime: localDateTime, localDateTime: localDateTime,
thumbHash: thumbHash, thumbHash: thumbHash,
deletedAt: deletedAt, deletedAt: deletedAt,
uploadedAt: uploadedAt,
livePhotoVideoId: livePhotoVideoId, livePhotoVideoId: livePhotoVideoId,
visibility: visibility, visibility: visibility,
stackId: stackId, stackId: stackId,
@ -818,6 +839,18 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
type: i0.DriftSqlType.dateTime, type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false, requiredDuringInsert: false,
); );
static const i0.VerificationMeta _uploadedAtMeta = const i0.VerificationMeta(
'uploadedAt',
);
@override
late final i0.GeneratedColumn<DateTime> uploadedAt =
i0.GeneratedColumn<DateTime>(
'uploaded_at',
aliasedName,
true,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
);
static const i0.VerificationMeta _livePhotoVideoIdMeta = static const i0.VerificationMeta _livePhotoVideoIdMeta =
const i0.VerificationMeta('livePhotoVideoId'); const i0.VerificationMeta('livePhotoVideoId');
@override @override
@ -894,6 +927,7 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
localDateTime, localDateTime,
thumbHash, thumbHash,
deletedAt, deletedAt,
uploadedAt,
livePhotoVideoId, livePhotoVideoId,
visibility, visibility,
stackId, stackId,
@ -998,6 +1032,12 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta), deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta),
); );
} }
if (data.containsKey('uploaded_at')) {
context.handle(
_uploadedAtMeta,
uploadedAt.isAcceptableOrUnknown(data['uploaded_at']!, _uploadedAtMeta),
);
}
if (data.containsKey('live_photo_video_id')) { if (data.containsKey('live_photo_video_id')) {
context.handle( context.handle(
_livePhotoVideoIdMeta, _livePhotoVideoIdMeta,
@ -1095,6 +1135,10 @@ class $RemoteAssetEntityTable extends i3.RemoteAssetEntity
i0.DriftSqlType.dateTime, i0.DriftSqlType.dateTime,
data['${effectivePrefix}deleted_at'], data['${effectivePrefix}deleted_at'],
), ),
uploadedAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}uploaded_at'],
),
livePhotoVideoId: attachedDatabase.typeMapping.read( livePhotoVideoId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string, i0.DriftSqlType.string,
data['${effectivePrefix}live_photo_video_id'], data['${effectivePrefix}live_photo_video_id'],
@ -1153,6 +1197,7 @@ class RemoteAssetEntityData extends i0.DataClass
final DateTime? localDateTime; final DateTime? localDateTime;
final String? thumbHash; final String? thumbHash;
final DateTime? deletedAt; final DateTime? deletedAt;
final DateTime? uploadedAt;
final String? livePhotoVideoId; final String? livePhotoVideoId;
final i2.AssetVisibility visibility; final i2.AssetVisibility visibility;
final String? stackId; final String? stackId;
@ -1173,6 +1218,7 @@ class RemoteAssetEntityData extends i0.DataClass
this.localDateTime, this.localDateTime,
this.thumbHash, this.thumbHash,
this.deletedAt, this.deletedAt,
this.uploadedAt,
this.livePhotoVideoId, this.livePhotoVideoId,
required this.visibility, required this.visibility,
this.stackId, this.stackId,
@ -1212,6 +1258,9 @@ class RemoteAssetEntityData extends i0.DataClass
if (!nullToAbsent || deletedAt != null) { if (!nullToAbsent || deletedAt != null) {
map['deleted_at'] = i0.Variable<DateTime>(deletedAt); map['deleted_at'] = i0.Variable<DateTime>(deletedAt);
} }
if (!nullToAbsent || uploadedAt != null) {
map['uploaded_at'] = i0.Variable<DateTime>(uploadedAt);
}
if (!nullToAbsent || livePhotoVideoId != null) { if (!nullToAbsent || livePhotoVideoId != null) {
map['live_photo_video_id'] = i0.Variable<String>(livePhotoVideoId); map['live_photo_video_id'] = i0.Variable<String>(livePhotoVideoId);
} }
@ -1252,6 +1301,7 @@ class RemoteAssetEntityData extends i0.DataClass
localDateTime: serializer.fromJson<DateTime?>(json['localDateTime']), localDateTime: serializer.fromJson<DateTime?>(json['localDateTime']),
thumbHash: serializer.fromJson<String?>(json['thumbHash']), thumbHash: serializer.fromJson<String?>(json['thumbHash']),
deletedAt: serializer.fromJson<DateTime?>(json['deletedAt']), deletedAt: serializer.fromJson<DateTime?>(json['deletedAt']),
uploadedAt: serializer.fromJson<DateTime?>(json['uploadedAt']),
livePhotoVideoId: serializer.fromJson<String?>(json['livePhotoVideoId']), livePhotoVideoId: serializer.fromJson<String?>(json['livePhotoVideoId']),
visibility: i1.$RemoteAssetEntityTable.$convertervisibility.fromJson( visibility: i1.$RemoteAssetEntityTable.$convertervisibility.fromJson(
serializer.fromJson<int>(json['visibility']), serializer.fromJson<int>(json['visibility']),
@ -1281,6 +1331,7 @@ class RemoteAssetEntityData extends i0.DataClass
'localDateTime': serializer.toJson<DateTime?>(localDateTime), 'localDateTime': serializer.toJson<DateTime?>(localDateTime),
'thumbHash': serializer.toJson<String?>(thumbHash), 'thumbHash': serializer.toJson<String?>(thumbHash),
'deletedAt': serializer.toJson<DateTime?>(deletedAt), 'deletedAt': serializer.toJson<DateTime?>(deletedAt),
'uploadedAt': serializer.toJson<DateTime?>(uploadedAt),
'livePhotoVideoId': serializer.toJson<String?>(livePhotoVideoId), 'livePhotoVideoId': serializer.toJson<String?>(livePhotoVideoId),
'visibility': serializer.toJson<int>( 'visibility': serializer.toJson<int>(
i1.$RemoteAssetEntityTable.$convertervisibility.toJson(visibility), i1.$RemoteAssetEntityTable.$convertervisibility.toJson(visibility),
@ -1306,6 +1357,7 @@ class RemoteAssetEntityData extends i0.DataClass
i0.Value<DateTime?> localDateTime = const i0.Value.absent(), i0.Value<DateTime?> localDateTime = const i0.Value.absent(),
i0.Value<String?> thumbHash = const i0.Value.absent(), i0.Value<String?> thumbHash = const i0.Value.absent(),
i0.Value<DateTime?> deletedAt = const i0.Value.absent(), i0.Value<DateTime?> deletedAt = const i0.Value.absent(),
i0.Value<DateTime?> uploadedAt = const i0.Value.absent(),
i0.Value<String?> livePhotoVideoId = const i0.Value.absent(), i0.Value<String?> livePhotoVideoId = const i0.Value.absent(),
i2.AssetVisibility? visibility, i2.AssetVisibility? visibility,
i0.Value<String?> stackId = const i0.Value.absent(), i0.Value<String?> stackId = const i0.Value.absent(),
@ -1328,6 +1380,7 @@ class RemoteAssetEntityData extends i0.DataClass
: this.localDateTime, : this.localDateTime,
thumbHash: thumbHash.present ? thumbHash.value : this.thumbHash, thumbHash: thumbHash.present ? thumbHash.value : this.thumbHash,
deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt,
uploadedAt: uploadedAt.present ? uploadedAt.value : this.uploadedAt,
livePhotoVideoId: livePhotoVideoId.present livePhotoVideoId: livePhotoVideoId.present
? livePhotoVideoId.value ? livePhotoVideoId.value
: this.livePhotoVideoId, : this.livePhotoVideoId,
@ -1358,6 +1411,9 @@ class RemoteAssetEntityData extends i0.DataClass
: this.localDateTime, : this.localDateTime,
thumbHash: data.thumbHash.present ? data.thumbHash.value : this.thumbHash, thumbHash: data.thumbHash.present ? data.thumbHash.value : this.thumbHash,
deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt,
uploadedAt: data.uploadedAt.present
? data.uploadedAt.value
: this.uploadedAt,
livePhotoVideoId: data.livePhotoVideoId.present livePhotoVideoId: data.livePhotoVideoId.present
? data.livePhotoVideoId.value ? data.livePhotoVideoId.value
: this.livePhotoVideoId, : this.livePhotoVideoId,
@ -1387,6 +1443,7 @@ class RemoteAssetEntityData extends i0.DataClass
..write('localDateTime: $localDateTime, ') ..write('localDateTime: $localDateTime, ')
..write('thumbHash: $thumbHash, ') ..write('thumbHash: $thumbHash, ')
..write('deletedAt: $deletedAt, ') ..write('deletedAt: $deletedAt, ')
..write('uploadedAt: $uploadedAt, ')
..write('livePhotoVideoId: $livePhotoVideoId, ') ..write('livePhotoVideoId: $livePhotoVideoId, ')
..write('visibility: $visibility, ') ..write('visibility: $visibility, ')
..write('stackId: $stackId, ') ..write('stackId: $stackId, ')
@ -1412,6 +1469,7 @@ class RemoteAssetEntityData extends i0.DataClass
localDateTime, localDateTime,
thumbHash, thumbHash,
deletedAt, deletedAt,
uploadedAt,
livePhotoVideoId, livePhotoVideoId,
visibility, visibility,
stackId, stackId,
@ -1436,6 +1494,7 @@ class RemoteAssetEntityData extends i0.DataClass
other.localDateTime == this.localDateTime && other.localDateTime == this.localDateTime &&
other.thumbHash == this.thumbHash && other.thumbHash == this.thumbHash &&
other.deletedAt == this.deletedAt && other.deletedAt == this.deletedAt &&
other.uploadedAt == this.uploadedAt &&
other.livePhotoVideoId == this.livePhotoVideoId && other.livePhotoVideoId == this.livePhotoVideoId &&
other.visibility == this.visibility && other.visibility == this.visibility &&
other.stackId == this.stackId && other.stackId == this.stackId &&
@ -1459,6 +1518,7 @@ class RemoteAssetEntityCompanion
final i0.Value<DateTime?> localDateTime; final i0.Value<DateTime?> localDateTime;
final i0.Value<String?> thumbHash; final i0.Value<String?> thumbHash;
final i0.Value<DateTime?> deletedAt; final i0.Value<DateTime?> deletedAt;
final i0.Value<DateTime?> uploadedAt;
final i0.Value<String?> livePhotoVideoId; final i0.Value<String?> livePhotoVideoId;
final i0.Value<i2.AssetVisibility> visibility; final i0.Value<i2.AssetVisibility> visibility;
final i0.Value<String?> stackId; final i0.Value<String?> stackId;
@ -1479,6 +1539,7 @@ class RemoteAssetEntityCompanion
this.localDateTime = const i0.Value.absent(), this.localDateTime = const i0.Value.absent(),
this.thumbHash = const i0.Value.absent(), this.thumbHash = const i0.Value.absent(),
this.deletedAt = const i0.Value.absent(), this.deletedAt = const i0.Value.absent(),
this.uploadedAt = const i0.Value.absent(),
this.livePhotoVideoId = const i0.Value.absent(), this.livePhotoVideoId = const i0.Value.absent(),
this.visibility = const i0.Value.absent(), this.visibility = const i0.Value.absent(),
this.stackId = const i0.Value.absent(), this.stackId = const i0.Value.absent(),
@ -1500,6 +1561,7 @@ class RemoteAssetEntityCompanion
this.localDateTime = const i0.Value.absent(), this.localDateTime = const i0.Value.absent(),
this.thumbHash = const i0.Value.absent(), this.thumbHash = const i0.Value.absent(),
this.deletedAt = const i0.Value.absent(), this.deletedAt = const i0.Value.absent(),
this.uploadedAt = const i0.Value.absent(),
this.livePhotoVideoId = const i0.Value.absent(), this.livePhotoVideoId = const i0.Value.absent(),
required i2.AssetVisibility visibility, required i2.AssetVisibility visibility,
this.stackId = const i0.Value.absent(), this.stackId = const i0.Value.absent(),
@ -1526,6 +1588,7 @@ class RemoteAssetEntityCompanion
i0.Expression<DateTime>? localDateTime, i0.Expression<DateTime>? localDateTime,
i0.Expression<String>? thumbHash, i0.Expression<String>? thumbHash,
i0.Expression<DateTime>? deletedAt, i0.Expression<DateTime>? deletedAt,
i0.Expression<DateTime>? uploadedAt,
i0.Expression<String>? livePhotoVideoId, i0.Expression<String>? livePhotoVideoId,
i0.Expression<int>? visibility, i0.Expression<int>? visibility,
i0.Expression<String>? stackId, i0.Expression<String>? stackId,
@ -1547,6 +1610,7 @@ class RemoteAssetEntityCompanion
if (localDateTime != null) 'local_date_time': localDateTime, if (localDateTime != null) 'local_date_time': localDateTime,
if (thumbHash != null) 'thumb_hash': thumbHash, if (thumbHash != null) 'thumb_hash': thumbHash,
if (deletedAt != null) 'deleted_at': deletedAt, if (deletedAt != null) 'deleted_at': deletedAt,
if (uploadedAt != null) 'uploaded_at': uploadedAt,
if (livePhotoVideoId != null) 'live_photo_video_id': livePhotoVideoId, if (livePhotoVideoId != null) 'live_photo_video_id': livePhotoVideoId,
if (visibility != null) 'visibility': visibility, if (visibility != null) 'visibility': visibility,
if (stackId != null) 'stack_id': stackId, if (stackId != null) 'stack_id': stackId,
@ -1570,6 +1634,7 @@ class RemoteAssetEntityCompanion
i0.Value<DateTime?>? localDateTime, i0.Value<DateTime?>? localDateTime,
i0.Value<String?>? thumbHash, i0.Value<String?>? thumbHash,
i0.Value<DateTime?>? deletedAt, i0.Value<DateTime?>? deletedAt,
i0.Value<DateTime?>? uploadedAt,
i0.Value<String?>? livePhotoVideoId, i0.Value<String?>? livePhotoVideoId,
i0.Value<i2.AssetVisibility>? visibility, i0.Value<i2.AssetVisibility>? visibility,
i0.Value<String?>? stackId, i0.Value<String?>? stackId,
@ -1591,6 +1656,7 @@ class RemoteAssetEntityCompanion
localDateTime: localDateTime ?? this.localDateTime, localDateTime: localDateTime ?? this.localDateTime,
thumbHash: thumbHash ?? this.thumbHash, thumbHash: thumbHash ?? this.thumbHash,
deletedAt: deletedAt ?? this.deletedAt, deletedAt: deletedAt ?? this.deletedAt,
uploadedAt: uploadedAt ?? this.uploadedAt,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
visibility: visibility ?? this.visibility, visibility: visibility ?? this.visibility,
stackId: stackId ?? this.stackId, stackId: stackId ?? this.stackId,
@ -1646,6 +1712,9 @@ class RemoteAssetEntityCompanion
if (deletedAt.present) { if (deletedAt.present) {
map['deleted_at'] = i0.Variable<DateTime>(deletedAt.value); map['deleted_at'] = i0.Variable<DateTime>(deletedAt.value);
} }
if (uploadedAt.present) {
map['uploaded_at'] = i0.Variable<DateTime>(uploadedAt.value);
}
if (livePhotoVideoId.present) { if (livePhotoVideoId.present) {
map['live_photo_video_id'] = i0.Variable<String>(livePhotoVideoId.value); map['live_photo_video_id'] = i0.Variable<String>(livePhotoVideoId.value);
} }
@ -1683,6 +1752,7 @@ class RemoteAssetEntityCompanion
..write('localDateTime: $localDateTime, ') ..write('localDateTime: $localDateTime, ')
..write('thumbHash: $thumbHash, ') ..write('thumbHash: $thumbHash, ')
..write('deletedAt: $deletedAt, ') ..write('deletedAt: $deletedAt, ')
..write('uploadedAt: $uploadedAt, ')
..write('livePhotoVideoId: $livePhotoVideoId, ') ..write('livePhotoVideoId: $livePhotoVideoId, ')
..write('visibility: $visibility, ') ..write('visibility: $visibility, ')
..write('stackId: $stackId, ') ..write('stackId: $stackId, ')

View file

@ -98,7 +98,7 @@ class Drift extends $Drift {
} }
@override @override
int get schemaVersion => 25; int get schemaVersion => 26;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@ -267,6 +267,9 @@ class Drift extends $Drift {
from24To25: (m, v25) async { from24To25: (m, v25) async {
await m.createTable(v25.metadata); await m.createTable(v25.metadata);
}, },
from25To26: (m, v26) async {
await m.addColumn(v26.remoteAssetEntity, v26.remoteAssetEntity.uploadedAt);
},
), ),
); );

View file

@ -12943,6 +12943,602 @@ i1.GeneratedColumn<String> _column_211(String aliasedName) =>
type: i1.DriftSqlType.string, type: i1.DriftSqlType.string,
$customConstraints: 'NOT NULL', $customConstraints: 'NOT NULL',
); );
final class Schema26 extends i0.VersionedSchema {
Schema26({required super.database}) : super(version: 26);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAlbumAssetAlbumAsset,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxStackPrimaryAssetId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
idxRemoteAssetStackId,
idxRemoteAssetLocalDateTimeDay,
idxRemoteAssetLocalDateTimeMonth,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
metadata,
idxPartnerSharedWithId,
idxLatLng,
idxRemoteAlbumAssetAlbumAsset,
idxRemoteAssetCloudId,
idxPersonOwnerId,
idxAssetFacePersonId,
idxAssetFaceAssetId,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
idxAssetEditAssetId,
];
late final Shape33 userEntity = Shape33(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 remoteAssetEntity = Shape50(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_212,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 stackEntity = Shape35(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_130,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape36 localAssetEntity = Shape36(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_131,
_column_120,
_column_132,
_column_133,
_column_134,
_column_135,
_column_136,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 remoteAlbumEntity = Shape48(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_138,
_column_114,
_column_115,
_column_139,
_column_140,
_column_141,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 localAlbumEntity = Shape38(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_115,
_column_142,
_column_143,
_column_144,
_column_145,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape39 localAlbumAssetEntity = Shape39(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_146, _column_147, _column_145],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAlbumAssetAlbumAsset = i1.Index(
'idx_local_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_local_album_asset_album_asset ON local_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxStackPrimaryAssetId = i1.Index(
'idx_stack_primary_asset_id',
'CREATE INDEX IF NOT EXISTS idx_stack_primary_asset_id ON stack_entity (primary_asset_id)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetStackId = i1.Index(
'idx_remote_asset_stack_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_stack_id ON remote_asset_entity (stack_id)',
);
final i1.Index idxRemoteAssetLocalDateTimeDay = i1.Index(
'idx_remote_asset_local_date_time_day',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_day ON remote_asset_entity (STRFTIME(\'%Y-%m-%d\', local_date_time))',
);
final i1.Index idxRemoteAssetLocalDateTimeMonth = i1.Index(
'idx_remote_asset_local_date_time_month',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_local_date_time_month ON remote_asset_entity (STRFTIME(\'%Y-%m\', local_date_time))',
);
late final Shape40 authUserEntity = Shape40(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_108,
_column_109,
_column_148,
_column_110,
_column_111,
_column_149,
_column_150,
_column_151,
_column_152,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_153, _column_154, _column_155],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 partnerEntity = Shape41(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_156, _column_157, _column_158],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 remoteExifEntity = Shape42(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_160,
_column_161,
_column_162,
_column_163,
_column_164,
_column_117,
_column_116,
_column_165,
_column_166,
_column_167,
_column_168,
_column_135,
_column_136,
_column_169,
_column_170,
_column_171,
_column_172,
_column_173,
_column_174,
_column_175,
_column_176,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_159, _column_177],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_177, _column_153, _column_178],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 remoteAssetCloudIdEntity = Shape43(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_159,
_column_179,
_column_180,
_column_134,
_column_135,
_column_136,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 memoryEntity = Shape44(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_124,
_column_121,
_column_113,
_column_181,
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_159, _column_187],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 personEntity = Shape45(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_114,
_column_115,
_column_121,
_column_108,
_column_188,
_column_189,
_column_190,
_column_191,
_column_192,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 assetFaceEntity = Shape46(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_193,
_column_194,
_column_195,
_column_196,
_column_197,
_column_198,
_column_199,
_column_200,
_column_201,
_column_124,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_202, _column_203, _column_204],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 trashedLocalAssetEntity = Shape47(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_108,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_107,
_column_205,
_column_131,
_column_120,
_column_132,
_column_206,
_column_137,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 assetEditEntity = Shape32(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_107,
_column_159,
_column_207,
_column_208,
_column_209,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape49 metadata = Shape49(
source: i0.VersionedTable(
entityName: 'metadata',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY("key")'],
columns: [_column_210, _column_211, _column_115],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxPartnerSharedWithId = i1.Index(
'idx_partner_shared_with_id',
'CREATE INDEX IF NOT EXISTS idx_partner_shared_with_id ON partner_entity (shared_with_id)',
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxRemoteAlbumAssetAlbumAsset = i1.Index(
'idx_remote_album_asset_album_asset',
'CREATE INDEX IF NOT EXISTS idx_remote_album_asset_album_asset ON remote_album_asset_entity (album_id, asset_id)',
);
final i1.Index idxRemoteAssetCloudId = i1.Index(
'idx_remote_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_cloud_id ON remote_asset_cloud_id_entity (cloud_id)',
);
final i1.Index idxPersonOwnerId = i1.Index(
'idx_person_owner_id',
'CREATE INDEX IF NOT EXISTS idx_person_owner_id ON person_entity (owner_id)',
);
final i1.Index idxAssetFacePersonId = i1.Index(
'idx_asset_face_person_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_person_id ON asset_face_entity (person_id)',
);
final i1.Index idxAssetFaceAssetId = i1.Index(
'idx_asset_face_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_face_asset_id ON asset_face_entity (asset_id)',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
final i1.Index idxAssetEditAssetId = i1.Index(
'idx_asset_edit_asset_id',
'CREATE INDEX IF NOT EXISTS idx_asset_edit_asset_id ON asset_edit_entity (asset_id)',
);
}
class Shape50 extends i0.VersionedTable {
Shape50({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationMs =>
columnsByName['duration_ms']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get ownerId =>
columnsByName['owner_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get localDateTime =>
columnsByName['local_date_time']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get thumbHash =>
columnsByName['thumb_hash']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get deletedAt =>
columnsByName['deleted_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get uploadedAt =>
columnsByName['uploaded_at']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get livePhotoVideoId =>
columnsByName['live_photo_video_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get visibility =>
columnsByName['visibility']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get stackId =>
columnsByName['stack_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get libraryId =>
columnsByName['library_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get isEdited =>
columnsByName['is_edited']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<String> _column_212(String aliasedName) =>
i1.GeneratedColumn<String>(
'uploaded_at',
aliasedName,
true,
type: i1.DriftSqlType.string,
$customConstraints: 'NULL',
);
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@ -12968,6 +13564,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema23 schema) from22To23, required Future<void> Function(i1.Migrator m, Schema23 schema) from22To23,
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24, required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25, required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@ -13091,6 +13688,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from24To25(migrator, schema); await from24To25(migrator, schema);
return 25; return 25;
case 25:
final schema = Schema26(database: database);
final migrator = i1.Migrator(database, schema);
await from25To26(migrator, schema);
return 26;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@ -13122,6 +13724,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema23 schema) from22To23, required Future<void> Function(i1.Migrator m, Schema23 schema) from22To23,
required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24, required Future<void> Function(i1.Migrator m, Schema24 schema) from23To24,
required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25, required Future<void> Function(i1.Migrator m, Schema25 schema) from24To25,
required Future<void> Function(i1.Migrator m, Schema26 schema) from25To26,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@ -13148,5 +13751,6 @@ i1.OnUpgrade stepByStep({
from22To23: from22To23, from22To23: from22To23,
from23To24: from23To24, from23To24: from23To24,
from24To25: from24To25, from24To25: from24To25,
from25To26: from25To26,
), ),
); );

View file

@ -191,6 +191,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
type: Value(asset.type.toAssetType()), type: Value(asset.type.toAssetType()),
createdAt: Value.absentIfNull(asset.fileCreatedAt), createdAt: Value.absentIfNull(asset.fileCreatedAt),
updatedAt: Value.absentIfNull(asset.fileModifiedAt), updatedAt: Value.absentIfNull(asset.fileModifiedAt),
uploadedAt: Value(asset.createdAt),
durationMs: Value(asset.duration?.toDuration()?.inMilliseconds ?? 0), durationMs: Value(asset.duration?.toDuration()?.inMilliseconds ?? 0),
checksum: Value(asset.checksum), checksum: Value(asset.checksum),
isFavorite: Value(asset.isFavorite), isFavorite: Value(asset.isFavorite),
@ -229,6 +230,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
type: Value(asset.type.toAssetType()), type: Value(asset.type.toAssetType()),
createdAt: Value.absentIfNull(asset.fileCreatedAt), createdAt: Value.absentIfNull(asset.fileCreatedAt),
updatedAt: Value.absentIfNull(asset.fileModifiedAt), updatedAt: Value.absentIfNull(asset.fileModifiedAt),
uploadedAt: Value(asset.createdAt),
durationMs: Value(asset.duration), durationMs: Value(asset.duration),
checksum: Value(asset.checksum), checksum: Value(asset.checksum),
isFavorite: Value(asset.isFavorite), isFavorite: Value(asset.isFavorite),

View file

@ -79,6 +79,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
type: row.type, type: row.type,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
uploadedAt: row.uploadedAt,
thumbHash: row.thumbHash, thumbHash: row.thumbHash,
width: row.width, width: row.width,
height: row.height, height: row.height,
@ -317,6 +318,17 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
origin: TimelineOrigin.remoteAssets, origin: TimelineOrigin.remoteAssets,
); );
TimelineQuery recentlyAdded(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
filter: (row) =>
row.uploadedAt.isNotNull() &
row.deletedAt.isNull() &
row.ownerId.equals(userId) &
(row.visibility.equalsValue(AssetVisibility.timeline) | row.visibility.equalsValue(AssetVisibility.archive)),
origin: TimelineOrigin.recentlyAdded,
groupBy: groupBy,
sortBy: SortAssetsBy.uploaded,
);
TimelineQuery favorite(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder( TimelineQuery favorite(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
filter: (row) => filter: (row) =>
row.deletedAt.isNull() & row.deletedAt.isNull() &
@ -597,9 +609,10 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
required TimelineOrigin origin, required TimelineOrigin origin,
GroupAssetsBy groupBy = GroupAssetsBy.day, GroupAssetsBy groupBy = GroupAssetsBy.day,
bool joinLocal = false, bool joinLocal = false,
SortAssetsBy sortBy = SortAssetsBy.taken,
}) { }) {
return ( return (
bucketSource: () => _watchRemoteBucket(filter: filter, groupBy: groupBy), bucketSource: () => _watchRemoteBucket(filter: filter, groupBy: groupBy, sortBy: sortBy),
assetSource: (offset, count) => assetSource: (offset, count) =>
_getRemoteAssets(filter: filter, offset: offset, count: count, joinLocal: joinLocal), _getRemoteAssets(filter: filter, offset: offset, count: count, joinLocal: joinLocal),
origin: origin, origin: origin,
@ -609,6 +622,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
Stream<List<Bucket>> _watchRemoteBucket({ Stream<List<Bucket>> _watchRemoteBucket({
required Expression<bool> Function($RemoteAssetEntityTable row) filter, required Expression<bool> Function($RemoteAssetEntityTable row) filter,
GroupAssetsBy groupBy = GroupAssetsBy.day, GroupAssetsBy groupBy = GroupAssetsBy.day,
SortAssetsBy sortBy = SortAssetsBy.taken,
}) { }) {
if (groupBy == GroupAssetsBy.none) { if (groupBy == GroupAssetsBy.none) {
final query = _db.remoteAssetEntity.count(where: filter); final query = _db.remoteAssetEntity.count(where: filter);
@ -616,7 +630,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
} }
final assetCountExp = _db.remoteAssetEntity.id.count(); final assetCountExp = _db.remoteAssetEntity.id.count();
final dateExp = _db.remoteAssetEntity.effectiveCreatedAt(groupBy); final dateExp = _db.remoteAssetEntity.effectiveCreatedAt(groupBy, sortBy: sortBy);
final query = _db.remoteAssetEntity.selectOnly() final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp]) ..addColumns([assetCountExp, dateExp])
@ -692,8 +706,13 @@ extension on Expression<DateTime> {
} }
extension on $RemoteAssetEntityTable { extension on $RemoteAssetEntityTable {
Expression<String> effectiveCreatedAt(GroupAssetsBy groupBy) => Expression<String> effectiveCreatedAt(GroupAssetsBy groupBy, {SortAssetsBy sortBy = SortAssetsBy.taken}) {
coalesce([localDateTime.dateFmt(groupBy), createdAt.dateFmt(groupBy, toLocal: true)]); if (sortBy == SortAssetsBy.uploaded) {
return uploadedAt.dateFmt(groupBy, toLocal: true);
}
return coalesce([localDateTime.dateFmt(groupBy), createdAt.dateFmt(groupBy, toLocal: true)]);
}
} }
extension on String { extension on String {

View file

@ -0,0 +1,32 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage()
class DriftRecentlyAddedPage extends StatelessWidget {
const DriftRecentlyAddedPage({super.key});
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access recently taken');
}
final timelineService = ref.watch(timelineFactoryProvider).recentlyAdded(user.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(appBar: MesmerizingSliverAppBar(title: 'recently_added'.t())),
);
}
}

View file

@ -13,6 +13,7 @@ import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
@ -879,6 +880,12 @@ class _QuickLinkList extends StatelessWidget {
isTop: true, isTop: true,
onTap: () => context.pushRoute(const DriftRecentlyTakenRoute()), onTap: () => context.pushRoute(const DriftRecentlyTakenRoute()),
), ),
_QuickLink(
title: context.t.recently_added,
icon: Icons.upload_outlined,
isTop: true,
onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()),
),
_QuickLink( _QuickLink(
title: 'videos'.t(context: context), title: 'videos'.t(context: context),
icon: Icons.play_circle_outline_rounded, icon: Icons.play_circle_outline_rounded,

View file

@ -58,6 +58,7 @@ import 'package:immich_mobile/presentation/pages/drift_person.page.dart';
import 'package:immich_mobile/presentation/pages/drift_place.page.dart'; import 'package:immich_mobile/presentation/pages/drift_place.page.dart';
import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart';
import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart';
import 'package:immich_mobile/presentation/pages/drift_recently_added.page.dart';
import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart'; import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart'; import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
@ -168,6 +169,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftAssetSelectionTimelineRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftAssetSelectionTimelineRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftPartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftPartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftRecentlyTakenRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftRecentlyTakenRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftRecentlyAddedRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftLocalAlbumsRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftLocalAlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftCreateAlbumRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftCreateAlbumRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftPlaceRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: DriftPlaceRoute.page, guards: [_authGuard, _duplicateGuard]),

View file

@ -1047,6 +1047,22 @@ class DriftPlaceRouteArgs {
int get hashCode => key.hashCode ^ currentLocation.hashCode; int get hashCode => key.hashCode ^ currentLocation.hashCode;
} }
/// generated route for
/// [DriftRecentlyAddedPage]
class DriftRecentlyAddedRoute extends PageRouteInfo<void> {
const DriftRecentlyAddedRoute({List<PageRouteInfo>? children})
: super(DriftRecentlyAddedRoute.name, initialChildren: children);
static const String name = 'DriftRecentlyAddedRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftRecentlyAddedPage();
},
);
}
/// generated route for /// generated route for
/// [DriftRecentlyTakenPage] /// [DriftRecentlyTakenPage]
class DriftRecentlyTakenRoute extends PageRouteInfo<void> { class DriftRecentlyTakenRoute extends PageRouteInfo<void> {

View file

@ -329,7 +329,7 @@ class ForegroundUploadService {
'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(), 'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(), 'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
'isFavorite': asset.isFavorite.toString(), 'isFavorite': asset.isFavorite.toString(),
'duration': asset.duration.toString(), 'duration': (asset.durationMs ?? 0).toString(),
}; };
// Upload live photo video first if available // Upload live photo video first if available

View file

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

View file

@ -123,6 +123,7 @@ 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_ocr_response_dto.dart'; part 'model/asset_ocr_response_dto.dart';
part 'model/asset_order.dart'; part 'model/asset_order.dart';
part 'model/asset_order_by.dart';
part 'model/asset_reject_reason.dart'; part 'model/asset_reject_reason.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';

View file

@ -44,6 +44,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:
/// Date to group and order assets by (takenAt for date taken, createdAt for date added to Immich)
///
/// * [String] personId: /// * [String] personId:
/// Filter assets containing a specific person (face recognition) /// Filter assets containing a specific person (face recognition)
/// ///
@ -66,7 +69,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, String? bbox, 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, String? bbox, 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';
@ -95,6 +98,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));
} }
@ -161,6 +167,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:
/// Date to group and order assets by (takenAt for date taken, createdAt for date added to Immich)
///
/// * [String] personId: /// * [String] personId:
/// Filter assets containing a specific person (face recognition) /// Filter assets containing a specific person (face recognition)
/// ///
@ -183,8 +192,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, String? bbox, 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, String? bbox, 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, bbox: bbox, 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, bbox: bbox, 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));
} }
@ -223,6 +232,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:
/// Date to group and order assets by (takenAt for date taken, createdAt for date added to Immich)
///
/// * [String] personId: /// * [String] personId:
/// Filter assets containing a specific person (face recognition) /// Filter assets containing a specific person (face recognition)
/// ///
@ -245,7 +257,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, String? bbox, 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, String? bbox, 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';
@ -274,6 +286,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));
} }
@ -336,6 +351,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:
/// Date to group and order assets by (takenAt for date taken, createdAt for date added to Immich)
///
/// * [String] personId: /// * [String] personId:
/// Filter assets containing a specific person (face recognition) /// Filter assets containing a specific person (face recognition)
/// ///
@ -358,8 +376,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, String? bbox, 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, String? bbox, 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, bbox: bbox, 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, bbox: bbox, 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

@ -292,6 +292,8 @@ class ApiClient {
return AssetOcrResponseDto.fromJson(value); return AssetOcrResponseDto.fromJson(value);
case 'AssetOrder': case 'AssetOrder':
return AssetOrderTypeTransformer().decode(value); return AssetOrderTypeTransformer().decode(value);
case 'AssetOrderBy':
return AssetOrderByTypeTransformer().decode(value);
case 'AssetRejectReason': case 'AssetRejectReason':
return AssetRejectReasonTypeTransformer().decode(value); return AssetRejectReasonTypeTransformer().decode(value);
case 'AssetResponseDto': case 'AssetResponseDto':

View file

@ -76,6 +76,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 AssetRejectReason) { if (value is AssetRejectReason) {
return AssetRejectReasonTypeTransformer().encode(value).toString(); return AssetRejectReasonTypeTransformer().encode(value).toString();
} }

View file

@ -0,0 +1,85 @@
//
// 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;
/// Asset sorting property
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 takenAt = AssetOrderBy._(r'takenAt');
static const createdAt = AssetOrderBy._(r'createdAt');
/// List of all possible values in this [enum][AssetOrderBy].
static const values = <AssetOrderBy>[
takenAt,
createdAt,
];
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'takenAt': return AssetOrderBy.takenAt;
case r'createdAt': return AssetOrderBy.createdAt;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [AssetOrderByTypeTransformer] instance.
static AssetOrderByTypeTransformer? _instance;
}

View file

@ -14,6 +14,7 @@ class SyncAssetV1 {
/// Returns a new [SyncAssetV1] instance. /// Returns a new [SyncAssetV1] instance.
SyncAssetV1({ SyncAssetV1({
required this.checksum, required this.checksum,
required this.createdAt,
required this.deletedAt, required this.deletedAt,
required this.duration, required this.duration,
required this.fileCreatedAt, required this.fileCreatedAt,
@ -37,6 +38,9 @@ class SyncAssetV1 {
/// Checksum /// Checksum
String checksum; String checksum;
/// Uploaded to Immich at
DateTime? createdAt;
/// Deleted at /// Deleted at
DateTime? deletedAt; DateTime? deletedAt;
@ -98,6 +102,7 @@ class SyncAssetV1 {
@override @override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetV1 && bool operator ==(Object other) => identical(this, other) || other is SyncAssetV1 &&
other.checksum == checksum && other.checksum == checksum &&
other.createdAt == createdAt &&
other.deletedAt == deletedAt && other.deletedAt == deletedAt &&
other.duration == duration && other.duration == duration &&
other.fileCreatedAt == fileCreatedAt && other.fileCreatedAt == fileCreatedAt &&
@ -121,6 +126,7 @@ class SyncAssetV1 {
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(checksum.hashCode) + (checksum.hashCode) +
(createdAt == null ? 0 : createdAt!.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode) + (deletedAt == null ? 0 : deletedAt!.hashCode) +
(duration == null ? 0 : duration!.hashCode) + (duration == null ? 0 : duration!.hashCode) +
(fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) + (fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) +
@ -141,11 +147,18 @@ class SyncAssetV1 {
(width == null ? 0 : width!.hashCode); (width == null ? 0 : width!.hashCode);
@override @override
String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isEdited=$isEdited, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]'; String toString() => 'SyncAssetV1[checksum=$checksum, createdAt=$createdAt, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isEdited=$isEdited, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'checksum'] = this.checksum; json[r'checksum'] = this.checksum;
if (this.createdAt != null) {
json[r'createdAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.createdAt!.millisecondsSinceEpoch
: this.createdAt!.toUtc().toIso8601String();
} else {
// json[r'createdAt'] = null;
}
if (this.deletedAt != null) { if (this.deletedAt != null) {
json[r'deletedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') json[r'deletedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.deletedAt!.millisecondsSinceEpoch ? this.deletedAt!.millisecondsSinceEpoch
@ -229,6 +242,7 @@ class SyncAssetV1 {
return SyncAssetV1( return SyncAssetV1(
checksum: mapValueOfType<String>(json, r'checksum')!, checksum: mapValueOfType<String>(json, r'checksum')!,
createdAt: mapDateTime(json, r'createdAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
deletedAt: mapDateTime(json, r'deletedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), deletedAt: mapDateTime(json, r'deletedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
duration: mapValueOfType<String>(json, r'duration'), duration: mapValueOfType<String>(json, r'duration'),
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
@ -295,6 +309,7 @@ class SyncAssetV1 {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'checksum', 'checksum',
'createdAt',
'deletedAt', 'deletedAt',
'duration', 'duration',
'fileCreatedAt', 'fileCreatedAt',

View file

@ -14,6 +14,7 @@ class SyncAssetV2 {
/// Returns a new [SyncAssetV2] instance. /// Returns a new [SyncAssetV2] instance.
SyncAssetV2({ SyncAssetV2({
required this.checksum, required this.checksum,
required this.createdAt,
required this.deletedAt, required this.deletedAt,
required this.duration, required this.duration,
required this.fileCreatedAt, required this.fileCreatedAt,
@ -37,6 +38,9 @@ class SyncAssetV2 {
/// Checksum /// Checksum
String checksum; String checksum;
/// Uploaded to Immich at
DateTime? createdAt;
/// Deleted at /// Deleted at
DateTime? deletedAt; DateTime? deletedAt;
@ -101,6 +105,7 @@ class SyncAssetV2 {
@override @override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetV2 && bool operator ==(Object other) => identical(this, other) || other is SyncAssetV2 &&
other.checksum == checksum && other.checksum == checksum &&
other.createdAt == createdAt &&
other.deletedAt == deletedAt && other.deletedAt == deletedAt &&
other.duration == duration && other.duration == duration &&
other.fileCreatedAt == fileCreatedAt && other.fileCreatedAt == fileCreatedAt &&
@ -124,6 +129,7 @@ class SyncAssetV2 {
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(checksum.hashCode) + (checksum.hashCode) +
(createdAt == null ? 0 : createdAt!.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode) + (deletedAt == null ? 0 : deletedAt!.hashCode) +
(duration == null ? 0 : duration!.hashCode) + (duration == null ? 0 : duration!.hashCode) +
(fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) + (fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) +
@ -144,11 +150,18 @@ class SyncAssetV2 {
(width == null ? 0 : width!.hashCode); (width == null ? 0 : width!.hashCode);
@override @override
String toString() => 'SyncAssetV2[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isEdited=$isEdited, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]'; String toString() => 'SyncAssetV2[checksum=$checksum, createdAt=$createdAt, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isEdited=$isEdited, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'checksum'] = this.checksum; json[r'checksum'] = this.checksum;
if (this.createdAt != null) {
json[r'createdAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.createdAt!.millisecondsSinceEpoch
: this.createdAt!.toUtc().toIso8601String();
} else {
// json[r'createdAt'] = null;
}
if (this.deletedAt != null) { if (this.deletedAt != null) {
json[r'deletedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/') json[r'deletedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/')
? this.deletedAt!.millisecondsSinceEpoch ? this.deletedAt!.millisecondsSinceEpoch
@ -232,6 +245,7 @@ class SyncAssetV2 {
return SyncAssetV2( return SyncAssetV2(
checksum: mapValueOfType<String>(json, r'checksum')!, checksum: mapValueOfType<String>(json, r'checksum')!,
createdAt: mapDateTime(json, r'createdAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
deletedAt: mapDateTime(json, r'deletedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), deletedAt: mapDateTime(json, r'deletedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
duration: mapValueOfType<int>(json, r'duration'), duration: mapValueOfType<int>(json, r'duration'),
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'),
@ -298,6 +312,7 @@ class SyncAssetV2 {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'checksum', 'checksum',
'createdAt',
'deletedAt', 'deletedAt',
'duration', 'duration',
'fileCreatedAt', 'fileCreatedAt',

View file

@ -15,6 +15,7 @@ class TimeBucketAssetResponseDto {
TimeBucketAssetResponseDto({ TimeBucketAssetResponseDto({
this.city = const [], this.city = const [],
this.country = const [], this.country = const [],
this.createdAt = const [],
this.duration = const [], this.duration = const [],
this.fileCreatedAt = const [], this.fileCreatedAt = const [],
this.id = const [], this.id = const [],
@ -39,6 +40,9 @@ class TimeBucketAssetResponseDto {
/// Array of country names extracted from EXIF GPS data /// Array of country names extracted from EXIF GPS data
List<String?> country; List<String?> country;
/// Array of UTC timestamps when each asset was originally uploaded to Immich
List<String> createdAt;
/// Array of video/gif durations in milliseconds (null for static images) /// Array of video/gif durations in milliseconds (null for static images)
List<int?> duration; List<int?> duration;
@ -91,6 +95,7 @@ class TimeBucketAssetResponseDto {
bool operator ==(Object other) => identical(this, other) || other is TimeBucketAssetResponseDto && bool operator ==(Object other) => identical(this, other) || other is TimeBucketAssetResponseDto &&
_deepEquality.equals(other.city, city) && _deepEquality.equals(other.city, city) &&
_deepEquality.equals(other.country, country) && _deepEquality.equals(other.country, country) &&
_deepEquality.equals(other.createdAt, createdAt) &&
_deepEquality.equals(other.duration, duration) && _deepEquality.equals(other.duration, duration) &&
_deepEquality.equals(other.fileCreatedAt, fileCreatedAt) && _deepEquality.equals(other.fileCreatedAt, fileCreatedAt) &&
_deepEquality.equals(other.id, id) && _deepEquality.equals(other.id, id) &&
@ -113,6 +118,7 @@ class TimeBucketAssetResponseDto {
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(city.hashCode) + (city.hashCode) +
(country.hashCode) + (country.hashCode) +
(createdAt.hashCode) +
(duration.hashCode) + (duration.hashCode) +
(fileCreatedAt.hashCode) + (fileCreatedAt.hashCode) +
(id.hashCode) + (id.hashCode) +
@ -131,12 +137,13 @@ class TimeBucketAssetResponseDto {
(visibility.hashCode); (visibility.hashCode);
@override @override
String toString() => 'TimeBucketAssetResponseDto[city=$city, country=$country, duration=$duration, fileCreatedAt=$fileCreatedAt, id=$id, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, localOffsetHours=$localOffsetHours, longitude=$longitude, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash, visibility=$visibility]'; String toString() => 'TimeBucketAssetResponseDto[city=$city, country=$country, createdAt=$createdAt, duration=$duration, fileCreatedAt=$fileCreatedAt, id=$id, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, localOffsetHours=$localOffsetHours, longitude=$longitude, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash, visibility=$visibility]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'city'] = this.city; json[r'city'] = this.city;
json[r'country'] = this.country; json[r'country'] = this.country;
json[r'createdAt'] = this.createdAt;
json[r'duration'] = this.duration; json[r'duration'] = this.duration;
json[r'fileCreatedAt'] = this.fileCreatedAt; json[r'fileCreatedAt'] = this.fileCreatedAt;
json[r'id'] = this.id; json[r'id'] = this.id;
@ -171,6 +178,9 @@ class TimeBucketAssetResponseDto {
country: json[r'country'] is Iterable country: json[r'country'] is Iterable
? (json[r'country'] as Iterable).cast<String>().toList(growable: false) ? (json[r'country'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
createdAt: json[r'createdAt'] is Iterable
? (json[r'createdAt'] as Iterable).cast<String>().toList(growable: false)
: const [],
duration: json[r'duration'] is Iterable duration: json[r'duration'] is Iterable
? (json[r'duration'] as Iterable).cast<int>().toList(growable: false) ? (json[r'duration'] as Iterable).cast<int>().toList(growable: false)
: const [], : const [],
@ -268,6 +278,7 @@ class TimeBucketAssetResponseDto {
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'city', 'city',
'country', 'country',
'createdAt',
'duration', 'duration',
'fileCreatedAt', 'fileCreatedAt',
'id', 'id',

View file

@ -34,6 +34,7 @@ SyncAssetV1 _createAsset({
isFavorite: false, isFavorite: false,
fileCreatedAt: DateTime(2024, 1, 1), fileCreatedAt: DateTime(2024, 1, 1),
fileModifiedAt: DateTime(2024, 1, 1), fileModifiedAt: DateTime(2024, 1, 1),
createdAt: DateTime(2024, 1, 1),
localDateTime: DateTime(2024, 1, 1), localDateTime: DateTime(2024, 1, 1),
visibility: AssetVisibility.timeline, visibility: AssetVisibility.timeline,
width: width, width: width,

View file

@ -29,6 +29,7 @@ import 'schema_v22.dart' as v22;
import 'schema_v23.dart' as v23; import 'schema_v23.dart' as v23;
import 'schema_v24.dart' as v24; import 'schema_v24.dart' as v24;
import 'schema_v25.dart' as v25; import 'schema_v25.dart' as v25;
import 'schema_v26.dart' as v26;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@ -84,6 +85,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v24.DatabaseAtV24(db); return v24.DatabaseAtV24(db);
case 25: case 25:
return v25.DatabaseAtV25(db); return v25.DatabaseAtV25(db);
case 26:
return v26.DatabaseAtV26(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
@ -115,5 +118,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
23, 23,
24, 24,
25, 25,
26,
]; ];
} }

File diff suppressed because it is too large Load diff

View file

@ -115,6 +115,7 @@ abstract final class SyncStreamStub {
duration: '0', duration: '0',
fileCreatedAt: DateTime(2025), fileCreatedAt: DateTime(2025),
fileModifiedAt: DateTime(2025, 1, 2), fileModifiedAt: DateTime(2025, 1, 2),
createdAt: DateTime(2025, 1, 2),
id: id, id: id,
isFavorite: false, isFavorite: false,
libraryId: null, libraryId: null,

View file

@ -38,6 +38,7 @@ void main() {
visibility: AssetVisibility.timeline, visibility: AssetVisibility.timeline,
createdAt: Value(createdAt), createdAt: Value(createdAt),
updatedAt: Value(createdAt), updatedAt: Value(createdAt),
uploadedAt: Value(createdAt),
localDateTime: const Value(null), localDateTime: const Value(null),
), ),
); );

View file

@ -58,6 +58,7 @@ abstract final class TestUtils {
type: domain.AssetType.image, type: domain.AssetType.image,
createdAt: DateTime(2024, 1, 1), createdAt: DateTime(2024, 1, 1),
updatedAt: DateTime(2024, 1, 1), updatedAt: DateTime(2024, 1, 1),
uploadedAt: DateTime(2024, 1, 1),
durationMs: 0, durationMs: 0,
isFavorite: false, isFavorite: false,
width: width, width: width,

View file

@ -35,6 +35,7 @@ RemoteAsset createRemoteAsset({
AssetType type = AssetType.image, AssetType type = AssetType.image,
DateTime? createdAt, DateTime? createdAt,
DateTime? updatedAt, DateTime? updatedAt,
DateTime? uploadedAt,
bool isFavorite = false, bool isFavorite = false,
}) { }) {
return RemoteAsset( return RemoteAsset(
@ -46,6 +47,7 @@ RemoteAsset createRemoteAsset({
ownerId: 'owner-id', ownerId: 'owner-id',
createdAt: createdAt ?? DateTime.now(), createdAt: createdAt ?? DateTime.now(),
updatedAt: updatedAt ?? DateTime.now(), updatedAt: updatedAt ?? DateTime.now(),
uploadedAt: uploadedAt ?? DateTime.now(),
isFavorite: isFavorite, isFavorite: isFavorite,
isEdited: false, isEdited: false,
); );

View file

@ -13322,6 +13322,15 @@
"$ref": "#/components/schemas/AssetOrder" "$ref": "#/components/schemas/AssetOrder"
} }
}, },
{
"name": "orderBy",
"required": false,
"in": "query",
"description": "Date to group and order assets by (takenAt for date taken, createdAt for date added to Immich)",
"schema": {
"$ref": "#/components/schemas/AssetOrderBy"
}
},
{ {
"name": "personId", "name": "personId",
"required": false, "required": false,
@ -13512,6 +13521,15 @@
"$ref": "#/components/schemas/AssetOrder" "$ref": "#/components/schemas/AssetOrder"
} }
}, },
{
"name": "orderBy",
"required": false,
"in": "query",
"description": "Date to group and order assets by (takenAt for date taken, createdAt for date added to Immich)",
"schema": {
"$ref": "#/components/schemas/AssetOrderBy"
}
},
{ {
"name": "personId", "name": "personId",
"required": false, "required": false,
@ -16557,6 +16575,14 @@
], ],
"type": "string" "type": "string"
}, },
"AssetOrderBy": {
"description": "Asset sorting property",
"enum": [
"takenAt",
"createdAt"
],
"type": "string"
},
"AssetRejectReason": { "AssetRejectReason": {
"description": "Rejection reason if rejected", "description": "Rejection reason if rejected",
"enum": [ "enum": [
@ -22914,6 +22940,14 @@
"description": "Checksum", "description": "Checksum",
"type": "string" "type": "string"
}, },
"createdAt": {
"description": "Uploaded to Immich at",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"nullable": true,
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"deletedAt": { "deletedAt": {
"description": "Deleted at", "description": "Deleted at",
"example": "2024-01-01T00:00:00.000Z", "example": "2024-01-01T00:00:00.000Z",
@ -23014,6 +23048,7 @@
}, },
"required": [ "required": [
"checksum", "checksum",
"createdAt",
"deletedAt", "deletedAt",
"duration", "duration",
"fileCreatedAt", "fileCreatedAt",
@ -23041,6 +23076,14 @@
"description": "Checksum", "description": "Checksum",
"type": "string" "type": "string"
}, },
"createdAt": {
"description": "Uploaded to Immich at",
"example": "2024-01-01T00:00:00.000Z",
"format": "date-time",
"nullable": true,
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
"type": "string"
},
"deletedAt": { "deletedAt": {
"description": "Deleted at", "description": "Deleted at",
"example": "2024-01-01T00:00:00.000Z", "example": "2024-01-01T00:00:00.000Z",
@ -23143,6 +23186,7 @@
}, },
"required": [ "required": [
"checksum", "checksum",
"createdAt",
"deletedAt", "deletedAt",
"duration", "duration",
"fileCreatedAt", "fileCreatedAt",
@ -24991,6 +25035,13 @@
}, },
"type": "array" "type": "array"
}, },
"createdAt": {
"description": "Array of UTC timestamps when each asset was originally uploaded to Immich",
"items": {
"type": "string"
},
"type": "array"
},
"duration": { "duration": {
"description": "Array of video/gif durations in milliseconds (null for static images)", "description": "Array of video/gif durations in milliseconds (null for static images)",
"items": { "items": {
@ -25121,6 +25172,7 @@
"required": [ "required": [
"city", "city",
"country", "country",
"createdAt",
"duration", "duration",
"fileCreatedAt", "fileCreatedAt",
"id", "id",

View file

@ -2636,6 +2636,8 @@ export type TimeBucketAssetResponseDto = {
city: (string | null)[]; city: (string | null)[];
/** Array of country names extracted from EXIF GPS data */ /** Array of country names extracted from EXIF GPS data */
country: (string | null)[]; country: (string | null)[];
/** Array of UTC timestamps when each asset was originally uploaded to Immich */
createdAt: string[];
/** Array of video/gif durations in milliseconds (null for static images) */ /** Array of video/gif durations in milliseconds (null for static images) */
duration: (number | null)[]; duration: (number | null)[];
/** Array of file creation timestamps in UTC */ /** Array of file creation timestamps in UTC */
@ -3003,6 +3005,8 @@ export type SyncAssetMetadataV1 = {
export type SyncAssetV1 = { export type SyncAssetV1 = {
/** Checksum */ /** Checksum */
checksum: string; checksum: string;
/** Uploaded to Immich at */
createdAt: string | null;
/** Deleted at */ /** Deleted at */
deletedAt: string | null; deletedAt: string | null;
/** Duration */ /** Duration */
@ -3041,6 +3045,8 @@ export type SyncAssetV1 = {
export type SyncAssetV2 = { export type SyncAssetV2 = {
/** Checksum */ /** Checksum */
checksum: string; checksum: string;
/** Uploaded to Immich at */
createdAt: string | null;
/** Deleted at */ /** Deleted at */
deletedAt: string | null; deletedAt: string | null;
/** Duration */ /** Duration */
@ -6289,13 +6295,14 @@ export function tagAssets({ id, bulkIdsDto }: {
/** /**
* Get time bucket * Get time bucket
*/ */
export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: { export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order, orderBy, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: {
albumId?: string; albumId?: string;
bbox?: string; bbox?: 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;
@ -6316,6 +6323,7 @@ export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order
isTrashed, isTrashed,
key, key,
order, order,
orderBy,
personId, personId,
slug, slug,
tagId, tagId,
@ -6332,13 +6340,14 @@ export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order
/** /**
* Get time buckets * Get time buckets
*/ */
export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: { export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, order, orderBy, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: {
albumId?: string; albumId?: string;
bbox?: string; bbox?: 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;
@ -6358,6 +6367,7 @@ export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, orde
isTrashed, isTrashed,
key, key,
order, order,
orderBy,
personId, personId,
slug, slug,
tagId, tagId,
@ -7257,6 +7267,10 @@ export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post", ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic" ClientSecretBasic = "client_secret_basic"
} }
export enum AssetOrderBy {
TakenAt = "takenAt",
CreatedAt = "createdAt"
}
export enum UserMetadataKey { export enum UserMetadataKey {
Preferences = "preferences", Preferences = "preferences",
License = "license", License = "license",

View file

@ -382,6 +382,7 @@ export const columns = {
'asset.checksum', 'asset.checksum',
'asset.fileCreatedAt', 'asset.fileCreatedAt',
'asset.fileModifiedAt', 'asset.fileModifiedAt',
'asset.createdAt',
'asset.localDateTime', 'asset.localDateTime',
'asset.type', 'asset.type',
'asset.deletedAt', 'asset.deletedAt',
@ -404,6 +405,7 @@ export const columns = {
'asset.fileCreatedAt', 'asset.fileCreatedAt',
'asset.fileModifiedAt', 'asset.fileModifiedAt',
'asset.localDateTime', 'asset.localDateTime',
'asset.createdAt',
'asset.type', 'asset.type',
'asset.deletedAt', 'asset.deletedAt',
'asset.visibility', 'asset.visibility',

View file

@ -75,6 +75,7 @@ const SyncAssetV1Schema = z
checksum: z.string().describe('Checksum'), checksum: z.string().describe('Checksum'),
fileCreatedAt: isoDatetimeToDate.nullable().describe('File created at'), fileCreatedAt: isoDatetimeToDate.nullable().describe('File created at'),
fileModifiedAt: isoDatetimeToDate.nullable().describe('File modified at'), fileModifiedAt: isoDatetimeToDate.nullable().describe('File modified at'),
createdAt: isoDatetimeToDate.nullable().describe('Uploaded to Immich at'),
localDateTime: isoDatetimeToDate.nullable().describe('Local date time'), localDateTime: isoDatetimeToDate.nullable().describe('Local date time'),
duration: z.string().nullable().describe('Duration'), duration: z.string().nullable().describe('Duration'),
type: AssetTypeSchema, type: AssetTypeSchema,
@ -99,6 +100,7 @@ const SyncAssetV2Schema = z
checksum: z.string().describe('Checksum'), checksum: z.string().describe('Checksum'),
fileCreatedAt: isoDatetimeToDate.nullable().describe('File created at'), fileCreatedAt: isoDatetimeToDate.nullable().describe('File created at'),
fileModifiedAt: isoDatetimeToDate.nullable().describe('File modified at'), fileModifiedAt: isoDatetimeToDate.nullable().describe('File modified at'),
createdAt: isoDatetimeToDate.nullable().describe('Uploaded to Immich at'),
localDateTime: isoDatetimeToDate.nullable().describe('Local date time'), localDateTime: isoDatetimeToDate.nullable().describe('Local date time'),
duration: z.int32().min(0).nullable().describe('Duration'), duration: z.int32().min(0).nullable().describe('Duration'),
type: AssetTypeSchema, type: AssetTypeSchema,

View file

@ -1,6 +1,6 @@
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { BBoxSchema } from 'src/dtos/bbox.dto'; import { BBoxSchema } from 'src/dtos/bbox.dto';
import { AssetOrderSchema, AssetVisibilitySchema } from 'src/enum'; import { AssetOrderBySchema, AssetOrderSchema, AssetVisibilitySchema } from 'src/enum';
import { stringToBool } from 'src/validation'; import { stringToBool } from 'src/validation';
import z from 'zod'; import z from 'zod';
@ -23,6 +23,9 @@ const TimeBucketQueryBaseSchema = z
order: AssetOrderSchema.optional().describe( order: AssetOrderSchema.optional().describe(
'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)',
), ),
orderBy: AssetOrderBySchema.optional().describe(
'Date to group and order assets by (takenAt for date taken, createdAt for date added to Immich)',
),
visibility: AssetVisibilitySchema.optional().describe( visibility: AssetVisibilitySchema.optional().describe(
'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)', 'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)',
), ),
@ -82,6 +85,9 @@ const TimeBucketAssetResponseSchema = z
thumbhash: z thumbhash: z
.array(z.string().nullable()) .array(z.string().nullable())
.describe('Array of BlurHash strings for generating asset previews (base64 encoded)'), .describe('Array of BlurHash strings for generating asset previews (base64 encoded)'),
createdAt: z
.array(z.string())
.describe('Array of UTC timestamps when each asset was originally uploaded to Immich'),
fileCreatedAt: z.array(z.string()).describe('Array of file creation timestamps in UTC'), fileCreatedAt: z.array(z.string()).describe('Array of file creation timestamps in UTC'),
localOffsetHours: z localOffsetHours: z
.array(z.number()) .array(z.number())

View file

@ -74,6 +74,13 @@ export enum AssetOrder {
export const AssetOrderSchema = z.enum(AssetOrder).describe('Asset sort order').meta({ id: 'AssetOrder' }); export const AssetOrderSchema = z.enum(AssetOrder).describe('Asset sort order').meta({ id: 'AssetOrder' });
export enum AssetOrderBy {
TakenAt = 'takenAt',
CreatedAt = 'createdAt',
}
export const AssetOrderBySchema = z.enum(AssetOrderBy).describe('Asset sorting property').meta({ id: 'AssetOrderBy' });
export enum MemoryType { export enum MemoryType {
/** pictures taken on this day X years ago */ /** pictures taken on this day X years ago */
OnThisDay = 'on_this_day', OnThisDay = 'on_this_day',

View file

@ -382,6 +382,7 @@ with
"asset"."ownerId", "asset"."ownerId",
"asset"."status", "asset"."status",
asset."fileCreatedAt" at time zone 'utc' as "fileCreatedAt", asset."fileCreatedAt" at time zone 'utc' as "fileCreatedAt",
asset."createdAt" at time zone 'utc' as "createdAt",
encode("asset"."thumbhash", 'base64') as "thumbhash", encode("asset"."thumbhash", 'base64') as "thumbhash",
"asset_exif"."city", "asset_exif"."city",
"asset_exif"."country", "asset_exif"."country",
@ -442,6 +443,7 @@ with
coalesce(array_agg("livePhotoVideoId"), '{}') as "livePhotoVideoId", coalesce(array_agg("livePhotoVideoId"), '{}') as "livePhotoVideoId",
coalesce(array_agg("fileCreatedAt"), '{}') as "fileCreatedAt", coalesce(array_agg("fileCreatedAt"), '{}') as "fileCreatedAt",
coalesce(array_agg("localOffsetHours"), '{}') as "localOffsetHours", coalesce(array_agg("localOffsetHours"), '{}') as "localOffsetHours",
coalesce(array_agg("createdAt"), '{}') as "createdAt",
coalesce(array_agg("ownerId"), '{}') as "ownerId", coalesce(array_agg("ownerId"), '{}') as "ownerId",
coalesce(array_agg("projectionType"), '{}') as "projectionType", coalesce(array_agg("projectionType"), '{}') as "projectionType",
coalesce(array_agg("ratio"), '{}') as "ratio", coalesce(array_agg("ratio"), '{}') as "ratio",
@ -485,6 +487,22 @@ where
limit limit
$5 $5
-- AssetRepository.getRecentlyCreatedAssetIds
select
"id" as "data",
"createdAt" as "value"
from
"asset"
where
"ownerId" = $1::uuid
and "asset"."visibility" = $2
and "type" = $3
and "deletedAt" is null
order by
"value" desc
limit
$4
-- AssetRepository.detectOfflineExternalAssets -- AssetRepository.detectOfflineExternalAssets
update "asset" update "asset"
set set

View file

@ -65,6 +65,7 @@ select
"asset"."checksum", "asset"."checksum",
"asset"."fileCreatedAt", "asset"."fileCreatedAt",
"asset"."fileModifiedAt", "asset"."fileModifiedAt",
"asset"."createdAt",
"asset"."localDateTime", "asset"."localDateTime",
"asset"."type", "asset"."type",
"asset"."deletedAt", "asset"."deletedAt",
@ -98,6 +99,7 @@ select
"asset"."checksum", "asset"."checksum",
"asset"."fileCreatedAt", "asset"."fileCreatedAt",
"asset"."fileModifiedAt", "asset"."fileModifiedAt",
"asset"."createdAt",
"asset"."localDateTime", "asset"."localDateTime",
"asset"."type", "asset"."type",
"asset"."deletedAt", "asset"."deletedAt",
@ -133,6 +135,7 @@ select
"asset"."checksum", "asset"."checksum",
"asset"."fileCreatedAt", "asset"."fileCreatedAt",
"asset"."fileModifiedAt", "asset"."fileModifiedAt",
"asset"."createdAt",
"asset"."localDateTime", "asset"."localDateTime",
"asset"."type", "asset"."type",
"asset"."deletedAt", "asset"."deletedAt",
@ -407,6 +410,7 @@ select
"asset"."checksum", "asset"."checksum",
"asset"."fileCreatedAt", "asset"."fileCreatedAt",
"asset"."fileModifiedAt", "asset"."fileModifiedAt",
"asset"."createdAt",
"asset"."localDateTime", "asset"."localDateTime",
"asset"."type", "asset"."type",
"asset"."deletedAt", "asset"."deletedAt",
@ -737,6 +741,7 @@ select
"asset"."fileCreatedAt", "asset"."fileCreatedAt",
"asset"."fileModifiedAt", "asset"."fileModifiedAt",
"asset"."localDateTime", "asset"."localDateTime",
"asset"."createdAt",
"asset"."type", "asset"."type",
"asset"."deletedAt", "asset"."deletedAt",
"asset"."visibility", "asset"."visibility",
@ -789,6 +794,7 @@ select
"asset"."fileCreatedAt", "asset"."fileCreatedAt",
"asset"."fileModifiedAt", "asset"."fileModifiedAt",
"asset"."localDateTime", "asset"."localDateTime",
"asset"."createdAt",
"asset"."type", "asset"."type",
"asset"."deletedAt", "asset"."deletedAt",
"asset"."visibility", "asset"."visibility",

View file

@ -17,7 +17,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { LockableProperty, Stack } from 'src/database'; import { LockableProperty, Stack } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { AssetFileType, AssetOrder, AssetOrderBy, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table'; import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@ -89,6 +89,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 {
@ -711,7 +712,7 @@ export class AssetRepository {
.with('asset', (qb) => .with('asset', (qb) =>
qb qb
.selectFrom('asset') .selectFrom('asset')
.select(truncatedDate<Date>().as('timeBucket')) .select(truncatedDate<Date>(options.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.bbox, (qb) => { .$if(!!options.bbox, (qb) => {
@ -783,6 +784,7 @@ export class AssetRepository {
'asset.ownerId', 'asset.ownerId',
'asset.status', 'asset.status',
sql`asset."fileCreatedAt" at time zone 'utc'`.as('fileCreatedAt'), sql`asset."fileCreatedAt" at time zone 'utc'`.as('fileCreatedAt'),
sql`asset."createdAt" at time zone 'utc'`.as('createdAt'),
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',
@ -815,7 +817,7 @@ export class AssetRepository {
return withBoundingBox(withBoundingCircle, bbox); return withBoundingBox(withBoundingCircle, bbox);
}) })
.where(truncatedDate(), '=', timeBucket.replace(/^[+-]/, '')) .where(truncatedDate(options.orderBy), '=', timeBucket.replace(/^[+-]/, ''))
.$if(!!options.albumId, (qb) => .$if(!!options.albumId, (qb) =>
qb.where((eb) => qb.where((eb) =>
eb.exists( eb.exists(
@ -861,7 +863,12 @@ 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(sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`, order) .orderBy(
options.orderBy == AssetOrderBy.CreatedAt
? sql`"createdAt"`
: sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`,
order,
)
.orderBy('asset.fileCreatedAt', order), .orderBy('asset.fileCreatedAt', order),
) )
.with('agg', (qb) => .with('agg', (qb) =>
@ -880,6 +887,7 @@ export class AssetRepository {
eb.fn.coalesce(eb.fn('array_agg', ['livePhotoVideoId']), sql.lit('{}')).as('livePhotoVideoId'), eb.fn.coalesce(eb.fn('array_agg', ['livePhotoVideoId']), sql.lit('{}')).as('livePhotoVideoId'),
eb.fn.coalesce(eb.fn('array_agg', ['fileCreatedAt']), sql.lit('{}')).as('fileCreatedAt'), eb.fn.coalesce(eb.fn('array_agg', ['fileCreatedAt']), sql.lit('{}')).as('fileCreatedAt'),
eb.fn.coalesce(eb.fn('array_agg', ['localOffsetHours']), sql.lit('{}')).as('localOffsetHours'), eb.fn.coalesce(eb.fn('array_agg', ['localOffsetHours']), sql.lit('{}')).as('localOffsetHours'),
eb.fn.coalesce(eb.fn('array_agg', ['createdAt']), sql.lit('{}')).as('createdAt'),
eb.fn.coalesce(eb.fn('array_agg', ['ownerId']), sql.lit('{}')).as('ownerId'), eb.fn.coalesce(eb.fn('array_agg', ['ownerId']), sql.lit('{}')).as('ownerId'),
eb.fn.coalesce(eb.fn('array_agg', ['projectionType']), sql.lit('{}')).as('projectionType'), eb.fn.coalesce(eb.fn('array_agg', ['projectionType']), sql.lit('{}')).as('projectionType'),
eb.fn.coalesce(eb.fn('array_agg', ['ratio']), sql.lit('{}')).as('ratio'), eb.fn.coalesce(eb.fn('array_agg', ['ratio']), sql.lit('{}')).as('ratio'),
@ -929,6 +937,22 @@ export class AssetRepository {
return { fieldName: 'exifInfo.city', items }; return { fieldName: 'exifInfo.city', items };
} }
@GenerateSql({ params: [DummyValue.UUID, 12] })
async getRecentlyCreatedAssetIds(ownerId: string, maxAssets: number) {
const items = await this.db
.selectFrom('asset')
.select(['id as data', 'createdAt as value'])
.where('ownerId', '=', asUuid(ownerId))
.where('asset.visibility', '=', AssetVisibility.Timeline)
.where('type', '=', AssetType.Image)
.where('deletedAt', 'is', null)
.orderBy('value', 'desc')
.limit(maxAssets)
.execute();
return { fieldName: 'createdAt', items };
}
async upsertFile( async upsertFile(
file: Pick< file: Pick<
Insertable<AssetFileTable>, Insertable<AssetFileTable>,

View file

@ -110,6 +110,7 @@ export class JobService extends BaseService {
checksum: hexOrBufferToBase64(asset.checksum), checksum: hexOrBufferToBase64(asset.checksum),
fileCreatedAt: asset.fileCreatedAt, fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt, fileModifiedAt: asset.fileModifiedAt,
createdAt: asset.createdAt,
localDateTime: asset.localDateTime, localDateTime: asset.localDateTime,
duration: asset.duration, duration: asset.duration,
type: asset.type, type: asset.type,
@ -166,6 +167,7 @@ export class JobService extends BaseService {
checksum: hexOrBufferToBase64(asset.checksum), checksum: hexOrBufferToBase64(asset.checksum),
fileCreatedAt: asset.fileCreatedAt, fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt, fileModifiedAt: asset.fileModifiedAt,
createdAt: asset.createdAt,
localDateTime: asset.localDateTime, localDateTime: asset.localDateTime,
duration: asset.duration, duration: asset.duration,
type: asset.type, type: asset.type,

View file

@ -65,7 +65,7 @@ describe(SearchService.name, () => {
}); });
describe('getExploreData', () => { describe('getExploreData', () => {
it('should get assets by city and tag', async () => { it('should get recent assets and assets by city and tag', async () => {
const auth = AuthFactory.create(); const auth = AuthFactory.create();
const asset = AssetFactory.from() const asset = AssetFactory.from()
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' }) .exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
@ -74,9 +74,17 @@ describe(SearchService.name, () => {
fieldName: 'exifInfo.city', fieldName: 'exifInfo.city',
items: [{ value: 'city', data: asset.id }], items: [{ value: 'city', data: asset.id }],
}); });
mocks.asset.getRecentlyCreatedAssetIds.mockResolvedValue({
fieldName: 'createdAt',
items: [{ value: asset.createdAt, data: asset.id }],
});
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([asset as never]); mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([asset as never]);
const expectedResponse = [ const expectedResponse = [
{ fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(getForAsset(asset)) }] }, { fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(getForAsset(asset)) }] },
{
fieldName: 'createdAt',
items: [{ value: asset.createdAt.toISOString(), data: mapAsset(getForAsset(asset)) }],
},
]; ];
const result = await sut.getExploreData(auth); const result = await sut.getExploreData(auth);

View file

@ -40,10 +40,26 @@ export class SearchService extends BaseService {
async getExploreData(auth: AuthDto) { async getExploreData(auth: AuthDto) {
const options = { maxFields: 12, minAssetsPerField: 5 }; const options = { maxFields: 12, minAssetsPerField: 5 };
const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options); const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options);
const assets = await this.assetRepository.getByIdsWithAllRelationsButStacks(cities.items.map(({ data }) => data)); const cityAssets = await this.assetRepository.getByIdsWithAllRelationsButStacks(
const items = assets.map((asset) => ({ value: asset.exifInfo!.city!, data: mapAsset(asset, { auth }) })); cities.items.map(({ data }) => data),
return [{ fieldName: cities.fieldName, items }]; );
const cityItems = cityAssets.map((asset) => ({ value: asset.exifInfo!.city!, data: mapAsset(asset, { auth }) }));
const recents = await this.assetRepository.getRecentlyCreatedAssetIds(auth.user.id, options.maxFields);
const recentAssets = await this.assetRepository.getByIdsWithAllRelationsButStacks(
recents.items.map((item) => item.data),
);
const recentItems = recentAssets.map((asset) => ({
value: asset.createdAt.toISOString(),
data: mapAsset(asset, { auth }),
}));
return [
{ fieldName: cities.fieldName, items: cityItems },
{ fieldName: recents.fieldName, items: recentItems },
];
} }
async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> { async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> {

View file

@ -17,7 +17,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { Notice, PostgresError } from 'postgres'; import { Notice, PostgresError } from 'postgres';
import { columns, lockableProperties, LockableProperty, Person } from 'src/database'; import { columns, lockableProperties, LockableProperty, Person } from 'src/database';
import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { AssetFileType, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum'; import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } 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 { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@ -298,8 +298,8 @@ export function withTags(eb: ExpressionBuilder<DB, 'asset'>) {
).as('tags'); ).as('tags');
} }
export function truncatedDate<O>() { export function truncatedDate<O>(order: AssetOrderBy = AssetOrderBy.TakenAt) {
return sql<O>`date_trunc(${sql.lit('MONTH')}, "localDateTime" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`; return sql<O>`date_trunc(${sql.lit('MONTH')}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`;
} }
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: string) { export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: string) {

View file

@ -118,6 +118,7 @@ describe(TimelineService.name, () => {
expect(response).toEqual({ expect(response).toEqual({
city: [], city: [],
country: [], country: [],
createdAt: [],
duration: [], duration: [],
id: [], id: [],
visibility: [], visibility: [],

View file

@ -47,6 +47,7 @@ describe(SyncRequestType.AlbumAssetsV2, () => {
fileCreatedAt: date, fileCreatedAt: date,
fileModifiedAt: date, fileModifiedAt: date,
localDateTime: date, localDateTime: date,
createdAt: date,
deletedAt: null, deletedAt: null,
duration: 600_000, duration: 600_000,
livePhotoVideoId: null, livePhotoVideoId: null,
@ -73,6 +74,7 @@ describe(SyncRequestType.AlbumAssetsV2, () => {
deletedAt: asset.deletedAt, deletedAt: asset.deletedAt,
fileCreatedAt: asset.fileCreatedAt, fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt, fileModifiedAt: asset.fileModifiedAt,
createdAt: asset.createdAt,
isFavorite: asset.isFavorite, isFavorite: asset.isFavorite,
localDateTime: asset.localDateTime, localDateTime: asset.localDateTime,
type: asset.type, type: asset.type,

View file

@ -34,6 +34,7 @@ describe(SyncEntityType.AssetV2, () => {
fileCreatedAt: date, fileCreatedAt: date,
fileModifiedAt: date, fileModifiedAt: date,
localDateTime: date, localDateTime: date,
createdAt: date,
deletedAt: null, deletedAt: null,
duration: 600_000, duration: 600_000,
libraryId: null, libraryId: null,
@ -54,6 +55,7 @@ describe(SyncEntityType.AssetV2, () => {
deletedAt: asset.deletedAt, deletedAt: asset.deletedAt,
fileCreatedAt: asset.fileCreatedAt, fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt, fileModifiedAt: asset.fileModifiedAt,
createdAt: asset.createdAt,
isFavorite: asset.isFavorite, isFavorite: asset.isFavorite,
localDateTime: asset.localDateTime, localDateTime: asset.localDateTime,
type: asset.type, type: asset.type,

View file

@ -38,6 +38,7 @@ describe(SyncRequestType.PartnerAssetsV2, () => {
fileCreatedAt: date, fileCreatedAt: date,
fileModifiedAt: date, fileModifiedAt: date,
localDateTime: date, localDateTime: date,
createdAt: date,
deletedAt: null, deletedAt: null,
duration: 600_000, duration: 600_000,
libraryId: null, libraryId: null,
@ -58,6 +59,7 @@ describe(SyncRequestType.PartnerAssetsV2, () => {
deletedAt: null, deletedAt: null,
fileCreatedAt: date, fileCreatedAt: date,
fileModifiedAt: date, fileModifiedAt: date,
createdAt: date,
isFavorite: false, isFavorite: false,
localDateTime: date, localDateTime: date,
type: asset.type, type: asset.type,

View file

@ -31,6 +31,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
getTimeBucket: vitest.fn(), getTimeBucket: vitest.fn(),
getTimeBuckets: vitest.fn(), getTimeBuckets: vitest.fn(),
getAssetIdByCity: vitest.fn(), getAssetIdByCity: vitest.fn(),
getRecentlyCreatedAssetIds: vitest.fn(),
upsertFile: vitest.fn(), upsertFile: vitest.fn(),
upsertFiles: vitest.fn(), upsertFiles: vitest.fn(),
deleteFile: vitest.fn(), deleteFile: vitest.fn(),

View file

@ -1,8 +1,8 @@
import { AssetOrder } from '@immich/sdk'; import { AssetOrder, AssetOrderBy } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import type { CommonLayoutOptions } from '$lib/utils/layout-utils'; import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { plainDateTimeCompare } from '$lib/utils/timeline-util'; import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util';
import type { TimelineMonth } from './timeline-month.svelte'; import type { TimelineMonth } from './timeline-month.svelte';
import type { Direction, MoveAsset, TimelineAsset } from './types'; import type { Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte'; import { ViewerAsset } from './viewer-asset.svelte';
@ -12,6 +12,7 @@ export class TimelineDay {
readonly index: number; readonly index: number;
readonly groupTitle: string; readonly groupTitle: string;
readonly day: number; readonly day: number;
readonly orderBy: AssetOrderBy;
viewerAssets: ViewerAsset[] = $state([]); viewerAssets: ViewerAsset[] = $state([]);
height = $state(0); height = $state(0);
@ -24,11 +25,12 @@ export class TimelineDay {
#col = $state(0); #col = $state(0);
#deferredLayout = false; #deferredLayout = false;
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string) { constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) {
this.index = index; this.index = index;
this.timelineMonth = timelineMonth; this.timelineMonth = timelineMonth;
this.day = day; this.day = day;
this.groupTitle = groupTitle; this.groupTitle = groupTitle;
this.orderBy = orderBy;
} }
get top() { get top() {
@ -115,10 +117,10 @@ export class TimelineDay {
continue; continue;
} }
const oldTime = { ...asset.localDateTime }; const oldTime = { ...getOrderingDate(asset, this.orderBy) };
const callbackResult = callback(asset); const callbackResult = callback(asset);
let remove = (callbackResult as { remove?: boolean } | undefined)?.remove ?? false; let remove = (callbackResult as { remove?: boolean } | undefined)?.remove ?? false;
const newTime = asset.localDateTime; const newTime = getOrderingDate(asset, this.orderBy);
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) { if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
const { year, month, day } = newTime; const { year, month, day } = newTime;
remove = true; remove = true;

View file

@ -1,4 +1,4 @@
import { AssetOrder, getAssetInfo, getTimeBuckets, type AssetResponseDto } from '@immich/sdk'; import { AssetOrder, getAssetInfo, getTimeBuckets, AssetOrderBy, type AssetResponseDto } from '@immich/sdk';
import { clamp, isEqual } from 'lodash-es'; import { clamp, isEqual } from 'lodash-es';
import { SvelteDate, SvelteSet } from 'svelte/reactivity'; import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
@ -20,6 +20,7 @@ import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websoc
import { CancellableTask } from '$lib/utils/cancellable-task'; import { CancellableTask } from '$lib/utils/cancellable-task';
import { PersistedLocalStorage } from '$lib/utils/persisted'; import { PersistedLocalStorage } from '$lib/utils/persisted';
import { import {
getOrderingDate,
isAssetResponseDto, isAssetResponseDto,
setDifference, setDifference,
toTimelineAsset, toTimelineAsset,
@ -252,6 +253,7 @@ export class TimelineManager extends VirtualScrollManager {
timeBucket.count, timeBucket.count,
false, false,
this.#options.order, this.#options.order,
this.#options.orderBy,
); );
}); });
this.albumAssets.clear(); this.albumAssets.clear();
@ -393,7 +395,10 @@ export class TimelineManager extends VirtualScrollManager {
return; return;
} }
timelineMonth = await this.#loadTimelineMonthAtTime(timelineAsset.localDateTime, { cancelable: false }); timelineMonth = await this.#loadTimelineMonthAtTime(
getOrderingDate(timelineAsset, this.#options.orderBy || AssetOrderBy.TakenAt),
{ cancelable: false },
);
if (timelineMonth?.findAssetById({ id })) { if (timelineMonth?.findAssetById({ id })) {
return timelineMonth; return timelineMonth;
} }
@ -462,10 +467,11 @@ export class TimelineManager extends VirtualScrollManager {
} }
protected upsertSegmentForAsset(asset: TimelineAsset) { protected upsertSegmentForAsset(asset: TimelineAsset) {
let month = getTimelineMonthByDate(this, asset.localDateTime); const dateTime = getOrderingDate(asset, this.#options.orderBy || AssetOrderBy.TakenAt);
let month = getTimelineMonthByDate(this, dateTime);
if (!month) { if (!month) {
month = new TimelineMonth(this, asset.localDateTime, 1, true, this.#options.order); month = new TimelineMonth(this, dateTime, 1, true, this.#options.order, this.#options.orderBy);
this.months.push(month); this.months.push(month);
} }
return month; return month;

View file

@ -1,4 +1,4 @@
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { AssetOrder, AssetOrderBy, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
@ -15,10 +15,12 @@ import {
fromTimelinePlainDate, fromTimelinePlainDate,
fromTimelinePlainDateTime, fromTimelinePlainDateTime,
fromTimelinePlainYearMonth, fromTimelinePlainYearMonth,
fromISODateTimeUTC,
getTimes, getTimes,
setDifference, setDifference,
type TimelineDateTime, type TimelineDateTime,
type TimelineYearMonth, type TimelineYearMonth,
getOrderingDate,
} from '$lib/utils/timeline-util'; } from '$lib/utils/timeline-util';
import { GroupInsertionCache } from './group-insertion-cache.svelte'; import { GroupInsertionCache } from './group-insertion-cache.svelte';
import { TimelineDay } from './timeline-day.svelte'; import { TimelineDay } from './timeline-day.svelte';
@ -37,6 +39,7 @@ export class TimelineMonth {
#initialCount: number = 0; #initialCount: number = 0;
#sortOrder: AssetOrder = AssetOrder.Desc; #sortOrder: AssetOrder = AssetOrder.Desc;
#orderBy: AssetOrderBy = AssetOrderBy.TakenAt;
percent: number = $state(0); percent: number = $state(0);
assetsCount: number = $derived( assetsCount: number = $derived(
@ -56,10 +59,12 @@ export class TimelineMonth {
initialCount: number, initialCount: number,
loaded: boolean, loaded: boolean,
order: AssetOrder = AssetOrder.Desc, order: AssetOrder = AssetOrder.Desc,
orderBy: AssetOrderBy = AssetOrderBy.TakenAt,
) { ) {
this.timelineManager = timelineManager; this.timelineManager = timelineManager;
this.#initialCount = initialCount; this.#initialCount = initialCount;
this.#sortOrder = order; this.#sortOrder = order;
this.#orderBy = orderBy;
this.yearMonth = { year: yearMonth.year, month: yearMonth.month }; this.yearMonth = { year: yearMonth.year, month: yearMonth.month };
this.title = formatTimelineMonthTitle(fromTimelinePlainYearMonth(yearMonth)); this.title = formatTimelineMonthTitle(fromTimelinePlainYearMonth(yearMonth));
@ -185,6 +190,7 @@ export class TimelineMonth {
isVideo: !bucketAssets.isImage[i], isVideo: !bucketAssets.isImage[i],
livePhotoVideoId: bucketAssets.livePhotoVideoId[i], livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
localDateTime, localDateTime,
createdAt: fromISODateTimeUTC(bucketAssets.createdAt[i]).setZone('local'),
fileCreatedAt, fileCreatedAt,
ownerId: bucketAssets.ownerId[i], ownerId: bucketAssets.ownerId[i],
projectionType: bucketAssets.projectionType[i], projectionType: bucketAssets.projectionType[i],
@ -229,22 +235,22 @@ export class TimelineMonth {
} }
addTimelineAsset(timelineAsset: TimelineAsset, addContext: GroupInsertionCache) { addTimelineAsset(timelineAsset: TimelineAsset, addContext: GroupInsertionCache) {
const { localDateTime } = timelineAsset; const dateTime = getOrderingDate(timelineAsset, this.#orderBy);
const { year, month } = this.yearMonth; const { year, month } = this.yearMonth;
if (month !== localDateTime.month || year !== localDateTime.year) { if (month !== dateTime.month || year !== dateTime.year) {
addContext.unprocessedAssets.push(timelineAsset); addContext.unprocessedAssets.push(timelineAsset);
return; return;
} }
let timelineDay = addContext.getTimelineDay(localDateTime) || this.findTimelineDayByDay(localDateTime.day); let timelineDay = addContext.getTimelineDay(dateTime) || this.findTimelineDayByDay(dateTime.day);
if (timelineDay) { if (timelineDay) {
addContext.setTimelineDay(timelineDay, localDateTime); addContext.setTimelineDay(timelineDay, dateTime);
} else { } else {
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime)); const groupTitle = formatGroupTitle(fromTimelinePlainDate(dateTime));
timelineDay = new TimelineDay(this, this.timelineDays.length, localDateTime.day, groupTitle); timelineDay = new TimelineDay(this, this.timelineDays.length, dateTime.day, groupTitle, this.#orderBy);
this.timelineDays.push(timelineDay); this.timelineDays.push(timelineDay);
addContext.setTimelineDay(timelineDay, localDateTime); addContext.setTimelineDay(timelineDay, dateTime);
addContext.newTimelineDays.add(timelineDay); addContext.newTimelineDays.add(timelineDay);
} }
@ -372,7 +378,7 @@ export class TimelineMonth {
let closest = undefined; let closest = undefined;
let smallestDiff = Infinity; let smallestDiff = Infinity;
for (const current of this.assetsIterator()) { for (const current of this.assetsIterator()) {
const currentAssetDate = fromTimelinePlainDateTime(current.localDateTime); const currentAssetDate = fromTimelinePlainDateTime(getOrderingDate(current, this.#orderBy));
const diff = Math.abs(targetDate.diff(currentAssetDate).as('milliseconds')); const diff = Math.abs(targetDate.diff(currentAssetDate).as('milliseconds'));
if (diff < smallestDiff) { if (diff < smallestDiff) {
smallestDiff = diff; smallestDiff = diff;

View file

@ -22,6 +22,7 @@ export type TimelineAsset = {
ratio: number; ratio: number;
thumbhash: string | null; thumbhash: string | null;
localDateTime: TimelineDateTime; localDateTime: TimelineDateTime;
createdAt: TimelineDateTime;
fileCreatedAt: TimelineDateTime; fileCreatedAt: TimelineDateTime;
visibility: AssetVisibility; visibility: AssetVisibility;
isFavorite: boolean; isFavorite: boolean;

View file

@ -105,6 +105,7 @@ export const Route = {
locked: () => '/locked', locked: () => '/locked',
trash: () => '/trash', trash: () => '/trash',
viewTrashedAsset: ({ id }: { id: string }) => `/trash/photos/${id}`, viewTrashedAsset: ({ id }: { id: string }) => `/trash/photos/${id}`,
recentlyAdded: () => '/recently-added',
// search // search
search: (dto?: MetadataSearchDto | SmartSearchDto) => { search: (dto?: MetadataSearchDto | SmartSearchDto) => {

View file

@ -71,6 +71,15 @@ describe('getAltText', () => {
second: testDate.getUTCSeconds(), second: testDate.getUTCSeconds(),
millisecond: testDate.getUTCMilliseconds(), millisecond: testDate.getUTCMilliseconds(),
}, },
createdAt: {
year: testDate.getUTCFullYear(),
month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based
day: testDate.getUTCDate(),
hour: testDate.getUTCHours(),
minute: testDate.getUTCMinutes(),
second: testDate.getUTCSeconds(),
millisecond: testDate.getUTCMilliseconds(),
},
localDateTime: { localDateTime: {
year: testDate.getUTCFullYear(), year: testDate.getUTCFullYear(),
month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based

View file

@ -1,4 +1,4 @@
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; import { AssetTypeEnum, AssetOrderBy, type AssetResponseDto } from '@immich/sdk';
import { DateTime, type LocaleOptions } from 'luxon'; import { DateTime, type LocaleOptions } from 'luxon';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
@ -166,6 +166,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
const localDateTime = fromISODateTimeUTCToObject(assetResponse.localDateTime); const localDateTime = fromISODateTimeUTCToObject(assetResponse.localDateTime);
const fileCreatedAt = fromISODateTimeToObject(assetResponse.fileCreatedAt, assetResponse.exifInfo?.timeZone ?? 'UTC'); const fileCreatedAt = fromISODateTimeToObject(assetResponse.fileCreatedAt, assetResponse.exifInfo?.timeZone ?? 'UTC');
const createdAt = fromISODateTimeUTCToObject(assetResponse.createdAt);
return { return {
id: assetResponse.id, id: assetResponse.id,
@ -174,6 +175,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
ratio, ratio,
thumbhash: assetResponse.thumbhash, thumbhash: assetResponse.thumbhash,
localDateTime, localDateTime,
createdAt,
fileCreatedAt, fileCreatedAt,
isFavorite: assetResponse.isFavorite, isFavorite: assetResponse.isFavorite,
visibility: assetResponse.visibility, visibility: assetResponse.visibility,
@ -236,3 +238,6 @@ export function setDifference<T>(setA: Set<T>, setB: Set<T>): SvelteSet<T> {
} }
return result; return result;
} }
export const getOrderingDate = (asset: TimelineAsset, order: AssetOrderBy) =>
order === AssetOrderBy.CreatedAt ? asset.createdAt : asset.localDateTime;

View file

@ -11,6 +11,8 @@
import { mdiHeart } from '@mdi/js'; import { mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAltText } from '$lib/utils/thumbnail-util';
interface Props { interface Props {
data: PageData; data: PageData;
@ -24,6 +26,9 @@
}; };
let places = $derived(getFieldItems(data.items, 'exifInfo.city')); let places = $derived(getFieldItems(data.items, 'exifInfo.city'));
let recents = $derived(
getFieldItems(data.items, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
);
let people = $state(data.response.people); let people = $state(data.response.people);
let hasPeople = $derived(data.response.total > 0); let hasPeople = $derived(data.response.total > 0);
@ -107,7 +112,31 @@
</div> </div>
{/if} {/if}
{#if !hasPeople && places.length === 0} {#if recents.length > 0}
<div class="mt-2 mb-6">
<div class="flex justify-between">
<p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('recently_added')}</p>
<a
href={Route.recentlyAdded()}
class="pe-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
draggable="false">{$t('view_all')}</a
>
</div>
<div class="flex h-24 flex-wrap gap-x-1 overflow-hidden md:h-42">
{#each recents as item (item.data.id)}
<a class="relative h-full flex-auto" href={Route.viewAsset({ id: item.data.id })} draggable="false">
<img
src={getAssetMediaUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
alt={$getAltText(toTimelineAsset(item.data))}
class="size-full min-w-max rounded-xl object-cover"
/>
</a>
{/each}
</div>
</div>
{/if}
{#if !hasPeople && places.length === 0 && recents.length === 0}
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mx-auto mt-10" /> <EmptyPlaceholder text={$t('no_explore_results_message')} class="mx-auto mt-10" />
{/if} {/if}
</UserPageLayout> </UserPageLayout>

View file

@ -0,0 +1,165 @@
<script lang="ts">
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.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 LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import StackAction from '$lib/components/timeline/actions/StackAction.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 { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { getAssetBulkActions } from '$lib/services/asset.service';
import {
updateStackedAssetInTimeline,
updateUnstackedAssetInTimeline,
type OnLink,
type OnUnlink,
} from '$lib/utils/actions';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetVisibility, AssetOrderBy } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
let { data }: Props = $props();
let timelineManager = $state<TimelineManager>() as TimelineManager;
const options = {
visibility: AssetVisibility.Timeline,
withStacked: true,
withPartners: true,
orderBy: AssetOrderBy.CreatedAt,
};
let selectedAssets = $derived(assetMultiSelectManager.assets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate =
selectedAssets.length === 2 &&
selectedAssets.some((asset) => asset.isImage) &&
selectedAssets.some((asset) => asset.isVideo);
return assetMultiSelectManager.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
});
const handleEscape = () => {
if (assetViewerManager.isViewing) {
return;
}
if (assetMultiSelectManager.selectionActive) {
assetMultiSelectManager.clear();
return;
}
};
const handleLink: OnLink = ({ still, motion }) => {
timelineManager.removeAssets([motion.id]);
timelineManager.upsertAssets([still]);
};
const handleUnlink: OnUnlink = ({ still, motion }) => {
timelineManager.upsertAssets([motion]);
timelineManager.upsertAssets([still]);
};
const handleSetVisibility = (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
assetMultiSelectManager.clear();
};
</script>
<UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} title={data.meta.title} scrollbar={false}>
<Timeline
enableRouting={true}
bind:timelineManager
{options}
assetInteraction={assetMultiSelectManager}
removeAction={AssetAction.ARCHIVE}
onEscape={handleEscape}
withStacked
>
{#snippet empty()}
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mx-auto mt-10" />
{/snippet}
</Timeline>
</UserPageLayout>
{#if assetMultiSelectManager.selectionActive}
<AssetSelectControlBar>
{@const Actions = getAssetBulkActions($t)}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<CreateSharedLink />
<SelectAllAssets {timelineManager} assetInteraction={assetMultiSelectManager} />
<ActionButton action={Actions.AddToAlbum} />
{#if assetMultiSelectManager.isAllUserOwned}
<FavoriteAction
removeFavorite={assetMultiSelectManager.isAllFavorite}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
/>
{/if}
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem
unlink={assetMultiSelectManager.assets.length === 1}
onLink={handleLink}
onUnlink={handleUnlink}
/>
{/if}
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction
menuItem
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
{#if authManager.preferences.tags.enabled}
<TagAction menuItem />
{/if}
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<hr />
<ActionMenuItem action={Actions.RegenerateThumbnailJob} />
<ActionMenuItem action={Actions.RefreshMetadataJob} />
<ActionMenuItem action={Actions.TranscodeVideoJob} />
</ButtonContextMenu>
{:else}
<DownloadAction />
{/if}
</AssetSelectControlBar>
{/if}

View file

@ -0,0 +1,14 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const $t = await getFormatter();
return {
meta: {
title: $t('recently_added_page_title'),
},
};
}) satisfies PageLoad;

View file

@ -38,6 +38,7 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
tags: [], tags: [],
thumbhash: Sync.each(() => faker.string.alphanumeric(28)), thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
localDateTime: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())), localDateTime: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),
createdAt: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),
fileCreatedAt: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())), fileCreatedAt: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),
isFavorite: Sync.each(() => faker.datatype.boolean()), isFavorite: Sync.each(() => faker.datatype.boolean()),
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
@ -66,6 +67,7 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
livePhotoVideoId: [], livePhotoVideoId: [],
fileCreatedAt: [], fileCreatedAt: [],
localOffsetHours: [], localOffsetHours: [],
createdAt: [],
ownerId: [], ownerId: [],
projectionType: [], projectionType: [],
ratio: [], ratio: [],