mirror of
https://github.com/immich-app/immich.git
synced 2025-05-24 01:12:58 -04:00
feat(server): lighter buckets (#17831)
* feat(web): lighter timeline buckets * GalleryViewer * weird ssr * Remove generics from AssetInteraction * ensure keys on getAssetInfo, alt-text * empty - trigger ci * re-add alt-text * test fix * update tests * tests * missing import * feat(server): lighter buckets * fix: flappy e2e test * lint * revert settings * unneeded cast * fix after merge * Adapt web client to consume new server response format * test * missing import * lint * Use nulls, make-sql * openapi battle * date->string * tests * tests * lint/tests * lint * test * push aggregation to query * openapi * stack as tuple * openapi * update references to description * update alt text tests * update sql * update sql * update timeline tests * linting, fix expected response * string tuple * fix spec * fix * silly generator * rename patch * minimize sorting * review * lint * lint * sql * test * avoid abbreviations * review comment - type safety in test * merge conflicts * lint * lint/abbreviations * remove unncessary code * review comments * sql * re-add package-lock * use booleans, fix visibility in openapi spec, less cursed controller * update sql * no need to use sql template * array access actually doesn't seem to matter * remove redundant code * re-add sql decorator * unused type * remove null assertions * bad merge * Fix test * shave * extra clean shave * use decorator for content type * redundant types * redundant comment * update comment * unnecessary res --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
59f666b115
commit
e7edbcdf04
@ -1,4 +1,4 @@
|
|||||||
import { AssetMediaResponseDto, AssetVisibility, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk';
|
import { AssetMediaResponseDto, AssetVisibility, LoginResponseDto, SharedLinkType } from '@immich/sdk';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { createUserDto } from 'src/fixtures';
|
import { createUserDto } from 'src/fixtures';
|
||||||
import { errorDto } from 'src/responses';
|
import { errorDto } from 'src/responses';
|
||||||
@ -52,7 +52,7 @@ describe('/timeline', () => {
|
|||||||
|
|
||||||
describe('GET /timeline/buckets', () => {
|
describe('GET /timeline/buckets', () => {
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
const { status, body } = await request(app).get('/timeline/buckets').query({ size: TimeBucketSize.Month });
|
const { status, body } = await request(app).get('/timeline/buckets');
|
||||||
expect(status).toBe(401);
|
expect(status).toBe(401);
|
||||||
expect(body).toEqual(errorDto.unauthorized);
|
expect(body).toEqual(errorDto.unauthorized);
|
||||||
});
|
});
|
||||||
@ -60,8 +60,7 @@ describe('/timeline', () => {
|
|||||||
it('should get time buckets by month', async () => {
|
it('should get time buckets by month', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get('/timeline/buckets')
|
.get('/timeline/buckets')
|
||||||
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
|
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
|
||||||
.query({ size: TimeBucketSize.Month });
|
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toEqual(
|
expect(body).toEqual(
|
||||||
@ -78,33 +77,17 @@ describe('/timeline', () => {
|
|||||||
assetIds: userAssets.map(({ id }) => id),
|
assetIds: userAssets.map(({ id }) => id),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app).get('/timeline/buckets').query({ key: sharedLink.key });
|
||||||
.get('/timeline/buckets')
|
|
||||||
.query({ key: sharedLink.key, size: TimeBucketSize.Month });
|
|
||||||
|
|
||||||
expect(status).toBe(400);
|
expect(status).toBe(400);
|
||||||
expect(body).toEqual(errorDto.noPermission);
|
expect(body).toEqual(errorDto.noPermission);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get time buckets by day', async () => {
|
|
||||||
const { status, body } = await request(app)
|
|
||||||
.get('/timeline/buckets')
|
|
||||||
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
|
|
||||||
.query({ size: TimeBucketSize.Day });
|
|
||||||
|
|
||||||
expect(status).toBe(200);
|
|
||||||
expect(body).toEqual([
|
|
||||||
{ count: 2, timeBucket: '1970-02-11T00:00:00.000Z' },
|
|
||||||
{ count: 1, timeBucket: '1970-02-10T00:00:00.000Z' },
|
|
||||||
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return error if time bucket is requested with partners asset and archived', async () => {
|
it('should return error if time bucket is requested with partners asset and archived', async () => {
|
||||||
const req1 = await request(app)
|
const req1 = await request(app)
|
||||||
.get('/timeline/buckets')
|
.get('/timeline/buckets')
|
||||||
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
|
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
|
||||||
.query({ size: TimeBucketSize.Month, withPartners: true, visibility: AssetVisibility.Archive });
|
.query({ withPartners: true, visibility: AssetVisibility.Archive });
|
||||||
|
|
||||||
expect(req1.status).toBe(400);
|
expect(req1.status).toBe(400);
|
||||||
expect(req1.body).toEqual(errorDto.badRequest());
|
expect(req1.body).toEqual(errorDto.badRequest());
|
||||||
@ -112,7 +95,7 @@ describe('/timeline', () => {
|
|||||||
const req2 = await request(app)
|
const req2 = await request(app)
|
||||||
.get('/timeline/buckets')
|
.get('/timeline/buckets')
|
||||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||||
.query({ size: TimeBucketSize.Month, withPartners: true, visibility: undefined });
|
.query({ withPartners: true, visibility: undefined });
|
||||||
|
|
||||||
expect(req2.status).toBe(400);
|
expect(req2.status).toBe(400);
|
||||||
expect(req2.body).toEqual(errorDto.badRequest());
|
expect(req2.body).toEqual(errorDto.badRequest());
|
||||||
@ -122,7 +105,7 @@ describe('/timeline', () => {
|
|||||||
const req1 = await request(app)
|
const req1 = await request(app)
|
||||||
.get('/timeline/buckets')
|
.get('/timeline/buckets')
|
||||||
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
|
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
|
||||||
.query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true });
|
.query({ withPartners: true, isFavorite: true });
|
||||||
|
|
||||||
expect(req1.status).toBe(400);
|
expect(req1.status).toBe(400);
|
||||||
expect(req1.body).toEqual(errorDto.badRequest());
|
expect(req1.body).toEqual(errorDto.badRequest());
|
||||||
@ -130,7 +113,7 @@ describe('/timeline', () => {
|
|||||||
const req2 = await request(app)
|
const req2 = await request(app)
|
||||||
.get('/timeline/buckets')
|
.get('/timeline/buckets')
|
||||||
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
|
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
|
||||||
.query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false });
|
.query({ withPartners: true, isFavorite: false });
|
||||||
|
|
||||||
expect(req2.status).toBe(400);
|
expect(req2.status).toBe(400);
|
||||||
expect(req2.body).toEqual(errorDto.badRequest());
|
expect(req2.body).toEqual(errorDto.badRequest());
|
||||||
@ -140,7 +123,7 @@ describe('/timeline', () => {
|
|||||||
const req = await request(app)
|
const req = await request(app)
|
||||||
.get('/timeline/buckets')
|
.get('/timeline/buckets')
|
||||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||||
.query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true });
|
.query({ withPartners: true, isTrashed: true });
|
||||||
|
|
||||||
expect(req.status).toBe(400);
|
expect(req.status).toBe(400);
|
||||||
expect(req.body).toEqual(errorDto.badRequest());
|
expect(req.body).toEqual(errorDto.badRequest());
|
||||||
@ -150,7 +133,6 @@ describe('/timeline', () => {
|
|||||||
describe('GET /timeline/bucket', () => {
|
describe('GET /timeline/bucket', () => {
|
||||||
it('should require authentication', async () => {
|
it('should require authentication', async () => {
|
||||||
const { status, body } = await request(app).get('/timeline/bucket').query({
|
const { status, body } = await request(app).get('/timeline/bucket').query({
|
||||||
size: TimeBucketSize.Month,
|
|
||||||
timeBucket: '1900-01-01',
|
timeBucket: '1900-01-01',
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -161,11 +143,27 @@ describe('/timeline', () => {
|
|||||||
it('should handle 5 digit years', async () => {
|
it('should handle 5 digit years', async () => {
|
||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get('/timeline/bucket')
|
.get('/timeline/bucket')
|
||||||
.query({ size: TimeBucketSize.Month, timeBucket: '012345-01-01' })
|
.query({ timeBucket: '012345-01-01' })
|
||||||
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
|
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toEqual([]);
|
expect(body).toEqual({
|
||||||
|
city: [],
|
||||||
|
country: [],
|
||||||
|
duration: [],
|
||||||
|
id: [],
|
||||||
|
visibility: [],
|
||||||
|
isFavorite: [],
|
||||||
|
isImage: [],
|
||||||
|
isTrashed: [],
|
||||||
|
livePhotoVideoId: [],
|
||||||
|
localDateTime: [],
|
||||||
|
ownerId: [],
|
||||||
|
projectionType: [],
|
||||||
|
ratio: [],
|
||||||
|
status: [],
|
||||||
|
thumbhash: [],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO enable date string validation while still accepting 5 digit years
|
// TODO enable date string validation while still accepting 5 digit years
|
||||||
@ -173,7 +171,7 @@ describe('/timeline', () => {
|
|||||||
// const { status, body } = await request(app)
|
// const { status, body } = await request(app)
|
||||||
// .get('/timeline/bucket')
|
// .get('/timeline/bucket')
|
||||||
// .set('Authorization', `Bearer ${user.accessToken}`)
|
// .set('Authorization', `Bearer ${user.accessToken}`)
|
||||||
// .query({ size: TimeBucketSize.Month, timeBucket: 'foo' });
|
// .query({ timeBucket: 'foo' });
|
||||||
|
|
||||||
// expect(status).toBe(400);
|
// expect(status).toBe(400);
|
||||||
// expect(body).toEqual(errorDto.badRequest);
|
// expect(body).toEqual(errorDto.badRequest);
|
||||||
@ -183,10 +181,26 @@ describe('/timeline', () => {
|
|||||||
const { status, body } = await request(app)
|
const { status, body } = await request(app)
|
||||||
.get('/timeline/bucket')
|
.get('/timeline/bucket')
|
||||||
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
|
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
|
||||||
.query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10' });
|
.query({ timeBucket: '1970-02-10' });
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
expect(body).toEqual([]);
|
expect(body).toEqual({
|
||||||
|
city: [],
|
||||||
|
country: [],
|
||||||
|
duration: [],
|
||||||
|
id: [],
|
||||||
|
visibility: [],
|
||||||
|
isFavorite: [],
|
||||||
|
isImage: [],
|
||||||
|
isTrashed: [],
|
||||||
|
livePhotoVideoId: [],
|
||||||
|
localDateTime: [],
|
||||||
|
ownerId: [],
|
||||||
|
projectionType: [],
|
||||||
|
ratio: [],
|
||||||
|
status: [],
|
||||||
|
thumbhash: [],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
4
mobile/openapi/README.md
generated
4
mobile/openapi/README.md
generated
@ -494,8 +494,8 @@ Class | Method | HTTP request | Description
|
|||||||
- [TemplateDto](doc//TemplateDto.md)
|
- [TemplateDto](doc//TemplateDto.md)
|
||||||
- [TemplateResponseDto](doc//TemplateResponseDto.md)
|
- [TemplateResponseDto](doc//TemplateResponseDto.md)
|
||||||
- [TestEmailResponseDto](doc//TestEmailResponseDto.md)
|
- [TestEmailResponseDto](doc//TestEmailResponseDto.md)
|
||||||
- [TimeBucketResponseDto](doc//TimeBucketResponseDto.md)
|
- [TimeBucketAssetResponseDto](doc//TimeBucketAssetResponseDto.md)
|
||||||
- [TimeBucketSize](doc//TimeBucketSize.md)
|
- [TimeBucketsResponseDto](doc//TimeBucketsResponseDto.md)
|
||||||
- [ToneMapping](doc//ToneMapping.md)
|
- [ToneMapping](doc//ToneMapping.md)
|
||||||
- [TranscodeHWAccel](doc//TranscodeHWAccel.md)
|
- [TranscodeHWAccel](doc//TranscodeHWAccel.md)
|
||||||
- [TranscodePolicy](doc//TranscodePolicy.md)
|
- [TranscodePolicy](doc//TranscodePolicy.md)
|
||||||
|
4
mobile/openapi/lib/api.dart
generated
4
mobile/openapi/lib/api.dart
generated
@ -289,8 +289,8 @@ part 'model/tags_update.dart';
|
|||||||
part 'model/template_dto.dart';
|
part 'model/template_dto.dart';
|
||||||
part 'model/template_response_dto.dart';
|
part 'model/template_response_dto.dart';
|
||||||
part 'model/test_email_response_dto.dart';
|
part 'model/test_email_response_dto.dart';
|
||||||
part 'model/time_bucket_response_dto.dart';
|
part 'model/time_bucket_asset_response_dto.dart';
|
||||||
part 'model/time_bucket_size.dart';
|
part 'model/time_buckets_response_dto.dart';
|
||||||
part 'model/tone_mapping.dart';
|
part 'model/tone_mapping.dart';
|
||||||
part 'model/transcode_hw_accel.dart';
|
part 'model/transcode_hw_accel.dart';
|
||||||
part 'model/transcode_policy.dart';
|
part 'model/transcode_policy.dart';
|
||||||
|
45
mobile/openapi/lib/api/timeline_api.dart
generated
45
mobile/openapi/lib/api/timeline_api.dart
generated
@ -19,8 +19,6 @@ class TimelineApi {
|
|||||||
/// Performs an HTTP 'GET /timeline/bucket' operation and returns the [Response].
|
/// Performs an HTTP 'GET /timeline/bucket' operation and returns the [Response].
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
/// * [TimeBucketSize] size (required):
|
|
||||||
///
|
|
||||||
/// * [String] timeBucket (required):
|
/// * [String] timeBucket (required):
|
||||||
///
|
///
|
||||||
/// * [String] albumId:
|
/// * [String] albumId:
|
||||||
@ -33,6 +31,10 @@ class TimelineApi {
|
|||||||
///
|
///
|
||||||
/// * [AssetOrder] order:
|
/// * [AssetOrder] order:
|
||||||
///
|
///
|
||||||
|
/// * [num] page:
|
||||||
|
///
|
||||||
|
/// * [num] pageSize:
|
||||||
|
///
|
||||||
/// * [String] personId:
|
/// * [String] personId:
|
||||||
///
|
///
|
||||||
/// * [String] tagId:
|
/// * [String] tagId:
|
||||||
@ -44,7 +46,7 @@ class TimelineApi {
|
|||||||
/// * [bool] withPartners:
|
/// * [bool] withPartners:
|
||||||
///
|
///
|
||||||
/// * [bool] withStacked:
|
/// * [bool] withStacked:
|
||||||
Future<Response> getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
Future<Response> getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, num? page, num? pageSize, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
||||||
// ignore: prefer_const_declarations
|
// ignore: prefer_const_declarations
|
||||||
final apiPath = r'/timeline/bucket';
|
final apiPath = r'/timeline/bucket';
|
||||||
|
|
||||||
@ -70,10 +72,15 @@ class TimelineApi {
|
|||||||
if (order != null) {
|
if (order != null) {
|
||||||
queryParams.addAll(_queryParams('', 'order', order));
|
queryParams.addAll(_queryParams('', 'order', order));
|
||||||
}
|
}
|
||||||
|
if (page != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'page', page));
|
||||||
|
}
|
||||||
|
if (pageSize != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'pageSize', pageSize));
|
||||||
|
}
|
||||||
if (personId != null) {
|
if (personId != null) {
|
||||||
queryParams.addAll(_queryParams('', 'personId', personId));
|
queryParams.addAll(_queryParams('', 'personId', personId));
|
||||||
}
|
}
|
||||||
queryParams.addAll(_queryParams('', 'size', size));
|
|
||||||
if (tagId != null) {
|
if (tagId != null) {
|
||||||
queryParams.addAll(_queryParams('', 'tagId', tagId));
|
queryParams.addAll(_queryParams('', 'tagId', tagId));
|
||||||
}
|
}
|
||||||
@ -107,8 +114,6 @@ class TimelineApi {
|
|||||||
|
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
/// * [TimeBucketSize] size (required):
|
|
||||||
///
|
|
||||||
/// * [String] timeBucket (required):
|
/// * [String] timeBucket (required):
|
||||||
///
|
///
|
||||||
/// * [String] albumId:
|
/// * [String] albumId:
|
||||||
@ -121,6 +126,10 @@ class TimelineApi {
|
|||||||
///
|
///
|
||||||
/// * [AssetOrder] order:
|
/// * [AssetOrder] order:
|
||||||
///
|
///
|
||||||
|
/// * [num] page:
|
||||||
|
///
|
||||||
|
/// * [num] pageSize:
|
||||||
|
///
|
||||||
/// * [String] personId:
|
/// * [String] personId:
|
||||||
///
|
///
|
||||||
/// * [String] tagId:
|
/// * [String] tagId:
|
||||||
@ -132,8 +141,8 @@ class TimelineApi {
|
|||||||
/// * [bool] withPartners:
|
/// * [bool] withPartners:
|
||||||
///
|
///
|
||||||
/// * [bool] withStacked:
|
/// * [bool] withStacked:
|
||||||
Future<List<AssetResponseDto>?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
Future<TimeBucketAssetResponseDto?> getTimeBucket(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, num? page, num? pageSize, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
||||||
final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, );
|
final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, page: page, pageSize: pageSize, personId: personId, tagId: tagId, userId: userId, visibility: visibility, 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));
|
||||||
}
|
}
|
||||||
@ -141,10 +150,7 @@ class TimelineApi {
|
|||||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||||
// FormatException when trying to decode an empty string.
|
// FormatException when trying to decode an empty string.
|
||||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
final responseBody = await _decodeBodyBytes(response);
|
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TimeBucketAssetResponseDto',) as TimeBucketAssetResponseDto;
|
||||||
return (await apiClient.deserializeAsync(responseBody, 'List<AssetResponseDto>') as List)
|
|
||||||
.cast<AssetResponseDto>()
|
|
||||||
.toList(growable: false);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -153,8 +159,6 @@ class TimelineApi {
|
|||||||
/// Performs an HTTP 'GET /timeline/buckets' operation and returns the [Response].
|
/// Performs an HTTP 'GET /timeline/buckets' operation and returns the [Response].
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
/// * [TimeBucketSize] size (required):
|
|
||||||
///
|
|
||||||
/// * [String] albumId:
|
/// * [String] albumId:
|
||||||
///
|
///
|
||||||
/// * [bool] isFavorite:
|
/// * [bool] isFavorite:
|
||||||
@ -176,7 +180,7 @@ class TimelineApi {
|
|||||||
/// * [bool] withPartners:
|
/// * [bool] withPartners:
|
||||||
///
|
///
|
||||||
/// * [bool] withStacked:
|
/// * [bool] withStacked:
|
||||||
Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
Future<Response> getTimeBucketsWithHttpInfo({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
||||||
// ignore: prefer_const_declarations
|
// ignore: prefer_const_declarations
|
||||||
final apiPath = r'/timeline/buckets';
|
final apiPath = r'/timeline/buckets';
|
||||||
|
|
||||||
@ -205,7 +209,6 @@ class TimelineApi {
|
|||||||
if (personId != null) {
|
if (personId != null) {
|
||||||
queryParams.addAll(_queryParams('', 'personId', personId));
|
queryParams.addAll(_queryParams('', 'personId', personId));
|
||||||
}
|
}
|
||||||
queryParams.addAll(_queryParams('', 'size', size));
|
|
||||||
if (tagId != null) {
|
if (tagId != null) {
|
||||||
queryParams.addAll(_queryParams('', 'tagId', tagId));
|
queryParams.addAll(_queryParams('', 'tagId', tagId));
|
||||||
}
|
}
|
||||||
@ -238,8 +241,6 @@ class TimelineApi {
|
|||||||
|
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
/// * [TimeBucketSize] size (required):
|
|
||||||
///
|
|
||||||
/// * [String] albumId:
|
/// * [String] albumId:
|
||||||
///
|
///
|
||||||
/// * [bool] isFavorite:
|
/// * [bool] isFavorite:
|
||||||
@ -261,8 +262,8 @@ class TimelineApi {
|
|||||||
/// * [bool] withPartners:
|
/// * [bool] withPartners:
|
||||||
///
|
///
|
||||||
/// * [bool] withStacked:
|
/// * [bool] withStacked:
|
||||||
Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
Future<List<TimeBucketsResponseDto>?> getTimeBuckets({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async {
|
||||||
final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, );
|
final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, visibility: visibility, 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));
|
||||||
}
|
}
|
||||||
@ -271,8 +272,8 @@ class TimelineApi {
|
|||||||
// FormatException when trying to decode an empty string.
|
// FormatException when trying to decode an empty string.
|
||||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||||
final responseBody = await _decodeBodyBytes(response);
|
final responseBody = await _decodeBodyBytes(response);
|
||||||
return (await apiClient.deserializeAsync(responseBody, 'List<TimeBucketResponseDto>') as List)
|
return (await apiClient.deserializeAsync(responseBody, 'List<TimeBucketsResponseDto>') as List)
|
||||||
.cast<TimeBucketResponseDto>()
|
.cast<TimeBucketsResponseDto>()
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
8
mobile/openapi/lib/api_client.dart
generated
8
mobile/openapi/lib/api_client.dart
generated
@ -634,10 +634,10 @@ class ApiClient {
|
|||||||
return TemplateResponseDto.fromJson(value);
|
return TemplateResponseDto.fromJson(value);
|
||||||
case 'TestEmailResponseDto':
|
case 'TestEmailResponseDto':
|
||||||
return TestEmailResponseDto.fromJson(value);
|
return TestEmailResponseDto.fromJson(value);
|
||||||
case 'TimeBucketResponseDto':
|
case 'TimeBucketAssetResponseDto':
|
||||||
return TimeBucketResponseDto.fromJson(value);
|
return TimeBucketAssetResponseDto.fromJson(value);
|
||||||
case 'TimeBucketSize':
|
case 'TimeBucketsResponseDto':
|
||||||
return TimeBucketSizeTypeTransformer().decode(value);
|
return TimeBucketsResponseDto.fromJson(value);
|
||||||
case 'ToneMapping':
|
case 'ToneMapping':
|
||||||
return ToneMappingTypeTransformer().decode(value);
|
return ToneMappingTypeTransformer().decode(value);
|
||||||
case 'TranscodeHWAccel':
|
case 'TranscodeHWAccel':
|
||||||
|
3
mobile/openapi/lib/api_helper.dart
generated
3
mobile/openapi/lib/api_helper.dart
generated
@ -139,9 +139,6 @@ String parameterToString(dynamic value) {
|
|||||||
if (value is SyncRequestType) {
|
if (value is SyncRequestType) {
|
||||||
return SyncRequestTypeTypeTransformer().encode(value).toString();
|
return SyncRequestTypeTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
if (value is TimeBucketSize) {
|
|
||||||
return TimeBucketSizeTypeTransformer().encode(value).toString();
|
|
||||||
}
|
|
||||||
if (value is ToneMapping) {
|
if (value is ToneMapping) {
|
||||||
return ToneMappingTypeTransformer().encode(value).toString();
|
return ToneMappingTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
|
241
mobile/openapi/lib/model/time_bucket_asset_response_dto.dart
generated
Normal file
241
mobile/openapi/lib/model/time_bucket_asset_response_dto.dart
generated
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
//
|
||||||
|
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||||
|
//
|
||||||
|
// @dart=2.18
|
||||||
|
|
||||||
|
// ignore_for_file: unused_element, unused_import
|
||||||
|
// ignore_for_file: always_put_required_named_parameters_first
|
||||||
|
// ignore_for_file: constant_identifier_names
|
||||||
|
// ignore_for_file: lines_longer_than_80_chars
|
||||||
|
|
||||||
|
part of openapi.api;
|
||||||
|
|
||||||
|
class TimeBucketAssetResponseDto {
|
||||||
|
/// Returns a new [TimeBucketAssetResponseDto] instance.
|
||||||
|
TimeBucketAssetResponseDto({
|
||||||
|
this.city = const [],
|
||||||
|
this.country = const [],
|
||||||
|
this.duration = const [],
|
||||||
|
this.id = const [],
|
||||||
|
this.isFavorite = const [],
|
||||||
|
this.isImage = const [],
|
||||||
|
this.isTrashed = const [],
|
||||||
|
this.livePhotoVideoId = const [],
|
||||||
|
this.localDateTime = const [],
|
||||||
|
this.ownerId = const [],
|
||||||
|
this.projectionType = const [],
|
||||||
|
this.ratio = const [],
|
||||||
|
this.stack = const [],
|
||||||
|
this.thumbhash = const [],
|
||||||
|
this.visibility = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
List<String?> city;
|
||||||
|
|
||||||
|
List<String?> country;
|
||||||
|
|
||||||
|
List<String?> duration;
|
||||||
|
|
||||||
|
List<String> id;
|
||||||
|
|
||||||
|
List<bool> isFavorite;
|
||||||
|
|
||||||
|
List<bool> isImage;
|
||||||
|
|
||||||
|
List<bool> isTrashed;
|
||||||
|
|
||||||
|
List<String?> livePhotoVideoId;
|
||||||
|
|
||||||
|
List<String> localDateTime;
|
||||||
|
|
||||||
|
List<String> ownerId;
|
||||||
|
|
||||||
|
List<String?> projectionType;
|
||||||
|
|
||||||
|
List<num> ratio;
|
||||||
|
|
||||||
|
/// (stack ID, stack asset count) tuple
|
||||||
|
List<List<String>?> stack;
|
||||||
|
|
||||||
|
List<String?> thumbhash;
|
||||||
|
|
||||||
|
List<AssetVisibility> visibility;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => identical(this, other) || other is TimeBucketAssetResponseDto &&
|
||||||
|
_deepEquality.equals(other.city, city) &&
|
||||||
|
_deepEquality.equals(other.country, country) &&
|
||||||
|
_deepEquality.equals(other.duration, duration) &&
|
||||||
|
_deepEquality.equals(other.id, id) &&
|
||||||
|
_deepEquality.equals(other.isFavorite, isFavorite) &&
|
||||||
|
_deepEquality.equals(other.isImage, isImage) &&
|
||||||
|
_deepEquality.equals(other.isTrashed, isTrashed) &&
|
||||||
|
_deepEquality.equals(other.livePhotoVideoId, livePhotoVideoId) &&
|
||||||
|
_deepEquality.equals(other.localDateTime, localDateTime) &&
|
||||||
|
_deepEquality.equals(other.ownerId, ownerId) &&
|
||||||
|
_deepEquality.equals(other.projectionType, projectionType) &&
|
||||||
|
_deepEquality.equals(other.ratio, ratio) &&
|
||||||
|
_deepEquality.equals(other.stack, stack) &&
|
||||||
|
_deepEquality.equals(other.thumbhash, thumbhash) &&
|
||||||
|
_deepEquality.equals(other.visibility, visibility);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
// ignore: unnecessary_parenthesis
|
||||||
|
(city.hashCode) +
|
||||||
|
(country.hashCode) +
|
||||||
|
(duration.hashCode) +
|
||||||
|
(id.hashCode) +
|
||||||
|
(isFavorite.hashCode) +
|
||||||
|
(isImage.hashCode) +
|
||||||
|
(isTrashed.hashCode) +
|
||||||
|
(livePhotoVideoId.hashCode) +
|
||||||
|
(localDateTime.hashCode) +
|
||||||
|
(ownerId.hashCode) +
|
||||||
|
(projectionType.hashCode) +
|
||||||
|
(ratio.hashCode) +
|
||||||
|
(stack.hashCode) +
|
||||||
|
(thumbhash.hashCode) +
|
||||||
|
(visibility.hashCode);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => 'TimeBucketAssetResponseDto[city=$city, country=$country, duration=$duration, id=$id, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash, visibility=$visibility]';
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
json[r'city'] = this.city;
|
||||||
|
json[r'country'] = this.country;
|
||||||
|
json[r'duration'] = this.duration;
|
||||||
|
json[r'id'] = this.id;
|
||||||
|
json[r'isFavorite'] = this.isFavorite;
|
||||||
|
json[r'isImage'] = this.isImage;
|
||||||
|
json[r'isTrashed'] = this.isTrashed;
|
||||||
|
json[r'livePhotoVideoId'] = this.livePhotoVideoId;
|
||||||
|
json[r'localDateTime'] = this.localDateTime;
|
||||||
|
json[r'ownerId'] = this.ownerId;
|
||||||
|
json[r'projectionType'] = this.projectionType;
|
||||||
|
json[r'ratio'] = this.ratio;
|
||||||
|
json[r'stack'] = this.stack;
|
||||||
|
json[r'thumbhash'] = this.thumbhash;
|
||||||
|
json[r'visibility'] = this.visibility;
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [TimeBucketAssetResponseDto] instance and imports its values from
|
||||||
|
/// [value] if it's a [Map], null otherwise.
|
||||||
|
// ignore: prefer_constructors_over_static_methods
|
||||||
|
static TimeBucketAssetResponseDto? fromJson(dynamic value) {
|
||||||
|
upgradeDto(value, "TimeBucketAssetResponseDto");
|
||||||
|
if (value is Map) {
|
||||||
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
|
return TimeBucketAssetResponseDto(
|
||||||
|
city: json[r'city'] is Iterable
|
||||||
|
? (json[r'city'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
country: json[r'country'] is Iterable
|
||||||
|
? (json[r'country'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
duration: json[r'duration'] is Iterable
|
||||||
|
? (json[r'duration'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
id: json[r'id'] is Iterable
|
||||||
|
? (json[r'id'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
isFavorite: json[r'isFavorite'] is Iterable
|
||||||
|
? (json[r'isFavorite'] as Iterable).cast<bool>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
isImage: json[r'isImage'] is Iterable
|
||||||
|
? (json[r'isImage'] as Iterable).cast<bool>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
isTrashed: json[r'isTrashed'] is Iterable
|
||||||
|
? (json[r'isTrashed'] as Iterable).cast<bool>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
livePhotoVideoId: json[r'livePhotoVideoId'] is Iterable
|
||||||
|
? (json[r'livePhotoVideoId'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
localDateTime: json[r'localDateTime'] is Iterable
|
||||||
|
? (json[r'localDateTime'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
ownerId: json[r'ownerId'] is Iterable
|
||||||
|
? (json[r'ownerId'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
projectionType: json[r'projectionType'] is Iterable
|
||||||
|
? (json[r'projectionType'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
ratio: json[r'ratio'] is Iterable
|
||||||
|
? (json[r'ratio'] as Iterable).cast<num>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
stack: json[r'stack'] is List
|
||||||
|
? (json[r'stack'] as List).map((e) =>
|
||||||
|
e == null ? null : (e as List).cast<String>()
|
||||||
|
).toList()
|
||||||
|
: const [],
|
||||||
|
thumbhash: json[r'thumbhash'] is Iterable
|
||||||
|
? (json[r'thumbhash'] as Iterable).cast<String>().toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
visibility: AssetVisibility.listFromJson(json[r'visibility']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<TimeBucketAssetResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final result = <TimeBucketAssetResponseDto>[];
|
||||||
|
if (json is List && json.isNotEmpty) {
|
||||||
|
for (final row in json) {
|
||||||
|
final value = TimeBucketAssetResponseDto.fromJson(row);
|
||||||
|
if (value != null) {
|
||||||
|
result.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.toList(growable: growable);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, TimeBucketAssetResponseDto> mapFromJson(dynamic json) {
|
||||||
|
final map = <String, TimeBucketAssetResponseDto>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
final value = TimeBucketAssetResponseDto.fromJson(entry.value);
|
||||||
|
if (value != null) {
|
||||||
|
map[entry.key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// maps a json object with a list of TimeBucketAssetResponseDto-objects as value to a dart map
|
||||||
|
static Map<String, List<TimeBucketAssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
|
final map = <String, List<TimeBucketAssetResponseDto>>{};
|
||||||
|
if (json is Map && json.isNotEmpty) {
|
||||||
|
// ignore: parameter_assignments
|
||||||
|
json = json.cast<String, dynamic>();
|
||||||
|
for (final entry in json.entries) {
|
||||||
|
map[entry.key] = TimeBucketAssetResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list of required keys that must be present in a JSON.
|
||||||
|
static const requiredKeys = <String>{
|
||||||
|
'city',
|
||||||
|
'country',
|
||||||
|
'duration',
|
||||||
|
'id',
|
||||||
|
'isFavorite',
|
||||||
|
'isImage',
|
||||||
|
'isTrashed',
|
||||||
|
'livePhotoVideoId',
|
||||||
|
'localDateTime',
|
||||||
|
'ownerId',
|
||||||
|
'projectionType',
|
||||||
|
'ratio',
|
||||||
|
'thumbhash',
|
||||||
|
'visibility',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
85
mobile/openapi/lib/model/time_bucket_size.dart
generated
85
mobile/openapi/lib/model/time_bucket_size.dart
generated
@ -1,85 +0,0 @@
|
|||||||
//
|
|
||||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
|
||||||
//
|
|
||||||
// @dart=2.18
|
|
||||||
|
|
||||||
// ignore_for_file: unused_element, unused_import
|
|
||||||
// ignore_for_file: always_put_required_named_parameters_first
|
|
||||||
// ignore_for_file: constant_identifier_names
|
|
||||||
// ignore_for_file: lines_longer_than_80_chars
|
|
||||||
|
|
||||||
part of openapi.api;
|
|
||||||
|
|
||||||
|
|
||||||
class TimeBucketSize {
|
|
||||||
/// Instantiate a new enum with the provided [value].
|
|
||||||
const TimeBucketSize._(this.value);
|
|
||||||
|
|
||||||
/// The underlying value of this enum member.
|
|
||||||
final String value;
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => value;
|
|
||||||
|
|
||||||
String toJson() => value;
|
|
||||||
|
|
||||||
static const DAY = TimeBucketSize._(r'DAY');
|
|
||||||
static const MONTH = TimeBucketSize._(r'MONTH');
|
|
||||||
|
|
||||||
/// List of all possible values in this [enum][TimeBucketSize].
|
|
||||||
static const values = <TimeBucketSize>[
|
|
||||||
DAY,
|
|
||||||
MONTH,
|
|
||||||
];
|
|
||||||
|
|
||||||
static TimeBucketSize? fromJson(dynamic value) => TimeBucketSizeTypeTransformer().decode(value);
|
|
||||||
|
|
||||||
static List<TimeBucketSize> listFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final result = <TimeBucketSize>[];
|
|
||||||
if (json is List && json.isNotEmpty) {
|
|
||||||
for (final row in json) {
|
|
||||||
final value = TimeBucketSize.fromJson(row);
|
|
||||||
if (value != null) {
|
|
||||||
result.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toList(growable: growable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transformation class that can [encode] an instance of [TimeBucketSize] to String,
|
|
||||||
/// and [decode] dynamic data back to [TimeBucketSize].
|
|
||||||
class TimeBucketSizeTypeTransformer {
|
|
||||||
factory TimeBucketSizeTypeTransformer() => _instance ??= const TimeBucketSizeTypeTransformer._();
|
|
||||||
|
|
||||||
const TimeBucketSizeTypeTransformer._();
|
|
||||||
|
|
||||||
String encode(TimeBucketSize data) => data.value;
|
|
||||||
|
|
||||||
/// Decodes a [dynamic value][data] to a TimeBucketSize.
|
|
||||||
///
|
|
||||||
/// 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.
|
|
||||||
TimeBucketSize? decode(dynamic data, {bool allowNull = true}) {
|
|
||||||
if (data != null) {
|
|
||||||
switch (data) {
|
|
||||||
case r'DAY': return TimeBucketSize.DAY;
|
|
||||||
case r'MONTH': return TimeBucketSize.MONTH;
|
|
||||||
default:
|
|
||||||
if (!allowNull) {
|
|
||||||
throw ArgumentError('Unknown enum value to decode: $data');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Singleton [TimeBucketSizeTypeTransformer] instance.
|
|
||||||
static TimeBucketSizeTypeTransformer? _instance;
|
|
||||||
}
|
|
||||||
|
|
@ -10,9 +10,9 @@
|
|||||||
|
|
||||||
part of openapi.api;
|
part of openapi.api;
|
||||||
|
|
||||||
class TimeBucketResponseDto {
|
class TimeBucketsResponseDto {
|
||||||
/// Returns a new [TimeBucketResponseDto] instance.
|
/// Returns a new [TimeBucketsResponseDto] instance.
|
||||||
TimeBucketResponseDto({
|
TimeBucketsResponseDto({
|
||||||
required this.count,
|
required this.count,
|
||||||
required this.timeBucket,
|
required this.timeBucket,
|
||||||
});
|
});
|
||||||
@ -22,7 +22,7 @@ class TimeBucketResponseDto {
|
|||||||
String timeBucket;
|
String timeBucket;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is TimeBucketResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is TimeBucketsResponseDto &&
|
||||||
other.count == count &&
|
other.count == count &&
|
||||||
other.timeBucket == timeBucket;
|
other.timeBucket == timeBucket;
|
||||||
|
|
||||||
@ -33,7 +33,7 @@ class TimeBucketResponseDto {
|
|||||||
(timeBucket.hashCode);
|
(timeBucket.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'TimeBucketResponseDto[count=$count, timeBucket=$timeBucket]';
|
String toString() => 'TimeBucketsResponseDto[count=$count, timeBucket=$timeBucket]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
@ -42,15 +42,15 @@ class TimeBucketResponseDto {
|
|||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns a new [TimeBucketResponseDto] instance and imports its values from
|
/// Returns a new [TimeBucketsResponseDto] instance and imports its values from
|
||||||
/// [value] if it's a [Map], null otherwise.
|
/// [value] if it's a [Map], null otherwise.
|
||||||
// ignore: prefer_constructors_over_static_methods
|
// ignore: prefer_constructors_over_static_methods
|
||||||
static TimeBucketResponseDto? fromJson(dynamic value) {
|
static TimeBucketsResponseDto? fromJson(dynamic value) {
|
||||||
upgradeDto(value, "TimeBucketResponseDto");
|
upgradeDto(value, "TimeBucketsResponseDto");
|
||||||
if (value is Map) {
|
if (value is Map) {
|
||||||
final json = value.cast<String, dynamic>();
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
return TimeBucketResponseDto(
|
return TimeBucketsResponseDto(
|
||||||
count: mapValueOfType<int>(json, r'count')!,
|
count: mapValueOfType<int>(json, r'count')!,
|
||||||
timeBucket: mapValueOfType<String>(json, r'timeBucket')!,
|
timeBucket: mapValueOfType<String>(json, r'timeBucket')!,
|
||||||
);
|
);
|
||||||
@ -58,11 +58,11 @@ class TimeBucketResponseDto {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<TimeBucketResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
static List<TimeBucketsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||||
final result = <TimeBucketResponseDto>[];
|
final result = <TimeBucketsResponseDto>[];
|
||||||
if (json is List && json.isNotEmpty) {
|
if (json is List && json.isNotEmpty) {
|
||||||
for (final row in json) {
|
for (final row in json) {
|
||||||
final value = TimeBucketResponseDto.fromJson(row);
|
final value = TimeBucketsResponseDto.fromJson(row);
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
result.add(value);
|
result.add(value);
|
||||||
}
|
}
|
||||||
@ -71,12 +71,12 @@ class TimeBucketResponseDto {
|
|||||||
return result.toList(growable: growable);
|
return result.toList(growable: growable);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Map<String, TimeBucketResponseDto> mapFromJson(dynamic json) {
|
static Map<String, TimeBucketsResponseDto> mapFromJson(dynamic json) {
|
||||||
final map = <String, TimeBucketResponseDto>{};
|
final map = <String, TimeBucketsResponseDto>{};
|
||||||
if (json is Map && json.isNotEmpty) {
|
if (json is Map && json.isNotEmpty) {
|
||||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||||
for (final entry in json.entries) {
|
for (final entry in json.entries) {
|
||||||
final value = TimeBucketResponseDto.fromJson(entry.value);
|
final value = TimeBucketsResponseDto.fromJson(entry.value);
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
map[entry.key] = value;
|
map[entry.key] = value;
|
||||||
}
|
}
|
||||||
@ -85,14 +85,14 @@ class TimeBucketResponseDto {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
// maps a json object with a list of TimeBucketResponseDto-objects as value to a dart map
|
// maps a json object with a list of TimeBucketsResponseDto-objects as value to a dart map
|
||||||
static Map<String, List<TimeBucketResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
static Map<String, List<TimeBucketsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||||
final map = <String, List<TimeBucketResponseDto>>{};
|
final map = <String, List<TimeBucketsResponseDto>>{};
|
||||||
if (json is Map && json.isNotEmpty) {
|
if (json is Map && json.isNotEmpty) {
|
||||||
// ignore: parameter_assignments
|
// ignore: parameter_assignments
|
||||||
json = json.cast<String, dynamic>();
|
json = json.cast<String, dynamic>();
|
||||||
for (final entry in json.entries) {
|
for (final entry in json.entries) {
|
||||||
map[entry.key] = TimeBucketResponseDto.listFromJson(entry.value, growable: growable,);
|
map[entry.key] = TimeBucketsResponseDto.listFromJson(entry.value, growable: growable,);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
OPENAPI_GENERATOR_VERSION=v7.8.0
|
OPENAPI_GENERATOR_VERSION=v7.12.0
|
||||||
|
|
||||||
# usage: ./bin/generate-open-api.sh
|
# usage: ./bin/generate-open-api.sh
|
||||||
|
|
||||||
@ -8,6 +8,7 @@ function dart {
|
|||||||
cd ./templates/mobile/serialization/native
|
cd ./templates/mobile/serialization/native
|
||||||
wget -O native_class.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache
|
wget -O native_class.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/serialization/native/native_class.mustache
|
||||||
patch --no-backup-if-mismatch -u native_class.mustache <native_class.mustache.patch
|
patch --no-backup-if-mismatch -u native_class.mustache <native_class.mustache.patch
|
||||||
|
patch --no-backup-if-mismatch -u native_class.mustache <native_class_nullable_items_in_arrays.patch
|
||||||
|
|
||||||
cd ../../
|
cd ../../
|
||||||
wget -O api.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/api.mustache
|
wget -O api.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/$OPENAPI_GENERATOR_VERSION/modules/openapi-generator/src/main/resources/dart2/api.mustache
|
||||||
|
@ -7284,6 +7284,24 @@
|
|||||||
"$ref": "#/components/schemas/AssetOrder"
|
"$ref": "#/components/schemas/AssetOrder"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "page",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "pageSize",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"minimum": 1,
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "personId",
|
"name": "personId",
|
||||||
"required": false,
|
"required": false,
|
||||||
@ -7293,14 +7311,6 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "size",
|
|
||||||
"required": true,
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/TimeBucketSize"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "tagId",
|
"name": "tagId",
|
||||||
"required": false,
|
"required": false,
|
||||||
@ -7357,10 +7367,7 @@
|
|||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"items": {
|
"$ref": "#/components/schemas/TimeBucketAssetResponseDto"
|
||||||
"$ref": "#/components/schemas/AssetResponseDto"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -7437,14 +7444,6 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "size",
|
|
||||||
"required": true,
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/TimeBucketSize"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "tagId",
|
"name": "tagId",
|
||||||
"required": false,
|
"required": false,
|
||||||
@ -7494,7 +7493,7 @@
|
|||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/TimeBucketResponseDto"
|
"$ref": "#/components/schemas/TimeBucketsResponseDto"
|
||||||
},
|
},
|
||||||
"type": "array"
|
"type": "array"
|
||||||
}
|
}
|
||||||
@ -14069,7 +14068,131 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"TimeBucketResponseDto": {
|
"TimeBucketAssetResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"city": {
|
||||||
|
"items": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"country": {
|
||||||
|
"items": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"duration": {
|
||||||
|
"items": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"isFavorite": {
|
||||||
|
"items": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"isImage": {
|
||||||
|
"items": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"isTrashed": {
|
||||||
|
"items": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"livePhotoVideoId": {
|
||||||
|
"items": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"localDateTime": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"ownerId": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"projectionType": {
|
||||||
|
"items": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"ratio": {
|
||||||
|
"items": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"stack": {
|
||||||
|
"description": "(stack ID, stack asset count) tuple",
|
||||||
|
"items": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"maxItems": 2,
|
||||||
|
"minItems": 2,
|
||||||
|
"nullable": true,
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"thumbhash": {
|
||||||
|
"items": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"visibility": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AssetVisibility"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"city",
|
||||||
|
"country",
|
||||||
|
"duration",
|
||||||
|
"id",
|
||||||
|
"isFavorite",
|
||||||
|
"isImage",
|
||||||
|
"isTrashed",
|
||||||
|
"livePhotoVideoId",
|
||||||
|
"localDateTime",
|
||||||
|
"ownerId",
|
||||||
|
"projectionType",
|
||||||
|
"ratio",
|
||||||
|
"thumbhash",
|
||||||
|
"visibility"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"TimeBucketsResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"count": {
|
"count": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@ -14084,13 +14207,6 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"TimeBucketSize": {
|
|
||||||
"enum": [
|
|
||||||
"DAY",
|
|
||||||
"MONTH"
|
|
||||||
],
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"ToneMapping": {
|
"ToneMapping": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"hable",
|
"hable",
|
||||||
|
@ -32,7 +32,7 @@ class {{{classname}}} {
|
|||||||
{{/required}}
|
{{/required}}
|
||||||
{{/isNullable}}
|
{{/isNullable}}
|
||||||
{{/isEnum}}
|
{{/isEnum}}
|
||||||
{{{datatypeWithEnum}}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}};
|
{{#isArray}}{{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}<{{{items.dataType}}}{{#items.isNullable}}?{{/items.isNullable}}>{{/isArray}}{{^isArray}}{{{datatypeWithEnum}}}{{/isArray}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}};
|
||||||
|
|
||||||
{{/vars}}
|
{{/vars}}
|
||||||
@override
|
@override
|
||||||
|
@ -0,0 +1,13 @@
|
|||||||
|
diff --git a/open-api/templates/mobile/serialization/native/native_class.mustache b/open-api/templates/mobile/serialization/native/native_class.mustache
|
||||||
|
index 9a7b1439b..9f40d5b0b 100644
|
||||||
|
--- a/open-api/templates/mobile/serialization/native/native_class.mustache
|
||||||
|
+++ b/open-api/templates/mobile/serialization/native/native_class.mustache
|
||||||
|
@@ -32,7 +32,7 @@ class {{{classname}}} {
|
||||||
|
{{/required}}
|
||||||
|
{{/isNullable}}
|
||||||
|
{{/isEnum}}
|
||||||
|
- {{{datatypeWithEnum}}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}};
|
||||||
|
+ {{#isArray}}{{#uniqueItems}}Set{{/uniqueItems}}{{^uniqueItems}}List{{/uniqueItems}}<{{{items.dataType}}}{{#items.isNullable}}?{{/items.isNullable}}>{{/isArray}}{{^isArray}}{{{datatypeWithEnum}}}{{/isArray}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}};
|
||||||
|
|
||||||
|
{{/vars}}
|
||||||
|
@override
|
@ -1420,7 +1420,25 @@ export type TagBulkAssetsResponseDto = {
|
|||||||
export type TagUpdateDto = {
|
export type TagUpdateDto = {
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
};
|
};
|
||||||
export type TimeBucketResponseDto = {
|
export type TimeBucketAssetResponseDto = {
|
||||||
|
city: (string | null)[];
|
||||||
|
country: (string | null)[];
|
||||||
|
duration: (string | null)[];
|
||||||
|
id: string[];
|
||||||
|
isFavorite: boolean[];
|
||||||
|
isImage: boolean[];
|
||||||
|
isTrashed: boolean[];
|
||||||
|
livePhotoVideoId: (string | null)[];
|
||||||
|
localDateTime: string[];
|
||||||
|
ownerId: string[];
|
||||||
|
projectionType: (string | null)[];
|
||||||
|
ratio: number[];
|
||||||
|
/** (stack ID, stack asset count) tuple */
|
||||||
|
stack?: (string[] | null)[];
|
||||||
|
thumbhash: (string | null)[];
|
||||||
|
visibility: AssetVisibility[];
|
||||||
|
};
|
||||||
|
export type TimeBucketsResponseDto = {
|
||||||
count: number;
|
count: number;
|
||||||
timeBucket: string;
|
timeBucket: string;
|
||||||
};
|
};
|
||||||
@ -3367,14 +3385,15 @@ export function tagAssets({ id, bulkIdsDto }: {
|
|||||||
body: bulkIdsDto
|
body: bulkIdsDto
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, visibility, withPartners, withStacked }: {
|
export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, page, pageSize, personId, tagId, timeBucket, userId, visibility, withPartners, withStacked }: {
|
||||||
albumId?: string;
|
albumId?: string;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
isTrashed?: boolean;
|
isTrashed?: boolean;
|
||||||
key?: string;
|
key?: string;
|
||||||
order?: AssetOrder;
|
order?: AssetOrder;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
personId?: string;
|
personId?: string;
|
||||||
size: TimeBucketSize;
|
|
||||||
tagId?: string;
|
tagId?: string;
|
||||||
timeBucket: string;
|
timeBucket: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
@ -3384,15 +3403,16 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
|
|||||||
}, opts?: Oazapfts.RequestOpts) {
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
status: 200;
|
status: 200;
|
||||||
data: AssetResponseDto[];
|
data: TimeBucketAssetResponseDto;
|
||||||
}>(`/timeline/bucket${QS.query(QS.explode({
|
}>(`/timeline/bucket${QS.query(QS.explode({
|
||||||
albumId,
|
albumId,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
isTrashed,
|
isTrashed,
|
||||||
key,
|
key,
|
||||||
order,
|
order,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
personId,
|
personId,
|
||||||
size,
|
|
||||||
tagId,
|
tagId,
|
||||||
timeBucket,
|
timeBucket,
|
||||||
userId,
|
userId,
|
||||||
@ -3403,14 +3423,13 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
|
|||||||
...opts
|
...opts
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, size, tagId, userId, visibility, withPartners, withStacked }: {
|
export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, tagId, userId, visibility, withPartners, withStacked }: {
|
||||||
albumId?: string;
|
albumId?: string;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
isTrashed?: boolean;
|
isTrashed?: boolean;
|
||||||
key?: string;
|
key?: string;
|
||||||
order?: AssetOrder;
|
order?: AssetOrder;
|
||||||
personId?: string;
|
personId?: string;
|
||||||
size: TimeBucketSize;
|
|
||||||
tagId?: string;
|
tagId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
visibility?: AssetVisibility;
|
visibility?: AssetVisibility;
|
||||||
@ -3419,7 +3438,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per
|
|||||||
}, opts?: Oazapfts.RequestOpts) {
|
}, opts?: Oazapfts.RequestOpts) {
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
status: 200;
|
status: 200;
|
||||||
data: TimeBucketResponseDto[];
|
data: TimeBucketsResponseDto[];
|
||||||
}>(`/timeline/buckets${QS.query(QS.explode({
|
}>(`/timeline/buckets${QS.query(QS.explode({
|
||||||
albumId,
|
albumId,
|
||||||
isFavorite,
|
isFavorite,
|
||||||
@ -3427,7 +3446,6 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per
|
|||||||
key,
|
key,
|
||||||
order,
|
order,
|
||||||
personId,
|
personId,
|
||||||
size,
|
|
||||||
tagId,
|
tagId,
|
||||||
userId,
|
userId,
|
||||||
visibility,
|
visibility,
|
||||||
@ -3921,7 +3939,3 @@ export enum OAuthTokenEndpointAuthMethod {
|
|||||||
ClientSecretPost = "client_secret_post",
|
ClientSecretPost = "client_secret_post",
|
||||||
ClientSecretBasic = "client_secret_basic"
|
ClientSecretBasic = "client_secret_basic"
|
||||||
}
|
}
|
||||||
export enum TimeBucketSize {
|
|
||||||
Day = "DAY",
|
|
||||||
Month = "MONTH"
|
|
||||||
}
|
|
||||||
|
@ -72,7 +72,9 @@ class SqlGenerator {
|
|||||||
await rm(this.options.targetDir, { force: true, recursive: true });
|
await rm(this.options.targetDir, { force: true, recursive: true });
|
||||||
await mkdir(this.options.targetDir);
|
await mkdir(this.options.targetDir);
|
||||||
|
|
||||||
|
if (!process.env.DB_HOSTNAME) {
|
||||||
process.env.DB_HOSTNAME = 'localhost';
|
process.env.DB_HOSTNAME = 'localhost';
|
||||||
|
}
|
||||||
const { database, cls, otel } = new ConfigRepository().getEnv();
|
const { database, cls, otel } = new ConfigRepository().getEnv();
|
||||||
|
|
||||||
const moduleFixture = await Test.createTestingModule({
|
const moduleFixture = await Test.createTestingModule({
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { Controller, Get, Query } from '@nestjs/common';
|
import { Controller, Get, Header, Query } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
|
import { TimeBucketAssetDto, TimeBucketAssetResponseDto, TimeBucketDto } from 'src/dtos/time-bucket.dto';
|
||||||
import { Permission } from 'src/enum';
|
import { Permission } from 'src/enum';
|
||||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||||
import { TimelineService } from 'src/services/timeline.service';
|
import { TimelineService } from 'src/services/timeline.service';
|
||||||
@ -14,13 +13,15 @@ export class TimelineController {
|
|||||||
|
|
||||||
@Get('buckets')
|
@Get('buckets')
|
||||||
@Authenticated({ permission: Permission.ASSET_READ, sharedLink: true })
|
@Authenticated({ permission: Permission.ASSET_READ, sharedLink: true })
|
||||||
getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
|
getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto) {
|
||||||
return this.service.getTimeBuckets(auth, dto);
|
return this.service.getTimeBuckets(auth, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('bucket')
|
@Get('bucket')
|
||||||
@Authenticated({ permission: Permission.ASSET_READ, sharedLink: true })
|
@Authenticated({ permission: Permission.ASSET_READ, sharedLink: true })
|
||||||
getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
|
@ApiOkResponse({ type: TimeBucketAssetResponseDto })
|
||||||
return this.service.getTimeBucket(auth, dto) as Promise<AssetResponseDto[]>;
|
@Header('Content-Type', 'application/json')
|
||||||
|
getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto) {
|
||||||
|
return this.service.getTimeBucket(auth, dto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ import {
|
|||||||
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
|
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
|
||||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||||
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||||
|
import { hexOrBufferToBase64 } from 'src/utils/bytes';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
|
|
||||||
export class SanitizedAssetResponseDto {
|
export class SanitizedAssetResponseDto {
|
||||||
@ -140,15 +141,6 @@ const mapStack = (entity: { stack?: Stack | null }) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings
|
|
||||||
export const hexOrBufferToBase64 = (encoded: string | Buffer) => {
|
|
||||||
if (typeof encoded === 'string') {
|
|
||||||
return Buffer.from(encoded.slice(2), 'hex').toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
return encoded.toString('base64');
|
|
||||||
};
|
|
||||||
|
|
||||||
export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto {
|
export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto {
|
||||||
const { stripMetadata = false, withStack = false } = options;
|
const { stripMetadata = false, withStack = false } = options;
|
||||||
|
|
||||||
@ -192,7 +184,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
|||||||
tags: entity.tags?.map((tag) => mapTag(tag)),
|
tags: entity.tags?.map((tag) => mapTag(tag)),
|
||||||
people: peopleWithFaces(entity.faces),
|
people: peopleWithFaces(entity.faces),
|
||||||
unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
|
unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
|
||||||
checksum: hexOrBufferToBase64(entity.checksum),
|
checksum: hexOrBufferToBase64(entity.checksum)!,
|
||||||
stack: withStack ? mapStack(entity) : undefined,
|
stack: withStack ? mapStack(entity) : undefined,
|
||||||
isOffline: entity.isOffline,
|
isOffline: entity.isOffline,
|
||||||
hasMetadata: true,
|
hasMetadata: true,
|
||||||
|
@ -1,15 +1,10 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
|
||||||
|
import { IsEnum, IsInt, IsString, Min } from 'class-validator';
|
||||||
import { AssetOrder, AssetVisibility } from 'src/enum';
|
import { AssetOrder, AssetVisibility } from 'src/enum';
|
||||||
import { TimeBucketSize } from 'src/repositories/asset.repository';
|
|
||||||
import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation';
|
import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
export class TimeBucketDto {
|
export class TimeBucketDto {
|
||||||
@IsNotEmpty()
|
|
||||||
@IsEnum(TimeBucketSize)
|
|
||||||
@ApiProperty({ enum: TimeBucketSize, enumName: 'TimeBucketSize' })
|
|
||||||
size!: TimeBucketSize;
|
|
||||||
|
|
||||||
@ValidateUUID({ optional: true })
|
@ValidateUUID({ optional: true })
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|
||||||
@ -46,9 +41,75 @@ export class TimeBucketDto {
|
|||||||
export class TimeBucketAssetDto extends TimeBucketDto {
|
export class TimeBucketAssetDto extends TimeBucketDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
timeBucket!: string;
|
timeBucket!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Optional()
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Optional()
|
||||||
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TimeBucketResponseDto {
|
export class TimelineStackResponseDto {
|
||||||
|
id!: string;
|
||||||
|
primaryAssetId!: string;
|
||||||
|
assetCount!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimeBucketAssetResponseDto {
|
||||||
|
id!: string[];
|
||||||
|
|
||||||
|
ownerId!: string[];
|
||||||
|
|
||||||
|
ratio!: number[];
|
||||||
|
|
||||||
|
isFavorite!: boolean[];
|
||||||
|
|
||||||
|
@ApiProperty({ enum: AssetVisibility, enumName: 'AssetVisibility', isArray: true })
|
||||||
|
visibility!: AssetVisibility[];
|
||||||
|
|
||||||
|
isTrashed!: boolean[];
|
||||||
|
|
||||||
|
isImage!: boolean[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
||||||
|
thumbhash!: (string | null)[];
|
||||||
|
|
||||||
|
localDateTime!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
||||||
|
duration!: (string | null)[];
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
minItems: 2,
|
||||||
|
maxItems: 2,
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
|
description: '(stack ID, stack asset count) tuple',
|
||||||
|
})
|
||||||
|
stack?: ([string, string] | null)[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
||||||
|
projectionType!: (string | null)[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
||||||
|
livePhotoVideoId!: (string | null)[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
||||||
|
city!: (string | null)[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: 'array', items: { type: 'string', nullable: true } })
|
||||||
|
country!: (string | null)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TimeBucketsResponseDto {
|
||||||
@ApiProperty({ type: 'string' })
|
@ApiProperty({ type: 'string' })
|
||||||
timeBucket!: string;
|
timeBucket!: string;
|
||||||
|
|
||||||
|
@ -235,14 +235,14 @@ limit
|
|||||||
with
|
with
|
||||||
"assets" as (
|
"assets" as (
|
||||||
select
|
select
|
||||||
date_trunc($1, "localDateTime" at time zone 'UTC') at time zone 'UTC' as "timeBucket"
|
date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC' as "timeBucket"
|
||||||
from
|
from
|
||||||
"assets"
|
"assets"
|
||||||
where
|
where
|
||||||
"assets"."deletedAt" is null
|
"assets"."deletedAt" is null
|
||||||
and (
|
and (
|
||||||
"assets"."visibility" = $2
|
"assets"."visibility" = $1
|
||||||
or "assets"."visibility" = $3
|
or "assets"."visibility" = $2
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
select
|
select
|
||||||
@ -256,40 +256,101 @@ order by
|
|||||||
"timeBucket" desc
|
"timeBucket" desc
|
||||||
|
|
||||||
-- AssetRepository.getTimeBucket
|
-- AssetRepository.getTimeBucket
|
||||||
|
with
|
||||||
|
"cte" as (
|
||||||
select
|
select
|
||||||
"assets".*,
|
"assets"."duration",
|
||||||
to_json("exif") as "exifInfo",
|
"assets"."id",
|
||||||
to_json("stacked_assets") as "stack"
|
"assets"."visibility",
|
||||||
|
"assets"."isFavorite",
|
||||||
|
assets.type = 'IMAGE' as "isImage",
|
||||||
|
assets."deletedAt" is null as "isTrashed",
|
||||||
|
"assets"."livePhotoVideoId",
|
||||||
|
"assets"."localDateTime",
|
||||||
|
"assets"."ownerId",
|
||||||
|
"assets"."status",
|
||||||
|
encode("assets"."thumbhash", 'base64') as "thumbhash",
|
||||||
|
"exif"."city",
|
||||||
|
"exif"."country",
|
||||||
|
"exif"."projectionType",
|
||||||
|
coalesce(
|
||||||
|
case
|
||||||
|
when exif."exifImageHeight" = 0
|
||||||
|
or exif."exifImageWidth" = 0 then 1
|
||||||
|
when "exif"."orientation" in ('5', '6', '7', '8', '-90', '90') then round(
|
||||||
|
exif."exifImageHeight"::numeric / exif."exifImageWidth"::numeric,
|
||||||
|
3
|
||||||
|
)
|
||||||
|
else round(
|
||||||
|
exif."exifImageWidth"::numeric / exif."exifImageHeight"::numeric,
|
||||||
|
3
|
||||||
|
)
|
||||||
|
end,
|
||||||
|
1
|
||||||
|
) as "ratio",
|
||||||
|
"stack"
|
||||||
from
|
from
|
||||||
"assets"
|
"assets"
|
||||||
left join "exif" on "assets"."id" = "exif"."assetId"
|
inner join "exif" on "assets"."id" = "exif"."assetId"
|
||||||
left join "asset_stack" on "asset_stack"."id" = "assets"."stackId"
|
|
||||||
left join lateral (
|
left join lateral (
|
||||||
select
|
select
|
||||||
"asset_stack".*,
|
array[stacked."stackId"::text, count('stacked')::text] as "stack"
|
||||||
count("stacked") as "assetCount"
|
|
||||||
from
|
from
|
||||||
"assets" as "stacked"
|
"assets" as "stacked"
|
||||||
where
|
where
|
||||||
"stacked"."stackId" = "asset_stack"."id"
|
"stacked"."stackId" = "assets"."stackId"
|
||||||
and "stacked"."deletedAt" is null
|
and "stacked"."deletedAt" is null
|
||||||
and "stacked"."visibility" != $1
|
and "stacked"."visibility" != $1
|
||||||
group by
|
group by
|
||||||
"asset_stack"."id"
|
"stacked"."stackId"
|
||||||
) as "stacked_assets" on "asset_stack"."id" is not null
|
) as "stacked_assets" on true
|
||||||
where
|
where
|
||||||
(
|
"assets"."deletedAt" is null
|
||||||
"asset_stack"."primaryAssetId" = "assets"."id"
|
|
||||||
or "assets"."stackId" is null
|
|
||||||
)
|
|
||||||
and "assets"."deletedAt" is null
|
|
||||||
and (
|
and (
|
||||||
"assets"."visibility" = $2
|
"assets"."visibility" = $2
|
||||||
or "assets"."visibility" = $3
|
or "assets"."visibility" = $3
|
||||||
)
|
)
|
||||||
and date_trunc($4, "localDateTime" at time zone 'UTC') at time zone 'UTC' = $5
|
and date_trunc('MONTH', "localDateTime" at time zone 'UTC') at time zone 'UTC' = $4
|
||||||
|
and (
|
||||||
|
"assets"."visibility" = $5
|
||||||
|
or "assets"."visibility" = $6
|
||||||
|
)
|
||||||
|
and not exists (
|
||||||
|
select
|
||||||
|
from
|
||||||
|
"asset_stack"
|
||||||
|
where
|
||||||
|
"asset_stack"."id" = "assets"."stackId"
|
||||||
|
and "asset_stack"."primaryAssetId" != "assets"."id"
|
||||||
|
)
|
||||||
order by
|
order by
|
||||||
"assets"."localDateTime" desc
|
"assets"."localDateTime" desc
|
||||||
|
),
|
||||||
|
"agg" as (
|
||||||
|
select
|
||||||
|
coalesce(array_agg("city"), '{}') as "city",
|
||||||
|
coalesce(array_agg("country"), '{}') as "country",
|
||||||
|
coalesce(array_agg("duration"), '{}') as "duration",
|
||||||
|
coalesce(array_agg("id"), '{}') as "id",
|
||||||
|
coalesce(array_agg("visibility"), '{}') as "visibility",
|
||||||
|
coalesce(array_agg("isFavorite"), '{}') as "isFavorite",
|
||||||
|
coalesce(array_agg("isImage"), '{}') as "isImage",
|
||||||
|
coalesce(array_agg("isTrashed"), '{}') as "isTrashed",
|
||||||
|
coalesce(array_agg("livePhotoVideoId"), '{}') as "livePhotoVideoId",
|
||||||
|
coalesce(array_agg("localDateTime"), '{}') as "localDateTime",
|
||||||
|
coalesce(array_agg("ownerId"), '{}') as "ownerId",
|
||||||
|
coalesce(array_agg("projectionType"), '{}') as "projectionType",
|
||||||
|
coalesce(array_agg("ratio"), '{}') as "ratio",
|
||||||
|
coalesce(array_agg("status"), '{}') as "status",
|
||||||
|
coalesce(array_agg("thumbhash"), '{}') as "thumbhash",
|
||||||
|
coalesce(json_agg("stack"), '[]') as "stack"
|
||||||
|
from
|
||||||
|
"cte"
|
||||||
|
)
|
||||||
|
select
|
||||||
|
to_json(agg)::text as "assets"
|
||||||
|
from
|
||||||
|
"agg"
|
||||||
|
|
||||||
-- AssetRepository.getDuplicates
|
-- AssetRepository.getDuplicates
|
||||||
with
|
with
|
||||||
|
@ -68,7 +68,6 @@ export interface AssetBuilderOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TimeBucketOptions extends AssetBuilderOptions {
|
export interface TimeBucketOptions extends AssetBuilderOptions {
|
||||||
size: TimeBucketSize;
|
|
||||||
order?: AssetOrder;
|
order?: AssetOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -539,7 +538,7 @@ export class AssetRepository {
|
|||||||
.with('assets', (qb) =>
|
.with('assets', (qb) =>
|
||||||
qb
|
qb
|
||||||
.selectFrom('assets')
|
.selectFrom('assets')
|
||||||
.select(truncatedDate<Date>(options.size).as('timeBucket'))
|
.select(truncatedDate<Date>(TimeBucketSize.MONTH).as('timeBucket'))
|
||||||
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
|
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
|
||||||
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
|
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
|
||||||
.$if(options.visibility === undefined, withDefaultVisibility)
|
.$if(options.visibility === undefined, withDefaultVisibility)
|
||||||
@ -581,40 +580,88 @@ export class AssetRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }] })
|
@GenerateSql({
|
||||||
async getTimeBucket(timeBucket: string, options: TimeBucketOptions) {
|
params: [DummyValue.TIME_BUCKET, { withStacked: true }],
|
||||||
return this.db
|
})
|
||||||
.selectFrom('assets')
|
getTimeBucket(timeBucket: string, options: TimeBucketOptions) {
|
||||||
.selectAll('assets')
|
const query = this.db
|
||||||
.$call(withExif)
|
.with('cte', (qb) =>
|
||||||
.$if(!!options.albumId, (qb) =>
|
|
||||||
qb
|
qb
|
||||||
.innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
|
.selectFrom('assets')
|
||||||
.where('albums_assets_assets.albumsId', '=', options.albumId!),
|
.innerJoin('exif', 'assets.id', 'exif.assetId')
|
||||||
|
.select((eb) => [
|
||||||
|
'assets.duration',
|
||||||
|
'assets.id',
|
||||||
|
'assets.visibility',
|
||||||
|
'assets.isFavorite',
|
||||||
|
sql`assets.type = 'IMAGE'`.as('isImage'),
|
||||||
|
sql`assets."deletedAt" is null`.as('isTrashed'),
|
||||||
|
'assets.livePhotoVideoId',
|
||||||
|
'assets.localDateTime',
|
||||||
|
'assets.ownerId',
|
||||||
|
'assets.status',
|
||||||
|
eb.fn('encode', ['assets.thumbhash', sql.lit('base64')]).as('thumbhash'),
|
||||||
|
'exif.city',
|
||||||
|
'exif.country',
|
||||||
|
'exif.projectionType',
|
||||||
|
eb.fn
|
||||||
|
.coalesce(
|
||||||
|
eb
|
||||||
|
.case()
|
||||||
|
.when(sql`exif."exifImageHeight" = 0 or exif."exifImageWidth" = 0`)
|
||||||
|
.then(eb.lit(1))
|
||||||
|
.when('exif.orientation', 'in', sql<string>`('5', '6', '7', '8', '-90', '90')`)
|
||||||
|
.then(sql`round(exif."exifImageHeight"::numeric / exif."exifImageWidth"::numeric, 3)`)
|
||||||
|
.else(sql`round(exif."exifImageWidth"::numeric / exif."exifImageHeight"::numeric, 3)`)
|
||||||
|
.end(),
|
||||||
|
eb.lit(1),
|
||||||
|
)
|
||||||
|
.as('ratio'),
|
||||||
|
])
|
||||||
|
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
|
||||||
|
.$if(options.visibility == undefined, withDefaultVisibility)
|
||||||
|
.$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
|
||||||
|
.where(truncatedDate(TimeBucketSize.MONTH), '=', timeBucket.replace(/^[+-]/, ''))
|
||||||
|
.$if(!!options.albumId, (qb) =>
|
||||||
|
qb.where((eb) =>
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom('albums_assets_assets')
|
||||||
|
.whereRef('albums_assets_assets.assetsId', '=', 'assets.id')
|
||||||
|
.where('albums_assets_assets.albumsId', '=', asUuid(options.albumId!)),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
|
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
|
||||||
.$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
|
.$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
|
||||||
|
.$if(options.visibility == undefined, withDefaultVisibility)
|
||||||
|
.$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
|
||||||
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
|
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
|
||||||
.$if(!!options.withStacked, (qb) =>
|
.$if(!!options.withStacked, (qb) =>
|
||||||
qb
|
qb
|
||||||
.leftJoin('asset_stack', 'asset_stack.id', 'assets.stackId')
|
|
||||||
.where((eb) =>
|
.where((eb) =>
|
||||||
eb.or([eb('asset_stack.primaryAssetId', '=', eb.ref('assets.id')), eb('assets.stackId', 'is', null)]),
|
eb.not(
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom('asset_stack')
|
||||||
|
.whereRef('asset_stack.id', '=', 'assets.stackId')
|
||||||
|
.whereRef('asset_stack.primaryAssetId', '!=', 'assets.id'),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.leftJoinLateral(
|
.leftJoinLateral(
|
||||||
(eb) =>
|
(eb) =>
|
||||||
eb
|
eb
|
||||||
.selectFrom('assets as stacked')
|
.selectFrom('assets as stacked')
|
||||||
.selectAll('asset_stack')
|
.select(sql`array[stacked."stackId"::text, count('stacked')::text]`.as('stack'))
|
||||||
.select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount'))
|
.whereRef('stacked.stackId', '=', 'assets.stackId')
|
||||||
.whereRef('stacked.stackId', '=', 'asset_stack.id')
|
|
||||||
.where('stacked.deletedAt', 'is', null)
|
.where('stacked.deletedAt', 'is', null)
|
||||||
.where('stacked.visibility', '!=', AssetVisibility.ARCHIVE)
|
.where('stacked.visibility', '!=', AssetVisibility.ARCHIVE)
|
||||||
.groupBy('asset_stack.id')
|
.groupBy('stacked.stackId')
|
||||||
.as('stacked_assets'),
|
.as('stacked_assets'),
|
||||||
(join) => join.on('asset_stack.id', 'is not', null),
|
(join) => join.onTrue(),
|
||||||
)
|
)
|
||||||
.select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack')),
|
.select('stack'),
|
||||||
)
|
)
|
||||||
.$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!))
|
.$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!))
|
||||||
.$if(options.isDuplicate !== undefined, (qb) =>
|
.$if(options.isDuplicate !== undefined, (qb) =>
|
||||||
@ -622,12 +669,37 @@ export class AssetRepository {
|
|||||||
)
|
)
|
||||||
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
|
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
|
||||||
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
|
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
|
||||||
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
|
.orderBy('assets.localDateTime', options.order ?? 'desc'),
|
||||||
.$if(options.visibility == undefined, withDefaultVisibility)
|
)
|
||||||
.$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
|
.with('agg', (qb) =>
|
||||||
.where(truncatedDate(options.size), '=', timeBucket.replace(/^[+-]/, ''))
|
qb
|
||||||
.orderBy('assets.localDateTime', options.order ?? 'desc')
|
.selectFrom('cte')
|
||||||
.execute();
|
.select((eb) => [
|
||||||
|
eb.fn.coalesce(eb.fn('array_agg', ['city']), sql.lit('{}')).as('city'),
|
||||||
|
eb.fn.coalesce(eb.fn('array_agg', ['country']), sql.lit('{}')).as('country'),
|
||||||
|
eb.fn.coalesce(eb.fn('array_agg', ['duration']), sql.lit('{}')).as('duration'),
|
||||||
|
eb.fn.coalesce(eb.fn('array_agg', ['id']), sql.lit('{}')).as('id'),
|
||||||
|
eb.fn.coalesce(eb.fn('array_agg', ['visibility']), sql.lit('{}')).as('visibility'),
|
||||||
|
eb.fn.coalesce(eb.fn('array_agg', ['isFavorite']), sql.lit('{}')).as('isFavorite'),
|
||||||
|
eb.fn.coalesce(eb.fn('array_agg', ['isImage']), sql.lit('{}')).as('isImage'),
|
||||||
|
// TODO: isTrashed is redundant as it will always be all true or false depending on the options
|
||||||
|
eb.fn.coalesce(eb.fn('array_agg', ['isTrashed']), sql.lit('{}')).as('isTrashed'),
|
||||||
|
eb.fn.coalesce(eb.fn('array_agg', ['livePhotoVideoId']), sql.lit('{}')).as('livePhotoVideoId'),
|
||||||
|
eb.fn.coalesce(eb.fn('array_agg', ['localDateTime']), sql.lit('{}')).as('localDateTime'),
|
||||||
|
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', ['ratio']), sql.lit('{}')).as('ratio'),
|
||||||
|
eb.fn.coalesce(eb.fn('array_agg', ['status']), sql.lit('{}')).as('status'),
|
||||||
|
eb.fn.coalesce(eb.fn('array_agg', ['thumbhash']), sql.lit('{}')).as('thumbhash'),
|
||||||
|
])
|
||||||
|
.$if(!!options.withStacked, (qb) =>
|
||||||
|
qb.select((eb) => eb.fn.coalesce(eb.fn('json_agg', ['stack']), sql.lit('[]')).as('stack')),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.selectFrom('agg')
|
||||||
|
.select(sql<string>`to_json(agg)::text`.as('assets'));
|
||||||
|
|
||||||
|
return query.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
|
@ -4,7 +4,7 @@ import { DateTime } from 'luxon';
|
|||||||
import { Writable } from 'node:stream';
|
import { Writable } from 'node:stream';
|
||||||
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
|
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
|
||||||
import { SessionSyncCheckpoints } from 'src/db';
|
import { SessionSyncCheckpoints } from 'src/db';
|
||||||
import { AssetResponseDto, hexOrBufferToBase64, mapAsset } from 'src/dtos/asset-response.dto';
|
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import {
|
import {
|
||||||
AssetDeltaSyncDto,
|
AssetDeltaSyncDto,
|
||||||
@ -18,6 +18,7 @@ import { AssetVisibility, DatabaseAction, EntityType, Permission, SyncEntityType
|
|||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { SyncAck } from 'src/types';
|
import { SyncAck } from 'src/types';
|
||||||
import { getMyPartnerIds } from 'src/utils/asset.util';
|
import { getMyPartnerIds } from 'src/utils/asset.util';
|
||||||
|
import { hexOrBufferToBase64 } from 'src/utils/bytes';
|
||||||
import { setIsEqual } from 'src/utils/set';
|
import { setIsEqual } from 'src/utils/set';
|
||||||
import { fromAck, serialize } from 'src/utils/sync';
|
import { fromAck, serialize } from 'src/utils/sync';
|
||||||
|
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { AssetVisibility } from 'src/enum';
|
import { AssetVisibility } from 'src/enum';
|
||||||
import { TimeBucketSize } from 'src/repositories/asset.repository';
|
|
||||||
import { TimelineService } from 'src/services/timeline.service';
|
import { TimelineService } from 'src/services/timeline.service';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { factory } from 'test/small.factory';
|
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
describe(TimelineService.name, () => {
|
describe(TimelineService.name, () => {
|
||||||
@ -19,13 +16,10 @@ describe(TimelineService.name, () => {
|
|||||||
it("should return buckets if userId and albumId aren't set", async () => {
|
it("should return buckets if userId and albumId aren't set", async () => {
|
||||||
mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]);
|
mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]);
|
||||||
|
|
||||||
await expect(
|
await expect(sut.getTimeBuckets(authStub.admin, {})).resolves.toEqual(
|
||||||
sut.getTimeBuckets(authStub.admin, {
|
expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]),
|
||||||
size: TimeBucketSize.DAY,
|
);
|
||||||
}),
|
|
||||||
).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]));
|
|
||||||
expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({
|
expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
userIds: [authStub.admin.user.id],
|
userIds: [authStub.admin.user.id],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -34,35 +28,34 @@ describe(TimelineService.name, () => {
|
|||||||
describe('getTimeBucket', () => {
|
describe('getTimeBucket', () => {
|
||||||
it('should return the assets for a album time bucket if user has album.read', async () => {
|
it('should return the assets for a album time bucket if user has album.read', async () => {
|
||||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
||||||
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
|
const json = `[{ id: ['asset-id'] }]`;
|
||||||
|
mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
|
||||||
|
|
||||||
await expect(
|
await expect(sut.getTimeBucket(authStub.admin, { timeBucket: 'bucket', albumId: 'album-id' })).resolves.toEqual(
|
||||||
sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }),
|
json,
|
||||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
);
|
||||||
|
|
||||||
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id']));
|
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id']));
|
||||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
timeBucket: 'bucket',
|
timeBucket: 'bucket',
|
||||||
albumId: 'album-id',
|
albumId: 'album-id',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the assets for a archive time bucket if user has archive.read', async () => {
|
it('should return the assets for a archive time bucket if user has archive.read', async () => {
|
||||||
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
|
const json = `[{ id: ['asset-id'] }]`;
|
||||||
|
mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.getTimeBucket(authStub.admin, {
|
sut.getTimeBucket(authStub.admin, {
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
timeBucket: 'bucket',
|
timeBucket: 'bucket',
|
||||||
visibility: AssetVisibility.ARCHIVE,
|
visibility: AssetVisibility.ARCHIVE,
|
||||||
userId: authStub.admin.user.id,
|
userId: authStub.admin.user.id,
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
).resolves.toEqual(json);
|
||||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith(
|
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith(
|
||||||
'bucket',
|
'bucket',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
timeBucket: 'bucket',
|
timeBucket: 'bucket',
|
||||||
visibility: AssetVisibility.ARCHIVE,
|
visibility: AssetVisibility.ARCHIVE,
|
||||||
userIds: [authStub.admin.user.id],
|
userIds: [authStub.admin.user.id],
|
||||||
@ -71,20 +64,19 @@ describe(TimelineService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should include partner shared assets', async () => {
|
it('should include partner shared assets', async () => {
|
||||||
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
|
const json = `[{ id: ['asset-id'] }]`;
|
||||||
|
mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
|
||||||
mocks.partner.getAll.mockResolvedValue([]);
|
mocks.partner.getAll.mockResolvedValue([]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.getTimeBucket(authStub.admin, {
|
sut.getTimeBucket(authStub.admin, {
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
timeBucket: 'bucket',
|
timeBucket: 'bucket',
|
||||||
visibility: AssetVisibility.TIMELINE,
|
visibility: AssetVisibility.TIMELINE,
|
||||||
userId: authStub.admin.user.id,
|
userId: authStub.admin.user.id,
|
||||||
withPartners: true,
|
withPartners: true,
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
).resolves.toEqual(json);
|
||||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
timeBucket: 'bucket',
|
timeBucket: 'bucket',
|
||||||
visibility: AssetVisibility.TIMELINE,
|
visibility: AssetVisibility.TIMELINE,
|
||||||
withPartners: true,
|
withPartners: true,
|
||||||
@ -93,62 +85,37 @@ describe(TimelineService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should check permissions to read tag', async () => {
|
it('should check permissions to read tag', async () => {
|
||||||
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
|
const json = `[{ id: ['asset-id'] }]`;
|
||||||
|
mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
|
||||||
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123']));
|
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-123']));
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.getTimeBucket(authStub.admin, {
|
sut.getTimeBucket(authStub.admin, {
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
timeBucket: 'bucket',
|
timeBucket: 'bucket',
|
||||||
userId: authStub.admin.user.id,
|
userId: authStub.admin.user.id,
|
||||||
tagId: 'tag-123',
|
tagId: 'tag-123',
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
).resolves.toEqual(json);
|
||||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
tagId: 'tag-123',
|
tagId: 'tag-123',
|
||||||
timeBucket: 'bucket',
|
timeBucket: 'bucket',
|
||||||
userIds: [authStub.admin.user.id],
|
userIds: [authStub.admin.user.id],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should strip metadata if showExif is disabled', async () => {
|
|
||||||
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set(['album-id']));
|
|
||||||
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
|
|
||||||
|
|
||||||
const auth = factory.auth({ sharedLink: { showExif: false } });
|
|
||||||
|
|
||||||
const buckets = await sut.getTimeBucket(auth, {
|
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
timeBucket: 'bucket',
|
|
||||||
visibility: AssetVisibility.ARCHIVE,
|
|
||||||
albumId: 'album-id',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(buckets).toEqual([expect.objectContaining({ id: 'asset-id' })]);
|
|
||||||
expect(buckets[0]).not.toHaveProperty('exif');
|
|
||||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
|
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
timeBucket: 'bucket',
|
|
||||||
visibility: AssetVisibility.ARCHIVE,
|
|
||||||
albumId: 'album-id',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return the assets for a library time bucket if user has library.read', async () => {
|
it('should return the assets for a library time bucket if user has library.read', async () => {
|
||||||
mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]);
|
const json = `[{ id: ['asset-id'] }]`;
|
||||||
|
mocks.asset.getTimeBucket.mockResolvedValue({ assets: json });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.getTimeBucket(authStub.admin, {
|
sut.getTimeBucket(authStub.admin, {
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
timeBucket: 'bucket',
|
timeBucket: 'bucket',
|
||||||
userId: authStub.admin.user.id,
|
userId: authStub.admin.user.id,
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
|
).resolves.toEqual(json);
|
||||||
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith(
|
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith(
|
||||||
'bucket',
|
'bucket',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
timeBucket: 'bucket',
|
timeBucket: 'bucket',
|
||||||
userIds: [authStub.admin.user.id],
|
userIds: [authStub.admin.user.id],
|
||||||
}),
|
}),
|
||||||
@ -158,7 +125,6 @@ describe(TimelineService.name, () => {
|
|||||||
it('should throw an error if withParners is true and visibility true or undefined', async () => {
|
it('should throw an error if withParners is true and visibility true or undefined', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
sut.getTimeBucket(authStub.admin, {
|
sut.getTimeBucket(authStub.admin, {
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
timeBucket: 'bucket',
|
timeBucket: 'bucket',
|
||||||
visibility: AssetVisibility.ARCHIVE,
|
visibility: AssetVisibility.ARCHIVE,
|
||||||
withPartners: true,
|
withPartners: true,
|
||||||
@ -168,7 +134,6 @@ describe(TimelineService.name, () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.getTimeBucket(authStub.admin, {
|
sut.getTimeBucket(authStub.admin, {
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
timeBucket: 'bucket',
|
timeBucket: 'bucket',
|
||||||
visibility: undefined,
|
visibility: undefined,
|
||||||
withPartners: true,
|
withPartners: true,
|
||||||
@ -180,7 +145,6 @@ describe(TimelineService.name, () => {
|
|||||||
it('should throw an error if withParners is true and isFavorite is either true or false', async () => {
|
it('should throw an error if withParners is true and isFavorite is either true or false', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
sut.getTimeBucket(authStub.admin, {
|
sut.getTimeBucket(authStub.admin, {
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
timeBucket: 'bucket',
|
timeBucket: 'bucket',
|
||||||
isFavorite: true,
|
isFavorite: true,
|
||||||
withPartners: true,
|
withPartners: true,
|
||||||
@ -190,7 +154,6 @@ describe(TimelineService.name, () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.getTimeBucket(authStub.admin, {
|
sut.getTimeBucket(authStub.admin, {
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
timeBucket: 'bucket',
|
timeBucket: 'bucket',
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
withPartners: true,
|
withPartners: true,
|
||||||
@ -202,7 +165,6 @@ describe(TimelineService.name, () => {
|
|||||||
it('should throw an error if withParners is true and isTrash is true', async () => {
|
it('should throw an error if withParners is true and isTrash is true', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
sut.getTimeBucket(authStub.admin, {
|
sut.getTimeBucket(authStub.admin, {
|
||||||
size: TimeBucketSize.DAY,
|
|
||||||
timeBucket: 'bucket',
|
timeBucket: 'bucket',
|
||||||
isTrashed: true,
|
isTrashed: true,
|
||||||
withPartners: true,
|
withPartners: true,
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
|
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketsResponseDto } from 'src/dtos/time-bucket.dto';
|
||||||
import { AssetVisibility, Permission } from 'src/enum';
|
import { AssetVisibility, Permission } from 'src/enum';
|
||||||
import { TimeBucketOptions } from 'src/repositories/asset.repository';
|
import { TimeBucketOptions } from 'src/repositories/asset.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
@ -9,22 +8,20 @@ import { getMyPartnerIds } from 'src/utils/asset.util';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TimelineService extends BaseService {
|
export class TimelineService extends BaseService {
|
||||||
async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
|
async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketsResponseDto[]> {
|
||||||
await this.timeBucketChecks(auth, dto);
|
await this.timeBucketChecks(auth, dto);
|
||||||
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
|
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
|
||||||
return this.assetRepository.getTimeBuckets(timeBucketOptions);
|
return await this.assetRepository.getTimeBuckets(timeBucketOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTimeBucket(
|
// pre-jsonified response
|
||||||
auth: AuthDto,
|
async getTimeBucket(auth: AuthDto, dto: TimeBucketAssetDto): Promise<string> {
|
||||||
dto: TimeBucketAssetDto,
|
|
||||||
): Promise<AssetResponseDto[] | SanitizedAssetResponseDto[]> {
|
|
||||||
await this.timeBucketChecks(auth, dto);
|
await this.timeBucketChecks(auth, dto);
|
||||||
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
|
const timeBucketOptions = await this.buildTimeBucketOptions(auth, { ...dto });
|
||||||
const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions);
|
|
||||||
return !auth.sharedLink || auth.sharedLink?.showExif
|
// TODO: use id cursor for pagination
|
||||||
? assets.map((asset) => mapAsset(asset, { withStack: true, auth }))
|
const bucket = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions);
|
||||||
: assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth }));
|
return bucket.assets;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketOptions> {
|
private async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketOptions> {
|
||||||
|
@ -22,3 +22,12 @@ export function asHumanReadable(bytes: number, precision = 1): string {
|
|||||||
|
|
||||||
return `${remainder.toFixed(magnitude == 0 ? 0 : precision)} ${units[magnitude]}`;
|
return `${remainder.toFixed(magnitude == 0 ? 0 : precision)} ${units[magnitude]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings
|
||||||
|
export const hexOrBufferToBase64 = (encoded: string | Buffer) => {
|
||||||
|
if (typeof encoded === 'string') {
|
||||||
|
return Buffer.from(encoded.slice(2), 'hex').toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
return encoded.toString('base64');
|
||||||
|
};
|
||||||
|
@ -271,7 +271,7 @@ export function withTags(eb: ExpressionBuilder<DB, 'assets'>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function truncatedDate<O>(size: TimeBucketSize) {
|
export function truncatedDate<O>(size: TimeBucketSize) {
|
||||||
return sql<O>`date_trunc(${size}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`;
|
return sql<O>`date_trunc(${sql.lit(size)}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagId: string) {
|
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagId: string) {
|
||||||
@ -285,6 +285,7 @@ export function withTagId<O>(qb: SelectQueryBuilder<DB, 'assets', O>, tagId: str
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
|
const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
|
||||||
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */
|
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */
|
||||||
|
|
||||||
|
@ -14,7 +14,6 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
|
|||||||
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
|
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
|
||||||
import { ApiService } from 'src/services/api.service';
|
import { ApiService } from 'src/services/api.service';
|
||||||
import { isStartUpError, useSwagger } from 'src/utils/misc';
|
import { isStartUpError, useSwagger } from 'src/utils/misc';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
process.title = 'immich-api';
|
process.title = 'immich-api';
|
||||||
|
|
||||||
|
4
server/test/fixtures/asset.stub.ts
vendored
4
server/test/fixtures/asset.stub.ts
vendored
@ -251,6 +251,10 @@ export const assetStub = {
|
|||||||
duplicateId: null,
|
duplicateId: null,
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
stack: null,
|
stack: null,
|
||||||
|
orientation: '',
|
||||||
|
projectionType: null,
|
||||||
|
height: 3840,
|
||||||
|
width: 2160,
|
||||||
visibility: AssetVisibility.TIMELINE,
|
visibility: AssetVisibility.TIMELINE,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
6
typescript-open-api/typescript-sdk/package-lock.json
generated
Normal file
6
typescript-open-api/typescript-sdk/package-lock.json
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "typescript-sdk",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
|
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||||
import { keepThisDeleteOthers } from '$lib/utils/asset-utils';
|
import { keepThisDeleteOthers } from '$lib/utils/asset-utils';
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
import { modalManager } from '$lib/managers/modal-manager.svelte';
|
||||||
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { AssetVisibility, updateAssets, Visibility } from '@immich/sdk';
|
import { AssetVisibility, updateAssets } from '@immich/sdk';
|
||||||
import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
|
import { mdiEyeOffOutline, mdiFolderMoveOutline } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { OnAction, PreAction } from './action';
|
import type { OnAction, PreAction } from './action';
|
||||||
@ -17,7 +17,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let { asset, onAction, preAction }: Props = $props();
|
let { asset, onAction, preAction }: Props = $props();
|
||||||
const isLocked = asset.visibility === Visibility.Locked;
|
const isLocked = asset.visibility === AssetVisibility.Locked;
|
||||||
|
|
||||||
const toggleLockedVisibility = async () => {
|
const toggleLockedVisibility = async () => {
|
||||||
const isConfirmed = await modalManager.showDialog({
|
const isConfirmed = await modalManager.showDialog({
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
|
||||||
import { timeToSeconds } from '$lib/utils/date-time';
|
import { timeToSeconds } from '$lib/utils/date-time';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { AssetMediaSize, Visibility } from '@immich/sdk';
|
import { AssetMediaSize, AssetVisibility } from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
mdiArchiveArrowDownOutline,
|
mdiArchiveArrowDownOutline,
|
||||||
mdiCameraBurst,
|
mdiCameraBurst,
|
||||||
@ -291,7 +291,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if !authManager.key && showArchiveIcon && asset.visibility === Visibility.Archive}
|
{#if !authManager.key && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
|
||||||
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
|
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
|
||||||
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
|
<Icon path={mdiArchiveArrowDownOutline} size="24" class="text-white" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import type { OnArchive } from '$lib/utils/actions';
|
import type { OnArchive } from '$lib/utils/actions';
|
||||||
import { archiveAssets } from '$lib/utils/asset-utils';
|
import { archiveAssets } from '$lib/utils/asset-utils';
|
||||||
import { AssetVisibility, Visibility } from '@immich/sdk';
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
|
import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline, mdiTimerSand } from '@mdi/js';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||||
@ -24,12 +24,12 @@
|
|||||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||||
|
|
||||||
const handleArchive = async () => {
|
const handleArchive = async () => {
|
||||||
const isArchived = unarchive ? Visibility.Timeline : Visibility.Archive;
|
const isArchived = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
|
||||||
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived);
|
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived);
|
||||||
loading = true;
|
loading = true;
|
||||||
const ids = await archiveAssets(assets, isArchived as unknown as AssetVisibility);
|
const ids = await archiveAssets(assets, isArchived as AssetVisibility);
|
||||||
if (ids) {
|
if (ids) {
|
||||||
onArchive?.(ids, isArchived);
|
onArchive?.(ids, isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline);
|
||||||
clearSelect();
|
clearSelect();
|
||||||
}
|
}
|
||||||
loading = false;
|
loading = false;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { resetSavedUser, user } from '$lib/stores/user.store';
|
import { resetSavedUser, user } from '$lib/stores/user.store';
|
||||||
import { Visibility } from '@immich/sdk';
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
import { timelineAssetFactory } from '@test-data/factories/asset-factory';
|
import { timelineAssetFactory } from '@test-data/factories/asset-factory';
|
||||||
import { userAdminFactory } from '@test-data/factories/user-factory';
|
import { userAdminFactory } from '@test-data/factories/user-factory';
|
||||||
|
|
||||||
@ -13,10 +13,10 @@ describe('AssetInteraction', () => {
|
|||||||
|
|
||||||
it('calculates derived values from selection', () => {
|
it('calculates derived values from selection', () => {
|
||||||
assetInteraction.selectAsset(
|
assetInteraction.selectAsset(
|
||||||
timelineAssetFactory.build({ isFavorite: true, visibility: Visibility.Archive, isTrashed: true }),
|
timelineAssetFactory.build({ isFavorite: true, visibility: AssetVisibility.Archive, isTrashed: true }),
|
||||||
);
|
);
|
||||||
assetInteraction.selectAsset(
|
assetInteraction.selectAsset(
|
||||||
timelineAssetFactory.build({ isFavorite: true, visibility: Visibility.Timeline, isTrashed: false }),
|
timelineAssetFactory.build({ isFavorite: true, visibility: AssetVisibility.Timeline, isTrashed: false }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(assetInteraction.selectionActive).toBe(true);
|
expect(assetInteraction.selectionActive).toBe(true);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { Visibility, type UserAdminResponseDto } from '@immich/sdk';
|
import { AssetVisibility, type UserAdminResponseDto } from '@immich/sdk';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
import { fromStore } from 'svelte/store';
|
import { fromStore } from 'svelte/store';
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ export class AssetInteraction {
|
|||||||
private userId = $derived(this.user.current?.id);
|
private userId = $derived(this.user.current?.id);
|
||||||
|
|
||||||
isAllTrashed = $derived(this.selectedAssets.every((asset) => asset.isTrashed));
|
isAllTrashed = $derived(this.selectedAssets.every((asset) => asset.isTrashed));
|
||||||
isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === Visibility.Archive));
|
isAllArchived = $derived(this.selectedAssets.every((asset) => asset.visibility === AssetVisibility.Archive));
|
||||||
isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite));
|
isAllFavorite = $derived(this.selectedAssets.every((asset) => asset.isFavorite));
|
||||||
isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId));
|
isAllUserOwned = $derived(this.selectedAssets.every((asset) => asset.ownerId === this.userId));
|
||||||
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||||
import { AbortError } from '$lib/utils';
|
import { AbortError } from '$lib/utils';
|
||||||
import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk';
|
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||||
import { assetFactory, timelineAssetFactory } from '@test-data/factories/asset-factory';
|
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
||||||
import { AssetStore } from './assets-store.svelte';
|
import { AssetStore, type TimelineAsset } from './assets-store.svelte';
|
||||||
|
|
||||||
describe('AssetStore', () => {
|
describe('AssetStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -11,18 +11,22 @@ describe('AssetStore', () => {
|
|||||||
|
|
||||||
describe('init', () => {
|
describe('init', () => {
|
||||||
let assetStore: AssetStore;
|
let assetStore: AssetStore;
|
||||||
const bucketAssets: Record<string, AssetResponseDto[]> = {
|
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||||
'2024-03-01T00:00:00.000Z': assetFactory
|
'2024-03-01T00:00:00.000Z': timelineAssetFactory
|
||||||
.buildList(1)
|
.buildList(1)
|
||||||
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
|
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
|
||||||
'2024-02-01T00:00:00.000Z': assetFactory
|
'2024-02-01T00:00:00.000Z': timelineAssetFactory
|
||||||
.buildList(100)
|
.buildList(100)
|
||||||
.map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
|
.map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
|
||||||
'2024-01-01T00:00:00.000Z': assetFactory
|
'2024-01-01T00:00:00.000Z': timelineAssetFactory
|
||||||
.buildList(3)
|
.buildList(3)
|
||||||
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
|
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||||
|
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||||
|
);
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetStore = new AssetStore();
|
assetStore = new AssetStore();
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||||
@ -30,13 +34,14 @@ describe('AssetStore', () => {
|
|||||||
{ count: 100, timeBucket: '2024-02-01T00:00:00.000Z' },
|
{ count: 100, timeBucket: '2024-02-01T00:00:00.000Z' },
|
||||||
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
||||||
]);
|
]);
|
||||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
|
|
||||||
|
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load buckets in viewport', () => {
|
it('should load buckets in viewport', () => {
|
||||||
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
|
||||||
expect(sdkMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.Month });
|
|
||||||
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
|
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -48,29 +53,31 @@ describe('AssetStore', () => {
|
|||||||
|
|
||||||
expect(plainBuckets).toEqual(
|
expect(plainBuckets).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 303 }),
|
expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 185.5 }),
|
||||||
expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 4514.333_333_333_333 }),
|
expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 12_016 }),
|
||||||
expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }),
|
expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 286 }),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates timeline height', () => {
|
it('calculates timeline height', () => {
|
||||||
expect(assetStore.timelineHeight).toBe(5103.333_333_333_333);
|
expect(assetStore.timelineHeight).toBe(12_487.5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('loadBucket', () => {
|
describe('loadBucket', () => {
|
||||||
let assetStore: AssetStore;
|
let assetStore: AssetStore;
|
||||||
const bucketAssets: Record<string, AssetResponseDto[]> = {
|
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||||
'2024-01-03T00:00:00.000Z': assetFactory
|
'2024-01-03T00:00:00.000Z': timelineAssetFactory
|
||||||
.buildList(1)
|
.buildList(1)
|
||||||
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
|
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
|
||||||
'2024-01-01T00:00:00.000Z': assetFactory
|
'2024-01-01T00:00:00.000Z': timelineAssetFactory
|
||||||
.buildList(3)
|
.buildList(3)
|
||||||
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
|
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
|
||||||
};
|
};
|
||||||
|
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||||
|
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||||
|
);
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetStore = new AssetStore();
|
assetStore = new AssetStore();
|
||||||
sdkMock.getTimeBuckets.mockResolvedValue([
|
sdkMock.getTimeBuckets.mockResolvedValue([
|
||||||
@ -82,7 +89,7 @@ describe('AssetStore', () => {
|
|||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw new AbortError();
|
throw new AbortError();
|
||||||
}
|
}
|
||||||
return bucketAssets[timeBucket];
|
return bucketAssetsResponse[timeBucket];
|
||||||
});
|
});
|
||||||
await assetStore.updateViewport({ width: 1588, height: 0 });
|
await assetStore.updateViewport({ width: 1588, height: 0 });
|
||||||
});
|
});
|
||||||
@ -296,7 +303,9 @@ describe('AssetStore', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('removes asset from bucket', () => {
|
it('removes asset from bucket', () => {
|
||||||
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, { localDateTime: '2024-01-20T12:00:00.000Z' });
|
const [assetOne, assetTwo] = timelineAssetFactory.buildList(2, {
|
||||||
|
localDateTime: '2024-01-20T12:00:00.000Z',
|
||||||
|
});
|
||||||
assetStore.addAssets([assetOne, assetTwo]);
|
assetStore.addAssets([assetOne, assetTwo]);
|
||||||
assetStore.removeAssets([assetOne.id]);
|
assetStore.removeAssets([assetOne.id]);
|
||||||
|
|
||||||
@ -342,17 +351,20 @@ describe('AssetStore', () => {
|
|||||||
|
|
||||||
describe('getPreviousAsset', () => {
|
describe('getPreviousAsset', () => {
|
||||||
let assetStore: AssetStore;
|
let assetStore: AssetStore;
|
||||||
const bucketAssets: Record<string, AssetResponseDto[]> = {
|
const bucketAssets: Record<string, TimelineAsset[]> = {
|
||||||
'2024-03-01T00:00:00.000Z': assetFactory
|
'2024-03-01T00:00:00.000Z': timelineAssetFactory
|
||||||
.buildList(1)
|
.buildList(1)
|
||||||
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
|
.map((asset) => ({ ...asset, localDateTime: '2024-03-01T00:00:00.000Z' })),
|
||||||
'2024-02-01T00:00:00.000Z': assetFactory
|
'2024-02-01T00:00:00.000Z': timelineAssetFactory
|
||||||
.buildList(6)
|
.buildList(6)
|
||||||
.map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
|
.map((asset) => ({ ...asset, localDateTime: '2024-02-01T00:00:00.000Z' })),
|
||||||
'2024-01-01T00:00:00.000Z': assetFactory
|
'2024-01-01T00:00:00.000Z': timelineAssetFactory
|
||||||
.buildList(3)
|
.buildList(3)
|
||||||
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
|
.map((asset) => ({ ...asset, localDateTime: '2024-01-01T00:00:00.000Z' })),
|
||||||
};
|
};
|
||||||
|
const bucketAssetsResponse: Record<string, TimeBucketAssetResponseDto> = Object.fromEntries(
|
||||||
|
Object.entries(bucketAssets).map(([key, assets]) => [key, toResponseDto(...assets)]),
|
||||||
|
);
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetStore = new AssetStore();
|
assetStore = new AssetStore();
|
||||||
@ -361,8 +373,7 @@ describe('AssetStore', () => {
|
|||||||
{ count: 6, timeBucket: '2024-02-01T00:00:00.000Z' },
|
{ count: 6, timeBucket: '2024-02-01T00:00:00.000Z' },
|
||||||
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
{ count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
|
||||||
]);
|
]);
|
||||||
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
|
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
|
||||||
|
|
||||||
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
await assetStore.updateViewport({ width: 1588, height: 1000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
|
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||||
import {
|
import {
|
||||||
@ -15,10 +16,8 @@ import {
|
|||||||
getAssetInfo,
|
getAssetInfo,
|
||||||
getTimeBucket,
|
getTimeBucket,
|
||||||
getTimeBuckets,
|
getTimeBuckets,
|
||||||
TimeBucketSize,
|
|
||||||
Visibility,
|
|
||||||
type AssetResponseDto,
|
|
||||||
type AssetStackResponseDto,
|
type AssetStackResponseDto,
|
||||||
|
type TimeBucketAssetResponseDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import { clamp, debounce, isEqual, throttle } from 'lodash-es';
|
import { clamp, debounce, isEqual, throttle } from 'lodash-es';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
@ -32,6 +31,7 @@ const {
|
|||||||
} = TUNABLES;
|
} = TUNABLES;
|
||||||
|
|
||||||
type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
|
type AssetApiGetTimeBucketsRequest = Parameters<typeof getTimeBuckets>[0];
|
||||||
|
|
||||||
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
|
export type AssetStoreOptions = Omit<AssetApiGetTimeBucketsRequest, 'size'> & {
|
||||||
timelineAlbumId?: string;
|
timelineAlbumId?: string;
|
||||||
deferInit?: boolean;
|
deferInit?: boolean;
|
||||||
@ -75,7 +75,7 @@ export type TimelineAsset = {
|
|||||||
ratio: number;
|
ratio: number;
|
||||||
thumbhash: string | null;
|
thumbhash: string | null;
|
||||||
localDateTime: string;
|
localDateTime: string;
|
||||||
visibility: Visibility;
|
visibility: AssetVisibility;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
isTrashed: boolean;
|
isTrashed: boolean;
|
||||||
isVideo: boolean;
|
isVideo: boolean;
|
||||||
@ -84,12 +84,11 @@ export type TimelineAsset = {
|
|||||||
duration: string | null;
|
duration: string | null;
|
||||||
projectionType: string | null;
|
projectionType: string | null;
|
||||||
livePhotoVideoId: string | null;
|
livePhotoVideoId: string | null;
|
||||||
text: {
|
|
||||||
city: string | null;
|
city: string | null;
|
||||||
country: string | null;
|
country: string | null;
|
||||||
people: string[];
|
people: string[];
|
||||||
};
|
};
|
||||||
};
|
|
||||||
class IntersectingAsset {
|
class IntersectingAsset {
|
||||||
// --- public ---
|
// --- public ---
|
||||||
readonly #group: AssetDateGroup;
|
readonly #group: AssetDateGroup;
|
||||||
@ -113,7 +112,7 @@ class IntersectingAsset {
|
|||||||
});
|
});
|
||||||
|
|
||||||
position: CommonPosition | undefined = $state();
|
position: CommonPosition | undefined = $state();
|
||||||
asset: TimelineAsset | undefined = $state();
|
asset: TimelineAsset = <TimelineAsset>$state();
|
||||||
id: string | undefined = $derived(this.asset?.id);
|
id: string | undefined = $derived(this.asset?.id);
|
||||||
|
|
||||||
constructor(group: AssetDateGroup, asset: TimelineAsset) {
|
constructor(group: AssetDateGroup, asset: TimelineAsset) {
|
||||||
@ -121,9 +120,11 @@ class IntersectingAsset {
|
|||||||
this.asset = asset;
|
this.asset = asset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
|
type AssetOperation = (asset: TimelineAsset) => { remove: boolean };
|
||||||
|
|
||||||
type MoveAsset = { asset: TimelineAsset; year: number; month: number };
|
type MoveAsset = { asset: TimelineAsset; year: number; month: number };
|
||||||
|
|
||||||
export class AssetDateGroup {
|
export class AssetDateGroup {
|
||||||
// --- public
|
// --- public
|
||||||
readonly bucket: AssetBucket;
|
readonly bucket: AssetBucket;
|
||||||
@ -166,6 +167,7 @@ export class AssetDateGroup {
|
|||||||
getFirstAsset() {
|
getFirstAsset() {
|
||||||
return this.intersetingAssets[0]?.asset;
|
return this.intersetingAssets[0]?.asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
getRandomAsset() {
|
getRandomAsset() {
|
||||||
const random = Math.floor(Math.random() * this.intersetingAssets.length);
|
const random = Math.floor(Math.random() * this.intersetingAssets.length);
|
||||||
return this.intersetingAssets[random];
|
return this.intersetingAssets[random];
|
||||||
@ -243,6 +245,7 @@ export interface Viewport {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ViewportXY = Viewport & {
|
export type ViewportXY = Viewport & {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@ -250,11 +253,46 @@ export type ViewportXY = Viewport & {
|
|||||||
|
|
||||||
class AddContext {
|
class AddContext {
|
||||||
lookupCache: {
|
lookupCache: {
|
||||||
[dayOfMonth: number]: AssetDateGroup;
|
[year: number]: { [month: number]: { [day: number]: AssetDateGroup } };
|
||||||
} = {};
|
} = {};
|
||||||
unprocessedAssets: TimelineAsset[] = [];
|
unprocessedAssets: TimelineAsset[] = [];
|
||||||
changedDateGroups = new Set<AssetDateGroup>();
|
changedDateGroups = new Set<AssetDateGroup>();
|
||||||
newDateGroups = new Set<AssetDateGroup>();
|
newDateGroups = new Set<AssetDateGroup>();
|
||||||
|
|
||||||
|
getDateGroup(year: number, month: number, day: number): AssetDateGroup | undefined {
|
||||||
|
return this.lookupCache[year]?.[month]?.[day];
|
||||||
|
}
|
||||||
|
|
||||||
|
setDateGroup(dateGroup: AssetDateGroup, year: number, month: number, day: number) {
|
||||||
|
if (!this.lookupCache[year]) {
|
||||||
|
this.lookupCache[year] = {};
|
||||||
|
}
|
||||||
|
if (!this.lookupCache[year][month]) {
|
||||||
|
this.lookupCache[year][month] = {};
|
||||||
|
}
|
||||||
|
this.lookupCache[year][month][day] = dateGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
get existingDateGroups() {
|
||||||
|
return this.changedDateGroups.difference(this.newDateGroups);
|
||||||
|
}
|
||||||
|
|
||||||
|
get updatedBuckets() {
|
||||||
|
const updated = new Set<AssetBucket>();
|
||||||
|
for (const group of this.changedDateGroups) {
|
||||||
|
updated.add(group.bucket);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
get bucketsWithNewDateGroups() {
|
||||||
|
const updated = new Set<AssetBucket>();
|
||||||
|
for (const group of this.newDateGroups) {
|
||||||
|
updated.add(group.bucket);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
sort(bucket: AssetBucket, sortOrder: AssetOrder = AssetOrder.Desc) {
|
sort(bucket: AssetBucket, sortOrder: AssetOrder = AssetOrder.Desc) {
|
||||||
for (const group of this.changedDateGroups) {
|
for (const group of this.changedDateGroups) {
|
||||||
group.sortAssets(sortOrder);
|
group.sortAssets(sortOrder);
|
||||||
@ -267,6 +305,7 @@ class AddContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AssetBucket {
|
export class AssetBucket {
|
||||||
// --- public ---
|
// --- public ---
|
||||||
#intersecting: boolean = $state(false);
|
#intersecting: boolean = $state(false);
|
||||||
@ -331,6 +370,7 @@ export class AssetBucket {
|
|||||||
this.handleLoadError,
|
this.handleLoadError,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
set intersecting(newValue: boolean) {
|
set intersecting(newValue: boolean) {
|
||||||
const old = this.#intersecting;
|
const old = this.#intersecting;
|
||||||
if (old !== newValue) {
|
if (old !== newValue) {
|
||||||
@ -422,52 +462,74 @@ export class AssetBucket {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// note - if the assets are not part of this bucket, they will not be added
|
addAssets(bucketAssets: TimeBucketAssetResponseDto) {
|
||||||
addAssets(bucketResponse: AssetResponseDto[]) {
|
|
||||||
const addContext = new AddContext();
|
const addContext = new AddContext();
|
||||||
for (const asset of bucketResponse) {
|
const people: string[] = [];
|
||||||
const timelineAsset = toTimelineAsset(asset);
|
for (let i = 0; i < bucketAssets.id.length; i++) {
|
||||||
|
const timelineAsset: TimelineAsset = {
|
||||||
|
city: bucketAssets.city[i],
|
||||||
|
country: bucketAssets.country[i],
|
||||||
|
duration: bucketAssets.duration[i],
|
||||||
|
id: bucketAssets.id[i],
|
||||||
|
visibility: bucketAssets.visibility[i],
|
||||||
|
isFavorite: bucketAssets.isFavorite[i],
|
||||||
|
isImage: bucketAssets.isImage[i],
|
||||||
|
isTrashed: bucketAssets.isTrashed[i],
|
||||||
|
isVideo: !bucketAssets.isImage[i],
|
||||||
|
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
|
||||||
|
localDateTime: bucketAssets.localDateTime[i],
|
||||||
|
ownerId: bucketAssets.ownerId[i],
|
||||||
|
people,
|
||||||
|
projectionType: bucketAssets.projectionType[i],
|
||||||
|
ratio: bucketAssets.ratio[i],
|
||||||
|
stack: bucketAssets.stack?.[i]
|
||||||
|
? {
|
||||||
|
id: bucketAssets.stack[i]![0],
|
||||||
|
primaryAssetId: bucketAssets.id[i],
|
||||||
|
assetCount: Number.parseInt(bucketAssets.stack[i]![1]),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
thumbhash: bucketAssets.thumbhash[i],
|
||||||
|
};
|
||||||
this.addTimelineAsset(timelineAsset, addContext);
|
this.addTimelineAsset(timelineAsset, addContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const group of addContext.existingDateGroups) {
|
||||||
|
group.sortAssets(this.#sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addContext.newDateGroups.size > 0) {
|
||||||
|
this.sortDateGroups();
|
||||||
|
}
|
||||||
|
|
||||||
addContext.sort(this, this.#sortOrder);
|
addContext.sort(this, this.#sortOrder);
|
||||||
|
|
||||||
return addContext.unprocessedAssets;
|
return addContext.unprocessedAssets;
|
||||||
}
|
}
|
||||||
|
|
||||||
addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) {
|
addTimelineAsset(timelineAsset: TimelineAsset, addContext: AddContext) {
|
||||||
const { id, localDateTime } = timelineAsset;
|
const { localDateTime } = timelineAsset;
|
||||||
const date = DateTime.fromISO(localDateTime).toUTC();
|
const date = DateTime.fromISO(localDateTime).toUTC();
|
||||||
|
|
||||||
const month = date.get('month');
|
const month = date.get('month');
|
||||||
const year = date.get('year');
|
const year = date.get('year');
|
||||||
|
|
||||||
// If the timeline asset does not belong to the current bucket, mark it as unprocessed
|
|
||||||
if (this.month !== month || this.year !== year) {
|
if (this.month !== month || this.year !== year) {
|
||||||
addContext.unprocessedAssets.push(timelineAsset);
|
addContext.unprocessedAssets.push(timelineAsset);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const day = date.get('day');
|
const day = date.get('day');
|
||||||
let dateGroup: AssetDateGroup | undefined = addContext.lookupCache[day] || this.findDateGroupByDay(day);
|
let dateGroup = addContext.getDateGroup(year, month, day) || this.findDateGroupByDay(day);
|
||||||
|
|
||||||
if (dateGroup) {
|
if (dateGroup) {
|
||||||
// Cache the found date group for future lookups
|
addContext.setDateGroup(dateGroup, year, month, day);
|
||||||
addContext.lookupCache[day] = dateGroup;
|
|
||||||
} else {
|
} else {
|
||||||
// Create a new date group if none exists for the given day
|
|
||||||
dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
|
dateGroup = new AssetDateGroup(this, this.dateGroups.length, date, day);
|
||||||
this.dateGroups.push(dateGroup);
|
this.dateGroups.push(dateGroup);
|
||||||
addContext.lookupCache[day] = dateGroup;
|
addContext.setDateGroup(dateGroup, year, month, day);
|
||||||
addContext.newDateGroups.add(dateGroup);
|
addContext.newDateGroups.add(dateGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate assets in the date group
|
|
||||||
if (dateGroup.intersetingAssets.some((a) => a.id === id)) {
|
|
||||||
console.error(`Ignoring attempt to add duplicate asset ${id} to ${dateGroup.groupTitle}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the timeline asset to the date group
|
|
||||||
const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset);
|
const intersectingAsset = new IntersectingAsset(dateGroup, timelineAsset);
|
||||||
dateGroup.intersetingAssets.push(intersectingAsset);
|
dateGroup.intersetingAssets.push(intersectingAsset);
|
||||||
addContext.changedDateGroups.add(dateGroup);
|
addContext.changedDateGroups.add(dateGroup);
|
||||||
@ -521,6 +583,7 @@ export class AssetBucket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get bucketHeight() {
|
get bucketHeight() {
|
||||||
return this.#bucketHeight;
|
return this.#bucketHeight;
|
||||||
}
|
}
|
||||||
@ -909,7 +972,6 @@ export class AssetStore {
|
|||||||
async #initialiazeTimeBuckets() {
|
async #initialiazeTimeBuckets() {
|
||||||
const timebuckets = await getTimeBuckets({
|
const timebuckets = await getTimeBuckets({
|
||||||
...this.#options,
|
...this.#options,
|
||||||
size: TimeBucketSize.Month,
|
|
||||||
key: authManager.key,
|
key: authManager.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1016,6 +1078,7 @@ export class AssetStore {
|
|||||||
rowWidth: Math.floor(viewportWidth),
|
rowWidth: Math.floor(viewportWidth),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
#updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) {
|
#updateGeometry(bucket: AssetBucket, invalidateHeight: boolean) {
|
||||||
if (invalidateHeight) {
|
if (invalidateHeight) {
|
||||||
bucket.isBucketHeightActual = false;
|
bucket.isBucketHeightActual = false;
|
||||||
@ -1117,7 +1180,7 @@ export class AssetStore {
|
|||||||
{
|
{
|
||||||
...this.#options,
|
...this.#options,
|
||||||
timeBucket: bucketDate,
|
timeBucket: bucketDate,
|
||||||
size: TimeBucketSize.Month,
|
|
||||||
key: authManager.key,
|
key: authManager.key,
|
||||||
},
|
},
|
||||||
{ signal },
|
{ signal },
|
||||||
@ -1128,12 +1191,11 @@ export class AssetStore {
|
|||||||
{
|
{
|
||||||
albumId: this.#options.timelineAlbumId,
|
albumId: this.#options.timelineAlbumId,
|
||||||
timeBucket: bucketDate,
|
timeBucket: bucketDate,
|
||||||
size: TimeBucketSize.Month,
|
|
||||||
key: authManager.key,
|
key: authManager.key,
|
||||||
},
|
},
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
for (const { id } of albumAssets) {
|
for (const id of albumAssets.id) {
|
||||||
this.albumAssets.add(id);
|
this.albumAssets.add(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1169,9 +1231,10 @@ export class AssetStore {
|
|||||||
if (assets.length === 0) {
|
if (assets.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const updatedBuckets = new Set<AssetBucket>();
|
|
||||||
const updatedDateGroups = new Set<AssetDateGroup>();
|
|
||||||
|
|
||||||
|
const addContext = new AddContext();
|
||||||
|
const updatedBuckets = new Set<AssetBucket>();
|
||||||
|
const bucketCount = this.buckets.length;
|
||||||
for (const asset of assets) {
|
for (const asset of assets) {
|
||||||
const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month');
|
const utc = DateTime.fromISO(asset.localDateTime).toUTC().startOf('month');
|
||||||
const year = utc.get('year');
|
const year = utc.get('year');
|
||||||
@ -1182,20 +1245,26 @@ export class AssetStore {
|
|||||||
bucket = new AssetBucket(this, utc, 1, this.#options.order);
|
bucket = new AssetBucket(this, utc, 1, this.#options.order);
|
||||||
this.buckets.push(bucket);
|
this.buckets.push(bucket);
|
||||||
}
|
}
|
||||||
const addContext = new AddContext();
|
|
||||||
bucket.addTimelineAsset(asset, addContext);
|
bucket.addTimelineAsset(asset, addContext);
|
||||||
addContext.sort(bucket, this.#options.order);
|
|
||||||
updatedBuckets.add(bucket);
|
updatedBuckets.add(bucket);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.buckets.length !== bucketCount) {
|
||||||
this.buckets.sort((a, b) => {
|
this.buckets.sort((a, b) => {
|
||||||
return a.year === b.year ? b.month - a.month : b.year - a.year;
|
return a.year === b.year ? b.month - a.month : b.year - a.year;
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const dateGroup of updatedDateGroups) {
|
|
||||||
dateGroup.sortAssets(this.#options.order);
|
|
||||||
}
|
}
|
||||||
for (const bucket of updatedBuckets) {
|
|
||||||
|
for (const group of addContext.existingDateGroups) {
|
||||||
|
group.sortAssets(this.#options.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bucket of addContext.bucketsWithNewDateGroups) {
|
||||||
|
bucket.sortDateGroups();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const bucket of addContext.updatedBuckets) {
|
||||||
bucket.sortDateGroups();
|
bucket.sortDateGroups();
|
||||||
this.#updateGeometry(bucket, true);
|
this.#updateGeometry(bucket, true);
|
||||||
}
|
}
|
||||||
@ -1421,7 +1490,7 @@ export class AssetStore {
|
|||||||
|
|
||||||
isExcluded(asset: TimelineAsset) {
|
isExcluded(asset: TimelineAsset) {
|
||||||
return (
|
return (
|
||||||
isMismatched(this.#options.visibility, asset.visibility as unknown as AssetVisibility) ||
|
isMismatched(this.#options.visibility, asset.visibility) ||
|
||||||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
|
||||||
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
isMismatched(this.#options.isTrashed, asset.isTrashed)
|
||||||
);
|
);
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
|
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
|
||||||
import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte';
|
import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import type { StackResponse } from '$lib/utils/asset-utils';
|
import type { StackResponse } from '$lib/utils/asset-utils';
|
||||||
import { deleteAssets as deleteBulk, Visibility } from '@immich/sdk';
|
import { AssetVisibility, deleteAssets as deleteBulk } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { handleError } from './handle-error';
|
import { handleError } from './handle-error';
|
||||||
@ -11,7 +11,7 @@ export type OnRestore = (ids: string[]) => void;
|
|||||||
export type OnLink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
|
export type OnLink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
|
||||||
export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
|
export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
|
||||||
export type OnAddToAlbum = (ids: string[], albumId: string) => void;
|
export type OnAddToAlbum = (ids: string[], albumId: string) => void;
|
||||||
export type OnArchive = (ids: string[], visibility: Visibility) => void;
|
export type OnArchive = (ids: string[], visibility: AssetVisibility) => void;
|
||||||
export type OnFavorite = (ids: string[], favorite: boolean) => void;
|
export type OnFavorite = (ids: string[], favorite: boolean) => void;
|
||||||
export type OnStack = (result: StackResponse) => void;
|
export type OnStack = (result: StackResponse) => void;
|
||||||
export type OnUnstack = (assets: TimelineAsset[]) => void;
|
export type OnUnstack = (assets: TimelineAsset[]) => void;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||||
import { Visibility } from '@immich/sdk';
|
import { AssetVisibility } from '@immich/sdk';
|
||||||
import { init, register, waitLocale } from 'svelte-i18n';
|
import { init, register, waitLocale } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Person {
|
interface Person {
|
||||||
@ -62,7 +62,7 @@ describe('getAltText', () => {
|
|||||||
ratio: 1,
|
ratio: 1,
|
||||||
thumbhash: null,
|
thumbhash: null,
|
||||||
localDateTime: '2024-01-01T12:00:00.000Z',
|
localDateTime: '2024-01-01T12:00:00.000Z',
|
||||||
visibility: Visibility.Timeline,
|
visibility: AssetVisibility.Timeline,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
isTrashed: false,
|
isTrashed: false,
|
||||||
isVideo,
|
isVideo,
|
||||||
@ -71,11 +71,9 @@ describe('getAltText', () => {
|
|||||||
duration: null,
|
duration: null,
|
||||||
projectionType: null,
|
projectionType: null,
|
||||||
livePhotoVideoId: null,
|
livePhotoVideoId: null,
|
||||||
text: {
|
|
||||||
city: city ?? null,
|
city: city ?? null,
|
||||||
country: country ?? null,
|
country: country ?? null,
|
||||||
people: people?.map((person: Person) => person.name) ?? [],
|
people: people?.map((person: Person) => person.name) ?? [],
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
getAltText.subscribe((fn) => {
|
getAltText.subscribe((fn) => {
|
||||||
|
@ -41,19 +41,18 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number
|
|||||||
export const getAltText = derived(t, ($t) => {
|
export const getAltText = derived(t, ($t) => {
|
||||||
return (asset: TimelineAsset) => {
|
return (asset: TimelineAsset) => {
|
||||||
const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) });
|
const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) });
|
||||||
const { city, country, people: names } = asset.text;
|
const hasPlace = asset.city && asset.country;
|
||||||
const hasPlace = city && country;
|
|
||||||
|
|
||||||
const peopleCount = names.length;
|
const peopleCount = asset.people.length;
|
||||||
const isVideo = asset.isVideo;
|
const isVideo = asset.isVideo;
|
||||||
|
|
||||||
const values = {
|
const values = {
|
||||||
date,
|
date,
|
||||||
city,
|
city: asset.city,
|
||||||
country,
|
country: asset.country,
|
||||||
person1: names[0],
|
person1: asset.people[0],
|
||||||
person2: names[1],
|
person2: asset.people[1],
|
||||||
person3: names[2],
|
person3: asset.people[2],
|
||||||
isVideo,
|
isVideo,
|
||||||
additionalCount: peopleCount > 3 ? peopleCount - 2 : 0,
|
additionalCount: peopleCount > 3 ? peopleCount - 2 : 0,
|
||||||
};
|
};
|
||||||
|
@ -2,7 +2,8 @@ import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
|||||||
import { locale } from '$lib/stores/preferences.store';
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||||
|
|
||||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
import { AssetTypeEnum, AssetVisibility, type AssetResponseDto } from '@immich/sdk';
|
||||||
|
|
||||||
import { memoize } from 'lodash-es';
|
import { memoize } from 'lodash-es';
|
||||||
import { DateTime, type LocaleOptions } from 'luxon';
|
import { DateTime, type LocaleOptions } from 'luxon';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
@ -65,17 +66,12 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
|
|||||||
if (isTimelineAsset(unknownAsset)) {
|
if (isTimelineAsset(unknownAsset)) {
|
||||||
return unknownAsset;
|
return unknownAsset;
|
||||||
}
|
}
|
||||||
const assetResponse = unknownAsset as AssetResponseDto;
|
const assetResponse = unknownAsset;
|
||||||
const { width, height } = getAssetRatio(assetResponse);
|
const { width, height } = getAssetRatio(assetResponse);
|
||||||
const ratio = width / height;
|
const ratio = width / height;
|
||||||
const city = assetResponse.exifInfo?.city;
|
const city = assetResponse.exifInfo?.city;
|
||||||
const country = assetResponse.exifInfo?.country;
|
const country = assetResponse.exifInfo?.country;
|
||||||
const people = assetResponse.people?.map((person) => person.name) || [];
|
const people = assetResponse.people?.map((person) => person.name) || [];
|
||||||
const text = {
|
|
||||||
city: city || null,
|
|
||||||
country: country || null,
|
|
||||||
people,
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
id: assetResponse.id,
|
id: assetResponse.id,
|
||||||
ownerId: assetResponse.ownerId,
|
ownerId: assetResponse.ownerId,
|
||||||
@ -83,7 +79,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
|
|||||||
thumbhash: assetResponse.thumbhash,
|
thumbhash: assetResponse.thumbhash,
|
||||||
localDateTime: assetResponse.localDateTime,
|
localDateTime: assetResponse.localDateTime,
|
||||||
isFavorite: assetResponse.isFavorite,
|
isFavorite: assetResponse.isFavorite,
|
||||||
visibility: assetResponse.visibility,
|
visibility: assetResponse.isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline,
|
||||||
isTrashed: assetResponse.isTrashed,
|
isTrashed: assetResponse.isTrashed,
|
||||||
isVideo: assetResponse.type == AssetTypeEnum.Video,
|
isVideo: assetResponse.type == AssetTypeEnum.Video,
|
||||||
isImage: assetResponse.type == AssetTypeEnum.Image,
|
isImage: assetResponse.type == AssetTypeEnum.Image,
|
||||||
@ -91,8 +87,10 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
|
|||||||
duration: assetResponse.duration || null,
|
duration: assetResponse.duration || null,
|
||||||
projectionType: assetResponse.exifInfo?.projectionType || null,
|
projectionType: assetResponse.exifInfo?.projectionType || null,
|
||||||
livePhotoVideoId: assetResponse.livePhotoVideoId || null,
|
livePhotoVideoId: assetResponse.livePhotoVideoId || null,
|
||||||
text,
|
city: city || null,
|
||||||
|
country: country || null,
|
||||||
|
people,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
export const isTimelineAsset = (asset: AssetResponseDto | TimelineAsset): asset is TimelineAsset =>
|
export const isTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): unknownAsset is TimelineAsset =>
|
||||||
(asset as TimelineAsset).ratio !== undefined;
|
(unknownAsset as TimelineAsset).ratio !== undefined;
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import { AssetTypeEnum, Visibility, type AssetResponseDto } from '@immich/sdk';
|
import {
|
||||||
|
AssetTypeEnum,
|
||||||
|
AssetVisibility,
|
||||||
|
Visibility,
|
||||||
|
type AssetResponseDto,
|
||||||
|
type TimeBucketAssetResponseDto,
|
||||||
|
} from '@immich/sdk';
|
||||||
import { Sync } from 'factory.ts';
|
import { Sync } from 'factory.ts';
|
||||||
|
|
||||||
export const assetFactory = Sync.makeFactory<AssetResponseDto>({
|
export const assetFactory = Sync.makeFactory<AssetResponseDto>({
|
||||||
@ -35,7 +41,7 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
|
|||||||
thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
|
thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
|
||||||
localDateTime: Sync.each(() => faker.date.past().toISOString()),
|
localDateTime: Sync.each(() => faker.date.past().toISOString()),
|
||||||
isFavorite: Sync.each(() => faker.datatype.boolean()),
|
isFavorite: Sync.each(() => faker.datatype.boolean()),
|
||||||
visibility: Visibility.Timeline,
|
visibility: AssetVisibility.Timeline,
|
||||||
isTrashed: false,
|
isTrashed: false,
|
||||||
isImage: true,
|
isImage: true,
|
||||||
isVideo: false,
|
isVideo: false,
|
||||||
@ -43,9 +49,46 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
|
|||||||
stack: null,
|
stack: null,
|
||||||
projectionType: null,
|
projectionType: null,
|
||||||
livePhotoVideoId: Sync.each(() => faker.string.uuid()),
|
livePhotoVideoId: Sync.each(() => faker.string.uuid()),
|
||||||
text: Sync.each(() => ({
|
|
||||||
city: faker.location.city(),
|
city: faker.location.city(),
|
||||||
country: faker.location.country(),
|
country: faker.location.country(),
|
||||||
people: [faker.person.fullName()],
|
people: [faker.person.fullName()],
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
|
||||||
|
const bucketAssets: TimeBucketAssetResponseDto = {
|
||||||
|
city: [],
|
||||||
|
country: [],
|
||||||
|
duration: [],
|
||||||
|
id: [],
|
||||||
|
visibility: [],
|
||||||
|
isFavorite: [],
|
||||||
|
isImage: [],
|
||||||
|
isTrashed: [],
|
||||||
|
livePhotoVideoId: [],
|
||||||
|
localDateTime: [],
|
||||||
|
ownerId: [],
|
||||||
|
projectionType: [],
|
||||||
|
ratio: [],
|
||||||
|
stack: [],
|
||||||
|
thumbhash: [],
|
||||||
|
};
|
||||||
|
for (const asset of timelineAsset) {
|
||||||
|
bucketAssets.city.push(asset.city);
|
||||||
|
bucketAssets.country.push(asset.country);
|
||||||
|
bucketAssets.duration.push(asset.duration!);
|
||||||
|
bucketAssets.id.push(asset.id);
|
||||||
|
bucketAssets.visibility.push(asset.visibility);
|
||||||
|
bucketAssets.isFavorite.push(asset.isFavorite);
|
||||||
|
bucketAssets.isImage.push(asset.isImage);
|
||||||
|
bucketAssets.isTrashed.push(asset.isTrashed);
|
||||||
|
bucketAssets.livePhotoVideoId.push(asset.livePhotoVideoId!);
|
||||||
|
bucketAssets.localDateTime.push(asset.localDateTime);
|
||||||
|
bucketAssets.ownerId.push(asset.ownerId);
|
||||||
|
bucketAssets.projectionType.push(asset.projectionType!);
|
||||||
|
bucketAssets.ratio.push(asset.ratio);
|
||||||
|
bucketAssets.stack?.push(asset.stack ? [asset.stack.id, asset.stack.assetCount.toString()] : null);
|
||||||
|
bucketAssets.thumbhash.push(asset.thumbhash!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bucketAssets;
|
||||||
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user