diff --git a/.vscode/settings.json b/.vscode/settings.json index 49692809bc..8ca2e47e61 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,7 +8,11 @@ "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.tabSize": 2, - "editor.formatOnSave": true + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.removeUnusedImports": "explicit", + "source.organizeImports": "explicit" + } }, "[css]": { "editor.defaultFormatter": "esbenp.prettier-vscode", @@ -17,13 +21,14 @@ }, "[svelte]": { "editor.defaultFormatter": "svelte.svelte-vscode", - "editor.tabSize": 2 + "editor.tabSize": 2, + "editor.codeActionsOnSave": { + "source.removeUnusedImports": "explicit", + "source.organizeImports": "explicit" + } }, "svelte.enable-ts-plugin": true, - "eslint.validate": [ - "javascript", - "svelte" - ], + "eslint.validate": ["javascript", "svelte"], "typescript.preferences.importModuleSpecifier": "non-relative", "[dart]": { "editor.formatOnSave": true, @@ -34,12 +39,10 @@ "editor.wordBasedSuggestions": "off", "editor.defaultFormatter": "Dart-Code.dart-code" }, - "cSpell.words": [ - "immich" - ], + "cSpell.words": ["immich"], "explorer.fileNesting.enabled": true, "explorer.fileNesting.patterns": { "*.ts": "${capture}.spec.ts,${capture}.mock.ts", "*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart" } -} \ No newline at end of file +} diff --git a/api.mustache b/api.mustache new file mode 100644 index 0000000000..c6289c2b76 --- /dev/null +++ b/api.mustache @@ -0,0 +1,194 @@ +{{>header}} +{{>part_of}} +{{#operations}} + +class {{{classname}}} { + {{{classname}}}([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + {{#operation}} + + {{#summary}} + /// {{{.}}} + {{/summary}} + {{#notes}} + {{#summary}} + /// + {{/summary}} + /// {{{notes}}} + /// + /// Note: This method returns the HTTP [Response]. + {{/notes}} + {{^notes}} + {{#summary}} + /// + /// Note: This method returns the HTTP [Response]. + {{/summary}} + {{^summary}} + /// Performs an HTTP '{{{httpMethod}}} {{{path}}}' operation and returns the [Response]. + {{/summary}} + {{/notes}} + {{#hasParams}} + {{#summary}} + /// + {{/summary}} + {{^summary}} + {{#notes}} + /// + {{/notes}} + {{/summary}} + /// Parameters: + /// + {{/hasParams}} + {{#allParams}} + /// * [{{{dataType}}}] {{{paramName}}}{{#required}} (required){{/required}}{{#optional}} (optional){{/optional}}: + {{#description}} + /// {{{.}}} + {{/description}} + {{^-last}} + /// + {{/-last}} + {{/allParams}} + Future {{{nickname}}}WithHttpInfo({{#allParams}}{{#required}}{{{dataType}}} {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}}{ {{#allParams}}{{^required}}{{{dataType}}}? {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} }{{/hasOptionalParams}}) async { + // ignore: prefer_const_declarations + final path = r'{{{path}}}'{{#pathParams}} + .replaceAll({{=<% %>=}}'{<% baseName %>}'<%={{ }}=%>, {{{paramName}}}{{^isString}}.toString(){{/isString}}){{/pathParams}}; + + // ignore: prefer_final_locals + Object? postBody{{#bodyParam}} = {{{paramName}}}{{/bodyParam}}; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + {{#hasQueryParams}} + + {{#queryParams}} + {{^required}} + if ({{{paramName}}} != null) { + {{/required}} + queryParams.addAll(_queryParams('{{{collectionFormat}}}', '{{{baseName}}}', {{{paramName}}})); + {{^required}} + } + {{/required}} + {{/queryParams}} + {{/hasQueryParams}} + {{#hasHeaderParams}} + + {{#headerParams}} + {{#required}} + headerParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}}); + {{/required}} + {{^required}} + if ({{{paramName}}} != null) { + headerParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}}); + } + {{/required}} + {{/headerParams}} + {{/hasHeaderParams}} + + const contentTypes = [{{#prioritizedContentTypes}}'{{{mediaType}}}'{{^-last}}, {{/-last}}{{/prioritizedContentTypes}}]; + + {{#isMultipart}} + bool hasFields = false; + final mp = MultipartRequest('{{{httpMethod}}}', Uri.parse(path)); + {{#formParams}} + {{^isFile}} + if ({{{paramName}}} != null) { + hasFields = true; + mp.fields[r'{{{baseName}}}'] = parameterToString({{{paramName}}}); + } + {{/isFile}} + {{#isFile}} + if ({{{paramName}}} != null) { + hasFields = true; + mp.fields[r'{{{baseName}}}'] = {{{paramName}}}.field; + mp.files.add({{{paramName}}}); + } + {{/isFile}} + {{/formParams}} + if (hasFields) { + postBody = mp; + } + {{/isMultipart}} + {{^isMultipart}} + {{#formParams}} + {{^isFile}} + if ({{{paramName}}} != null) { + formParams[r'{{{baseName}}}'] = parameterToString({{{paramName}}}); + } + {{/isFile}} + {{/formParams}} + {{/isMultipart}} + + return apiClient.invokeAPI( + path, + '{{{httpMethod}}}', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + {{#summary}} + /// {{{.}}} + {{/summary}} + {{#notes}} + {{#summary}} + /// + {{/summary}} + /// {{{notes}}} + {{/notes}} + {{#hasParams}} + {{#summary}} + /// + {{/summary}} + {{^summary}} + {{#notes}} + /// + {{/notes}} + {{/summary}} + /// Parameters: + /// + {{/hasParams}} + {{#allParams}} + /// * [{{{dataType}}}] {{{paramName}}}{{#required}} (required){{/required}}{{#optional}} (optional){{/optional}}: + {{#description}} + /// {{{.}}} + {{/description}} + {{^-last}} + /// + {{/-last}} + {{/allParams}} + Future<{{#returnType}}{{{.}}}?{{/returnType}}{{^returnType}}void{{/returnType}}> {{{nickname}}}({{#allParams}}{{#required}}{{{dataType}}} {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}}{ {{#allParams}}{{^required}}{{{dataType}}}? {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} }{{/hasOptionalParams}}) async { + final response = await {{{nickname}}}WithHttpInfo({{#allParams}}{{#required}}{{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}}{{#hasOptionalParams}} {{#allParams}}{{^required}}{{{paramName}}}: {{{paramName}}},{{^-last}} {{/-last}}{{/required}}{{/allParams}} {{/hasOptionalParams}}); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + {{#returnType}} + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + {{#native_serialization}} + {{#isArray}} + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, '{{{returnType}}}') as List) + .cast<{{{returnBaseType}}}>() + .{{#uniqueItems}}toSet(){{/uniqueItems}}{{^uniqueItems}}toList(growable: false){{/uniqueItems}}; + {{/isArray}} + {{^isArray}} + {{#isMap}} + return {{{returnType}}}.from(await apiClient.deserializeAsync(await _decodeBodyBytes(response), '{{{returnType}}}'),); + {{/isMap}} + {{^isMap}} + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), '{{{returnType}}}',) as {{{returnType}}}; + {{/isMap}}{{/isArray}}{{/native_serialization}} + } + return null; + {{/returnType}} + } + {{/operation}} +} +{{/operations}} diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 073ae932ce..6b326582fc 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -475,8 +475,11 @@ Class | Method | HTTP request | Description - [TemplateDto](doc//TemplateDto.md) - [TemplateResponseDto](doc//TemplateResponseDto.md) - [TestEmailResponseDto](doc//TestEmailResponseDto.md) + - [TimeBucketAssetResponseDto](doc//TimeBucketAssetResponseDto.md) + - [TimeBucketAssetResponseDtoDurationInner](doc//TimeBucketAssetResponseDtoDurationInner.md) - [TimeBucketResponseDto](doc//TimeBucketResponseDto.md) - - [TimeBucketSize](doc//TimeBucketSize.md) + - [TimeBucketsResponseDto](doc//TimeBucketsResponseDto.md) + - [TimelineStackResponseDto](doc//TimelineStackResponseDto.md) - [ToneMapping](doc//ToneMapping.md) - [TranscodeHWAccel](doc//TranscodeHWAccel.md) - [TranscodePolicy](doc//TranscodePolicy.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index ff5a95bbbc..3815af4960 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -282,8 +282,11 @@ part 'model/tags_update.dart'; part 'model/template_dto.dart'; part 'model/template_response_dto.dart'; part 'model/test_email_response_dto.dart'; +part 'model/time_bucket_asset_response_dto.dart'; +part 'model/time_bucket_asset_response_dto_duration_inner.dart'; part 'model/time_bucket_response_dto.dart'; -part 'model/time_bucket_size.dart'; +part 'model/time_buckets_response_dto.dart'; +part 'model/timeline_stack_response_dto.dart'; part 'model/tone_mapping.dart'; part 'model/transcode_hw_accel.dart'; part 'model/transcode_policy.dart'; diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 7ea7189b00..6513038d40 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -19,8 +19,6 @@ class TimelineApi { /// Performs an HTTP 'GET /timeline/bucket' operation and returns the [Response]. /// Parameters: /// - /// * [TimeBucketSize] size (required): - /// /// * [String] timeBucket (required): /// /// * [String] albumId: @@ -35,6 +33,10 @@ class TimelineApi { /// /// * [AssetOrder] order: /// + /// * [num] page: + /// + /// * [num] pageSize: + /// /// * [String] personId: /// /// * [String] tagId: @@ -44,7 +46,7 @@ class TimelineApi { /// * [bool] withPartners: /// /// * [bool] withStacked: - Future getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, num? page, num? pageSize, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final apiPath = r'/timeline/bucket'; @@ -73,10 +75,15 @@ class TimelineApi { if (order != null) { queryParams.addAll(_queryParams('', 'order', order)); } + if (page != null) { + queryParams.addAll(_queryParams('', 'page', page)); + } + if (pageSize != null) { + queryParams.addAll(_queryParams('', 'pageSize', pageSize)); + } if (personId != null) { queryParams.addAll(_queryParams('', 'personId', personId)); } - queryParams.addAll(_queryParams('', 'size', size)); if (tagId != null) { queryParams.addAll(_queryParams('', 'tagId', tagId)); } @@ -107,8 +114,6 @@ class TimelineApi { /// Parameters: /// - /// * [TimeBucketSize] size (required): - /// /// * [String] timeBucket (required): /// /// * [String] albumId: @@ -123,6 +128,10 @@ class TimelineApi { /// /// * [AssetOrder] order: /// + /// * [num] page: + /// + /// * [num] pageSize: + /// /// * [String] personId: /// /// * [String] tagId: @@ -132,8 +141,8 @@ class TimelineApi { /// * [bool] withPartners: /// /// * [bool] withStacked: - Future?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + Future getTimeBucket(String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, num? page, num? pageSize, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, page: page, pageSize: pageSize, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -141,11 +150,8 @@ class TimelineApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() - .toList(growable: false); - + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TimeBucketResponseDto',) as TimeBucketResponseDto; + } return null; } @@ -153,8 +159,6 @@ class TimelineApi { /// Performs an HTTP 'GET /timeline/buckets' operation and returns the [Response]. /// Parameters: /// - /// * [TimeBucketSize] size (required): - /// /// * [String] albumId: /// /// * [bool] isArchived: @@ -176,7 +180,7 @@ class TimelineApi { /// * [bool] withPartners: /// /// * [bool] withStacked: - Future getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketsWithHttpInfo({ String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final apiPath = r'/timeline/buckets'; @@ -208,7 +212,6 @@ class TimelineApi { if (personId != null) { queryParams.addAll(_queryParams('', 'personId', personId)); } - queryParams.addAll(_queryParams('', 'size', size)); if (tagId != null) { queryParams.addAll(_queryParams('', 'tagId', tagId)); } @@ -238,8 +241,6 @@ class TimelineApi { /// Parameters: /// - /// * [TimeBucketSize] size (required): - /// /// * [String] albumId: /// /// * [bool] isArchived: @@ -261,8 +262,8 @@ class TimelineApi { /// * [bool] withPartners: /// /// * [bool] withStacked: - Future?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + Future?> getTimeBuckets({ String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -271,8 +272,8 @@ class TimelineApi { // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() .toList(growable: false); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 5759217f41..51d99f5559 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -620,10 +620,16 @@ class ApiClient { return TemplateResponseDto.fromJson(value); case 'TestEmailResponseDto': return TestEmailResponseDto.fromJson(value); + case 'TimeBucketAssetResponseDto': + return TimeBucketAssetResponseDto.fromJson(value); + case 'TimeBucketAssetResponseDtoDurationInner': + return TimeBucketAssetResponseDtoDurationInner.fromJson(value); case 'TimeBucketResponseDto': return TimeBucketResponseDto.fromJson(value); - case 'TimeBucketSize': - return TimeBucketSizeTypeTransformer().decode(value); + case 'TimeBucketsResponseDto': + return TimeBucketsResponseDto.fromJson(value); + case 'TimelineStackResponseDto': + return TimelineStackResponseDto.fromJson(value); case 'ToneMapping': return ToneMappingTypeTransformer().decode(value); case 'TranscodeHWAccel': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 1ebf8314ad..57a841418d 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -133,9 +133,6 @@ String parameterToString(dynamic value) { if (value is SyncRequestType) { return SyncRequestTypeTypeTransformer().encode(value).toString(); } - if (value is TimeBucketSize) { - return TimeBucketSizeTypeTransformer().encode(value).toString(); - } if (value is ToneMapping) { return ToneMappingTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart new file mode 100644 index 0000000000..7a0c03a886 --- /dev/null +++ b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart @@ -0,0 +1,219 @@ +// +// 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.duration = const [], + this.id = const [], + this.isArchived = const [], + this.isFavorite = const [], + this.isImage = const [], + this.isTrashed = const [], + this.isVideo = const [], + this.livePhotoVideoId = const [], + this.localDateTime = const [], + this.ownerId = const [], + this.projectionType = const [], + this.ratio = const [], + this.stack = const [], + this.thumbhash = const [], + }); + + List duration; + + List id; + + List isArchived; + + List isFavorite; + + List isImage; + + List isTrashed; + + List isVideo; + + List livePhotoVideoId; + + List localDateTime; + + List ownerId; + + List projectionType; + + List ratio; + + List stack; + + List thumbhash; + + @override + bool operator ==(Object other) => identical(this, other) || other is TimeBucketAssetResponseDto && + _deepEquality.equals(other.duration, duration) && + _deepEquality.equals(other.id, id) && + _deepEquality.equals(other.isArchived, isArchived) && + _deepEquality.equals(other.isFavorite, isFavorite) && + _deepEquality.equals(other.isImage, isImage) && + _deepEquality.equals(other.isTrashed, isTrashed) && + _deepEquality.equals(other.isVideo, isVideo) && + _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); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (duration.hashCode) + + (id.hashCode) + + (isArchived.hashCode) + + (isFavorite.hashCode) + + (isImage.hashCode) + + (isTrashed.hashCode) + + (isVideo.hashCode) + + (livePhotoVideoId.hashCode) + + (localDateTime.hashCode) + + (ownerId.hashCode) + + (projectionType.hashCode) + + (ratio.hashCode) + + (stack.hashCode) + + (thumbhash.hashCode); + + @override + String toString() => 'TimeBucketAssetResponseDto[duration=$duration, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isImage=$isImage, isTrashed=$isTrashed, isVideo=$isVideo, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, ownerId=$ownerId, projectionType=$projectionType, ratio=$ratio, stack=$stack, thumbhash=$thumbhash]'; + + Map toJson() { + final json = {}; + json[r'duration'] = this.duration; + json[r'id'] = this.id; + json[r'isArchived'] = this.isArchived; + json[r'isFavorite'] = this.isFavorite; + json[r'isImage'] = this.isImage; + json[r'isTrashed'] = this.isTrashed; + json[r'isVideo'] = this.isVideo; + 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; + 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(); + + return TimeBucketAssetResponseDto( + duration: TimeBucketAssetResponseDtoDurationInner.listFromJson(json[r'duration']), + id: json[r'id'] is Iterable + ? (json[r'id'] as Iterable).cast().toList(growable: false) + : const [], + isArchived: json[r'isArchived'] is Iterable + ? (json[r'isArchived'] as Iterable).cast().toList(growable: false) + : const [], + isFavorite: json[r'isFavorite'] is Iterable + ? (json[r'isFavorite'] as Iterable).cast().toList(growable: false) + : const [], + isImage: json[r'isImage'] is Iterable + ? (json[r'isImage'] as Iterable).cast().toList(growable: false) + : const [], + isTrashed: json[r'isTrashed'] is Iterable + ? (json[r'isTrashed'] as Iterable).cast().toList(growable: false) + : const [], + isVideo: json[r'isVideo'] is Iterable + ? (json[r'isVideo'] as Iterable).cast().toList(growable: false) + : const [], + livePhotoVideoId: TimeBucketAssetResponseDtoDurationInner.listFromJson(json[r'livePhotoVideoId']), + localDateTime: DateTime.listFromJson(json[r'localDateTime']), + ownerId: json[r'ownerId'] is Iterable + ? (json[r'ownerId'] as Iterable).cast().toList(growable: false) + : const [], + projectionType: TimeBucketAssetResponseDtoDurationInner.listFromJson(json[r'projectionType']), + ratio: json[r'ratio'] is Iterable + ? (json[r'ratio'] as Iterable).cast().toList(growable: false) + : const [], + stack: TimelineStackResponseDto.listFromJson(json[r'stack']), + thumbhash: TimeBucketAssetResponseDtoDurationInner.listFromJson(json[r'thumbhash']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + 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 mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + 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 = { + 'duration', + 'id', + 'isArchived', + 'isFavorite', + 'isImage', + 'isTrashed', + 'isVideo', + 'livePhotoVideoId', + 'localDateTime', + 'ownerId', + 'projectionType', + 'ratio', + 'stack', + 'thumbhash', + }; +} + diff --git a/mobile/openapi/lib/model/time_bucket_asset_response_dto_duration_inner.dart b/mobile/openapi/lib/model/time_bucket_asset_response_dto_duration_inner.dart new file mode 100644 index 0000000000..b9b7527c3b --- /dev/null +++ b/mobile/openapi/lib/model/time_bucket_asset_response_dto_duration_inner.dart @@ -0,0 +1,91 @@ +// +// 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 TimeBucketAssetResponseDtoDurationInner { + /// Returns a new [TimeBucketAssetResponseDtoDurationInner] instance. + TimeBucketAssetResponseDtoDurationInner({ + }); + + @override + bool operator ==(Object other) => identical(this, other) || other is TimeBucketAssetResponseDtoDurationInner && + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + + @override + String toString() => 'TimeBucketAssetResponseDtoDurationInner[]'; + + Map toJson() { + final json = {}; + return json; + } + + /// Returns a new [TimeBucketAssetResponseDtoDurationInner] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TimeBucketAssetResponseDtoDurationInner? fromJson(dynamic value) { + upgradeDto(value, "TimeBucketAssetResponseDtoDurationInner"); + if (value is Map) { + final json = value.cast(); + + return TimeBucketAssetResponseDtoDurationInner( + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TimeBucketAssetResponseDtoDurationInner.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TimeBucketAssetResponseDtoDurationInner.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TimeBucketAssetResponseDtoDurationInner-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TimeBucketAssetResponseDtoDurationInner.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/time_bucket_response_dto.dart b/mobile/openapi/lib/model/time_bucket_response_dto.dart index 56044b27a8..a79d87474d 100644 --- a/mobile/openapi/lib/model/time_bucket_response_dto.dart +++ b/mobile/openapi/lib/model/time_bucket_response_dto.dart @@ -13,32 +13,32 @@ part of openapi.api; class TimeBucketResponseDto { /// Returns a new [TimeBucketResponseDto] instance. TimeBucketResponseDto({ - required this.count, - required this.timeBucket, + required this.bucketAssets, + required this.hasNextPage, }); - int count; + TimeBucketAssetResponseDto bucketAssets; - String timeBucket; + bool hasNextPage; @override bool operator ==(Object other) => identical(this, other) || other is TimeBucketResponseDto && - other.count == count && - other.timeBucket == timeBucket; + other.bucketAssets == bucketAssets && + other.hasNextPage == hasNextPage; @override int get hashCode => // ignore: unnecessary_parenthesis - (count.hashCode) + - (timeBucket.hashCode); + (bucketAssets.hashCode) + + (hasNextPage.hashCode); @override - String toString() => 'TimeBucketResponseDto[count=$count, timeBucket=$timeBucket]'; + String toString() => 'TimeBucketResponseDto[bucketAssets=$bucketAssets, hasNextPage=$hasNextPage]'; Map toJson() { final json = {}; - json[r'count'] = this.count; - json[r'timeBucket'] = this.timeBucket; + json[r'bucketAssets'] = this.bucketAssets; + json[r'hasNextPage'] = this.hasNextPage; return json; } @@ -51,8 +51,8 @@ class TimeBucketResponseDto { final json = value.cast(); return TimeBucketResponseDto( - count: mapValueOfType(json, r'count')!, - timeBucket: mapValueOfType(json, r'timeBucket')!, + bucketAssets: TimeBucketAssetResponseDto.fromJson(json[r'bucketAssets'])!, + hasNextPage: mapValueOfType(json, r'hasNextPage')!, ); } return null; @@ -100,8 +100,8 @@ class TimeBucketResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'count', - 'timeBucket', + 'bucketAssets', + 'hasNextPage', }; } diff --git a/mobile/openapi/lib/model/time_bucket_size.dart b/mobile/openapi/lib/model/time_bucket_size.dart deleted file mode 100644 index e843b43f43..0000000000 --- a/mobile/openapi/lib/model/time_bucket_size.dart +++ /dev/null @@ -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 = [ - DAY, - MONTH, - ]; - - static TimeBucketSize? fromJson(dynamic value) => TimeBucketSizeTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = 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; -} - diff --git a/mobile/openapi/lib/model/time_buckets_response_dto.dart b/mobile/openapi/lib/model/time_buckets_response_dto.dart new file mode 100644 index 0000000000..8c9f8dab61 --- /dev/null +++ b/mobile/openapi/lib/model/time_buckets_response_dto.dart @@ -0,0 +1,107 @@ +// +// 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 TimeBucketsResponseDto { + /// Returns a new [TimeBucketsResponseDto] instance. + TimeBucketsResponseDto({ + required this.count, + required this.timeBucket, + }); + + int count; + + String timeBucket; + + @override + bool operator ==(Object other) => identical(this, other) || other is TimeBucketsResponseDto && + other.count == count && + other.timeBucket == timeBucket; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (count.hashCode) + + (timeBucket.hashCode); + + @override + String toString() => 'TimeBucketsResponseDto[count=$count, timeBucket=$timeBucket]'; + + Map toJson() { + final json = {}; + json[r'count'] = this.count; + json[r'timeBucket'] = this.timeBucket; + return json; + } + + /// Returns a new [TimeBucketsResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TimeBucketsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TimeBucketsResponseDto"); + if (value is Map) { + final json = value.cast(); + + return TimeBucketsResponseDto( + count: mapValueOfType(json, r'count')!, + timeBucket: mapValueOfType(json, r'timeBucket')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TimeBucketsResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TimeBucketsResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TimeBucketsResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TimeBucketsResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'count', + 'timeBucket', + }; +} + diff --git a/mobile/openapi/lib/model/timeline_stack_response_dto.dart b/mobile/openapi/lib/model/timeline_stack_response_dto.dart new file mode 100644 index 0000000000..132790ef03 --- /dev/null +++ b/mobile/openapi/lib/model/timeline_stack_response_dto.dart @@ -0,0 +1,115 @@ +// +// 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 TimelineStackResponseDto { + /// Returns a new [TimelineStackResponseDto] instance. + TimelineStackResponseDto({ + required this.assetCount, + required this.id, + required this.primaryAssetId, + }); + + num assetCount; + + String id; + + String primaryAssetId; + + @override + bool operator ==(Object other) => identical(this, other) || other is TimelineStackResponseDto && + other.assetCount == assetCount && + other.id == id && + other.primaryAssetId == primaryAssetId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetCount.hashCode) + + (id.hashCode) + + (primaryAssetId.hashCode); + + @override + String toString() => 'TimelineStackResponseDto[assetCount=$assetCount, id=$id, primaryAssetId=$primaryAssetId]'; + + Map toJson() { + final json = {}; + json[r'assetCount'] = this.assetCount; + json[r'id'] = this.id; + json[r'primaryAssetId'] = this.primaryAssetId; + return json; + } + + /// Returns a new [TimelineStackResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static TimelineStackResponseDto? fromJson(dynamic value) { + upgradeDto(value, "TimelineStackResponseDto"); + if (value is Map) { + final json = value.cast(); + + return TimelineStackResponseDto( + assetCount: num.parse('${json[r'assetCount']}'), + id: mapValueOfType(json, r'id')!, + primaryAssetId: mapValueOfType(json, r'primaryAssetId')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = TimelineStackResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = TimelineStackResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of TimelineStackResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = TimelineStackResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetCount', + 'id', + 'primaryAssetId', + }; +} + diff --git a/open-api/bin/native_class.mustache b/open-api/bin/native_class.mustache new file mode 100644 index 0000000000..1d406ea463 --- /dev/null +++ b/open-api/bin/native_class.mustache @@ -0,0 +1,301 @@ +class {{{classname}}} { +{{>dart_constructor}} +{{#vars}} + {{#description}} + /// {{{.}}} + {{/description}} + {{^isEnum}} + {{#minimum}} + {{#description}} + /// + {{/description}} + /// Minimum value: {{{.}}} + {{/minimum}} + {{#maximum}} + {{#description}} + {{^minimum}} + /// + {{/minimum}} + {{/description}} + /// Maximum value: {{{.}}} + {{/maximum}} + {{^isNullable}} + {{^required}} + {{^defaultValue}} + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + {{/defaultValue}} + {{/required}} + {{/isNullable}} + {{/isEnum}} + {{{datatypeWithEnum}}}{{#isNullable}}?{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}?{{/defaultValue}}{{/required}}{{/isNullable}} {{{name}}}; + +{{/vars}} + @override + bool operator ==(Object other) => identical(this, other) || other is {{{classname}}} && + {{#vars}} + {{#isMap}}_deepEquality.equals(other.{{{name}}}, {{{name}}}){{/isMap}}{{^isMap}}{{#isArray}}_deepEquality.equals(other.{{{name}}}, {{{name}}}){{/isArray}}{{^isArray}}other.{{{name}}} == {{{name}}}{{/isArray}}{{/isMap}}{{^-last}} &&{{/-last}}{{#-last}};{{/-last}} + {{/vars}} + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + {{#vars}} + ({{#isNullable}}{{{name}}} == null ? 0 : {{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}{{{name}}} == null ? 0 : {{/defaultValue}}{{/required}}{{/isNullable}}{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.hashCode){{^-last}} +{{/-last}}{{#-last}};{{/-last}} + {{/vars}} + + @override + String toString() => '{{{classname}}}[{{#vars}}{{{name}}}=${{{name}}}{{^-last}}, {{/-last}}{{/vars}}]'; + + Map toJson() { + final json = {}; + {{#vars}} + {{#isNullable}} + if (this.{{{name}}} != null) { + {{/isNullable}} + {{^isNullable}} + {{^required}} + {{^defaultValue}} + if (this.{{{name}}} != null) { + {{/defaultValue}} + {{/required}} + {{/isNullable}} + {{#isDateTime}} + {{#pattern}} + json[r'{{{baseName}}}'] = _isEpochMarker(r'{{{pattern}}}') + ? this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.millisecondsSinceEpoch + : this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc().toIso8601String(); + {{/pattern}} + {{^pattern}} + json[r'{{{baseName}}}'] = this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc().toIso8601String(); + {{/pattern}} + {{/isDateTime}} + {{#isDate}} + {{#pattern}} + json[r'{{{baseName}}}'] = _isEpochMarker(r'{{{pattern}}}') + ? this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.millisecondsSinceEpoch + : _dateFormatter.format(this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc()); + {{/pattern}} + {{^pattern}} + json[r'{{{baseName}}}'] = _dateFormatter.format(this.{{{name}}}{{#isNullable}}!{{/isNullable}}{{^isNullable}}{{^required}}{{^defaultValue}}!{{/defaultValue}}{{/required}}{{/isNullable}}.toUtc()); + {{/pattern}} + {{/isDate}} + {{^isDateTime}} + {{^isDate}} + json[r'{{{baseName}}}'] = this.{{{name}}}{{#isArray}}{{#uniqueItems}}{{#isNullable}}!{{/isNullable}}.toList(growable: false){{/uniqueItems}}{{/isArray}}; + {{/isDate}} + {{/isDateTime}} + {{#isNullable}} + } else { + json[r'{{{baseName}}}'] = null; + } + {{/isNullable}} + {{^isNullable}} + {{^required}} + {{^defaultValue}} + } else { + json[r'{{{baseName}}}'] = null; + } + {{/defaultValue}} + {{/required}} + {{/isNullable}} + {{/vars}} + return json; + } + + /// Returns a new [{{{classname}}}] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static {{{classname}}}? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + // Ensure that the map contains the required keys. + // Note 1: the values aren't checked for validity beyond being non-null. + // Note 2: this code is stripped in release mode! + assert(() { + requiredKeys.forEach((key) { + assert(json.containsKey(key), 'Required key "{{{classname}}}[$key]" is missing from JSON.'); + assert(json[key] != null, 'Required key "{{{classname}}}[$key]" has a null value in JSON.'); + }); + return true; + }()); + + return {{{classname}}}( + {{#vars}} + {{#isDateTime}} + {{{name}}}: mapDateTime(json, r'{{{baseName}}}', r'{{{pattern}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, + {{/isDateTime}} + {{#isDate}} + {{{name}}}: mapDateTime(json, r'{{{baseName}}}', r'{{{pattern}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, + {{/isDate}} + {{^isDateTime}} + {{^isDate}} + {{#complexType}} + {{#isArray}} + {{#items.isArray}} + {{{name}}}: json[r'{{{baseName}}}'] is List + ? (json[r'{{{baseName}}}'] as List).map((e) => + {{#items.complexType}} + {{items.complexType}}.listFromJson(json[r'{{{baseName}}}']){{#uniqueItems}}.toSet(){{/uniqueItems}} + {{/items.complexType}} + {{^items.complexType}} + e == null ? {{#items.isNullable}}null{{/items.isNullable}}{{^items.isNullable}}const <{{items.items.dataType}}>[]{{/items.isNullable}} : (e as List).cast<{{items.items.dataType}}>() + {{/items.complexType}} + ).toList() + : {{#isNullable}}null{{/isNullable}}{{^isNullable}}const []{{/isNullable}}, + {{/items.isArray}} + {{^items.isArray}} + {{{name}}}: {{{complexType}}}.listFromJson(json[r'{{{baseName}}}']){{#uniqueItems}}.toSet(){{/uniqueItems}}, + {{/items.isArray}} + {{/isArray}} + {{^isArray}} + {{#isMap}} + {{#items.isArray}} + {{{name}}}: json[r'{{{baseName}}}'] == null + ? {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}} + {{#items.complexType}} + : {{items.complexType}}.mapListFromJson(json[r'{{{baseName}}}']), + {{/items.complexType}} + {{^items.complexType}} + : mapCastOfType(json, r'{{{baseName}}}'), + {{/items.complexType}} + {{/items.isArray}} + {{^items.isArray}} + {{#items.isMap}} + {{#items.complexType}} + {{{name}}}: {{items.complexType}}.mapFromJson(json[r'{{{baseName}}}']), + {{/items.complexType}} + {{^items.complexType}} + {{{name}}}: mapCastOfType(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, + {{/items.complexType}} + {{/items.isMap}} + {{^items.isMap}} + {{#items.complexType}} + {{{name}}}: {{{items.complexType}}}.mapFromJson(json[r'{{{baseName}}}']), + {{/items.complexType}} + {{^items.complexType}} + {{{name}}}: mapCastOfType(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, + {{/items.complexType}} + {{/items.isMap}} + {{/items.isArray}} + {{/isMap}} + {{^isMap}} + {{#isBinary}} + {{{name}}}: null, // No support for decoding binary content from JSON + {{/isBinary}} + {{^isBinary}} + {{{name}}}: {{{complexType}}}.fromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, + {{/isBinary}} + {{/isMap}} + {{/isArray}} + {{/complexType}} + {{^complexType}} + {{#isArray}} + {{#isEnum}} + {{{name}}}: {{{items.datatypeWithEnum}}}.listFromJson(json[r'{{{baseName}}}']){{#uniqueItems}}.toSet(){{/uniqueItems}}, + {{/isEnum}} + {{^isEnum}} + {{{name}}}: json[r'{{{baseName}}}'] is Iterable + ? (json[r'{{{baseName}}}'] as Iterable).cast<{{{items.datatype}}}>().{{#uniqueItems}}toSet(){{/uniqueItems}}{{^uniqueItems}}toList(growable: false){{/uniqueItems}} + : {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}}, + {{/isEnum}} + {{/isArray}} + {{^isArray}} + {{#isMap}} + {{{name}}}: mapCastOfType(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, + {{/isMap}} + {{^isMap}} + {{#isNumber}} + {{{name}}}: {{#isNullable}}json[r'{{{baseName}}}'] == null + ? {{#defaultValue}}{{{.}}}{{/defaultValue}}{{^defaultValue}}null{{/defaultValue}} + : {{/isNullable}}{{{datatypeWithEnum}}}.parse('${json[r'{{{baseName}}}']}'), + {{/isNumber}} + {{^isNumber}} + {{^isEnum}} + {{{name}}}: mapValueOfType<{{{datatypeWithEnum}}}>(json, r'{{{baseName}}}'){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, + {{/isEnum}} + {{#isEnum}} + {{{name}}}: {{{enumName}}}.fromJson(json[r'{{{baseName}}}']){{#required}}{{^isNullable}}!{{/isNullable}}{{/required}}{{^required}}{{#defaultValue}} ?? {{{.}}}{{/defaultValue}}{{/required}}, + {{/isEnum}} + {{/isNumber}} + {{/isMap}} + {{/isArray}} + {{/complexType}} + {{/isDate}} + {{/isDateTime}} + {{/vars}} + ); + } + return null; + } + + static List<{{{classname}}}> listFromJson(dynamic json, {bool growable = false,}) { + final result = <{{{classname}}}>[]; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = {{{classname}}}.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = {{{classname}}}.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of {{{classname}}}-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = {{{classname}}}.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { +{{#vars}} + {{#required}} + '{{{baseName}}}', + {{/required}} +{{/vars}} + }; +} +{{#vars}} + {{^isModel}} + {{#isEnum}} + {{^isContainer}} + +{{>serialization/native/native_enum_inline}} + {{/isContainer}} + {{#isContainer}} + {{#mostInnerItems}} + +{{>serialization/native/native_enum_inline}} + {{/mostInnerItems}} + {{/isContainer}} + {{/isEnum}} + {{/isModel}} +{{/vars}} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 53709f3f0c..fed8276951 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6815,6 +6815,23 @@ "$ref": "#/components/schemas/AssetOrder" } }, + { + "name": "page", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "type": "number" + } + }, + { + "name": "pageSize", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, { "name": "personId", "required": false, @@ -6824,14 +6841,6 @@ "type": "string" } }, - { - "name": "size", - "required": true, - "in": "query", - "schema": { - "$ref": "#/components/schemas/TimeBucketSize" - } - }, { "name": "tagId", "required": false, @@ -6880,10 +6889,7 @@ "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - }, - "type": "array" + "$ref": "#/components/schemas/TimeBucketResponseDto" } } }, @@ -6968,14 +6974,6 @@ "type": "string" } }, - { - "name": "size", - "required": true, - "in": "query", - "schema": { - "$ref": "#/components/schemas/TimeBucketSize" - } - }, { "name": "tagId", "required": false, @@ -7017,7 +7015,7 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/TimeBucketResponseDto" + "$ref": "#/components/schemas/TimeBucketsResponseDto" }, "type": "array" } @@ -13405,7 +13403,170 @@ ], "type": "object" }, + "TimeBucketAssetResponseDto": { + "properties": { + "duration": { + "default": [], + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "type": "array" + }, + "id": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "isArchived": { + "default": [], + "items": { + "type": "number" + }, + "type": "array" + }, + "isFavorite": { + "default": [], + "items": { + "type": "number" + }, + "type": "array" + }, + "isImage": { + "default": [], + "items": { + "type": "number" + }, + "type": "array" + }, + "isTrashed": { + "default": [], + "items": { + "type": "number" + }, + "type": "array" + }, + "isVideo": { + "default": [], + "items": { + "type": "number" + }, + "type": "array" + }, + "livePhotoVideoId": { + "default": [], + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "type": "array" + }, + "localDateTime": { + "default": [], + "items": { + "format": "date-time", + "type": "string" + }, + "type": "array" + }, + "ownerId": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "projectionType": { + "default": [], + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "type": "array" + }, + "ratio": { + "default": [], + "items": { + "type": "number" + }, + "type": "array" + }, + "stack": { + "default": [], + "items": { + "$ref": "#/components/schemas/TimelineStackResponseDto" + }, + "type": "array" + }, + "thumbhash": { + "default": [], + "items": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "type": "array" + } + }, + "required": [ + "duration", + "id", + "isArchived", + "isFavorite", + "isImage", + "isTrashed", + "isVideo", + "livePhotoVideoId", + "localDateTime", + "ownerId", + "projectionType", + "ratio", + "stack", + "thumbhash" + ], + "type": "object" + }, "TimeBucketResponseDto": { + "properties": { + "bucketAssets": { + "$ref": "#/components/schemas/TimeBucketAssetResponseDto" + }, + "hasNextPage": { + "type": "boolean" + } + }, + "required": [ + "bucketAssets", + "hasNextPage" + ], + "type": "object" + }, + "TimeBucketsResponseDto": { "properties": { "count": { "type": "integer" @@ -13420,12 +13581,24 @@ ], "type": "object" }, - "TimeBucketSize": { - "enum": [ - "DAY", - "MONTH" + "TimelineStackResponseDto": { + "properties": { + "assetCount": { + "type": "number" + }, + "id": { + "type": "string" + }, + "primaryAssetId": { + "type": "string" + } + }, + "required": [ + "assetCount", + "id", + "primaryAssetId" ], - "type": "string" + "type": "object" }, "ToneMapping": { "enum": [ diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 01f476517e..f31482c4ac 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1380,7 +1380,32 @@ export type TagBulkAssetsResponseDto = { export type TagUpdateDto = { color?: string | null; }; +export type TimelineStackResponseDto = { + assetCount: number; + id: string; + primaryAssetId: string; +}; +export type TimeBucketAssetResponseDto = { + duration: (string | number)[]; + id: string[]; + isArchived: number[]; + isFavorite: number[]; + isImage: number[]; + isTrashed: number[]; + isVideo: number[]; + livePhotoVideoId: (string | number)[]; + localDateTime: string[]; + ownerId: string[]; + projectionType: (string | number)[]; + ratio: number[]; + stack: TimelineStackResponseDto[]; + thumbhash: (string | number)[]; +}; export type TimeBucketResponseDto = { + bucketAssets: TimeBucketAssetResponseDto; + hasNextPage: boolean; +}; +export type TimeBucketsResponseDto = { count: number; timeBucket: string; }; @@ -3201,15 +3226,16 @@ export function tagAssets({ id, bulkIdsDto }: { body: bulkIdsDto }))); } -export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, withPartners, withStacked }: { +export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, page, pageSize, personId, tagId, timeBucket, userId, withPartners, withStacked }: { albumId?: string; isArchived?: boolean; isFavorite?: boolean; isTrashed?: boolean; key?: string; order?: AssetOrder; + page?: number; + pageSize?: number; personId?: string; - size: TimeBucketSize; tagId?: string; timeBucket: string; userId?: string; @@ -3218,7 +3244,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: AssetResponseDto[]; + data: TimeBucketResponseDto; }>(`/timeline/bucket${QS.query(QS.explode({ albumId, isArchived, @@ -3226,8 +3252,9 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, isTrashed, key, order, + page, + pageSize, personId, - size, tagId, timeBucket, userId, @@ -3237,7 +3264,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, ...opts })); } -export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, userId, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, tagId, userId, withPartners, withStacked }: { albumId?: string; isArchived?: boolean; isFavorite?: boolean; @@ -3245,7 +3272,6 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key key?: string; order?: AssetOrder; personId?: string; - size: TimeBucketSize; tagId?: string; userId?: string; withPartners?: boolean; @@ -3253,7 +3279,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: TimeBucketResponseDto[]; + data: TimeBucketsResponseDto[]; }>(`/timeline/buckets${QS.query(QS.explode({ albumId, isArchived, @@ -3262,7 +3288,6 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key key, order, personId, - size, tagId, userId, withPartners, @@ -3736,7 +3761,3 @@ export enum LogLevel { Error = "error", Fatal = "fatal" } -export enum TimeBucketSize { - Day = "DAY", - Month = "MONTH" -} diff --git a/server/src/controllers/timeline.controller.ts b/server/src/controllers/timeline.controller.ts index 92de84d346..505d2d3b30 100644 --- a/server/src/controllers/timeline.controller.ts +++ b/server/src/controllers/timeline.controller.ts @@ -1,8 +1,7 @@ import { Controller, Get, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; +import { TimeBucketAssetDto, TimeBucketDto } from 'src/dtos/time-bucket.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { TimelineService } from 'src/services/timeline.service'; @@ -14,13 +13,13 @@ export class TimelineController { @Get('buckets') @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true }) - getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto): Promise { + getTimeBuckets(@Auth() auth: AuthDto, @Query() dto: TimeBucketDto) { return this.service.getTimeBuckets(auth, dto); } @Get('bucket') @Authenticated({ permission: Permission.ASSET_READ, sharedLink: true }) - getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto): Promise { - return this.service.getTimeBucket(auth, dto) as Promise; + getTimeBucket(@Auth() auth: AuthDto, @Query() dto: TimeBucketAssetDto) { + return this.service.getTimeBucket(auth, dto); } } diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index c0e589f380..086ee0f668 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -13,6 +13,7 @@ import { import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { AssetStatus, AssetType } from 'src/enum'; +import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { mimeTypes } from 'src/utils/mime-types'; 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 { const { stripMetadata = false, withStack = false } = options; diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index a9dfa49a07..99bec06fb3 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -1,15 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; + +import { IsEnum, IsInt, IsString, Min } from 'class-validator'; import { AssetOrder } from 'src/enum'; -import { TimeBucketSize } from 'src/repositories/asset.repository'; +import { TimeBucketAssets, TimelineStack } from 'src/services/timeline.service.types'; import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation'; export class TimeBucketDto { - @IsNotEmpty() - @IsEnum(TimeBucketSize) - @ApiProperty({ enum: TimeBucketSize, enumName: 'TimeBucketSize' }) - size!: TimeBucketSize; - @ValidateUUID({ optional: true }) userId?: string; @@ -46,12 +42,132 @@ export class TimeBucketDto { export class TimeBucketAssetDto extends TimeBucketDto { @IsString() timeBucket!: string; + + @IsInt() + @Min(1) + @Optional() + page?: number; + + @IsInt() + @Optional() + pageSize?: number; } -export class TimeBucketResponseDto { +export class TimelineStackResponseDto implements TimelineStack { + @ApiProperty() + id!: string; + + @ApiProperty() + primaryAssetId!: string; + + @ApiProperty() + assetCount!: number; +} + +export class TimeBucketAssetResponseDto implements TimeBucketAssets { + @ApiProperty({ type: [String] }) + id: string[] = []; + + @ApiProperty({ type: [String] }) + ownerId: string[] = []; + + @ApiProperty() + ratio: number[] = []; + + @ApiProperty() + isFavorite: number[] = []; + + @ApiProperty() + isArchived: number[] = []; + + @ApiProperty() + isTrashed: number[] = []; + + @ApiProperty() + isImage: number[] = []; + + @ApiProperty() + isVideo: number[] = []; + + @ApiProperty({ + type: 'array', + items: { + oneOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + ], + }, + }) + thumbhash: (string | number)[] = []; + + @ApiProperty() + localDateTime: Date[] = []; + + @ApiProperty({ + type: 'array', + items: { + oneOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + ], + }, + }) + duration: (string | number)[] = []; + + @ApiProperty({ type: [TimelineStackResponseDto] }) + stack: (TimelineStackResponseDto | number)[] = []; + + @ApiProperty({ + type: 'array', + items: { + oneOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + ], + }, + }) + projectionType: (string | number)[] = []; + + @ApiProperty({ + type: 'array', + items: { + oneOf: [ + { + type: 'string', + }, + { + type: 'number', + }, + ], + }, + }) + livePhotoVideoId: (string | number)[] = []; +} + +export class TimeBucketsResponseDto { @ApiProperty({ type: 'string' }) timeBucket!: string; @ApiProperty({ type: 'integer' }) count!: number; } + +export class TimeBucketResponseDto { + @ApiProperty({ type: TimeBucketAssetResponseDto }) + bucketAssets!: TimeBucketAssetResponseDto; + + @ApiProperty() + hasNextPage!: boolean; +} diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 7a68ba907f..e6224eb349 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -12,6 +12,7 @@ import { anyUuid, asUuid, hasPeople, + hasPeopleNoJoin, removeUndefinedKeys, searchAssetBuilder, truncatedDate, @@ -24,6 +25,7 @@ import { withOwner, withSmartSearch, withTagId, + withTagIdNoWhere, withTags, } from 'src/utils/database'; import { globToSqlPattern } from 'src/utils/misc'; @@ -80,7 +82,6 @@ export interface AssetBuilderOptions { } export interface TimeBucketOptions extends AssetBuilderOptions { - size: TimeBucketSize; order?: AssetOrder; } @@ -637,7 +638,7 @@ export class AssetRepository { .with('assets', (qb) => qb .selectFrom('assets') - .select(truncatedDate(options.size).as('timeBucket')) + .select(truncatedDate(TimeBucketSize.MONTH).as('timeBucket')) .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) .where('assets.isVisible', '=', true) @@ -679,18 +680,39 @@ export class AssetRepository { ); } - @GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }] }) - async getTimeBucket(timeBucket: string, options: TimeBucketOptions) { - return this.db + @GenerateSql({ + params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }, { skip: -1, take: 1000 }], + }) + async getTimeBucket(timeBucket: string, options: TimeBucketOptions, pagination: PaginationOptions) { + const paginate = pagination.skip! >= 1 && pagination.take >= 1; + const query = this.db .selectFrom('assets') - .selectAll('assets') - .$call(withExif) + .select([ + 'assets.id as id', + 'assets.ownerId', + 'assets.status', + 'deletedAt', + 'type', + 'duration', + 'isFavorite', + 'isArchived', + 'thumbhash', + 'localDateTime', + 'livePhotoVideoId', + ]) + .leftJoin('exif', 'assets.id', 'exif.assetId') + .select(['exif.exifImageHeight as height', 'exifImageWidth as width', 'exif.orientation', 'exif.projectionType']) .$if(!!options.albumId, (qb) => qb .innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id') .where('albums_assets_assets.albumsId', '=', options.albumId!), ) - .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) + .$if(!!options.personId, (qb) => + qb.innerJoin( + () => hasPeopleNoJoin([options.personId!]), + (join) => join.onRef('has_people.assetId', '=', 'assets.id'), + ), + ) .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) @@ -720,12 +742,15 @@ export class AssetRepository { qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null), ) .$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED)) - .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)) + .$if(!!options.tagId, (qb) => qb.where((eb) => withTagIdNoWhere(options.tagId!, eb.ref('assets.id')))) .where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null) .where('assets.isVisible', '=', true) - .where(truncatedDate(options.size), '=', timeBucket.replace(/^[+-]/, '')) + .where(truncatedDate(TimeBucketSize.MONTH), '=', timeBucket.replace(/^[+-]/, '')) .orderBy('assets.localDateTime', options.order ?? 'desc') - .execute(); + .$if(paginate, (qb) => qb.offset(pagination.skip!)) + .$if(paginate, (qb) => qb.limit(pagination.take + 1)); + + return await query.execute(); } @GenerateSql({ params: [DummyValue.UUID] }) diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index c88348b39e..c42770eff2 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -4,7 +4,7 @@ import { DateTime } from 'luxon'; import { Writable } from 'node:stream'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; 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 { AssetDeltaSyncDto, @@ -18,6 +18,7 @@ import { DatabaseAction, EntityType, Permission, SyncEntityType, SyncRequestType import { BaseService } from 'src/services/base.service'; import { SyncAck } from 'src/types'; import { getMyPartnerIds } from 'src/utils/asset.util'; +import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { setIsEqual } from 'src/utils/set'; import { fromAck, serialize } from 'src/utils/sync'; diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index c6a09d2fdf..37eee0cea4 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -1,9 +1,7 @@ import { BadRequestException } from '@nestjs/common'; -import { TimeBucketSize } from 'src/repositories/asset.repository'; import { TimelineService } from 'src/services/timeline.service'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(TimelineService.name, () => { @@ -18,13 +16,10 @@ describe(TimelineService.name, () => { it("should return buckets if userId and albumId aren't set", async () => { mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]); - await expect( - sut.getTimeBuckets(authStub.admin, { - size: TimeBucketSize.DAY, - }), - ).resolves.toEqual(expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }])); + await expect(sut.getTimeBuckets(authStub.admin, {})).resolves.toEqual( + expect.arrayContaining([{ timeBucket: 'bucket', count: 1 }]), + ); expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({ - size: TimeBucketSize.DAY, userIds: [authStub.admin.user.id], }); }); @@ -35,16 +30,24 @@ describe(TimelineService.name, () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id'])); mocks.asset.getTimeBucket.mockResolvedValue([assetStub.image]); - await expect( - sut.getTimeBucket(authStub.admin, { size: TimeBucketSize.DAY, timeBucket: 'bucket', albumId: 'album-id' }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + await expect(sut.getTimeBucket(authStub.admin, { timeBucket: 'bucket', albumId: 'album-id' })).resolves.toEqual( + expect.objectContaining({ + bucketAssets: expect.objectContaining({ id: expect.arrayContaining(['asset-id']) }), + }), + ); expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-id'])); - expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - albumId: 'album-id', - }); + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( + 'bucket', + { + timeBucket: 'bucket', + albumId: 'album-id', + }, + { + skip: 1, + take: -1, + }, + ); }); it('should return the assets for a archive time bucket if user has archive.read', async () => { @@ -52,20 +55,26 @@ describe(TimelineService.name, () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', isArchived: true, userId: authStub.admin.user.id, }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + ).resolves.toEqual( + expect.objectContaining({ + bucketAssets: expect.objectContaining({ id: expect.arrayContaining(['asset-id']) }), + }), + ); expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( 'bucket', expect.objectContaining({ - size: TimeBucketSize.DAY, timeBucket: 'bucket', isArchived: true, userIds: [authStub.admin.user.id], }), + { + skip: 1, + take: -1, + }, ); }); @@ -75,20 +84,29 @@ describe(TimelineService.name, () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', isArchived: false, userId: authStub.admin.user.id, withPartners: true, }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { - size: TimeBucketSize.DAY, - timeBucket: 'bucket', - isArchived: false, - withPartners: true, - userIds: [authStub.admin.user.id], - }); + ).resolves.toEqual( + expect.objectContaining({ + bucketAssets: expect.objectContaining({ id: expect.arrayContaining(['asset-id']) }), + }), + ); + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( + 'bucket', + { + timeBucket: 'bucket', + isArchived: false, + withPartners: true, + userIds: [authStub.admin.user.id], + }, + { + skip: 1, + take: -1, + }, + ); }); it('should check permissions to read tag', async () => { @@ -97,41 +115,27 @@ describe(TimelineService.name, () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', userId: authStub.admin.user.id, tagId: 'tag-123', }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); - expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', { - size: TimeBucketSize.DAY, - tagId: 'tag-123', - timeBucket: 'bucket', - 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', - isArchived: true, - 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', - isArchived: true, - albumId: 'album-id', - }); + ).resolves.toEqual( + expect.objectContaining({ + bucketAssets: expect.objectContaining({ id: expect.arrayContaining(['asset-id']) }), + }), + ); + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( + 'bucket', + { + tagId: 'tag-123', + timeBucket: 'bucket', + userIds: [authStub.admin.user.id], + }, + { + skip: 1, + take: -1, + }, + ); }); it('should return the assets for a library time bucket if user has library.read', async () => { @@ -139,25 +143,30 @@ describe(TimelineService.name, () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', userId: authStub.admin.user.id, }), - ).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })])); + ).resolves.toEqual( + expect.objectContaining({ + bucketAssets: expect.objectContaining({ id: expect.arrayContaining(['asset-id']) }), + }), + ); expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( 'bucket', expect.objectContaining({ - size: TimeBucketSize.DAY, timeBucket: 'bucket', userIds: [authStub.admin.user.id], }), + { + skip: 1, + take: -1, + }, ); }); it('should throw an error if withParners is true and isArchived true or undefined', async () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', isArchived: true, withPartners: true, @@ -167,7 +176,6 @@ describe(TimelineService.name, () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', isArchived: undefined, withPartners: true, @@ -179,7 +187,6 @@ describe(TimelineService.name, () => { it('should throw an error if withParners is true and isFavorite is either true or false', async () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', isFavorite: true, withPartners: true, @@ -189,7 +196,6 @@ describe(TimelineService.name, () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', isFavorite: false, withPartners: true, @@ -201,7 +207,6 @@ describe(TimelineService.name, () => { it('should throw an error if withParners is true and isTrash is true', async () => { await expect( sut.getTimeBucket(authStub.admin, { - size: TimeBucketSize.DAY, timeBucket: 'bucket', isTrashed: true, withPartners: true, diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index 4c2332afaa..90e535df8a 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -1,30 +1,105 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; +import { round } from 'lodash'; +import { Stack } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; -import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto'; -import { Permission } from 'src/enum'; +import { + TimeBucketAssetDto, + TimeBucketDto, + TimeBucketResponseDto, + TimeBucketsResponseDto, +} from 'src/dtos/time-bucket.dto'; +import { AssetType, Permission } from 'src/enum'; import { TimeBucketOptions } from 'src/repositories/asset.repository'; import { BaseService } from 'src/services/base.service'; -import { getMyPartnerIds } from 'src/utils/asset.util'; +import { TimeBucketAssets } from 'src/services/timeline.service.types'; +import { getMyPartnerIds, isFlipped } from 'src/utils/asset.util'; +import { hexOrBufferToBase64 } from 'src/utils/bytes'; @Injectable() export class TimelineService extends BaseService { - async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { + async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise { await this.timeBucketChecks(auth, dto); const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); return this.assetRepository.getTimeBuckets(timeBucketOptions); } - async getTimeBucket( - auth: AuthDto, - dto: TimeBucketAssetDto, - ): Promise { + async getTimeBucket(auth: AuthDto, dto: TimeBucketAssetDto): Promise { await this.timeBucketChecks(auth, dto); - const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto); - const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions); - return !auth.sharedLink || auth.sharedLink?.showExif - ? assets.map((asset) => mapAsset(asset, { withStack: true, auth })) - : assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth })); + const timeBucketOptions = await this.buildTimeBucketOptions(auth, { ...dto }); + + const page = dto.page || 1; + const size = dto.pageSize || -1; + if (dto.pageSize === 0) { + throw new BadRequestException('pageSize must not be 0'); + } + const paginate = page >= 1 && size >= 1; + const items = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions, { + skip: page, + take: size, + }); + + const hasNextPage = paginate && items.length > size; + if (paginate) { + items.splice(size); + } + + const bucketAssets: TimeBucketAssets = { + id: [], + ownerId: [], + ratio: [], + isFavorite: [], + isArchived: [], + isTrashed: [], + isVideo: [], + isImage: [], + thumbhash: [], + localDateTime: [], + stack: [], + duration: [], + projectionType: [], + livePhotoVideoId: [], + }; + for (const item of items) { + let width = item.width!; + let height = item.height!; + if (isFlipped(item.orientation)) { + const w = item.width!; + const h = item.height!; + height = w; + width = h; + } + bucketAssets.id.push(item.id); + bucketAssets.ownerId.push(item.ownerId); + bucketAssets.ratio.push(round(width / height, 2)); + bucketAssets.isArchived.push(item.isArchived ? 1 : 0); + bucketAssets.isFavorite.push(item.isFavorite ? 1 : 0); + bucketAssets.isTrashed.push(item.deletedAt === null ? 0 : 1); + bucketAssets.thumbhash.push(item.thumbhash ? hexOrBufferToBase64(item.thumbhash) : 0); + bucketAssets.localDateTime.push(item.localDateTime); + bucketAssets.stack.push(this.mapStack(item.stack) || 0); + bucketAssets.duration.push(item.duration || 0); + bucketAssets.projectionType.push(item.projectionType || 0); + bucketAssets.livePhotoVideoId.push(item.livePhotoVideoId || 0); + bucketAssets.isImage.push(item.type === AssetType.IMAGE ? 1 : 0); + bucketAssets.isVideo.push(item.type === AssetType.VIDEO ? 1 : 0); + } + + return { + bucketAssets, + hasNextPage, + }; + } + + mapStack(entity?: Stack | null) { + if (!entity) { + return; + } + + return { + id: entity.id!, + primaryAssetId: entity.primaryAssetId!, + assetCount: entity.assetCount as number, + }; } private async buildTimeBucketOptions(auth: AuthDto, dto: TimeBucketDto): Promise { diff --git a/server/src/services/timeline.service.types.ts b/server/src/services/timeline.service.types.ts new file mode 100644 index 0000000000..e9a60fed37 --- /dev/null +++ b/server/src/services/timeline.service.types.ts @@ -0,0 +1,22 @@ +export type TimelineStack = { + id: string; + primaryAssetId: string; + assetCount: number; +}; + +export type TimeBucketAssets = { + id: string[]; + ownerId: string[]; + ratio: number[]; + isFavorite: number[]; + isArchived: number[]; + isTrashed: number[]; + isVideo: number[]; + isImage: number[]; + thumbhash: (string | number)[]; + localDateTime: Date[]; + stack: (TimelineStack | number)[]; + duration: (string | number)[]; + projectionType: (string | number)[]; + livePhotoVideoId: (string | number)[]; +}; diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 8905f84165..058746f388 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -197,3 +197,16 @@ export const asRequest = (request: AuthRequest, file: Express.Multer.File) => { file: mapToUploadFile(file as ImmichFile), }; }; + +function isRotated90CW(orientation: number) { + return orientation === 5 || orientation === 6 || orientation === 90; +} + +function isRotated270CW(orientation: number) { + return orientation === 7 || orientation === 8 || orientation === -90; +} + +export function isFlipped(orientation?: string | null) { + const value = Number(orientation); + return value && (isRotated270CW(value) || isRotated90CW(value)); +} diff --git a/server/src/utils/bytes.ts b/server/src/utils/bytes.ts index e837c81b9e..5e476f4dea 100644 --- a/server/src/utils/bytes.ts +++ b/server/src/utils/bytes.ts @@ -22,3 +22,12 @@ export function asHumanReadable(bytes: number, precision = 1): string { 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'); +}; diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 1af0aa4b4e..17b8f3235f 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -1,6 +1,7 @@ import { DeduplicateJoinsPlugin, Expression, + expressionBuilder, ExpressionBuilder, ExpressionWrapper, Kysely, @@ -180,18 +181,19 @@ export function withFacesAndPeople(eb: ExpressionBuilder, withDele } export function hasPeople(qb: SelectQueryBuilder, personIds: string[]) { - return qb.innerJoin( - (eb) => - eb - .selectFrom('asset_faces') - .select('assetId') - .where('personId', '=', anyUuid(personIds!)) - .where('deletedAt', 'is', null) - .groupBy('assetId') - .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) - .as('has_people'), - (join) => join.onRef('has_people.assetId', '=', 'assets.id'), - ); + return qb.innerJoin(hasPeopleNoJoin(personIds), (join) => join.onRef('has_people.assetId', '=', 'assets.id')); +} + +export function hasPeopleNoJoin(personIds: string[]) { + const eb = expressionBuilder(); + return eb + .selectFrom('asset_faces') + .select('assetId') + .where('personId', '=', anyUuid(personIds!)) + .where('deletedAt', 'is', null) + .groupBy('assetId') + .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) + .as('has_people'); } export function hasTags(qb: SelectQueryBuilder, tagIds: string[]) { @@ -236,16 +238,20 @@ export function truncatedDate(size: TimeBucketSize) { } export function withTagId(qb: SelectQueryBuilder, tagId: string) { - return qb.where((eb) => - eb.exists( - eb - .selectFrom('tags_closure') - .innerJoin('tag_asset', 'tag_asset.tagsId', 'tags_closure.id_descendant') - .whereRef('tag_asset.assetsId', '=', 'assets.id') - .where('tags_closure.id_ancestor', '=', tagId), - ), + return qb.where((eb) => withTagIdNoWhere(tagId, eb.ref('assets.id'))); +} + +export function withTagIdNoWhere(tagId: string, assetId: Expression) { + const eb = expressionBuilder(); + return eb.exists( + eb + .selectFrom('tags_closure') + .innerJoin('tag_asset', 'tag_asset.tagsId', 'tags_closure.id_descendant') + .whereRef('tag_asset.assetsId', '=', assetId) + .where('tags_closure.id_ancestor', '=', tagId), ); } + const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); /** TODO: This should only be used for search-related queries, not as a general purpose query builder */ diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index ddf6e50aa2..cb2d05ef09 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -13,7 +13,6 @@ import { LoggingRepository } from 'src/repositories/logging.repository'; import { bootstrapTelemetry } from 'src/repositories/telemetry.repository'; import { ApiService } from 'src/services/api.service'; import { isStartUpError, useSwagger } from 'src/utils/misc'; - async function bootstrap() { process.title = 'immich-api'; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index d1b8e7cf28..6a78e414f0 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -257,6 +257,10 @@ export const assetStub = { duplicateId: null, isOffline: false, stack: null, + orientation: '', + projectionType: null, + height: 3840, + width: 2160, }), trashed: Object.freeze({ diff --git a/typescript-open-api/typescript-sdk/package-lock.json b/typescript-open-api/typescript-sdk/package-lock.json new file mode 100644 index 0000000000..ca6fc5e1de --- /dev/null +++ b/typescript-open-api/typescript-sdk/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "typescript-sdk", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}