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 0ae07e9efd..776e0bdc93 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -475,8 +475,12 @@ 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 3986362c96..f3ac869578 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -282,8 +282,12 @@ 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..872f5b037d 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -35,6 +35,10 @@ class TimelineApi { /// /// * [AssetOrder] order: /// + /// * [num] page: + /// + /// * [num] pageSize: + /// /// * [String] personId: /// /// * [String] tagId: @@ -44,7 +48,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(TimeBucketSize size, 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,6 +77,12 @@ 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)); } @@ -123,6 +133,10 @@ class TimelineApi { /// /// * [AssetOrder] order: /// + /// * [num] page: + /// + /// * [num] pageSize: + /// /// * [String] personId: /// /// * [String] tagId: @@ -132,8 +146,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(TimeBucketSize size, 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(size, 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 +155,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; } @@ -261,7 +272,7 @@ 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 { + 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, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -271,8 +282,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..e485b244e7 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -620,10 +620,18 @@ 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/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_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 4e8e7ab834..db54655037 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, @@ -6880,10 +6897,7 @@ "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - }, - "type": "array" + "$ref": "#/components/schemas/TimeBucketResponseDto" } } }, @@ -7017,7 +7031,7 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/TimeBucketResponseDto" + "$ref": "#/components/schemas/TimeBucketsResponseDto" }, "type": "array" } @@ -13393,7 +13407,177 @@ ], "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" + }, + "TimeBucketSize": { + "enum": [ + "DAY", + "MONTH" + ], + "type": "string" + }, + "TimeBucketsResponseDto": { "properties": { "count": { "type": "integer" @@ -13408,12 +13592,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 f82f5bc9a7..f9cf464805 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1376,7 +1376,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; }; @@ -3197,13 +3222,15 @@ 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, size, 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; @@ -3214,7 +3241,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, @@ -3222,6 +3249,8 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, isTrashed, key, order, + page, + pageSize, personId, size, tagId, @@ -3249,7 +3278,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, 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/db.d.ts b/server/src/db.d.ts index 7115b701ce..a95beaa82f 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -153,7 +153,7 @@ export interface Assets { isVisible: Generated; libraryId: string | null; livePhotoVideoId: string | null; - localDateTime: Timestamp | null; + localDateTime: Timestamp; originalFileName: string; originalPath: string; ownerId: string; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 985ad04729..5640dbb26f 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -13,6 +13,7 @@ import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetType } from 'src/enum'; +import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { mimeTypes } from 'src/utils/mime-types'; export class SanitizedAssetResponseDto { @@ -102,15 +103,6 @@ const mapStack = (entity: AssetEntity) => { }; }; -// 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: AssetEntity, 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..554153d8eb 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -1,7 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; + +import { IsEnum, IsInt, IsNotEmpty, 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 { @@ -46,12 +48,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/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index 64c038a689..4d5cdb1529 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -1,4 +1,12 @@ -import { DeduplicateJoinsPlugin, ExpressionBuilder, Kysely, SelectQueryBuilder, sql } from 'kysely'; +import { + DeduplicateJoinsPlugin, + Expression, + expressionBuilder, + ExpressionBuilder, + Kysely, + SelectQueryBuilder, + sql, +} from 'kysely'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { AssetFace, AssetFile, AssetJobStatus, columns, Exif, Stack, Tag, User } from 'src/database'; import { DB } from 'src/db'; @@ -105,19 +113,30 @@ export function withFacesAndPeople(eb: ExpressionBuilder, withDele ).as('faces'); } -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'), - ); +// 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'), +// ); +// } +export function hasPeople(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[]) { @@ -159,15 +178,25 @@ export function truncatedDate(size: TimeBucketSize) { return sql`date_trunc(${size}, "localDateTime" at time zone 'UTC') at time zone 'UTC'`; } -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), - ), +// 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), +// ), +// ); +// } +export function withTagId(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), ); } @@ -177,94 +206,106 @@ const joinDeduplicationPlugin = new DeduplicateJoinsPlugin(); export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuilderOptions) { options.isArchived ??= options.withArchived ? undefined : false; options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline); - return kysely - .withPlugin(joinDeduplicationPlugin) - .selectFrom('assets') - .selectAll('assets') - .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!)) - .$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!)) - .$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!)) - .$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter!)) - .$if(!!options.updatedBefore, (qb) => qb.where('assets.updatedAt', '<=', options.updatedBefore!)) - .$if(!!options.updatedAfter, (qb) => qb.where('assets.updatedAt', '>=', options.updatedAfter!)) - .$if(!!options.trashedBefore, (qb) => qb.where('assets.deletedAt', '<=', options.trashedBefore!)) - .$if(!!options.trashedAfter, (qb) => qb.where('assets.deletedAt', '>=', options.trashedAfter!)) - .$if(!!options.takenBefore, (qb) => qb.where('assets.fileCreatedAt', '<=', options.takenBefore!)) - .$if(!!options.takenAfter, (qb) => qb.where('assets.fileCreatedAt', '>=', options.takenAfter!)) - .$if(options.city !== undefined, (qb) => - qb - .innerJoin('exif', 'assets.id', 'exif.assetId') - .where('exif.city', options.city === null ? 'is' : '=', options.city!), - ) - .$if(options.state !== undefined, (qb) => - qb - .innerJoin('exif', 'assets.id', 'exif.assetId') - .where('exif.state', options.state === null ? 'is' : '=', options.state!), - ) - .$if(options.country !== undefined, (qb) => - qb - .innerJoin('exif', 'assets.id', 'exif.assetId') - .where('exif.country', options.country === null ? 'is' : '=', options.country!), - ) - .$if(options.make !== undefined, (qb) => - qb - .innerJoin('exif', 'assets.id', 'exif.assetId') - .where('exif.make', options.make === null ? 'is' : '=', options.make!), - ) - .$if(options.model !== undefined, (qb) => - qb - .innerJoin('exif', 'assets.id', 'exif.assetId') - .where('exif.model', options.model === null ? 'is' : '=', options.model!), - ) - .$if(options.lensModel !== undefined, (qb) => - qb - .innerJoin('exif', 'assets.id', 'exif.assetId') - .where('exif.lensModel', options.lensModel === null ? 'is' : '=', options.lensModel!), - ) - .$if(options.rating !== undefined, (qb) => - qb - .innerJoin('exif', 'assets.id', 'exif.assetId') - .where('exif.rating', options.rating === null ? 'is' : '=', options.rating!), - ) - .$if(!!options.checksum, (qb) => qb.where('assets.checksum', '=', options.checksum!)) - .$if(!!options.deviceAssetId, (qb) => qb.where('assets.deviceAssetId', '=', options.deviceAssetId!)) - .$if(!!options.deviceId, (qb) => qb.where('assets.deviceId', '=', options.deviceId!)) - .$if(!!options.id, (qb) => qb.where('assets.id', '=', asUuid(options.id!))) - .$if(!!options.libraryId, (qb) => qb.where('assets.libraryId', '=', asUuid(options.libraryId!))) - .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) - .$if(!!options.encodedVideoPath, (qb) => qb.where('assets.encodedVideoPath', '=', options.encodedVideoPath!)) - .$if(!!options.originalPath, (qb) => - qb.where(sql`f_unaccent(assets."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`), - ) - .$if(!!options.originalFileName, (qb) => - qb.where( - sql`f_unaccent(assets."originalFileName")`, - 'ilike', - sql`'%' || f_unaccent(${options.originalFileName}) || '%'`, - ), - ) - .$if(!!options.description, (qb) => - qb - .innerJoin('exif', 'assets.id', 'exif.assetId') - .where(sql`f_unaccent(exif.description)`, 'ilike', sql`'%' || f_unaccent(${options.description}) || '%'`), - ) - .$if(!!options.type, (qb) => qb.where('assets.type', '=', options.type!)) - .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) - .$if(options.isOffline !== undefined, (qb) => qb.where('assets.isOffline', '=', options.isOffline!)) - .$if(options.isVisible !== undefined, (qb) => qb.where('assets.isVisible', '=', options.isVisible!)) - .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) - .$if(options.isEncoded !== undefined, (qb) => - qb.where('assets.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null), - ) - .$if(options.isMotion !== undefined, (qb) => - qb.where('assets.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null), - ) - .$if(!!options.isNotInAlbum, (qb) => - qb.where((eb) => - eb.not(eb.exists((eb) => eb.selectFrom('albums_assets_assets').whereRef('assetsId', '=', 'assets.id'))), - ), - ) - .$if(!!options.withExif, withExifInner) - .$if(!!(options.withFaces || options.withPeople || options.personIds), (qb) => qb.select(withFacesAndPeople)) - .$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null)); + return ( + kysely + .withPlugin(joinDeduplicationPlugin) + .selectFrom('assets') + .selectAll('assets') + .$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!)) + // .$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!)) + .$if(!!options.personIds && options.personIds.length > 0, (qb) => + qb.innerJoin( + () => hasPeople(options.personIds!), + (join) => join.onRef('has_people.assetId', '=', 'assets.id'), + ), + ) + .$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!)) + .$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter!)) + .$if(!!options.updatedBefore, (qb) => qb.where('assets.updatedAt', '<=', options.updatedBefore!)) + .$if(!!options.updatedAfter, (qb) => qb.where('assets.updatedAt', '>=', options.updatedAfter!)) + .$if(!!options.trashedBefore, (qb) => qb.where('assets.deletedAt', '<=', options.trashedBefore!)) + .$if(!!options.trashedAfter, (qb) => qb.where('assets.deletedAt', '>=', options.trashedAfter!)) + .$if(!!options.takenBefore, (qb) => qb.where('assets.fileCreatedAt', '<=', options.takenBefore!)) + .$if(!!options.takenAfter, (qb) => qb.where('assets.fileCreatedAt', '>=', options.takenAfter!)) + .$if(options.city !== undefined, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('exif.city', options.city === null ? 'is' : '=', options.city!), + ) + .$if(options.state !== undefined, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('exif.state', options.state === null ? 'is' : '=', options.state!), + ) + .$if(options.country !== undefined, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('exif.country', options.country === null ? 'is' : '=', options.country!), + ) + .$if(options.make !== undefined, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('exif.make', options.make === null ? 'is' : '=', options.make!), + ) + .$if(options.model !== undefined, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('exif.model', options.model === null ? 'is' : '=', options.model!), + ) + .$if(options.lensModel !== undefined, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('exif.lensModel', options.lensModel === null ? 'is' : '=', options.lensModel!), + ) + .$if(options.rating !== undefined, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where('exif.rating', options.rating === null ? 'is' : '=', options.rating!), + ) + .$if(!!options.checksum, (qb) => qb.where('assets.checksum', '=', options.checksum!)) + .$if(!!options.deviceAssetId, (qb) => qb.where('assets.deviceAssetId', '=', options.deviceAssetId!)) + .$if(!!options.deviceId, (qb) => qb.where('assets.deviceId', '=', options.deviceId!)) + .$if(!!options.id, (qb) => qb.where('assets.id', '=', asUuid(options.id!))) + .$if(!!options.libraryId, (qb) => qb.where('assets.libraryId', '=', asUuid(options.libraryId!))) + .$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!))) + .$if(!!options.encodedVideoPath, (qb) => qb.where('assets.encodedVideoPath', '=', options.encodedVideoPath!)) + .$if(!!options.originalPath, (qb) => + qb.where( + sql`f_unaccent(assets."originalPath")`, + 'ilike', + sql`'%' || f_unaccent(${options.originalPath}) || '%'`, + ), + ) + .$if(!!options.originalFileName, (qb) => + qb.where( + sql`f_unaccent(assets."originalFileName")`, + 'ilike', + sql`'%' || f_unaccent(${options.originalFileName}) || '%'`, + ), + ) + .$if(!!options.description, (qb) => + qb + .innerJoin('exif', 'assets.id', 'exif.assetId') + .where(sql`f_unaccent(exif.description)`, 'ilike', sql`'%' || f_unaccent(${options.description}) || '%'`), + ) + .$if(!!options.type, (qb) => qb.where('assets.type', '=', options.type!)) + .$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!)) + .$if(options.isOffline !== undefined, (qb) => qb.where('assets.isOffline', '=', options.isOffline!)) + .$if(options.isVisible !== undefined, (qb) => qb.where('assets.isVisible', '=', options.isVisible!)) + .$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!)) + .$if(options.isEncoded !== undefined, (qb) => + qb.where('assets.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null), + ) + .$if(options.isMotion !== undefined, (qb) => + qb.where('assets.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null), + ) + .$if(!!options.isNotInAlbum, (qb) => + qb.where((eb) => + eb.not(eb.exists((eb) => eb.selectFrom('albums_assets_assets').whereRef('assetsId', '=', 'assets.id'))), + ), + ) + .$if(!!options.withExif, withExifInner) + .$if(!!(options.withFaces || options.withPeople || options.personIds), (qb) => qb.select(withFacesAndPeople)) + .$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null)) + ); } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 52c390c162..ee92692856 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Selectable, UpdateResult, Updateable, sql } from 'kysely'; -import { isEmpty, isUndefined, omitBy } from 'lodash'; +import { isEmpty, isUndefined, omitBy, round } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; @@ -25,6 +25,10 @@ import { anyUuid, asUuid, removeUndefinedKeys, unnest } from 'src/utils/database import { globToSqlPattern } from 'src/utils/misc'; import { Paginated, PaginationOptions, paginationHelper } from 'src/utils/pagination'; +import { TimeBucketAssets } from 'src/services/timeline.service.types'; +import { isFlipped } from 'src/utils/asset.util'; +import { hexOrBufferToBase64 } from 'src/utils/bytes'; + export type AssetStats = Record; export interface AssetStatsOptions { @@ -710,7 +714,13 @@ export class AssetRepository { .innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId') .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.personId, (qb) => + qb.innerJoin( + () => hasPeople([options.personId!]), + (join) => join.onRef('has_people.assetId', '=', 'assets.id'), + ), + ) .$if(!!options.withStacked, (qb) => qb .leftJoin('asset_stack', (join) => @@ -727,7 +737,8 @@ export class AssetRepository { .$if(options.isDuplicate !== undefined, (qb) => qb.where('assets.duplicateId', options.isDuplicate ? 'is not' : 'is', null), ) - .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)), + // .$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)), + .$if(!!options.tagId, (qb) => qb.where((eb) => withTagId(options.tagId!, eb.ref('assets.id')))), ) .selectFrom('assets') .select('timeBucket') @@ -744,17 +755,37 @@ export class AssetRepository { } @GenerateSql({ params: [DummyValue.TIME_BUCKET, { size: TimeBucketSize.MONTH, withStacked: true }] }) - async getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise { - return this.db + 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', + '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']) + .select(sql`('assets.deletedAt' IS NOT NULL)`.as('isTrashed')) .$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) => hasPeople(qb, [options.personId!])) + .$if(!!options.personId, (qb) => + qb.innerJoin( + () => hasPeople([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!)) @@ -784,12 +815,79 @@ 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) => withTagId(qb, options.tagId!)) + .$if(!!options.tagId, (qb) => qb.where((eb) => withTagId(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(/^[+-]/, '')) .orderBy('assets.localDateTime', options.order ?? 'desc') - .execute() as any as Promise; + .$if(paginate, (qb) => qb.offset(pagination.skip!)) + .$if(paginate, (qb) => qb.limit(pagination.take + 1)); + + const items = await query.execute(); + + const hasNextPage = paginate && items.length > pagination.take; + if (paginate) { + items.splice(pagination.take); + } + + 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.isTrashed ? 1 : 0); + 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?: { id: string | null; primaryAssetId: string | null; assetCount: string | number | bigint | null }) { + if (!entity) { + return; + } + + return { + id: entity.id!, + primaryAssetId: entity.primaryAssetId!, + assetCount: entity.assetCount as number, + }; } @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.ts b/server/src/services/timeline.service.ts index 4c2332afaa..a7112b7259 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -1,30 +1,36 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } 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, + TimeBucketResponseDto, + TimeBucketsResponseDto, +} from 'src/dtos/time-bucket.dto'; import { Permission } from 'src/enum'; -import { TimeBucketOptions } from 'src/repositories/asset.repository'; +import { TimeBucketOptions, TimeBucketSize } from 'src/repositories/asset.repository'; import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; @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, size: TimeBucketSize.MONTH }); + + const page = dto.page || 1; + const size = dto.pageSize || -1; + if (dto.pageSize === 0) { + throw new BadRequestException('pageSize must not be 0'); + } + const a = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions, { skip: page, take: size }); + console.log(a); + return a; } 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 a15f006cda..29db9dd574 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -200,3 +200,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/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/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": {} +} diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index ea9e7e3dd1..b5e70958d7 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -1,25 +1,25 @@ diff --git a/web/src/lib/components/asset-viewer/actions/archive-action.svelte b/web/src/lib/components/asset-viewer/actions/archive-action.svelte index ed19dff864..362a0a693a 100644 --- a/web/src/lib/components/asset-viewer/actions/archive-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/archive-action.svelte @@ -4,6 +4,7 @@ import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import { AssetAction } from '$lib/constants'; import { toggleArchive } from '$lib/utils/asset-utils'; + import { toTimelineAsset } from '$lib/utils/timeline-util'; import type { AssetResponseDto } from '@immich/sdk'; import { mdiArchiveArrowDownOutline, mdiArchiveArrowUpOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -18,11 +19,11 @@ const onArchive = async () => { if (!asset.isArchived) { - preAction({ type: AssetAction.ARCHIVE, asset }); + preAction({ type: AssetAction.ARCHIVE, asset: toTimelineAsset(asset) }); } const updatedAsset = await toggleArchive(asset); if (updatedAsset) { - onAction({ type: asset.isArchived ? AssetAction.ARCHIVE : AssetAction.UNARCHIVE, asset }); + onAction({ type: asset.isArchived ? AssetAction.ARCHIVE : AssetAction.UNARCHIVE, asset: toTimelineAsset(asset) }); } }; diff --git a/web/src/lib/components/asset-viewer/actions/delete-action.svelte b/web/src/lib/components/asset-viewer/actions/delete-action.svelte index 24ba2c845d..90322c00f0 100644 --- a/web/src/lib/components/asset-viewer/actions/delete-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/delete-action.svelte @@ -11,6 +11,7 @@ import { showDeleteModal } from '$lib/stores/preferences.store'; import { featureFlags } from '$lib/stores/server-config.store'; import { handleError } from '$lib/utils/handle-error'; + import { toTimelineAsset } from '$lib/utils/timeline-util'; import { deleteAssets, type AssetResponseDto } from '@immich/sdk'; import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -42,9 +43,9 @@ const trashAsset = async () => { try { - preAction({ type: AssetAction.TRASH, asset }); + preAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) }); await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } }); - onAction({ type: AssetAction.TRASH, asset }); + onAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) }); notificationController.show({ message: $t('moved_to_trash'), @@ -58,7 +59,7 @@ const deleteAsset = async () => { try { await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } }); - onAction({ type: AssetAction.DELETE, asset }); + onAction({ type: AssetAction.DELETE, asset: toTimelineAsset(asset) }); notificationController.show({ message: $t('permanently_deleted_asset'), diff --git a/web/src/lib/components/asset-viewer/actions/favorite-action.svelte b/web/src/lib/components/asset-viewer/actions/favorite-action.svelte index 0cc3188d51..bb1a9343d9 100644 --- a/web/src/lib/components/asset-viewer/actions/favorite-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/favorite-action.svelte @@ -7,6 +7,7 @@ } from '$lib/components/shared-components/notification/notification'; import { AssetAction } from '$lib/constants'; import { handleError } from '$lib/utils/handle-error'; + import { toTimelineAsset } from '$lib/utils/timeline-util'; import { updateAsset, type AssetResponseDto } from '@immich/sdk'; import { mdiHeart, mdiHeartOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -30,7 +31,10 @@ asset = { ...asset, isFavorite: data.isFavorite }; - onAction({ type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE, asset }); + onAction({ + type: asset.isFavorite ? AssetAction.FAVORITE : AssetAction.UNFAVORITE, + asset: toTimelineAsset(asset), + }); notificationController.show({ type: NotificationType.Info, diff --git a/web/src/lib/components/asset-viewer/actions/keep-this-delete-others.svelte b/web/src/lib/components/asset-viewer/actions/keep-this-delete-others.svelte index 8705476d8d..d5bc1db9bf 100644 --- a/web/src/lib/components/asset-viewer/actions/keep-this-delete-others.svelte +++ b/web/src/lib/components/asset-viewer/actions/keep-this-delete-others.svelte @@ -1,12 +1,13 @@ diff --git a/web/src/lib/components/asset-viewer/actions/restore-action.svelte b/web/src/lib/components/asset-viewer/actions/restore-action.svelte index abcae5c4c9..c790dab853 100644 --- a/web/src/lib/components/asset-viewer/actions/restore-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/restore-action.svelte @@ -6,6 +6,7 @@ } from '$lib/components/shared-components/notification/notification'; import { AssetAction } from '$lib/constants'; import { handleError } from '$lib/utils/handle-error'; + import { toTimelineAsset } from '$lib/utils/timeline-util'; import { restoreAssets, type AssetResponseDto } from '@immich/sdk'; import { mdiHistory } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -23,7 +24,7 @@ await restoreAssets({ bulkIdsDto: { ids: [asset.id] } }); asset.isTrashed = false; - onAction({ type: AssetAction.RESTORE, asset }); + onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) }); notificationController.show({ type: NotificationType.Info, diff --git a/web/src/lib/components/asset-viewer/actions/unstack-action.svelte b/web/src/lib/components/asset-viewer/actions/unstack-action.svelte index f2a50cce13..f389249183 100644 --- a/web/src/lib/components/asset-viewer/actions/unstack-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/unstack-action.svelte @@ -2,6 +2,7 @@ import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import { AssetAction } from '$lib/constants'; import { deleteStack } from '$lib/utils/asset-utils'; + import { toTimelineAsset } from '$lib/utils/timeline-util'; import type { StackResponseDto } from '@immich/sdk'; import { mdiImageMinusOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; @@ -17,7 +18,7 @@ const handleUnstack = async () => { const unstackedAssets = await deleteStack([stack.id]); if (unstackedAssets) { - onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets }); + onAction({ type: AssetAction.UNSTACK, assets: unstackedAssets.map((a) => toTimelineAsset(a)) }); } }; diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts index a25ea6bf90..d075ef4dbb 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts @@ -13,8 +13,9 @@ describe('AssetViewerNavBar component', () => { showDownloadButton: false, showMotionPlayButton: false, showShareButton: false, + preAction: () => {}, onZoomImage: () => {}, - onCopyImage: () => {}, + onCopyImage: async () => {}, onAction: () => {}, onRunJob: () => {}, onPlaySlideshow: () => {}, diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 91461d574d..ac512b6766 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -15,6 +15,7 @@ import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; import { SlideshowHistory } from '$lib/utils/slideshow-history'; + import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetJobName, AssetTypeEnum, @@ -52,7 +53,7 @@ interface Props { asset: AssetResponseDto; - preloadAssets?: AssetResponseDto[]; + preloadAssets?: { id: string }[]; showNavigation?: boolean; withStacked?: boolean; isShared?: boolean; @@ -62,7 +63,7 @@ onAction?: OnAction | undefined; reactions?: ActivityResponseDto[]; showCloseButton?: boolean; - onClose: (dto: { asset: AssetResponseDto }) => void; + onClose: (asset: AssetResponseDto) => void; onNext: () => Promise; onPrevious: () => Promise; onRandom: () => Promise; @@ -267,7 +268,7 @@ }; const closeViewer = () => { - onClose({ asset }); + onClose(asset); }; const closeEditor = () => { @@ -605,8 +606,8 @@ imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }} brokenAssetClass="text-xs" dimmed={stackedAsset.id !== asset.id} - asset={stackedAsset} - onClick={(stackedAsset) => { + asset={toTimelineAsset(stackedAsset)} + onClick={() => { asset = stackedAsset; }} onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)} diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index d3a9da3633..a67de24a74 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -10,7 +10,7 @@ import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils'; import { getBoundingBox } from '$lib/utils/people-utils'; import { getAltText } from '$lib/utils/thumbnail-util'; - import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; + import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk'; import { onDestroy, onMount } from 'svelte'; import { t } from 'svelte-i18n'; import { type SwipeCustomEvent, swipe } from 'svelte-gestures'; @@ -24,7 +24,7 @@ interface Props { asset: AssetResponseDto; - preloadAssets?: AssetResponseDto[] | undefined; + preloadAssets?: { id: string }[] | undefined; element?: HTMLDivElement | undefined; haveFadeTransition?: boolean; sharedLink?: SharedLinkResponseDto | undefined; @@ -68,12 +68,10 @@ $boundingBoxesArray = []; }); - const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: AssetResponseDto[]) => { + const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: { id: string }[]) => { for (const preloadAsset of preloadAssets || []) { - if (preloadAsset.type === AssetTypeEnum.Image) { - let img = new Image(); - img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash); - } + let img = new Image(); + img.src = getAssetUrl(preloadAsset.id, targetSize, null); } }; diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 93a4e3c6cc..802c04cf16 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -4,8 +4,8 @@ import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store'; import { getAssetPlaybackUrl, getAssetThumbnailUrl, isSharedLink } from '$lib/utils'; import { timeToSeconds } from '$lib/utils/date-time'; - import { getAltText } from '$lib/utils/thumbnail-util'; - import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; + // import { getAltText } from '$lib/utils/thumbnail-util'; + import { AssetMediaSize } from '@immich/sdk'; import { mdiArchiveArrowDownOutline, mdiCameraBurst, @@ -17,22 +17,23 @@ } from '@mdi/js'; import { thumbhash } from '$lib/actions/thumbhash'; + import type { TimelineAsset } from '$lib/stores/assets-store.svelte'; import { mobileDevice } from '$lib/stores/mobile-device.svelte'; + import { getFocusable } from '$lib/utils/focus-util'; import { currentUrlReplaceAssetId } from '$lib/utils/navigation'; import { TUNABLES } from '$lib/utils/tunables'; + import { onMount } from 'svelte'; import type { ClassValue } from 'svelte/elements'; import { fade } from 'svelte/transition'; import ImageThumbnail from './image-thumbnail.svelte'; import VideoThumbnail from './video-thumbnail.svelte'; - import { onMount } from 'svelte'; - import { getFocusable } from '$lib/utils/focus-util'; interface Props { - asset: AssetResponseDto; + asset: TimelineAsset; groupIndex?: number; - thumbnailSize?: number | undefined; - thumbnailWidth?: number | undefined; - thumbnailHeight?: number | undefined; + thumbnailSize?: number; + thumbnailWidth?: number; + thumbnailHeight?: number; selected?: boolean; focussed?: boolean; selectionCandidate?: boolean; @@ -44,10 +45,10 @@ imageClass?: ClassValue; brokenAssetClass?: ClassValue; dimmed?: boolean; - onClick?: ((asset: AssetResponseDto) => void) | undefined; - onSelect?: ((asset: AssetResponseDto) => void) | undefined; - onMouseEvent?: ((event: { isMouseOver: boolean; selectedGroupIndex: number }) => void) | undefined; - handleFocus?: (() => void) | undefined; + onClick?: (asset: TimelineAsset) => void; + onSelect?: (asset: TimelineAsset) => void; + onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void; + handleFocus?: () => void; } let { @@ -331,7 +332,7 @@ {/if} - {#if asset.type === AssetTypeEnum.Image && asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR} + {#if asset.isImage && asset.projectionType === ProjectionType.EQUIRECTANGULAR}
@@ -344,7 +345,7 @@
@@ -354,27 +355,28 @@
{/if}
+ ((loaded = true), (thumbError = errored))} /> - {#if asset.type === AssetTypeEnum.Video} + {#if asset.isVideo}
- {:else if asset.type === AssetTypeEnum.Image && asset.livePhotoVideoId} + {:else if asset.isImage && asset.livePhotoVideoId}
(); let progressBarController: Tween | undefined = $state(undefined); let videoPlayer: HTMLVideoElement | undefined = $state(); const asHref = (asset: AssetResponseDto) => `?${QueryParameter.ID}=${asset.id}`; diff --git a/web/src/lib/components/photos-page/actions/archive-action.svelte b/web/src/lib/components/photos-page/actions/archive-action.svelte index ddc5f3cae4..4fc15f1d84 100644 --- a/web/src/lib/components/photos-page/actions/archive-action.svelte +++ b/web/src/lib/components/photos-page/actions/archive-action.svelte @@ -1,11 +1,11 @@ diff --git a/web/src/lib/components/photos-page/actions/select-all-assets.svelte b/web/src/lib/components/photos-page/actions/select-all-assets.svelte index cc3f75ab56..3c6bbcbb3d 100644 --- a/web/src/lib/components/photos-page/actions/select-all-assets.svelte +++ b/web/src/lib/components/photos-page/actions/select-all-assets.svelte @@ -1,14 +1,14 @@