Merge 963f822606 into 963862b1b9
commit
7f42a38b43
|
|
@ -1399,6 +1399,7 @@
|
|||
"leave": "Leave",
|
||||
"leave_album": "Leave album",
|
||||
"lens_model": "Lens model",
|
||||
"less": "Less",
|
||||
"let_others_respond": "Let others respond",
|
||||
"level": "Level",
|
||||
"library": "Library",
|
||||
|
|
@ -2409,6 +2410,12 @@
|
|||
"updated_at": "Updated",
|
||||
"updated_password": "Updated password",
|
||||
"upload": "Upload",
|
||||
"upload_activity": "Upload activity",
|
||||
"upload_activity_day_count": "{date}: {count, plural, one {# upload} other {# uploads}}",
|
||||
"upload_activity_day_friday": "Fri",
|
||||
"upload_activity_day_monday": "Mon",
|
||||
"upload_activity_day_wednesday": "Wed",
|
||||
"upload_activity_total_count": "{count, plural, one {# upload} other {# uploads}}",
|
||||
"upload_concurrency": "Upload concurrency",
|
||||
"upload_details": "Upload Details",
|
||||
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
|
||||
|
|
|
|||
|
|
@ -294,6 +294,7 @@ Class | Method | HTTP request | Description
|
|||
*UsersApi* | [**deleteUserLicense**](doc//UsersApi.md#deleteuserlicense) | **DELETE** /users/me/license | Delete user product key
|
||||
*UsersApi* | [**deleteUserOnboarding**](doc//UsersApi.md#deleteuseronboarding) | **DELETE** /users/me/onboarding | Delete user onboarding
|
||||
*UsersApi* | [**getMyPreferences**](doc//UsersApi.md#getmypreferences) | **GET** /users/me/preferences | Get my preferences
|
||||
*UsersApi* | [**getMyUploadStatistics**](doc//UsersApi.md#getmyuploadstatistics) | **GET** /users/me/stats/uploads | Get current user upload statistics
|
||||
*UsersApi* | [**getMyUser**](doc//UsersApi.md#getmyuser) | **GET** /users/me | Get current user
|
||||
*UsersApi* | [**getProfileImage**](doc//UsersApi.md#getprofileimage) | **GET** /users/{id}/profile-image | Retrieve user profile image
|
||||
*UsersApi* | [**getUser**](doc//UsersApi.md#getuser) | **GET** /users/{id} | Retrieve a user
|
||||
|
|
@ -667,6 +668,8 @@ Class | Method | HTTP request | Description
|
|||
- [UserResponseDto](doc//UserResponseDto.md)
|
||||
- [UserStatus](doc//UserStatus.md)
|
||||
- [UserUpdateMeDto](doc//UserUpdateMeDto.md)
|
||||
- [UserUploadStatsResponseDto](doc//UserUploadStatsResponseDto.md)
|
||||
- [UserUploadStatsResponseDtoSeriesInner](doc//UserUploadStatsResponseDtoSeriesInner.md)
|
||||
- [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md)
|
||||
- [ValidateLibraryDto](doc//ValidateLibraryDto.md)
|
||||
- [ValidateLibraryImportPathResponseDto](doc//ValidateLibraryImportPathResponseDto.md)
|
||||
|
|
|
|||
|
|
@ -409,6 +409,8 @@ part 'model/user_preferences_update_dto.dart';
|
|||
part 'model/user_response_dto.dart';
|
||||
part 'model/user_status.dart';
|
||||
part 'model/user_update_me_dto.dart';
|
||||
part 'model/user_upload_stats_response_dto.dart';
|
||||
part 'model/user_upload_stats_response_dto_series_inner.dart';
|
||||
part 'model/validate_access_token_response_dto.dart';
|
||||
part 'model/validate_library_dto.dart';
|
||||
part 'model/validate_library_import_path_response_dto.dart';
|
||||
|
|
|
|||
|
|
@ -257,6 +257,78 @@ class UsersApi {
|
|||
return null;
|
||||
}
|
||||
|
||||
/// Get current user upload statistics
|
||||
///
|
||||
/// Retrieve daily upload counts and totals for the current user.
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [DateTime] from:
|
||||
/// Start date in UTC
|
||||
///
|
||||
/// * [DateTime] to:
|
||||
/// End date in UTC
|
||||
Future<Response> getMyUploadStatisticsWithHttpInfo({ DateTime? from, DateTime? to, Future<void>? abortTrigger, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/users/me/stats/uploads';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (from != null) {
|
||||
queryParams.addAll(_queryParams('', 'from', from));
|
||||
}
|
||||
if (to != null) {
|
||||
queryParams.addAll(_queryParams('', 'to', to));
|
||||
}
|
||||
|
||||
const contentTypes = <String>[];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'GET',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
abortTrigger: abortTrigger,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get current user upload statistics
|
||||
///
|
||||
/// Retrieve daily upload counts and totals for the current user.
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [DateTime] from:
|
||||
/// Start date in UTC
|
||||
///
|
||||
/// * [DateTime] to:
|
||||
/// End date in UTC
|
||||
Future<UserUploadStatsResponseDto?> getMyUploadStatistics({ DateTime? from, DateTime? to, Future<void>? abortTrigger, }) async {
|
||||
final response = await getMyUploadStatisticsWithHttpInfo(from: from, to: to, abortTrigger: abortTrigger,);
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// 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) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'UserUploadStatsResponseDto',) as UserUploadStatsResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get current user
|
||||
///
|
||||
/// Retrieve information about the user making the API request.
|
||||
|
|
|
|||
|
|
@ -863,6 +863,10 @@ class ApiClient {
|
|||
return UserStatusTypeTransformer().decode(value);
|
||||
case 'UserUpdateMeDto':
|
||||
return UserUpdateMeDto.fromJson(value);
|
||||
case 'UserUploadStatsResponseDto':
|
||||
return UserUploadStatsResponseDto.fromJson(value);
|
||||
case 'UserUploadStatsResponseDtoSeriesInner':
|
||||
return UserUploadStatsResponseDtoSeriesInner.fromJson(value);
|
||||
case 'ValidateAccessTokenResponseDto':
|
||||
return ValidateAccessTokenResponseDto.fromJson(value);
|
||||
case 'ValidateLibraryDto':
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
//
|
||||
// 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 UserUploadStatsResponseDto {
|
||||
/// Returns a new [UserUploadStatsResponseDto] instance.
|
||||
UserUploadStatsResponseDto({
|
||||
required this.from,
|
||||
this.series = const [],
|
||||
required this.to,
|
||||
required this.totalCount,
|
||||
required this.userId,
|
||||
});
|
||||
|
||||
/// Start date in UTC
|
||||
String from;
|
||||
|
||||
List<UserUploadStatsResponseDtoSeriesInner> series;
|
||||
|
||||
/// End date in UTC
|
||||
String to;
|
||||
|
||||
/// Total number of uploads
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int totalCount;
|
||||
|
||||
/// User ID
|
||||
String userId;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is UserUploadStatsResponseDto &&
|
||||
other.from == from &&
|
||||
_deepEquality.equals(other.series, series) &&
|
||||
other.to == to &&
|
||||
other.totalCount == totalCount &&
|
||||
other.userId == userId;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(from.hashCode) +
|
||||
(series.hashCode) +
|
||||
(to.hashCode) +
|
||||
(totalCount.hashCode) +
|
||||
(userId.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'UserUploadStatsResponseDto[from=$from, series=$series, to=$to, totalCount=$totalCount, userId=$userId]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'from'] = this.from;
|
||||
json[r'series'] = this.series;
|
||||
json[r'to'] = this.to;
|
||||
json[r'totalCount'] = this.totalCount;
|
||||
json[r'userId'] = this.userId;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [UserUploadStatsResponseDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static UserUploadStatsResponseDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "UserUploadStatsResponseDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return UserUploadStatsResponseDto(
|
||||
from: mapValueOfType<String>(json, r'from')!,
|
||||
series: UserUploadStatsResponseDtoSeriesInner.listFromJson(json[r'series']),
|
||||
to: mapValueOfType<String>(json, r'to')!,
|
||||
totalCount: mapValueOfType<int>(json, r'totalCount')!,
|
||||
userId: mapValueOfType<String>(json, r'userId')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<UserUploadStatsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <UserUploadStatsResponseDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = UserUploadStatsResponseDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, UserUploadStatsResponseDto> mapFromJson(dynamic json) {
|
||||
final map = <String, UserUploadStatsResponseDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = UserUploadStatsResponseDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of UserUploadStatsResponseDto-objects as value to a dart map
|
||||
static Map<String, List<UserUploadStatsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<UserUploadStatsResponseDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = UserUploadStatsResponseDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'from',
|
||||
'series',
|
||||
'to',
|
||||
'totalCount',
|
||||
'userId',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
//
|
||||
// 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 UserUploadStatsResponseDtoSeriesInner {
|
||||
/// Returns a new [UserUploadStatsResponseDtoSeriesInner] instance.
|
||||
UserUploadStatsResponseDtoSeriesInner({
|
||||
required this.count,
|
||||
required this.date,
|
||||
});
|
||||
|
||||
/// Number of uploads
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 9007199254740991
|
||||
int count;
|
||||
|
||||
/// Date in UTC
|
||||
String date;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is UserUploadStatsResponseDtoSeriesInner &&
|
||||
other.count == count &&
|
||||
other.date == date;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(count.hashCode) +
|
||||
(date.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'UserUploadStatsResponseDtoSeriesInner[count=$count, date=$date]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'count'] = this.count;
|
||||
json[r'date'] = this.date;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [UserUploadStatsResponseDtoSeriesInner] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static UserUploadStatsResponseDtoSeriesInner? fromJson(dynamic value) {
|
||||
upgradeDto(value, "UserUploadStatsResponseDtoSeriesInner");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return UserUploadStatsResponseDtoSeriesInner(
|
||||
count: mapValueOfType<int>(json, r'count')!,
|
||||
date: mapValueOfType<String>(json, r'date')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<UserUploadStatsResponseDtoSeriesInner> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <UserUploadStatsResponseDtoSeriesInner>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = UserUploadStatsResponseDtoSeriesInner.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, UserUploadStatsResponseDtoSeriesInner> mapFromJson(dynamic json) {
|
||||
final map = <String, UserUploadStatsResponseDtoSeriesInner>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = UserUploadStatsResponseDtoSeriesInner.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of UserUploadStatsResponseDtoSeriesInner-objects as value to a dart map
|
||||
static Map<String, List<UserUploadStatsResponseDtoSeriesInner>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<UserUploadStatsResponseDtoSeriesInner>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = UserUploadStatsResponseDtoSeriesInner.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'count',
|
||||
'date',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -14886,6 +14886,77 @@
|
|||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/users/me/stats/uploads": {
|
||||
"get": {
|
||||
"description": "Retrieve daily upload counts and totals for the current user.",
|
||||
"operationId": "getMyUploadStatistics",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "from",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Start date in UTC",
|
||||
"schema": {
|
||||
"format": "date",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$",
|
||||
"example": "2024-01-01",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "to",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "End date in UTC",
|
||||
"schema": {
|
||||
"format": "date",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))$",
|
||||
"example": "2024-01-01",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UserUploadStatsResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Get current user upload statistics",
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v3",
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
"x-immich-permission": "user.read",
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/users/profile-image": {
|
||||
"delete": {
|
||||
"description": "Delete the profile image of the current user.",
|
||||
|
|
@ -26539,6 +26610,63 @@
|
|||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UserUploadStatsResponseDto": {
|
||||
"properties": {
|
||||
"from": {
|
||||
"description": "Start date in UTC",
|
||||
"example": "2024-01-01",
|
||||
"type": "string"
|
||||
},
|
||||
"series": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"count": {
|
||||
"description": "Number of uploads",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"date": {
|
||||
"description": "Date in UTC",
|
||||
"example": "2024-01-01",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"date",
|
||||
"count"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"to": {
|
||||
"description": "End date in UTC",
|
||||
"example": "2024-12-31",
|
||||
"type": "string"
|
||||
},
|
||||
"totalCount": {
|
||||
"description": "Total number of uploads",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"userId": {
|
||||
"description": "User ID",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"from",
|
||||
"series",
|
||||
"to",
|
||||
"totalCount",
|
||||
"userId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ValidateAccessTokenResponseDto": {
|
||||
"properties": {
|
||||
"authStatus": {
|
||||
|
|
|
|||
|
|
@ -2695,6 +2695,22 @@ export type OnboardingDto = {
|
|||
/** Is user onboarded */
|
||||
isOnboarded: boolean;
|
||||
};
|
||||
export type UserUploadStatsResponseDto = {
|
||||
/** Start date in UTC */
|
||||
"from": string;
|
||||
series: {
|
||||
/** Number of uploads */
|
||||
count: number;
|
||||
/** Date in UTC */
|
||||
date: string;
|
||||
}[];
|
||||
/** End date in UTC */
|
||||
to: string;
|
||||
/** Total number of uploads */
|
||||
totalCount: number;
|
||||
/** User ID */
|
||||
userId: string;
|
||||
};
|
||||
export type CreateProfileImageDto = {
|
||||
/** Profile image file */
|
||||
file: Blob;
|
||||
|
|
@ -6676,6 +6692,23 @@ export function updateMyPreferences({ userPreferencesUpdateDto }: {
|
|||
body: userPreferencesUpdateDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Get current user upload statistics
|
||||
*/
|
||||
export function getMyUploadStatistics({ $from, to }: {
|
||||
$from?: string;
|
||||
to?: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: UserUploadStatsResponseDto;
|
||||
}>(`/users/me/stats/uploads${QS.query(QS.explode({
|
||||
"from": $from,
|
||||
to
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Delete user profile image
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
|
|
@ -21,7 +22,13 @@ import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
|||
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
||||
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
|
||||
import {
|
||||
UserAdminResponseDto,
|
||||
UserResponseDto,
|
||||
UserUpdateMeDto,
|
||||
UserUploadStatsDto,
|
||||
UserUploadStatsResponseDto,
|
||||
} from 'src/dtos/user.dto';
|
||||
import { ApiTag, Permission, RouteKey } from 'src/enum';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
||||
|
|
@ -60,6 +67,17 @@ export class UserController {
|
|||
return this.service.getMe(auth);
|
||||
}
|
||||
|
||||
@Get('me/stats/uploads')
|
||||
@Authenticated({ permission: Permission.UserRead })
|
||||
@Endpoint({
|
||||
summary: 'Get current user upload statistics',
|
||||
description: 'Retrieve daily upload counts and totals for the current user.',
|
||||
history: new HistoryBuilder().added('v3').stable('v3'),
|
||||
})
|
||||
getMyUploadStatistics(@Auth() auth: AuthDto, @Query() dto: UserUploadStatsDto): Promise<UserUploadStatsResponseDto> {
|
||||
return this.service.getUploadStatistics(auth, dto);
|
||||
}
|
||||
|
||||
@Put('me')
|
||||
@Authenticated({ permission: Permission.UserUpdate })
|
||||
@Endpoint({
|
||||
|
|
|
|||
|
|
@ -4,7 +4,14 @@ import { pinCodeRegex } from 'src/dtos/auth.dto';
|
|||
import { UserAvatarColor, UserAvatarColorSchema, UserMetadataKey, UserStatusSchema } from 'src/enum';
|
||||
import { MaybeDehydrated, UserMetadataItem } from 'src/types';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
import { emptyStringToNull, isoDatetimeToDate, sanitizeFilename, stringToBool, toEmail } from 'src/validation';
|
||||
import {
|
||||
emptyStringToNull,
|
||||
isoDateToDate,
|
||||
isoDatetimeToDate,
|
||||
sanitizeFilename,
|
||||
stringToBool,
|
||||
toEmail,
|
||||
} from 'src/validation';
|
||||
import z from 'zod';
|
||||
|
||||
export const UserUpdateMeSchema = z
|
||||
|
|
@ -22,6 +29,33 @@ export const UserUpdateMeSchema = z
|
|||
|
||||
export class UserUpdateMeDto extends createZodDto(UserUpdateMeSchema) {}
|
||||
|
||||
const UserUploadStatsSchema = z
|
||||
.object({
|
||||
from: isoDateToDate.optional().describe('Start date in UTC'),
|
||||
to: isoDateToDate.optional().describe('End date in UTC'),
|
||||
})
|
||||
.refine((dto) => !dto.from || !dto.to || dto.from <= dto.to, { message: 'from must be before to', path: ['from'] })
|
||||
.meta({ id: 'UserUploadStatsDto' });
|
||||
|
||||
export class UserUploadStatsDto extends createZodDto(UserUploadStatsSchema) {}
|
||||
|
||||
const UserUploadStatsResponseSchema = z
|
||||
.object({
|
||||
userId: z.uuidv4().describe('User ID'),
|
||||
from: z.string().describe('Start date in UTC').meta({ example: '2024-01-01' }),
|
||||
to: z.string().describe('End date in UTC').meta({ example: '2024-12-31' }),
|
||||
series: z.array(
|
||||
z.object({
|
||||
date: z.string().describe('Date in UTC').meta({ example: '2024-01-01' }),
|
||||
count: z.int().nonnegative().describe('Number of uploads'),
|
||||
}),
|
||||
),
|
||||
totalCount: z.int().nonnegative().describe('Total number of uploads'),
|
||||
})
|
||||
.meta({ id: 'UserUploadStatsResponseDto' });
|
||||
|
||||
export class UserUploadStatsResponseDto extends createZodDto(UserUploadStatsResponseSchema) {}
|
||||
|
||||
export const UserResponseSchema = z
|
||||
.object({
|
||||
id: z.uuidv4().describe('User ID'),
|
||||
|
|
|
|||
|
|
@ -339,6 +339,22 @@ where
|
|||
limit
|
||||
$3
|
||||
|
||||
-- AssetRepository.getUploadStatistics
|
||||
select
|
||||
date_trunc('day', "createdAt" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' as "date",
|
||||
count(*) as "count"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"ownerId" = $1::uuid
|
||||
and "createdAt" >= $2
|
||||
and "createdAt" < $3
|
||||
and "deletedAt" is null
|
||||
group by
|
||||
date_trunc('day', "createdAt" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'
|
||||
order by
|
||||
"date" asc
|
||||
|
||||
-- AssetRepository.getTimeBuckets
|
||||
with
|
||||
"asset" as (
|
||||
|
|
|
|||
|
|
@ -706,6 +706,23 @@ export class AssetRepository {
|
|||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, { from: DummyValue.DATE, to: DummyValue.DATE }] })
|
||||
getUploadStatistics(ownerId: string, options: { from: Date; to: Date }) {
|
||||
const uploadDate = sql<Date>`date_trunc('day', "createdAt" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`;
|
||||
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(uploadDate.as('date'))
|
||||
.select((eb) => eb.fn.countAll<number>().as('count'))
|
||||
.where('ownerId', '=', asUuid(ownerId))
|
||||
.where('createdAt', '>=', options.from)
|
||||
.where('createdAt', '<', options.to)
|
||||
.where('deletedAt', 'is', null)
|
||||
.groupBy(uploadDate)
|
||||
.orderBy('date', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{}] })
|
||||
async getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
|
||||
return this.db
|
||||
|
|
|
|||
|
|
@ -9,7 +9,15 @@ import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
|||
import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
|
||||
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import {
|
||||
UserAdminResponseDto,
|
||||
UserResponseDto,
|
||||
UserUpdateMeDto,
|
||||
UserUploadStatsDto,
|
||||
UserUploadStatsResponseDto,
|
||||
mapUser,
|
||||
mapUserAdmin,
|
||||
} from 'src/dtos/user.dto';
|
||||
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { UserFindOptions } from 'src/repositories/user.repository';
|
||||
|
|
@ -46,6 +54,33 @@ export class UserService extends BaseService {
|
|||
return mapUserAdmin(user);
|
||||
}
|
||||
|
||||
async getUploadStatistics(auth: AuthDto, dto: UserUploadStatsDto): Promise<UserUploadStatsResponseDto> {
|
||||
const formatUploadDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||
const toDate = DateTime.fromJSDate(dto.to ?? new Date(), { zone: 'utc' }).startOf('day');
|
||||
const fromDate = (
|
||||
dto.from ? DateTime.fromJSDate(dto.from, { zone: 'utc' }) : toDate.minus({ weeks: 52 }).plus({ days: 1 })
|
||||
).startOf('day');
|
||||
const uploadCounts = await this.assetRepository.getUploadStatistics(auth.user.id, {
|
||||
from: fromDate.toJSDate(),
|
||||
to: toDate.plus({ days: 1 }).toJSDate(),
|
||||
});
|
||||
const countsByDate = new Map(uploadCounts.map((item) => [formatUploadDate(item.date), item.count]));
|
||||
const series: UserUploadStatsResponseDto['series'] = [];
|
||||
|
||||
for (let date = fromDate; date <= toDate; date = date.plus({ days: 1 })) {
|
||||
const dateKey = date.toISODate()!;
|
||||
series.push({ date: dateKey, count: countsByDate.get(dateKey) ?? 0 });
|
||||
}
|
||||
|
||||
return {
|
||||
userId: auth.user.id,
|
||||
from: fromDate.toISODate()!,
|
||||
to: toDate.toISODate()!,
|
||||
series,
|
||||
totalCount: series.reduce((totalCount, item) => totalCount + item.count, 0),
|
||||
};
|
||||
}
|
||||
|
||||
async updateMe({ user }: AuthDto, dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
|
||||
if (dto.email) {
|
||||
const duplicate = await this.userRepository.getByEmail(dto.email);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
|||
remove: vitest.fn(),
|
||||
findLivePhotoMatch: vitest.fn(),
|
||||
getStatistics: vitest.fn(),
|
||||
getUploadStatistics: vitest.fn(),
|
||||
getTimeBucket: vitest.fn(),
|
||||
getTimeBuckets: vitest.fn(),
|
||||
getAssetIdByCity: vitest.fn(),
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@
|
|||
AssetVisibility,
|
||||
getAlbumStatistics,
|
||||
getAssetStatistics,
|
||||
getMyUploadStatistics,
|
||||
type AlbumStatisticsResponseDto,
|
||||
type AssetStatsResponseDto,
|
||||
type UserUploadStatsResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Heading, Table, TableBody, TableCell, TableHeader, TableHeading, TableRow } from '@immich/ui';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
|
|
@ -41,16 +44,65 @@
|
|||
notShared: 0,
|
||||
});
|
||||
|
||||
let uploadStats: UserUploadStatsResponseDto = $state({
|
||||
userId: '',
|
||||
from: '',
|
||||
to: '',
|
||||
series: [],
|
||||
totalCount: 0,
|
||||
});
|
||||
|
||||
const today = DateTime.utc().startOf('day');
|
||||
const uploadActivityTo = today.toISODate();
|
||||
const uploadActivityFrom = today.minus({ weeks: 52 }).plus({ days: 1 }).toISODate();
|
||||
|
||||
const getUsage = async () => {
|
||||
[timelineStats, favoriteStats, archiveStats, trashStats, albumStats] = await Promise.all([
|
||||
[timelineStats, favoriteStats, archiveStats, trashStats, albumStats, uploadStats] = await Promise.all([
|
||||
getAssetStatistics({ visibility: AssetVisibility.Timeline }),
|
||||
getAssetStatistics({ isFavorite: true }),
|
||||
getAssetStatistics({ visibility: AssetVisibility.Archive }),
|
||||
getAssetStatistics({ isTrashed: true }),
|
||||
getAlbumStatistics(),
|
||||
getMyUploadStatistics({ $from: uploadActivityFrom, to: uploadActivityTo }),
|
||||
]);
|
||||
};
|
||||
|
||||
const getUploadActivityWeeks = () => {
|
||||
return Array.from({ length: Math.ceil(uploadStats.series.length / 7) }, (_, index) =>
|
||||
uploadStats.series.slice(index * 7, index * 7 + 7),
|
||||
);
|
||||
};
|
||||
|
||||
const getUploadActivityLevel = (count: number) => {
|
||||
const maxCount = Math.max(...uploadStats.series.map((item) => item.count), 0);
|
||||
|
||||
if (count === 0 || maxCount === 0) {
|
||||
return 'bg-gray-200 dark:bg-gray-700';
|
||||
}
|
||||
|
||||
if (count <= Math.ceil(maxCount * 0.25)) {
|
||||
return 'bg-immich-primary/30';
|
||||
}
|
||||
|
||||
if (count <= Math.ceil(maxCount * 0.5)) {
|
||||
return 'bg-immich-primary/50';
|
||||
}
|
||||
|
||||
if (count <= Math.ceil(maxCount * 0.75)) {
|
||||
return 'bg-immich-primary/70';
|
||||
}
|
||||
|
||||
return 'bg-immich-primary';
|
||||
};
|
||||
|
||||
const getUploadActivityMonths = () => {
|
||||
const endDate = uploadStats.to ? DateTime.fromISO(uploadStats.to, { zone: 'utc' }) : today;
|
||||
return Array.from({ length: 12 }, (_, index) => {
|
||||
const monthDate = endDate.minus({ months: 11 - index });
|
||||
return monthDate.toLocaleString({ month: 'short' }, { locale: $locale });
|
||||
});
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
await getUsage();
|
||||
});
|
||||
|
|
@ -95,4 +147,52 @@
|
|||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Heading size="tiny" class="mt-8">{$t('upload_activity')}</Heading>
|
||||
<div class="mt-4 w-full">
|
||||
<div class="w-full">
|
||||
<div class="mb-1 ml-7 flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
{#each getUploadActivityMonths() as month (month)}
|
||||
<div>{month}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1">
|
||||
<div class="grid w-6 shrink-0 grid-rows-7 gap-px py-0.5 text-xs text-gray-500 sm:gap-1 dark:text-gray-400">
|
||||
<div></div>
|
||||
<div>{$t('upload_activity_day_monday')}</div>
|
||||
<div></div>
|
||||
<div>{$t('upload_activity_day_wednesday')}</div>
|
||||
<div></div>
|
||||
<div>{$t('upload_activity_day_friday')}</div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
<div class="grid flex-1 grid-cols-52 gap-px sm:gap-1">
|
||||
{#each getUploadActivityWeeks() as week (week[0]?.date)}
|
||||
<div class="grid grid-rows-7 gap-px sm:gap-1">
|
||||
{#each week as day (day.date)}
|
||||
<div
|
||||
class={`aspect-square w-full min-w-0 rounded-sm ${getUploadActivityLevel(day.count)}`}
|
||||
title={$t('upload_activity_day_count', { values: { date: day.date, count: day.count } })}
|
||||
aria-label={$t('upload_activity_day_count', { values: { date: day.date, count: day.count } })}
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{$t('less')}</span>
|
||||
<span class="size-3 rounded-sm bg-gray-200 dark:bg-gray-700"></span>
|
||||
<span class="size-3 rounded-sm bg-immich-primary/30"></span>
|
||||
<span class="size-3 rounded-sm bg-immich-primary/50"></span>
|
||||
<span class="size-3 rounded-sm bg-immich-primary/70"></span>
|
||||
<span class="size-3 rounded-sm bg-immich-primary"></span>
|
||||
<span>{$t('more')}</span>
|
||||
<span class="ml-4">{$t('upload_activity_total_count', { values: { count: uploadStats.totalCount } })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Reference in New Issue