From 88ac3ec31ee3e808db82663c04a31cf730d1221e Mon Sep 17 00:00:00 2001 From: boojack Date: Sat, 9 May 2026 09:11:04 +0800 Subject: [PATCH] feat(stats): support filtered all-user stats - Add state and filter inputs to ListAllUserStats and reuse it for explore/archive sidebar stats. - Reduce duplicate home initialization requests by sharing stats/settings data paths. - Include memo paragraph regression coverage from the current working tree. --- proto/api/v1/user_service.proto | 7 +- proto/gen/api/v1/user_service.pb.go | 218 ++++++++++-------- proto/gen/api/v1/user_service.pb.gw.go | 14 ++ proto/gen/openapi.yaml | 18 ++ .../api/v1/test/user_service_stats_test.go | 42 ++++ server/router/api/v1/user_service_stats.go | 22 +- .../MemoContent/markdown/Paragraph.tsx | 17 +- .../MemoExplorer/ShortcutsSection.tsx | 6 +- web/src/contexts/AuthContext.tsx | 21 +- web/src/hooks/useFilteredMemoStats.ts | 90 ++++---- web/src/hooks/useLiveMemoRefresh.ts | 7 +- web/src/hooks/useUserQueries.ts | 43 +++- web/src/pages/Shortcuts.tsx | 4 - web/src/types/proto/api/v1/user_service_pb.ts | 18 +- web/tests/memo-content-paragraph.test.tsx | 34 +++ 15 files changed, 375 insertions(+), 186 deletions(-) create mode 100644 web/tests/memo-content-paragraph.test.tsx diff --git a/proto/api/v1/user_service.proto b/proto/api/v1/user_service.proto index 1511deef5..87f6c1118 100644 --- a/proto/api/v1/user_service.proto +++ b/proto/api/v1/user_service.proto @@ -396,7 +396,12 @@ message GetUserStatsRequest { } message ListAllUserStatsRequest { - // This endpoint doesn't take any parameters. + // Optional. The state of memos to include. Defaults to NORMAL. + State state = 1 [(google.api.field_behavior) = OPTIONAL]; + + // Optional. Filter to apply to memo stats. + // Uses the same filter syntax as ListMemos. + string filter = 2 [(google.api.field_behavior) = OPTIONAL]; } message ListAllUserStatsResponse { diff --git a/proto/gen/api/v1/user_service.pb.go b/proto/gen/api/v1/user_service.pb.go index fd48b03bc..ebaba7618 100644 --- a/proto/gen/api/v1/user_service.pb.go +++ b/proto/gen/api/v1/user_service.pb.go @@ -993,7 +993,12 @@ func (x *GetUserStatsRequest) GetName() string { } type ListAllUserStatsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // Optional. The state of memos to include. Defaults to NORMAL. + State State `protobuf:"varint,1,opt,name=state,proto3,enum=memos.api.v1.State" json:"state,omitempty"` + // Optional. Filter to apply to memo stats. + // Uses the same filter syntax as ListMemos. + Filter string `protobuf:"bytes,2,opt,name=filter,proto3" json:"filter,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1028,6 +1033,20 @@ func (*ListAllUserStatsRequest) Descriptor() ([]byte, []int) { return file_api_v1_user_service_proto_rawDescGZIP(), []int{11} } +func (x *ListAllUserStatsRequest) GetState() State { + if x != nil { + return x.State + } + return State_STATE_UNSPECIFIED +} + +func (x *ListAllUserStatsRequest) GetFilter() string { + if x != nil { + return x.Filter + } + return "" +} + type ListAllUserStatsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The list of user statistics. @@ -2808,7 +2827,7 @@ type UserStats_MemoTypeStats struct { func (x *UserStats_MemoTypeStats) Reset() { *x = UserStats_MemoTypeStats{} - mi := &file_api_v1_user_service_proto_msgTypes[42] + mi := &file_api_v1_user_service_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2820,7 +2839,7 @@ func (x *UserStats_MemoTypeStats) String() string { func (*UserStats_MemoTypeStats) ProtoMessage() {} func (x *UserStats_MemoTypeStats) ProtoReflect() protoreflect.Message { - mi := &file_api_v1_user_service_proto_msgTypes[42] + mi := &file_api_v1_user_service_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2833,7 +2852,7 @@ func (x *UserStats_MemoTypeStats) ProtoReflect() protoreflect.Message { // Deprecated: Use UserStats_MemoTypeStats.ProtoReflect.Descriptor instead. func (*UserStats_MemoTypeStats) Descriptor() ([]byte, []int) { - return file_api_v1_user_service_proto_rawDescGZIP(), []int{9, 1} + return file_api_v1_user_service_proto_rawDescGZIP(), []int{9, 0} } func (x *UserStats_MemoTypeStats) GetLinkCount() int32 { @@ -3191,10 +3210,7 @@ const file_api_v1_user_service_proto_rawDesc = "" + "\x17memo_created_timestamps\x18\a \x03(\v2\x1a.google.protobuf.TimestampR\x15memoCreatedTimestamps\x12R\n" + "\x17memo_updated_timestamps\x18\b \x03(\v2\x1a.google.protobuf.TimestampR\x15memoUpdatedTimestamps\x12!\n" + "\fpinned_memos\x18\x05 \x03(\tR\vpinnedMemos\x12(\n" + - "\x10total_memo_count\x18\x06 \x01(\x05R\x0etotalMemoCount\x1a;\n" + - "\rTagCountEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\x05R\x05value:\x028\x01\x1a\x8b\x01\n" + + "\x10total_memo_count\x18\x06 \x01(\x05R\x0etotalMemoCount\x1a\x8b\x01\n" + "\rMemoTypeStats\x12\x1d\n" + "\n" + "link_count\x18\x01 \x01(\x05R\tlinkCount\x12\x1d\n" + @@ -3203,12 +3219,17 @@ const file_api_v1_user_service_proto_rawDesc = "" + "\n" + "todo_count\x18\x03 \x01(\x05R\ttodoCount\x12\x1d\n" + "\n" + - "undo_count\x18\x04 \x01(\x05R\tundoCount:?\xeaA<\n" + + "undo_count\x18\x04 \x01(\x05R\tundoCount\x1a;\n" + + "\rTagCountEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\x05R\x05value:\x028\x01:?\xeaA<\n" + "\x16memos.api.v1/UserStats\x12\fusers/{user}*\tuserStats2\tuserStatsJ\x04\b\x02\x10\x03R\x17memo_display_timestamps\"D\n" + "\x13GetUserStatsRequest\x12-\n" + "\x04name\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" + - "\x11memos.api.v1/UserR\x04name\"\x19\n" + - "\x17ListAllUserStatsRequest\"I\n" + + "\x11memos.api.v1/UserR\x04name\"f\n" + + "\x17ListAllUserStatsRequest\x12.\n" + + "\x05state\x18\x01 \x01(\x0e2\x13.memos.api.v1.StateB\x03\xe0A\x01R\x05state\x12\x1b\n" + + "\x06filter\x18\x02 \x01(\tB\x03\xe0A\x01R\x06filter\"I\n" + "\x18ListAllUserStatsResponse\x12-\n" + "\x05stats\x18\x01 \x03(\v2\x17.memos.api.v1.UserStatsR\x05stats\"\xb4\x04\n" + "\vUserSetting\x12\x17\n" + @@ -3465,8 +3486,8 @@ var file_api_v1_user_service_proto_goTypes = []any{ (*ListUserNotificationsResponse)(nil), // 42: memos.api.v1.ListUserNotificationsResponse (*UpdateUserNotificationRequest)(nil), // 43: memos.api.v1.UpdateUserNotificationRequest (*DeleteUserNotificationRequest)(nil), // 44: memos.api.v1.DeleteUserNotificationRequest - nil, // 45: memos.api.v1.UserStats.TagCountEntry - (*UserStats_MemoTypeStats)(nil), // 46: memos.api.v1.UserStats.MemoTypeStats + (*UserStats_MemoTypeStats)(nil), // 45: memos.api.v1.UserStats.MemoTypeStats + nil, // 46: memos.api.v1.UserStats.TagCountEntry (*UserSetting_GeneralSetting)(nil), // 47: memos.api.v1.UserSetting.GeneralSetting (*UserSetting_WebhooksSetting)(nil), // 48: memos.api.v1.UserSetting.WebhooksSetting (*UserNotification_MemoCommentPayload)(nil), // 49: memos.api.v1.UserNotification.MemoCommentPayload @@ -3487,93 +3508,94 @@ var file_api_v1_user_service_proto_depIdxs = []int32{ 4, // 7: memos.api.v1.CreateUserRequest.user:type_name -> memos.api.v1.User 4, // 8: memos.api.v1.UpdateUserRequest.user:type_name -> memos.api.v1.User 53, // 9: memos.api.v1.UpdateUserRequest.update_mask:type_name -> google.protobuf.FieldMask - 46, // 10: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats - 45, // 11: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry + 45, // 10: memos.api.v1.UserStats.memo_type_stats:type_name -> memos.api.v1.UserStats.MemoTypeStats + 46, // 11: memos.api.v1.UserStats.tag_count:type_name -> memos.api.v1.UserStats.TagCountEntry 52, // 12: memos.api.v1.UserStats.memo_created_timestamps:type_name -> google.protobuf.Timestamp 52, // 13: memos.api.v1.UserStats.memo_updated_timestamps:type_name -> google.protobuf.Timestamp - 13, // 14: memos.api.v1.ListAllUserStatsResponse.stats:type_name -> memos.api.v1.UserStats - 47, // 15: memos.api.v1.UserSetting.general_setting:type_name -> memos.api.v1.UserSetting.GeneralSetting - 48, // 16: memos.api.v1.UserSetting.webhooks_setting:type_name -> memos.api.v1.UserSetting.WebhooksSetting - 17, // 17: memos.api.v1.UpdateUserSettingRequest.setting:type_name -> memos.api.v1.UserSetting - 53, // 18: memos.api.v1.UpdateUserSettingRequest.update_mask:type_name -> google.protobuf.FieldMask - 17, // 19: memos.api.v1.ListUserSettingsResponse.settings:type_name -> memos.api.v1.UserSetting - 22, // 20: memos.api.v1.ListLinkedIdentitiesResponse.linked_identities:type_name -> memos.api.v1.LinkedIdentity - 52, // 21: memos.api.v1.PersonalAccessToken.created_at:type_name -> google.protobuf.Timestamp - 52, // 22: memos.api.v1.PersonalAccessToken.expires_at:type_name -> google.protobuf.Timestamp - 52, // 23: memos.api.v1.PersonalAccessToken.last_used_at:type_name -> google.protobuf.Timestamp - 28, // 24: memos.api.v1.ListPersonalAccessTokensResponse.personal_access_tokens:type_name -> memos.api.v1.PersonalAccessToken - 28, // 25: memos.api.v1.CreatePersonalAccessTokenResponse.personal_access_token:type_name -> memos.api.v1.PersonalAccessToken - 52, // 26: memos.api.v1.UserWebhook.create_time:type_name -> google.protobuf.Timestamp - 52, // 27: memos.api.v1.UserWebhook.update_time:type_name -> google.protobuf.Timestamp - 34, // 28: memos.api.v1.ListUserWebhooksResponse.webhooks:type_name -> memos.api.v1.UserWebhook - 34, // 29: memos.api.v1.CreateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook - 34, // 30: memos.api.v1.UpdateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook - 53, // 31: memos.api.v1.UpdateUserWebhookRequest.update_mask:type_name -> google.protobuf.FieldMask - 4, // 32: memos.api.v1.UserNotification.sender_user:type_name -> memos.api.v1.User - 2, // 33: memos.api.v1.UserNotification.status:type_name -> memos.api.v1.UserNotification.Status - 52, // 34: memos.api.v1.UserNotification.create_time:type_name -> google.protobuf.Timestamp - 3, // 35: memos.api.v1.UserNotification.type:type_name -> memos.api.v1.UserNotification.Type - 49, // 36: memos.api.v1.UserNotification.memo_comment:type_name -> memos.api.v1.UserNotification.MemoCommentPayload - 50, // 37: memos.api.v1.UserNotification.memo_mention:type_name -> memos.api.v1.UserNotification.MemoMentionPayload - 40, // 38: memos.api.v1.ListUserNotificationsResponse.notifications:type_name -> memos.api.v1.UserNotification - 40, // 39: memos.api.v1.UpdateUserNotificationRequest.notification:type_name -> memos.api.v1.UserNotification - 53, // 40: memos.api.v1.UpdateUserNotificationRequest.update_mask:type_name -> google.protobuf.FieldMask - 34, // 41: memos.api.v1.UserSetting.WebhooksSetting.webhooks:type_name -> memos.api.v1.UserWebhook - 5, // 42: memos.api.v1.UserService.ListUsers:input_type -> memos.api.v1.ListUsersRequest - 7, // 43: memos.api.v1.UserService.BatchGetUsers:input_type -> memos.api.v1.BatchGetUsersRequest - 9, // 44: memos.api.v1.UserService.GetUser:input_type -> memos.api.v1.GetUserRequest - 10, // 45: memos.api.v1.UserService.CreateUser:input_type -> memos.api.v1.CreateUserRequest - 11, // 46: memos.api.v1.UserService.UpdateUser:input_type -> memos.api.v1.UpdateUserRequest - 12, // 47: memos.api.v1.UserService.DeleteUser:input_type -> memos.api.v1.DeleteUserRequest - 15, // 48: memos.api.v1.UserService.ListAllUserStats:input_type -> memos.api.v1.ListAllUserStatsRequest - 14, // 49: memos.api.v1.UserService.GetUserStats:input_type -> memos.api.v1.GetUserStatsRequest - 18, // 50: memos.api.v1.UserService.GetUserSetting:input_type -> memos.api.v1.GetUserSettingRequest - 19, // 51: memos.api.v1.UserService.UpdateUserSetting:input_type -> memos.api.v1.UpdateUserSettingRequest - 20, // 52: memos.api.v1.UserService.ListUserSettings:input_type -> memos.api.v1.ListUserSettingsRequest - 23, // 53: memos.api.v1.UserService.ListLinkedIdentities:input_type -> memos.api.v1.ListLinkedIdentitiesRequest - 25, // 54: memos.api.v1.UserService.CreateLinkedIdentity:input_type -> memos.api.v1.CreateLinkedIdentityRequest - 26, // 55: memos.api.v1.UserService.GetLinkedIdentity:input_type -> memos.api.v1.GetLinkedIdentityRequest - 27, // 56: memos.api.v1.UserService.DeleteLinkedIdentity:input_type -> memos.api.v1.DeleteLinkedIdentityRequest - 29, // 57: memos.api.v1.UserService.ListPersonalAccessTokens:input_type -> memos.api.v1.ListPersonalAccessTokensRequest - 31, // 58: memos.api.v1.UserService.CreatePersonalAccessToken:input_type -> memos.api.v1.CreatePersonalAccessTokenRequest - 33, // 59: memos.api.v1.UserService.DeletePersonalAccessToken:input_type -> memos.api.v1.DeletePersonalAccessTokenRequest - 35, // 60: memos.api.v1.UserService.ListUserWebhooks:input_type -> memos.api.v1.ListUserWebhooksRequest - 37, // 61: memos.api.v1.UserService.CreateUserWebhook:input_type -> memos.api.v1.CreateUserWebhookRequest - 38, // 62: memos.api.v1.UserService.UpdateUserWebhook:input_type -> memos.api.v1.UpdateUserWebhookRequest - 39, // 63: memos.api.v1.UserService.DeleteUserWebhook:input_type -> memos.api.v1.DeleteUserWebhookRequest - 41, // 64: memos.api.v1.UserService.ListUserNotifications:input_type -> memos.api.v1.ListUserNotificationsRequest - 43, // 65: memos.api.v1.UserService.UpdateUserNotification:input_type -> memos.api.v1.UpdateUserNotificationRequest - 44, // 66: memos.api.v1.UserService.DeleteUserNotification:input_type -> memos.api.v1.DeleteUserNotificationRequest - 6, // 67: memos.api.v1.UserService.ListUsers:output_type -> memos.api.v1.ListUsersResponse - 8, // 68: memos.api.v1.UserService.BatchGetUsers:output_type -> memos.api.v1.BatchGetUsersResponse - 4, // 69: memos.api.v1.UserService.GetUser:output_type -> memos.api.v1.User - 4, // 70: memos.api.v1.UserService.CreateUser:output_type -> memos.api.v1.User - 4, // 71: memos.api.v1.UserService.UpdateUser:output_type -> memos.api.v1.User - 54, // 72: memos.api.v1.UserService.DeleteUser:output_type -> google.protobuf.Empty - 16, // 73: memos.api.v1.UserService.ListAllUserStats:output_type -> memos.api.v1.ListAllUserStatsResponse - 13, // 74: memos.api.v1.UserService.GetUserStats:output_type -> memos.api.v1.UserStats - 17, // 75: memos.api.v1.UserService.GetUserSetting:output_type -> memos.api.v1.UserSetting - 17, // 76: memos.api.v1.UserService.UpdateUserSetting:output_type -> memos.api.v1.UserSetting - 21, // 77: memos.api.v1.UserService.ListUserSettings:output_type -> memos.api.v1.ListUserSettingsResponse - 24, // 78: memos.api.v1.UserService.ListLinkedIdentities:output_type -> memos.api.v1.ListLinkedIdentitiesResponse - 22, // 79: memos.api.v1.UserService.CreateLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity - 22, // 80: memos.api.v1.UserService.GetLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity - 54, // 81: memos.api.v1.UserService.DeleteLinkedIdentity:output_type -> google.protobuf.Empty - 30, // 82: memos.api.v1.UserService.ListPersonalAccessTokens:output_type -> memos.api.v1.ListPersonalAccessTokensResponse - 32, // 83: memos.api.v1.UserService.CreatePersonalAccessToken:output_type -> memos.api.v1.CreatePersonalAccessTokenResponse - 54, // 84: memos.api.v1.UserService.DeletePersonalAccessToken:output_type -> google.protobuf.Empty - 36, // 85: memos.api.v1.UserService.ListUserWebhooks:output_type -> memos.api.v1.ListUserWebhooksResponse - 34, // 86: memos.api.v1.UserService.CreateUserWebhook:output_type -> memos.api.v1.UserWebhook - 34, // 87: memos.api.v1.UserService.UpdateUserWebhook:output_type -> memos.api.v1.UserWebhook - 54, // 88: memos.api.v1.UserService.DeleteUserWebhook:output_type -> google.protobuf.Empty - 42, // 89: memos.api.v1.UserService.ListUserNotifications:output_type -> memos.api.v1.ListUserNotificationsResponse - 40, // 90: memos.api.v1.UserService.UpdateUserNotification:output_type -> memos.api.v1.UserNotification - 54, // 91: memos.api.v1.UserService.DeleteUserNotification:output_type -> google.protobuf.Empty - 67, // [67:92] is the sub-list for method output_type - 42, // [42:67] is the sub-list for method input_type - 42, // [42:42] is the sub-list for extension type_name - 42, // [42:42] is the sub-list for extension extendee - 0, // [0:42] is the sub-list for field type_name + 51, // 14: memos.api.v1.ListAllUserStatsRequest.state:type_name -> memos.api.v1.State + 13, // 15: memos.api.v1.ListAllUserStatsResponse.stats:type_name -> memos.api.v1.UserStats + 47, // 16: memos.api.v1.UserSetting.general_setting:type_name -> memos.api.v1.UserSetting.GeneralSetting + 48, // 17: memos.api.v1.UserSetting.webhooks_setting:type_name -> memos.api.v1.UserSetting.WebhooksSetting + 17, // 18: memos.api.v1.UpdateUserSettingRequest.setting:type_name -> memos.api.v1.UserSetting + 53, // 19: memos.api.v1.UpdateUserSettingRequest.update_mask:type_name -> google.protobuf.FieldMask + 17, // 20: memos.api.v1.ListUserSettingsResponse.settings:type_name -> memos.api.v1.UserSetting + 22, // 21: memos.api.v1.ListLinkedIdentitiesResponse.linked_identities:type_name -> memos.api.v1.LinkedIdentity + 52, // 22: memos.api.v1.PersonalAccessToken.created_at:type_name -> google.protobuf.Timestamp + 52, // 23: memos.api.v1.PersonalAccessToken.expires_at:type_name -> google.protobuf.Timestamp + 52, // 24: memos.api.v1.PersonalAccessToken.last_used_at:type_name -> google.protobuf.Timestamp + 28, // 25: memos.api.v1.ListPersonalAccessTokensResponse.personal_access_tokens:type_name -> memos.api.v1.PersonalAccessToken + 28, // 26: memos.api.v1.CreatePersonalAccessTokenResponse.personal_access_token:type_name -> memos.api.v1.PersonalAccessToken + 52, // 27: memos.api.v1.UserWebhook.create_time:type_name -> google.protobuf.Timestamp + 52, // 28: memos.api.v1.UserWebhook.update_time:type_name -> google.protobuf.Timestamp + 34, // 29: memos.api.v1.ListUserWebhooksResponse.webhooks:type_name -> memos.api.v1.UserWebhook + 34, // 30: memos.api.v1.CreateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook + 34, // 31: memos.api.v1.UpdateUserWebhookRequest.webhook:type_name -> memos.api.v1.UserWebhook + 53, // 32: memos.api.v1.UpdateUserWebhookRequest.update_mask:type_name -> google.protobuf.FieldMask + 4, // 33: memos.api.v1.UserNotification.sender_user:type_name -> memos.api.v1.User + 2, // 34: memos.api.v1.UserNotification.status:type_name -> memos.api.v1.UserNotification.Status + 52, // 35: memos.api.v1.UserNotification.create_time:type_name -> google.protobuf.Timestamp + 3, // 36: memos.api.v1.UserNotification.type:type_name -> memos.api.v1.UserNotification.Type + 49, // 37: memos.api.v1.UserNotification.memo_comment:type_name -> memos.api.v1.UserNotification.MemoCommentPayload + 50, // 38: memos.api.v1.UserNotification.memo_mention:type_name -> memos.api.v1.UserNotification.MemoMentionPayload + 40, // 39: memos.api.v1.ListUserNotificationsResponse.notifications:type_name -> memos.api.v1.UserNotification + 40, // 40: memos.api.v1.UpdateUserNotificationRequest.notification:type_name -> memos.api.v1.UserNotification + 53, // 41: memos.api.v1.UpdateUserNotificationRequest.update_mask:type_name -> google.protobuf.FieldMask + 34, // 42: memos.api.v1.UserSetting.WebhooksSetting.webhooks:type_name -> memos.api.v1.UserWebhook + 5, // 43: memos.api.v1.UserService.ListUsers:input_type -> memos.api.v1.ListUsersRequest + 7, // 44: memos.api.v1.UserService.BatchGetUsers:input_type -> memos.api.v1.BatchGetUsersRequest + 9, // 45: memos.api.v1.UserService.GetUser:input_type -> memos.api.v1.GetUserRequest + 10, // 46: memos.api.v1.UserService.CreateUser:input_type -> memos.api.v1.CreateUserRequest + 11, // 47: memos.api.v1.UserService.UpdateUser:input_type -> memos.api.v1.UpdateUserRequest + 12, // 48: memos.api.v1.UserService.DeleteUser:input_type -> memos.api.v1.DeleteUserRequest + 15, // 49: memos.api.v1.UserService.ListAllUserStats:input_type -> memos.api.v1.ListAllUserStatsRequest + 14, // 50: memos.api.v1.UserService.GetUserStats:input_type -> memos.api.v1.GetUserStatsRequest + 18, // 51: memos.api.v1.UserService.GetUserSetting:input_type -> memos.api.v1.GetUserSettingRequest + 19, // 52: memos.api.v1.UserService.UpdateUserSetting:input_type -> memos.api.v1.UpdateUserSettingRequest + 20, // 53: memos.api.v1.UserService.ListUserSettings:input_type -> memos.api.v1.ListUserSettingsRequest + 23, // 54: memos.api.v1.UserService.ListLinkedIdentities:input_type -> memos.api.v1.ListLinkedIdentitiesRequest + 25, // 55: memos.api.v1.UserService.CreateLinkedIdentity:input_type -> memos.api.v1.CreateLinkedIdentityRequest + 26, // 56: memos.api.v1.UserService.GetLinkedIdentity:input_type -> memos.api.v1.GetLinkedIdentityRequest + 27, // 57: memos.api.v1.UserService.DeleteLinkedIdentity:input_type -> memos.api.v1.DeleteLinkedIdentityRequest + 29, // 58: memos.api.v1.UserService.ListPersonalAccessTokens:input_type -> memos.api.v1.ListPersonalAccessTokensRequest + 31, // 59: memos.api.v1.UserService.CreatePersonalAccessToken:input_type -> memos.api.v1.CreatePersonalAccessTokenRequest + 33, // 60: memos.api.v1.UserService.DeletePersonalAccessToken:input_type -> memos.api.v1.DeletePersonalAccessTokenRequest + 35, // 61: memos.api.v1.UserService.ListUserWebhooks:input_type -> memos.api.v1.ListUserWebhooksRequest + 37, // 62: memos.api.v1.UserService.CreateUserWebhook:input_type -> memos.api.v1.CreateUserWebhookRequest + 38, // 63: memos.api.v1.UserService.UpdateUserWebhook:input_type -> memos.api.v1.UpdateUserWebhookRequest + 39, // 64: memos.api.v1.UserService.DeleteUserWebhook:input_type -> memos.api.v1.DeleteUserWebhookRequest + 41, // 65: memos.api.v1.UserService.ListUserNotifications:input_type -> memos.api.v1.ListUserNotificationsRequest + 43, // 66: memos.api.v1.UserService.UpdateUserNotification:input_type -> memos.api.v1.UpdateUserNotificationRequest + 44, // 67: memos.api.v1.UserService.DeleteUserNotification:input_type -> memos.api.v1.DeleteUserNotificationRequest + 6, // 68: memos.api.v1.UserService.ListUsers:output_type -> memos.api.v1.ListUsersResponse + 8, // 69: memos.api.v1.UserService.BatchGetUsers:output_type -> memos.api.v1.BatchGetUsersResponse + 4, // 70: memos.api.v1.UserService.GetUser:output_type -> memos.api.v1.User + 4, // 71: memos.api.v1.UserService.CreateUser:output_type -> memos.api.v1.User + 4, // 72: memos.api.v1.UserService.UpdateUser:output_type -> memos.api.v1.User + 54, // 73: memos.api.v1.UserService.DeleteUser:output_type -> google.protobuf.Empty + 16, // 74: memos.api.v1.UserService.ListAllUserStats:output_type -> memos.api.v1.ListAllUserStatsResponse + 13, // 75: memos.api.v1.UserService.GetUserStats:output_type -> memos.api.v1.UserStats + 17, // 76: memos.api.v1.UserService.GetUserSetting:output_type -> memos.api.v1.UserSetting + 17, // 77: memos.api.v1.UserService.UpdateUserSetting:output_type -> memos.api.v1.UserSetting + 21, // 78: memos.api.v1.UserService.ListUserSettings:output_type -> memos.api.v1.ListUserSettingsResponse + 24, // 79: memos.api.v1.UserService.ListLinkedIdentities:output_type -> memos.api.v1.ListLinkedIdentitiesResponse + 22, // 80: memos.api.v1.UserService.CreateLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity + 22, // 81: memos.api.v1.UserService.GetLinkedIdentity:output_type -> memos.api.v1.LinkedIdentity + 54, // 82: memos.api.v1.UserService.DeleteLinkedIdentity:output_type -> google.protobuf.Empty + 30, // 83: memos.api.v1.UserService.ListPersonalAccessTokens:output_type -> memos.api.v1.ListPersonalAccessTokensResponse + 32, // 84: memos.api.v1.UserService.CreatePersonalAccessToken:output_type -> memos.api.v1.CreatePersonalAccessTokenResponse + 54, // 85: memos.api.v1.UserService.DeletePersonalAccessToken:output_type -> google.protobuf.Empty + 36, // 86: memos.api.v1.UserService.ListUserWebhooks:output_type -> memos.api.v1.ListUserWebhooksResponse + 34, // 87: memos.api.v1.UserService.CreateUserWebhook:output_type -> memos.api.v1.UserWebhook + 34, // 88: memos.api.v1.UserService.UpdateUserWebhook:output_type -> memos.api.v1.UserWebhook + 54, // 89: memos.api.v1.UserService.DeleteUserWebhook:output_type -> google.protobuf.Empty + 42, // 90: memos.api.v1.UserService.ListUserNotifications:output_type -> memos.api.v1.ListUserNotificationsResponse + 40, // 91: memos.api.v1.UserService.UpdateUserNotification:output_type -> memos.api.v1.UserNotification + 54, // 92: memos.api.v1.UserService.DeleteUserNotification:output_type -> google.protobuf.Empty + 68, // [68:93] is the sub-list for method output_type + 43, // [43:68] is the sub-list for method input_type + 43, // [43:43] is the sub-list for extension type_name + 43, // [43:43] is the sub-list for extension extendee + 0, // [0:43] is the sub-list for field type_name } func init() { file_api_v1_user_service_proto_init() } diff --git a/proto/gen/api/v1/user_service.pb.gw.go b/proto/gen/api/v1/user_service.pb.gw.go index ce91aa75c..d50afe14f 100644 --- a/proto/gen/api/v1/user_service.pb.gw.go +++ b/proto/gen/api/v1/user_service.pb.gw.go @@ -325,6 +325,8 @@ func local_request_UserService_DeleteUser_0(ctx context.Context, marshaler runti return msg, metadata, err } +var filter_UserService_ListAllUserStats_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} + func request_UserService_ListAllUserStats_0(ctx context.Context, marshaler runtime.Marshaler, client UserServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { var ( protoReq ListAllUserStatsRequest @@ -333,6 +335,12 @@ func request_UserService_ListAllUserStats_0(ctx context.Context, marshaler runti if req.Body != nil { _, _ = io.Copy(io.Discard, req.Body) } + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_UserService_ListAllUserStats_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } msg, err := client.ListAllUserStats(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) return msg, metadata, err } @@ -342,6 +350,12 @@ func local_request_UserService_ListAllUserStats_0(ctx context.Context, marshaler protoReq ListAllUserStatsRequest metadata runtime.ServerMetadata ) + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_UserService_ListAllUserStats_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } msg, err := server.ListAllUserStats(ctx, &protoReq) return msg, metadata, err } diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index 3610c6e4a..e84f3c643 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -2241,6 +2241,24 @@ paths: - UserService description: ListAllUserStats returns statistics for all users. operationId: UserService_ListAllUserStats + parameters: + - name: state + in: query + description: Optional. The state of memos to include. Defaults to NORMAL. + schema: + enum: + - STATE_UNSPECIFIED + - NORMAL + - ARCHIVED + type: string + format: enum + - name: filter + in: query + description: |- + Optional. Filter to apply to memo stats. + Uses the same filter syntax as ListMemos. + schema: + type: string responses: "200": description: OK diff --git a/server/router/api/v1/test/user_service_stats_test.go b/server/router/api/v1/test/user_service_stats_test.go index bac17e137..fb62728cb 100644 --- a/server/router/api/v1/test/user_service_stats_test.go +++ b/server/router/api/v1/test/user_service_stats_test.go @@ -158,3 +158,45 @@ func TestGetUserStats_MemoUpdatedTimestamps(t *testing.T) { "updated_ts should be after created_ts after an edit", ) } + +func TestListAllUserStats_FilterExcludesPrivateMemos(t *testing.T) { + ctx := context.Background() + + ts := NewTestService(t) + defer ts.Cleanup() + + user, err := ts.CreateHostUser(ctx, "stats-filter-user") + require.NoError(t, err) + userCtx := ts.CreateUserContext(ctx, user.ID) + + _, err = ts.Store.CreateMemo(ctx, &store.Memo{ + UID: "stats-filter-public", + CreatorID: user.ID, + Content: "public memo", + Visibility: store.Public, + Payload: &storepb.MemoPayload{Tags: []string{"public"}}, + }) + require.NoError(t, err) + _, err = ts.Store.CreateMemo(ctx, &store.Memo{ + UID: "stats-filter-private", + CreatorID: user.ID, + Content: "private memo", + Visibility: store.Private, + Payload: &storepb.MemoPayload{Tags: []string{"private"}}, + }) + require.NoError(t, err) + + unfilteredResp, err := ts.Service.ListAllUserStats(userCtx, &v1pb.ListAllUserStatsRequest{}) + require.NoError(t, err) + require.Len(t, unfilteredResp.Stats, 1) + require.Equal(t, int32(1), unfilteredResp.Stats[0].TagCount["public"]) + require.Equal(t, int32(1), unfilteredResp.Stats[0].TagCount["private"]) + + filteredResp, err := ts.Service.ListAllUserStats(userCtx, &v1pb.ListAllUserStatsRequest{ + Filter: `visibility in ["PUBLIC", "PROTECTED"]`, + }) + require.NoError(t, err) + require.Len(t, filteredResp.Stats, 1) + require.Equal(t, int32(1), filteredResp.Stats[0].TagCount["public"]) + require.NotContains(t, filteredResp.Stats[0].TagCount, "private") +} diff --git a/server/router/api/v1/user_service_stats.go b/server/router/api/v1/user_service_stats.go index bc47797f4..3f6c99b9e 100644 --- a/server/router/api/v1/user_service_stats.go +++ b/server/router/api/v1/user_service_stats.go @@ -53,20 +53,34 @@ func (s *APIV1Service) listUsernamesByID(ctx context.Context, userIDs []int32) ( return usernamesByID, nil } -func (s *APIV1Service) ListAllUserStats(ctx context.Context, _ *v1pb.ListAllUserStatsRequest) (*v1pb.ListAllUserStatsResponse, error) { - normalStatus := store.Normal +func (s *APIV1Service) ListAllUserStats(ctx context.Context, request *v1pb.ListAllUserStatsRequest) (*v1pb.ListAllUserStatsResponse, error) { + rowStatus := convertStateToStore(request.State) memoFind := &store.FindMemo{ // Exclude comments by default. ExcludeComments: true, ExcludeContent: true, - RowStatus: &normalStatus, + RowStatus: &rowStatus, } currentUser, err := s.fetchCurrentUser(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get user: %v", err) } - if currentUser == nil { + + if request.Filter != "" { + if err := s.validateFilter(ctx, request.Filter); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid filter: %v", err) + } + memoFind.Filters = append(memoFind.Filters, request.Filter) + } + + if request.State == v1pb.State_ARCHIVED { + // Archived memos are only visible to their creator. + if currentUser == nil { + return &v1pb.ListAllUserStatsResponse{}, nil + } + memoFind.CreatorID = ¤tUser.ID + } else if currentUser == nil { memoFind.VisibilityList = []store.Visibility{store.Public} } else { if memoFind.CreatorID == nil { diff --git a/web/src/components/MemoContent/markdown/Paragraph.tsx b/web/src/components/MemoContent/markdown/Paragraph.tsx index 00331598b..059ce7d71 100644 --- a/web/src/components/MemoContent/markdown/Paragraph.tsx +++ b/web/src/components/MemoContent/markdown/Paragraph.tsx @@ -8,7 +8,7 @@ interface ParagraphProps extends React.HTMLAttributes, Rea children: React.ReactNode; } -function getSingleLinkHref(node?: Element): string | undefined { +export function getSingleLinkHref(node?: Element): string | undefined { if (!node || node.tagName !== "p") { return undefined; } @@ -27,7 +27,20 @@ function getSingleLinkHref(node?: Element): string | undefined { } const href = onlyChild.properties?.href; - return typeof href === "string" ? href : undefined; + if (typeof href !== "string") { + return undefined; + } + + const meaningfulLinkChildren = onlyChild.children.filter((child) => { + return !(child.type === "text" && child.value.trim() === ""); + }); + + if (meaningfulLinkChildren.length !== 1) { + return undefined; + } + + const onlyLinkChild = meaningfulLinkChildren[0]; + return onlyLinkChild.type === "text" && onlyLinkChild.value === href ? href : undefined; } export const Paragraph = ({ children, className, node, ...props }: ParagraphProps) => { diff --git a/web/src/components/MemoExplorer/ShortcutsSection.tsx b/web/src/components/MemoExplorer/ShortcutsSection.tsx index df5348657..73f197f17 100644 --- a/web/src/components/MemoExplorer/ShortcutsSection.tsx +++ b/web/src/components/MemoExplorer/ShortcutsSection.tsx @@ -1,5 +1,5 @@ import { Edit3Icon, MoreVerticalIcon, PlusIcon, TrashIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import toast from "react-hot-toast"; import { useNavigate } from "react-router-dom"; import ConfirmDialog from "@/components/ConfirmDialog"; @@ -29,10 +29,6 @@ function ShortcutsSection() { const { shortcut: selectedShortcut, setShortcut } = useMemoFilterContext(); const [deleteTarget, setDeleteTarget] = useState(); - useEffect(() => { - refetchSettings(); - }, [refetchSettings]); - const handleDeleteShortcut = async (shortcut: Shortcut) => { setDeleteTarget(shortcut); }; diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index f902e8dff..14e64dd24 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -142,18 +142,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, [queryClient]); const refetchSettings = useCallback(async () => { - // Use functional setState to get current user without including state in dependencies - setState((prev) => { - if (!prev.currentUser) return prev; - - // Fetch settings asynchronously - fetchUserSettings(prev.currentUser.name).then((settings) => { - setState((current) => ({ ...current, ...settings })); - }); + const currentUserName = state.currentUser?.name; + if (!currentUserName) { + return; + } - return prev; + const settings = await fetchUserSettings(currentUserName); + setState((prev) => { + if (prev.currentUser?.name !== currentUserName) { + return prev; + } + return { ...prev, ...settings }; }); - }, [fetchUserSettings]); + }, [fetchUserSettings, state.currentUser?.name]); // Sync the updated user to AuthContext and React Query cache after profile changes const setCurrentUser = useCallback( diff --git a/web/src/hooks/useFilteredMemoStats.ts b/web/src/hooks/useFilteredMemoStats.ts index f9f0b4112..27f3fab57 100644 --- a/web/src/hooks/useFilteredMemoStats.ts +++ b/web/src/hooks/useFilteredMemoStats.ts @@ -5,9 +5,9 @@ import { useMemo } from "react"; import type { MemoExplorerContext } from "@/components/MemoExplorer"; import { type MemoTimeBasis, useView } from "@/contexts/ViewContext"; import useCurrentUser from "@/hooks/useCurrentUser"; -import { useMemos } from "@/hooks/useMemoQueries"; -import { useUserStats } from "@/hooks/useUserQueries"; -import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; +import { useAllUserStats, useUserStats } from "@/hooks/useUserQueries"; +import { State } from "@/types/proto/api/v1/common_pb"; +import type { UserStats } from "@/types/proto/api/v1/user_service_pb"; import type { StatisticsData } from "@/types/statistics"; export interface FilteredMemoStats { @@ -23,9 +23,15 @@ export interface UseFilteredMemoStatsOptions { const toDateString = (date: Date) => dayjs(date).format("YYYY-MM-DD"); -const memoTimestampForBasis = (memo: Memo, basis: MemoTimeBasis): Date | undefined => { - const ts = basis === "update_time" ? memo.updateTime : memo.createTime; - return ts ? timestampDate(ts) : undefined; +const timestampsForBasis = (stats: UserStats, basis: MemoTimeBasis) => { + const createdArray = stats.memoCreatedTimestamps ?? []; + const updatedArray = stats.memoUpdatedTimestamps ?? []; + const wantUpdated = basis === "update_time"; + const oldServerFallback = wantUpdated && updatedArray.length === 0 && createdArray.length > 0; + if (oldServerFallback) { + console.warn("UserStats.memo_updated_timestamps not present; falling back to memo_created_timestamps"); + } + return wantUpdated && !oldServerFallback ? updatedArray : createdArray; }; export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): FilteredMemoStats => { @@ -35,49 +41,43 @@ export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): // home/profile: use backend per-user stats (full tag set, not page-limited) const { data: userStats, isLoading: isLoadingUserStats } = useUserStats(userName); - - // explore: fetch memos with visibility filter to exclude private content. - // ListMemos AND's the request filter with the server's auth filter, so private - // memos are always excluded regardless of backend version. - // other contexts: fetch with default params for the fallback memo-based path. + // explore/archived: fetch backend grouped stats and aggregate them locally. + // ListAllUserStats AND's the request filter with the server's auth filter, so + // private memos are not included unless explicitly visible to the current user. const exploreVisibilityFilter = currentUser != null ? 'visibility in ["PUBLIC", "PROTECTED"]' : 'visibility in ["PUBLIC"]'; - const memoQueryParams = context === "explore" ? { filter: exploreVisibilityFilter, pageSize: 1000 } : {}; - const { data: memosResponse, isLoading: isLoadingMemos } = useMemos(memoQueryParams); + const allUserStatsRequest = + context === "explore" + ? { state: State.NORMAL, filter: exploreVisibilityFilter } + : context === "archived" + ? { state: State.ARCHIVED } + : {}; + const shouldFetchAllUserStats = context === "explore" || (context === "archived" && !!currentUser?.name); + const { data: allUserStats = [], isLoading: isLoadingAllUserStats } = useAllUserStats(allUserStatsRequest, { + enabled: shouldFetchAllUserStats, + }); const data = useMemo(() => { - const loading = isLoadingUserStats || isLoadingMemos; + const loading = isLoadingUserStats || isLoadingAllUserStats; let activityStats: Record = {}; let tagCount: Record = {}; - if (context === "explore") { - // Tags and activity stats from visibility-filtered memos (no private content). - for (const memo of memosResponse?.memos ?? []) { - for (const tag of memo.tags ?? []) { - tagCount[tag] = (tagCount[tag] ?? 0) + 1; + if (context === "explore" || context === "archived") { + const displayDates: string[] = []; + for (const stats of allUserStats) { + for (const [tag, count] of Object.entries(stats.tagCount ?? {})) { + tagCount[tag] = (tagCount[tag] ?? 0) + count; } + displayDates.push( + ...timestampsForBasis(stats, timeBasis) + .map((ts) => (ts ? timestampDate(ts) : undefined)) + .filter((date): date is Date => date !== undefined) + .map(toDateString), + ); } - const displayDates = (memosResponse?.memos ?? []) - .map((memo) => memoTimestampForBasis(memo, timeBasis)) - .filter((date): date is Date => date !== undefined) - .map(toDateString); activityStats = countBy(displayDates); } else if (userName && userStats) { // home/profile: use backend per-user stats. - // - // protobuf-es generates repeated fields as non-optional T[], so an old - // server that doesn't know the new field deserializes it as []. Since - // memo.updated_ts is initialized to created_ts at row creation, the two - // arrays are always the same length when there are memos. Length - // divergence (created non-empty AND updated empty) therefore reliably - // signals "old server" and is the only case where we fall back. - const createdArray = userStats.memoCreatedTimestamps ?? []; - const updatedArray = userStats.memoUpdatedTimestamps ?? []; - const wantUpdated = timeBasis === "update_time"; - const oldServerFallback = wantUpdated && updatedArray.length === 0 && createdArray.length > 0; - if (oldServerFallback) { - console.warn("UserStats.memo_updated_timestamps not present; falling back to memo_created_timestamps"); - } - const sourceArray = wantUpdated && !oldServerFallback ? updatedArray : createdArray; + const sourceArray = timestampsForBasis(userStats, timeBasis); if (sourceArray.length > 0) { activityStats = countBy( sourceArray @@ -89,22 +89,10 @@ export const useFilteredMemoStats = (options: UseFilteredMemoStatsOptions = {}): if (userStats.tagCount) { tagCount = userStats.tagCount; } - } else if (memosResponse?.memos) { - // archived/fallback: compute from cached memos - const displayDates = memosResponse.memos - .map((memo) => memoTimestampForBasis(memo, timeBasis)) - .filter((date): date is Date => date !== undefined) - .map(toDateString); - activityStats = countBy(displayDates); - for (const memo of memosResponse.memos) { - for (const tag of memo.tags ?? []) { - tagCount[tag] = (tagCount[tag] || 0) + 1; - } - } } return { statistics: { activityStats, timeBasis }, tags: tagCount, loading }; - }, [context, userName, userStats, memosResponse, isLoadingUserStats, isLoadingMemos, timeBasis]); + }, [context, userName, userStats, allUserStats, isLoadingUserStats, isLoadingAllUserStats, timeBasis]); return data; }; diff --git a/web/src/hooks/useLiveMemoRefresh.ts b/web/src/hooks/useLiveMemoRefresh.ts index 22fdfcd9e..40700dde1 100644 --- a/web/src/hooks/useLiveMemoRefresh.ts +++ b/web/src/hooks/useLiveMemoRefresh.ts @@ -84,10 +84,15 @@ export function useLiveMemoRefresh() { const connect = async () => { if (!mounted) return; + if (!currentUserName) { + setSSEStatus("disconnected"); + return; + } + const token = getAccessToken(); if (!token) { setSSEStatus("disconnected"); - // Not logged in; do not retry. Effect will re-run when currentUser is set (login). + // Not logged in; do not retry. Effect will re-run when currentUser is set. return; } diff --git a/web/src/hooks/useUserQueries.ts b/web/src/hooks/useUserQueries.ts index 0723332b0..39918c0d4 100644 --- a/web/src/hooks/useUserQueries.ts +++ b/web/src/hooks/useUserQueries.ts @@ -4,9 +4,19 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { shortcutServiceClient, userServiceClient } from "@/connect"; import { buildUserSettingName } from "@/helpers/resource-names"; import useCurrentUser from "@/hooks/useCurrentUser"; -import { User, UserSetting, UserSetting_GeneralSetting, UserSetting_Key, UserSettingSchema } from "@/types/proto/api/v1/user_service_pb"; +import { + type ListAllUserStatsRequest, + ListAllUserStatsRequestSchema, + User, + UserSetting, + UserSetting_GeneralSetting, + UserSetting_Key, + UserSettingSchema, + UserStats, +} from "@/types/proto/api/v1/user_service_pb"; const BATCH_GET_USERS_LIMIT = 100; +type ListAllUserStatsQuery = Pick; // Query keys factory export const userKeys = { @@ -15,6 +25,7 @@ export const userKeys = { detail: (name: string) => [...userKeys.details(), name] as const, stats: () => [...userKeys.all, "stats"] as const, userStats: (name: string) => [...userKeys.stats(), name] as const, + allUserStats: (request: Partial) => [...userKeys.stats(), "all", request] as const, currentUser: () => [...userKeys.all, "current"] as const, shortcuts: () => [...userKeys.all, "shortcuts"] as const, notifications: () => [...userKeys.all, "notifications"] as const, @@ -48,6 +59,17 @@ export function useUserStats(username?: string) { }); } +export function useAllUserStats(request: Partial = {}, options?: { enabled?: boolean }) { + return useQuery({ + queryKey: userKeys.allUserStats(request), + queryFn: async () => { + const { stats } = await userServiceClient.listAllUserStats(create(ListAllUserStatsRequestSchema, request)); + return stats; + }, + enabled: options?.enabled ?? true, + }); +} + export function useShortcuts() { return useQuery({ queryKey: userKeys.shortcuts(), @@ -78,16 +100,17 @@ export function useNotifications() { export function useTagCounts(forCurrentUser = false) { const currentUser = useCurrentUser(); - return useQuery({ - queryKey: forCurrentUser ? [...userKeys.stats(), "tagCounts", "current"] : [...userKeys.stats(), "tagCounts", "all"], - queryFn: async () => { + return useQuery, Error, Record>({ + queryKey: + forCurrentUser && currentUser?.name + ? userKeys.userStats(currentUser.name) + : [...userKeys.stats(), "tagCounts", forCurrentUser ? "current" : "all"], + queryFn: async (): Promise> => { if (forCurrentUser) { - // Fetch current user stats only if (!currentUser?.name) { return {}; } - const stats = await userServiceClient.getUserStats({ name: currentUser.name }); - return stats.tagCount || {}; + return userServiceClient.getUserStats({ name: currentUser.name }); } else { // Fetch all user stats const { stats } = await userServiceClient.listAllUserStats({}); @@ -104,6 +127,12 @@ export function useTagCounts(forCurrentUser = false) { return tagCount; } }, + select: (data) => { + if (forCurrentUser) { + return (data as UserStats).tagCount || {}; + } + return data as Record; + }, enabled: !forCurrentUser || !!currentUser?.name, staleTime: 1000 * 60 * 2, // 2 minutes - tags don't change frequently }); diff --git a/web/src/pages/Shortcuts.tsx b/web/src/pages/Shortcuts.tsx index 6e2913e73..996ddf745 100644 --- a/web/src/pages/Shortcuts.tsx +++ b/web/src/pages/Shortcuts.tsx @@ -178,10 +178,6 @@ const Shortcuts = () => { const isEditing = draft.name !== ""; const isSaving = createState.isLoading || updateState.isLoading; - useEffect(() => { - refetchSettings(); - }, [refetchSettings]); - useEffect(() => { const state = location.state as ShortcutsRouteState | null; if (!state) return; diff --git a/web/src/types/proto/api/v1/user_service_pb.ts b/web/src/types/proto/api/v1/user_service_pb.ts index 747ee0fb9..7e5c63f10 100644 --- a/web/src/types/proto/api/v1/user_service_pb.ts +++ b/web/src/types/proto/api/v1/user_service_pb.ts @@ -18,7 +18,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file api/v1/user_service.proto. */ export const file_api_v1_user_service: GenFile = /*@__PURE__*/ - fileDesc("ChlhcGkvdjEvdXNlcl9zZXJ2aWNlLnByb3RvEgxtZW1vcy5hcGkudjEi1gMKBFVzZXISEQoEbmFtZRgBIAEoCUID4EEIEioKBHJvbGUYAiABKA4yFy5tZW1vcy5hcGkudjEuVXNlci5Sb2xlQgPgQQISFQoIdXNlcm5hbWUYAyABKAlCA+BBAhISCgVlbWFpbBgEIAEoCUID4EEBEhkKDGRpc3BsYXlfbmFtZRgFIAEoCUID4EEBEhcKCmF2YXRhcl91cmwYBiABKAlCA+BBARIYCgtkZXNjcmlwdGlvbhgHIAEoCUID4EEBEhUKCHBhc3N3b3JkGAggASgJQgPgQQQSJwoFc3RhdGUYCSABKA4yEy5tZW1vcy5hcGkudjEuU3RhdGVCA+BBAhI0CgtjcmVhdGVfdGltZRgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAxI0Cgt1cGRhdGVfdGltZRgLIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAyIxCgRSb2xlEhQKEFJPTEVfVU5TUEVDSUZJRUQQABIJCgVBRE1JThACEggKBFVTRVIQAzo36kE0ChFtZW1vcy5hcGkudjEvVXNlchIMdXNlcnMve3VzZXJ9GgRuYW1lKgV1c2VyczIEdXNlciJzChBMaXN0VXNlcnNSZXF1ZXN0EhYKCXBhZ2Vfc2l6ZRgBIAEoBUID4EEBEhcKCnBhZ2VfdG9rZW4YAiABKAlCA+BBARITCgZmaWx0ZXIYAyABKAlCA+BBARIZCgxzaG93X2RlbGV0ZWQYBCABKAhCA+BBASJjChFMaXN0VXNlcnNSZXNwb25zZRIhCgV1c2VycxgBIAMoCzISLm1lbW9zLmFwaS52MS5Vc2VyEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCRISCgp0b3RhbF9zaXplGAMgASgFIikKFEJhdGNoR2V0VXNlcnNSZXF1ZXN0EhEKCXVzZXJuYW1lcxgBIAMoCSI6ChVCYXRjaEdldFVzZXJzUmVzcG9uc2USIQoFdXNlcnMYASADKAsyEi5tZW1vcy5hcGkudjEuVXNlciJtCg5HZXRVc2VyUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEjIKCXJlYWRfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCA+BBASKIAQoRQ3JlYXRlVXNlclJlcXVlc3QSKAoEdXNlchgBIAEoCzISLm1lbW9zLmFwaS52MS5Vc2VyQgbgQQLgQQQSFAoHdXNlcl9pZBgCIAEoCUID4EEBEhoKDXZhbGlkYXRlX29ubHkYAyABKAhCA+BBARIXCgpyZXF1ZXN0X2lkGAQgASgJQgPgQQEijAEKEVVwZGF0ZVVzZXJSZXF1ZXN0EiUKBHVzZXIYASABKAsyEi5tZW1vcy5hcGkudjEuVXNlckID4EECEjQKC3VwZGF0ZV9tYXNrGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFza0ID4EECEhoKDWFsbG93X21pc3NpbmcYAyABKAhCA+BBASJQChFEZWxldGVVc2VyUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEhIKBWZvcmNlGAIgASgIQgPgQQEitAQKCVVzZXJTdGF0cxIRCgRuYW1lGAEgASgJQgPgQQgSPgoPbWVtb190eXBlX3N0YXRzGAMgASgLMiUubWVtb3MuYXBpLnYxLlVzZXJTdGF0cy5NZW1vVHlwZVN0YXRzEjgKCXRhZ19jb3VudBgEIAMoCzIlLm1lbW9zLmFwaS52MS5Vc2VyU3RhdHMuVGFnQ291bnRFbnRyeRI7ChdtZW1vX2NyZWF0ZWRfdGltZXN0YW1wcxgHIAMoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASOwoXbWVtb191cGRhdGVkX3RpbWVzdGFtcHMYCCADKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhQKDHBpbm5lZF9tZW1vcxgFIAMoCRIYChB0b3RhbF9tZW1vX2NvdW50GAYgASgFGi8KDVRhZ0NvdW50RW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgFOgI4ARpfCg1NZW1vVHlwZVN0YXRzEhIKCmxpbmtfY291bnQYASABKAUSEgoKY29kZV9jb3VudBgCIAEoBRISCgp0b2RvX2NvdW50GAMgASgFEhIKCnVuZG9fY291bnQYBCABKAU6P+pBPAoWbWVtb3MuYXBpLnYxL1VzZXJTdGF0cxIMdXNlcnMve3VzZXJ9Kgl1c2VyU3RhdHMyCXVzZXJTdGF0c0oECAIQA1IXbWVtb19kaXNwbGF5X3RpbWVzdGFtcHMiPgoTR2V0VXNlclN0YXRzUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyIhkKF0xpc3RBbGxVc2VyU3RhdHNSZXF1ZXN0IkIKGExpc3RBbGxVc2VyU3RhdHNSZXNwb25zZRImCgVzdGF0cxgBIAMoCzIXLm1lbW9zLmFwaS52MS5Vc2VyU3RhdHMi5AMKC1VzZXJTZXR0aW5nEhEKBG5hbWUYASABKAlCA+BBCBJDCg9nZW5lcmFsX3NldHRpbmcYAiABKAsyKC5tZW1vcy5hcGkudjEuVXNlclNldHRpbmcuR2VuZXJhbFNldHRpbmdIABJFChB3ZWJob29rc19zZXR0aW5nGAUgASgLMikubWVtb3MuYXBpLnYxLlVzZXJTZXR0aW5nLldlYmhvb2tzU2V0dGluZ0gAGlcKDkdlbmVyYWxTZXR0aW5nEhMKBmxvY2FsZRgBIAEoCUID4EEBEhwKD21lbW9fdmlzaWJpbGl0eRgDIAEoCUID4EEBEhIKBXRoZW1lGAQgASgJQgPgQQEaPgoPV2ViaG9va3NTZXR0aW5nEisKCHdlYmhvb2tzGAEgAygLMhkubWVtb3MuYXBpLnYxLlVzZXJXZWJob29rIjUKA0tleRITCg9LRVlfVU5TUEVDSUZJRUQQABILCgdHRU5FUkFMEAESDAoIV0VCSE9PS1MQBDpd6kFaChhtZW1vcy5hcGkudjEvVXNlclNldHRpbmcSI3VzZXJzL3t1c2VybmFtZX0vc2V0dGluZ3Mve3NldHRpbmd9Kgx1c2VyU2V0dGluZ3MyC3VzZXJTZXR0aW5nQgcKBXZhbHVlIkcKFUdldFVzZXJTZXR0aW5nUmVxdWVzdBIuCgRuYW1lGAEgASgJQiDgQQL6QRoKGG1lbW9zLmFwaS52MS9Vc2VyU2V0dGluZyKBAQoYVXBkYXRlVXNlclNldHRpbmdSZXF1ZXN0Ei8KB3NldHRpbmcYASABKAsyGS5tZW1vcy5hcGkudjEuVXNlclNldHRpbmdCA+BBAhI0Cgt1cGRhdGVfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCA+BBAiJ1ChdMaXN0VXNlclNldHRpbmdzUmVxdWVzdBIpCgZwYXJlbnQYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL1VzZXISFgoJcGFnZV9zaXplGAIgASgFQgPgQQESFwoKcGFnZV90b2tlbhgDIAEoCUID4EEBInQKGExpc3RVc2VyU2V0dGluZ3NSZXNwb25zZRIrCghzZXR0aW5ncxgBIAMoCzIZLm1lbW9zLmFwaS52MS5Vc2VyU2V0dGluZxIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkSEgoKdG90YWxfc2l6ZRgDIAEoBSLqAQoOTGlua2VkSWRlbnRpdHkSEQoEbmFtZRgBIAEoCUID4EEIEjcKCGlkcF9uYW1lGAIgASgJQiXgQQP6QR8KHW1lbW9zLmFwaS52MS9JZGVudGl0eVByb3ZpZGVyEhcKCmV4dGVybl91aWQYAyABKAlCA+BBAzpz6kFwChttZW1vcy5hcGkudjEvTGlua2VkSWRlbnRpdHkSL3VzZXJzL3t1c2VyfS9saW5rZWRJZGVudGl0aWVzL3tsaW5rZWRfaWRlbnRpdHl9KhBsaW5rZWRJZGVudGl0aWVzMg5saW5rZWRJZGVudGl0eSJIChtMaXN0TGlua2VkSWRlbnRpdGllc1JlcXVlc3QSKQoGcGFyZW50GAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyIlcKHExpc3RMaW5rZWRJZGVudGl0aWVzUmVzcG9uc2USNwoRbGlua2VkX2lkZW50aXRpZXMYASADKAsyHC5tZW1vcy5hcGkudjEuTGlua2VkSWRlbnRpdHkiywEKG0NyZWF0ZUxpbmtlZElkZW50aXR5UmVxdWVzdBIpCgZwYXJlbnQYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL1VzZXISNwoIaWRwX25hbWUYAiABKAlCJeBBAvpBHwodbWVtb3MuYXBpLnYxL0lkZW50aXR5UHJvdmlkZXISEQoEY29kZRgDIAEoCUID4EECEhkKDHJlZGlyZWN0X3VyaRgEIAEoCUID4EECEhoKDWNvZGVfdmVyaWZpZXIYBSABKAlCA+BBASJNChhHZXRMaW5rZWRJZGVudGl0eVJlcXVlc3QSMQoEbmFtZRgBIAEoCUIj4EEC+kEdChttZW1vcy5hcGkudjEvTGlua2VkSWRlbnRpdHkiUAobRGVsZXRlTGlua2VkSWRlbnRpdHlSZXF1ZXN0EjEKBG5hbWUYASABKAlCI+BBAvpBHQobbWVtb3MuYXBpLnYxL0xpbmtlZElkZW50aXR5IvICChNQZXJzb25hbEFjY2Vzc1Rva2VuEhEKBG5hbWUYASABKAlCA+BBCBIYCgtkZXNjcmlwdGlvbhgCIAEoCUID4EEBEjMKCmNyZWF0ZWRfYXQYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQMSMwoKZXhwaXJlc19hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBARI1CgxsYXN0X3VzZWRfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQM6jAHqQYgBCiBtZW1vcy5hcGkudjEvUGVyc29uYWxBY2Nlc3NUb2tlbhI5dXNlcnMve3VzZXJ9L3BlcnNvbmFsQWNjZXNzVG9rZW5zL3twZXJzb25hbF9hY2Nlc3NfdG9rZW59KhRwZXJzb25hbEFjY2Vzc1Rva2VuczITcGVyc29uYWxBY2Nlc3NUb2tlbiJ9Ch9MaXN0UGVyc29uYWxBY2Nlc3NUb2tlbnNSZXF1ZXN0EikKBnBhcmVudBgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvVXNlchIWCglwYWdlX3NpemUYAiABKAVCA+BBARIXCgpwYWdlX3Rva2VuGAMgASgJQgPgQQEikgEKIExpc3RQZXJzb25hbEFjY2Vzc1Rva2Vuc1Jlc3BvbnNlEkEKFnBlcnNvbmFsX2FjY2Vzc190b2tlbnMYASADKAsyIS5tZW1vcy5hcGkudjEuUGVyc29uYWxBY2Nlc3NUb2tlbhIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkSEgoKdG90YWxfc2l6ZRgDIAEoBSKFAQogQ3JlYXRlUGVyc29uYWxBY2Nlc3NUb2tlblJlcXVlc3QSKQoGcGFyZW50GAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEhgKC2Rlc2NyaXB0aW9uGAIgASgJQgPgQQESHAoPZXhwaXJlc19pbl9kYXlzGAMgASgFQgPgQQEidAohQ3JlYXRlUGVyc29uYWxBY2Nlc3NUb2tlblJlc3BvbnNlEkAKFXBlcnNvbmFsX2FjY2Vzc190b2tlbhgBIAEoCzIhLm1lbW9zLmFwaS52MS5QZXJzb25hbEFjY2Vzc1Rva2VuEg0KBXRva2VuGAIgASgJIloKIERlbGV0ZVBlcnNvbmFsQWNjZXNzVG9rZW5SZXF1ZXN0EjYKBG5hbWUYASABKAlCKOBBAvpBIgogbWVtb3MuYXBpLnYxL1BlcnNvbmFsQWNjZXNzVG9rZW4iqgEKC1VzZXJXZWJob29rEgwKBG5hbWUYASABKAkSCwoDdXJsGAIgASgJEhQKDGRpc3BsYXlfbmFtZRgDIAEoCRI0CgtjcmVhdGVfdGltZRgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAxI0Cgt1cGRhdGVfdGltZRgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAyIuChdMaXN0VXNlcldlYmhvb2tzUmVxdWVzdBITCgZwYXJlbnQYASABKAlCA+BBAiJHChhMaXN0VXNlcldlYmhvb2tzUmVzcG9uc2USKwoId2ViaG9va3MYASADKAsyGS5tZW1vcy5hcGkudjEuVXNlcldlYmhvb2siYAoYQ3JlYXRlVXNlcldlYmhvb2tSZXF1ZXN0EhMKBnBhcmVudBgBIAEoCUID4EECEi8KB3dlYmhvb2sYAiABKAsyGS5tZW1vcy5hcGkudjEuVXNlcldlYmhvb2tCA+BBAiJ8ChhVcGRhdGVVc2VyV2ViaG9va1JlcXVlc3QSLwoHd2ViaG9vaxgBIAEoCzIZLm1lbW9zLmFwaS52MS5Vc2VyV2ViaG9va0ID4EECEi8KC3VwZGF0ZV9tYXNrGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFzayItChhEZWxldGVVc2VyV2ViaG9va1JlcXVlc3QSEQoEbmFtZRgBIAEoCUID4EECIqIHChBVc2VyTm90aWZpY2F0aW9uEhQKBG5hbWUYASABKAlCBuBBA+BBCBIpCgZzZW5kZXIYAiABKAlCGeBBA/pBEwoRbWVtb3MuYXBpLnYxL1VzZXISLAoLc2VuZGVyX3VzZXIYCCABKAsyEi5tZW1vcy5hcGkudjEuVXNlckID4EEDEjoKBnN0YXR1cxgDIAEoDjIlLm1lbW9zLmFwaS52MS5Vc2VyTm90aWZpY2F0aW9uLlN0YXR1c0ID4EEBEjQKC2NyZWF0ZV90aW1lGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEDEjYKBHR5cGUYBSABKA4yIy5tZW1vcy5hcGkudjEuVXNlck5vdGlmaWNhdGlvbi5UeXBlQgPgQQMSTgoMbWVtb19jb21tZW50GAYgASgLMjEubWVtb3MuYXBpLnYxLlVzZXJOb3RpZmljYXRpb24uTWVtb0NvbW1lbnRQYXlsb2FkQgPgQQNIABJOCgxtZW1vX21lbnRpb24YByABKAsyMS5tZW1vcy5hcGkudjEuVXNlck5vdGlmaWNhdGlvbi5NZW1vTWVudGlvblBheWxvYWRCA+BBA0gAGmwKEk1lbW9Db21tZW50UGF5bG9hZBIMCgRtZW1vGAEgASgJEhQKDHJlbGF0ZWRfbWVtbxgCIAEoCRIUCgxtZW1vX3NuaXBwZXQYAyABKAkSHAoUcmVsYXRlZF9tZW1vX3NuaXBwZXQYBCABKAkabAoSTWVtb01lbnRpb25QYXlsb2FkEgwKBG1lbW8YASABKAkSFAoMcmVsYXRlZF9tZW1vGAIgASgJEhQKDG1lbW9fc25pcHBldBgDIAEoCRIcChRyZWxhdGVkX21lbW9fc25pcHBldBgEIAEoCSI6CgZTdGF0dXMSFgoSU1RBVFVTX1VOU1BFQ0lGSUVEEAASCgoGVU5SRUFEEAESDAoIQVJDSElWRUQQAiJACgRUeXBlEhQKEFRZUEVfVU5TUEVDSUZJRUQQABIQCgxNRU1PX0NPTU1FTlQQARIQCgxNRU1PX01FTlRJT04QAjpw6kFtCh1tZW1vcy5hcGkudjEvVXNlck5vdGlmaWNhdGlvbhIpdXNlcnMve3VzZXJ9L25vdGlmaWNhdGlvbnMve25vdGlmaWNhdGlvbn0aBG5hbWUqDW5vdGlmaWNhdGlvbnMyDG5vdGlmaWNhdGlvbkIJCgdwYXlsb2FkIo8BChxMaXN0VXNlck5vdGlmaWNhdGlvbnNSZXF1ZXN0EikKBnBhcmVudBgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvVXNlchIWCglwYWdlX3NpemUYAiABKAVCA+BBARIXCgpwYWdlX3Rva2VuGAMgASgJQgPgQQESEwoGZmlsdGVyGAQgASgJQgPgQQEibwodTGlzdFVzZXJOb3RpZmljYXRpb25zUmVzcG9uc2USNQoNbm90aWZpY2F0aW9ucxgBIAMoCzIeLm1lbW9zLmFwaS52MS5Vc2VyTm90aWZpY2F0aW9uEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCSKQAQodVXBkYXRlVXNlck5vdGlmaWNhdGlvblJlcXVlc3QSOQoMbm90aWZpY2F0aW9uGAEgASgLMh4ubWVtb3MuYXBpLnYxLlVzZXJOb3RpZmljYXRpb25CA+BBAhI0Cgt1cGRhdGVfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCA+BBAiJUCh1EZWxldGVVc2VyTm90aWZpY2F0aW9uUmVxdWVzdBIzCgRuYW1lGAEgASgJQiXgQQL6QR8KHW1lbW9zLmFwaS52MS9Vc2VyTm90aWZpY2F0aW9uMoIdCgtVc2VyU2VydmljZRJjCglMaXN0VXNlcnMSHi5tZW1vcy5hcGkudjEuTGlzdFVzZXJzUmVxdWVzdBofLm1lbW9zLmFwaS52MS5MaXN0VXNlcnNSZXNwb25zZSIVgtPkkwIPEg0vYXBpL3YxL3VzZXJzEnsKDUJhdGNoR2V0VXNlcnMSIi5tZW1vcy5hcGkudjEuQmF0Y2hHZXRVc2Vyc1JlcXVlc3QaIy5tZW1vcy5hcGkudjEuQmF0Y2hHZXRVc2Vyc1Jlc3BvbnNlIiGC0+STAhs6ASoiFi9hcGkvdjEvdXNlcnM6YmF0Y2hHZXQSYgoHR2V0VXNlchIcLm1lbW9zLmFwaS52MS5HZXRVc2VyUmVxdWVzdBoSLm1lbW9zLmFwaS52MS5Vc2VyIiXaQQRuYW1lgtPkkwIYEhYvYXBpL3YxL3tuYW1lPXVzZXJzLyp9EmUKCkNyZWF0ZVVzZXISHy5tZW1vcy5hcGkudjEuQ3JlYXRlVXNlclJlcXVlc3QaEi5tZW1vcy5hcGkudjEuVXNlciIi2kEEdXNlcoLT5JMCFToEdXNlciINL2FwaS92MS91c2VycxJ/CgpVcGRhdGVVc2VyEh8ubWVtb3MuYXBpLnYxLlVwZGF0ZVVzZXJSZXF1ZXN0GhIubWVtb3MuYXBpLnYxLlVzZXIiPNpBEHVzZXIsdXBkYXRlX21hc2uC0+STAiM6BHVzZXIyGy9hcGkvdjEve3VzZXIubmFtZT11c2Vycy8qfRJsCgpEZWxldGVVc2VyEh8ubWVtb3MuYXBpLnYxLkRlbGV0ZVVzZXJSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IiXaQQRuYW1lgtPkkwIYKhYvYXBpL3YxL3tuYW1lPXVzZXJzLyp9En4KEExpc3RBbGxVc2VyU3RhdHMSJS5tZW1vcy5hcGkudjEuTGlzdEFsbFVzZXJTdGF0c1JlcXVlc3QaJi5tZW1vcy5hcGkudjEuTGlzdEFsbFVzZXJTdGF0c1Jlc3BvbnNlIhuC0+STAhUSEy9hcGkvdjEvdXNlcnM6c3RhdHMSegoMR2V0VXNlclN0YXRzEiEubWVtb3MuYXBpLnYxLkdldFVzZXJTdGF0c1JlcXVlc3QaFy5tZW1vcy5hcGkudjEuVXNlclN0YXRzIi7aQQRuYW1lgtPkkwIhEh8vYXBpL3YxL3tuYW1lPXVzZXJzLyp9OmdldFN0YXRzEoIBCg5HZXRVc2VyU2V0dGluZxIjLm1lbW9zLmFwaS52MS5HZXRVc2VyU2V0dGluZ1JlcXVlc3QaGS5tZW1vcy5hcGkudjEuVXNlclNldHRpbmciMNpBBG5hbWWC0+STAiMSIS9hcGkvdjEve25hbWU9dXNlcnMvKi9zZXR0aW5ncy8qfRKoAQoRVXBkYXRlVXNlclNldHRpbmcSJi5tZW1vcy5hcGkudjEuVXBkYXRlVXNlclNldHRpbmdSZXF1ZXN0GhkubWVtb3MuYXBpLnYxLlVzZXJTZXR0aW5nIlDaQRNzZXR0aW5nLHVwZGF0ZV9tYXNrgtPkkwI0OgdzZXR0aW5nMikvYXBpL3YxL3tzZXR0aW5nLm5hbWU9dXNlcnMvKi9zZXR0aW5ncy8qfRKVAQoQTGlzdFVzZXJTZXR0aW5ncxIlLm1lbW9zLmFwaS52MS5MaXN0VXNlclNldHRpbmdzUmVxdWVzdBomLm1lbW9zLmFwaS52MS5MaXN0VXNlclNldHRpbmdzUmVzcG9uc2UiMtpBBnBhcmVudILT5JMCIxIhL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L3NldHRpbmdzEqkBChRMaXN0TGlua2VkSWRlbnRpdGllcxIpLm1lbW9zLmFwaS52MS5MaXN0TGlua2VkSWRlbnRpdGllc1JlcXVlc3QaKi5tZW1vcy5hcGkudjEuTGlzdExpbmtlZElkZW50aXRpZXNSZXNwb25zZSI62kEGcGFyZW50gtPkkwIrEikvYXBpL3YxL3twYXJlbnQ9dXNlcnMvKn0vbGlua2VkSWRlbnRpdGllcxKnAQoUQ3JlYXRlTGlua2VkSWRlbnRpdHkSKS5tZW1vcy5hcGkudjEuQ3JlYXRlTGlua2VkSWRlbnRpdHlSZXF1ZXN0GhwubWVtb3MuYXBpLnYxLkxpbmtlZElkZW50aXR5IkbaQQ9wYXJlbnQsaWRwX25hbWWC0+STAi46ASoiKS9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9saW5rZWRJZGVudGl0aWVzEpMBChFHZXRMaW5rZWRJZGVudGl0eRImLm1lbW9zLmFwaS52MS5HZXRMaW5rZWRJZGVudGl0eVJlcXVlc3QaHC5tZW1vcy5hcGkudjEuTGlua2VkSWRlbnRpdHkiONpBBG5hbWWC0+STAisSKS9hcGkvdjEve25hbWU9dXNlcnMvKi9saW5rZWRJZGVudGl0aWVzLyp9EpMBChREZWxldGVMaW5rZWRJZGVudGl0eRIpLm1lbW9zLmFwaS52MS5EZWxldGVMaW5rZWRJZGVudGl0eVJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiONpBBG5hbWWC0+STAisqKS9hcGkvdjEve25hbWU9dXNlcnMvKi9saW5rZWRJZGVudGl0aWVzLyp9ErkBChhMaXN0UGVyc29uYWxBY2Nlc3NUb2tlbnMSLS5tZW1vcy5hcGkudjEuTGlzdFBlcnNvbmFsQWNjZXNzVG9rZW5zUmVxdWVzdBouLm1lbW9zLmFwaS52MS5MaXN0UGVyc29uYWxBY2Nlc3NUb2tlbnNSZXNwb25zZSI+2kEGcGFyZW50gtPkkwIvEi0vYXBpL3YxL3twYXJlbnQ9dXNlcnMvKn0vcGVyc29uYWxBY2Nlc3NUb2tlbnMStgEKGUNyZWF0ZVBlcnNvbmFsQWNjZXNzVG9rZW4SLi5tZW1vcy5hcGkudjEuQ3JlYXRlUGVyc29uYWxBY2Nlc3NUb2tlblJlcXVlc3QaLy5tZW1vcy5hcGkudjEuQ3JlYXRlUGVyc29uYWxBY2Nlc3NUb2tlblJlc3BvbnNlIjiC0+STAjI6ASoiLS9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9wZXJzb25hbEFjY2Vzc1Rva2VucxKhAQoZRGVsZXRlUGVyc29uYWxBY2Nlc3NUb2tlbhIuLm1lbW9zLmFwaS52MS5EZWxldGVQZXJzb25hbEFjY2Vzc1Rva2VuUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSI82kEEbmFtZYLT5JMCLyotL2FwaS92MS97bmFtZT11c2Vycy8qL3BlcnNvbmFsQWNjZXNzVG9rZW5zLyp9EpUBChBMaXN0VXNlcldlYmhvb2tzEiUubWVtb3MuYXBpLnYxLkxpc3RVc2VyV2ViaG9va3NSZXF1ZXN0GiYubWVtb3MuYXBpLnYxLkxpc3RVc2VyV2ViaG9va3NSZXNwb25zZSIy2kEGcGFyZW50gtPkkwIjEiEvYXBpL3YxL3twYXJlbnQ9dXNlcnMvKn0vd2ViaG9va3MSmwEKEUNyZWF0ZVVzZXJXZWJob29rEiYubWVtb3MuYXBpLnYxLkNyZWF0ZVVzZXJXZWJob29rUmVxdWVzdBoZLm1lbW9zLmFwaS52MS5Vc2VyV2ViaG9vayJD2kEOcGFyZW50LHdlYmhvb2uC0+STAiw6B3dlYmhvb2siIS9hcGkvdjEve3BhcmVudD11c2Vycy8qfS93ZWJob29rcxKoAQoRVXBkYXRlVXNlcldlYmhvb2sSJi5tZW1vcy5hcGkudjEuVXBkYXRlVXNlcldlYmhvb2tSZXF1ZXN0GhkubWVtb3MuYXBpLnYxLlVzZXJXZWJob29rIlDaQRN3ZWJob29rLHVwZGF0ZV9tYXNrgtPkkwI0Ogd3ZWJob29rMikvYXBpL3YxL3t3ZWJob29rLm5hbWU9dXNlcnMvKi93ZWJob29rcy8qfRKFAQoRRGVsZXRlVXNlcldlYmhvb2sSJi5tZW1vcy5hcGkudjEuRGVsZXRlVXNlcldlYmhvb2tSZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IjDaQQRuYW1lgtPkkwIjKiEvYXBpL3YxL3tuYW1lPXVzZXJzLyovd2ViaG9va3MvKn0SqQEKFUxpc3RVc2VyTm90aWZpY2F0aW9ucxIqLm1lbW9zLmFwaS52MS5MaXN0VXNlck5vdGlmaWNhdGlvbnNSZXF1ZXN0GisubWVtb3MuYXBpLnYxLkxpc3RVc2VyTm90aWZpY2F0aW9uc1Jlc3BvbnNlIjfaQQZwYXJlbnSC0+STAigSJi9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9ub3RpZmljYXRpb25zEssBChZVcGRhdGVVc2VyTm90aWZpY2F0aW9uEisubWVtb3MuYXBpLnYxLlVwZGF0ZVVzZXJOb3RpZmljYXRpb25SZXF1ZXN0Gh4ubWVtb3MuYXBpLnYxLlVzZXJOb3RpZmljYXRpb24iZNpBGG5vdGlmaWNhdGlvbix1cGRhdGVfbWFza4LT5JMCQzoMbm90aWZpY2F0aW9uMjMvYXBpL3YxL3tub3RpZmljYXRpb24ubmFtZT11c2Vycy8qL25vdGlmaWNhdGlvbnMvKn0SlAEKFkRlbGV0ZVVzZXJOb3RpZmljYXRpb24SKy5tZW1vcy5hcGkudjEuRGVsZXRlVXNlck5vdGlmaWNhdGlvblJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiNdpBBG5hbWWC0+STAigqJi9hcGkvdjEve25hbWU9dXNlcnMvKi9ub3RpZmljYXRpb25zLyp9QqgBChBjb20ubWVtb3MuYXBpLnYxQhBVc2VyU2VydmljZVByb3RvUAFaMGdpdGh1Yi5jb20vdXNlbWVtb3MvbWVtb3MvcHJvdG8vZ2VuL2FwaS92MTthcGl2MaICA01BWKoCDE1lbW9zLkFwaS5WMcoCDE1lbW9zXEFwaVxWMeICGE1lbW9zXEFwaVxWMVxHUEJNZXRhZGF0YeoCDk1lbW9zOjpBcGk6OlYxYgZwcm90bzM", [file_api_v1_common, file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask, file_google_protobuf_timestamp]); + fileDesc("ChlhcGkvdjEvdXNlcl9zZXJ2aWNlLnByb3RvEgxtZW1vcy5hcGkudjEi1gMKBFVzZXISEQoEbmFtZRgBIAEoCUID4EEIEioKBHJvbGUYAiABKA4yFy5tZW1vcy5hcGkudjEuVXNlci5Sb2xlQgPgQQISFQoIdXNlcm5hbWUYAyABKAlCA+BBAhISCgVlbWFpbBgEIAEoCUID4EEBEhkKDGRpc3BsYXlfbmFtZRgFIAEoCUID4EEBEhcKCmF2YXRhcl91cmwYBiABKAlCA+BBARIYCgtkZXNjcmlwdGlvbhgHIAEoCUID4EEBEhUKCHBhc3N3b3JkGAggASgJQgPgQQQSJwoFc3RhdGUYCSABKA4yEy5tZW1vcy5hcGkudjEuU3RhdGVCA+BBAhI0CgtjcmVhdGVfdGltZRgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAxI0Cgt1cGRhdGVfdGltZRgLIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAyIxCgRSb2xlEhQKEFJPTEVfVU5TUEVDSUZJRUQQABIJCgVBRE1JThACEggKBFVTRVIQAzo36kE0ChFtZW1vcy5hcGkudjEvVXNlchIMdXNlcnMve3VzZXJ9GgRuYW1lKgV1c2VyczIEdXNlciJzChBMaXN0VXNlcnNSZXF1ZXN0EhYKCXBhZ2Vfc2l6ZRgBIAEoBUID4EEBEhcKCnBhZ2VfdG9rZW4YAiABKAlCA+BBARITCgZmaWx0ZXIYAyABKAlCA+BBARIZCgxzaG93X2RlbGV0ZWQYBCABKAhCA+BBASJjChFMaXN0VXNlcnNSZXNwb25zZRIhCgV1c2VycxgBIAMoCzISLm1lbW9zLmFwaS52MS5Vc2VyEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCRISCgp0b3RhbF9zaXplGAMgASgFIikKFEJhdGNoR2V0VXNlcnNSZXF1ZXN0EhEKCXVzZXJuYW1lcxgBIAMoCSI6ChVCYXRjaEdldFVzZXJzUmVzcG9uc2USIQoFdXNlcnMYASADKAsyEi5tZW1vcy5hcGkudjEuVXNlciJtCg5HZXRVc2VyUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEjIKCXJlYWRfbWFzaxgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5GaWVsZE1hc2tCA+BBASKIAQoRQ3JlYXRlVXNlclJlcXVlc3QSKAoEdXNlchgBIAEoCzISLm1lbW9zLmFwaS52MS5Vc2VyQgbgQQLgQQQSFAoHdXNlcl9pZBgCIAEoCUID4EEBEhoKDXZhbGlkYXRlX29ubHkYAyABKAhCA+BBARIXCgpyZXF1ZXN0X2lkGAQgASgJQgPgQQEijAEKEVVwZGF0ZVVzZXJSZXF1ZXN0EiUKBHVzZXIYASABKAsyEi5tZW1vcy5hcGkudjEuVXNlckID4EECEjQKC3VwZGF0ZV9tYXNrGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFza0ID4EECEhoKDWFsbG93X21pc3NpbmcYAyABKAhCA+BBASJQChFEZWxldGVVc2VyUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEhIKBWZvcmNlGAIgASgIQgPgQQEitAQKCVVzZXJTdGF0cxIRCgRuYW1lGAEgASgJQgPgQQgSPgoPbWVtb190eXBlX3N0YXRzGAMgASgLMiUubWVtb3MuYXBpLnYxLlVzZXJTdGF0cy5NZW1vVHlwZVN0YXRzEjgKCXRhZ19jb3VudBgEIAMoCzIlLm1lbW9zLmFwaS52MS5Vc2VyU3RhdHMuVGFnQ291bnRFbnRyeRI7ChdtZW1vX2NyZWF0ZWRfdGltZXN0YW1wcxgHIAMoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASOwoXbWVtb191cGRhdGVkX3RpbWVzdGFtcHMYCCADKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhQKDHBpbm5lZF9tZW1vcxgFIAMoCRIYChB0b3RhbF9tZW1vX2NvdW50GAYgASgFGl8KDU1lbW9UeXBlU3RhdHMSEgoKbGlua19jb3VudBgBIAEoBRISCgpjb2RlX2NvdW50GAIgASgFEhIKCnRvZG9fY291bnQYAyABKAUSEgoKdW5kb19jb3VudBgEIAEoBRovCg1UYWdDb3VudEVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoBToCOAE6P+pBPAoWbWVtb3MuYXBpLnYxL1VzZXJTdGF0cxIMdXNlcnMve3VzZXJ9Kgl1c2VyU3RhdHMyCXVzZXJTdGF0c0oECAIQA1IXbWVtb19kaXNwbGF5X3RpbWVzdGFtcHMiPgoTR2V0VXNlclN0YXRzUmVxdWVzdBInCgRuYW1lGAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyIlcKF0xpc3RBbGxVc2VyU3RhdHNSZXF1ZXN0EicKBXN0YXRlGAEgASgOMhMubWVtb3MuYXBpLnYxLlN0YXRlQgPgQQESEwoGZmlsdGVyGAIgASgJQgPgQQEiQgoYTGlzdEFsbFVzZXJTdGF0c1Jlc3BvbnNlEiYKBXN0YXRzGAEgAygLMhcubWVtb3MuYXBpLnYxLlVzZXJTdGF0cyLkAwoLVXNlclNldHRpbmcSEQoEbmFtZRgBIAEoCUID4EEIEkMKD2dlbmVyYWxfc2V0dGluZxgCIAEoCzIoLm1lbW9zLmFwaS52MS5Vc2VyU2V0dGluZy5HZW5lcmFsU2V0dGluZ0gAEkUKEHdlYmhvb2tzX3NldHRpbmcYBSABKAsyKS5tZW1vcy5hcGkudjEuVXNlclNldHRpbmcuV2ViaG9va3NTZXR0aW5nSAAaVwoOR2VuZXJhbFNldHRpbmcSEwoGbG9jYWxlGAEgASgJQgPgQQESHAoPbWVtb192aXNpYmlsaXR5GAMgASgJQgPgQQESEgoFdGhlbWUYBCABKAlCA+BBARo+Cg9XZWJob29rc1NldHRpbmcSKwoId2ViaG9va3MYASADKAsyGS5tZW1vcy5hcGkudjEuVXNlcldlYmhvb2siNQoDS2V5EhMKD0tFWV9VTlNQRUNJRklFRBAAEgsKB0dFTkVSQUwQARIMCghXRUJIT09LUxAEOl3qQVoKGG1lbW9zLmFwaS52MS9Vc2VyU2V0dGluZxIjdXNlcnMve3VzZXJuYW1lfS9zZXR0aW5ncy97c2V0dGluZ30qDHVzZXJTZXR0aW5nczILdXNlclNldHRpbmdCBwoFdmFsdWUiRwoVR2V0VXNlclNldHRpbmdSZXF1ZXN0Ei4KBG5hbWUYASABKAlCIOBBAvpBGgoYbWVtb3MuYXBpLnYxL1VzZXJTZXR0aW5nIoEBChhVcGRhdGVVc2VyU2V0dGluZ1JlcXVlc3QSLwoHc2V0dGluZxgBIAEoCzIZLm1lbW9zLmFwaS52MS5Vc2VyU2V0dGluZ0ID4EECEjQKC3VwZGF0ZV9tYXNrGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFza0ID4EECInUKF0xpc3RVc2VyU2V0dGluZ3NSZXF1ZXN0EikKBnBhcmVudBgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvVXNlchIWCglwYWdlX3NpemUYAiABKAVCA+BBARIXCgpwYWdlX3Rva2VuGAMgASgJQgPgQQEidAoYTGlzdFVzZXJTZXR0aW5nc1Jlc3BvbnNlEisKCHNldHRpbmdzGAEgAygLMhkubWVtb3MuYXBpLnYxLlVzZXJTZXR0aW5nEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCRISCgp0b3RhbF9zaXplGAMgASgFIuoBCg5MaW5rZWRJZGVudGl0eRIRCgRuYW1lGAEgASgJQgPgQQgSNwoIaWRwX25hbWUYAiABKAlCJeBBA/pBHwodbWVtb3MuYXBpLnYxL0lkZW50aXR5UHJvdmlkZXISFwoKZXh0ZXJuX3VpZBgDIAEoCUID4EEDOnPqQXAKG21lbW9zLmFwaS52MS9MaW5rZWRJZGVudGl0eRIvdXNlcnMve3VzZXJ9L2xpbmtlZElkZW50aXRpZXMve2xpbmtlZF9pZGVudGl0eX0qEGxpbmtlZElkZW50aXRpZXMyDmxpbmtlZElkZW50aXR5IkgKG0xpc3RMaW5rZWRJZGVudGl0aWVzUmVxdWVzdBIpCgZwYXJlbnQYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL1VzZXIiVwocTGlzdExpbmtlZElkZW50aXRpZXNSZXNwb25zZRI3ChFsaW5rZWRfaWRlbnRpdGllcxgBIAMoCzIcLm1lbW9zLmFwaS52MS5MaW5rZWRJZGVudGl0eSLLAQobQ3JlYXRlTGlua2VkSWRlbnRpdHlSZXF1ZXN0EikKBnBhcmVudBgBIAEoCUIZ4EEC+kETChFtZW1vcy5hcGkudjEvVXNlchI3CghpZHBfbmFtZRgCIAEoCUIl4EEC+kEfCh1tZW1vcy5hcGkudjEvSWRlbnRpdHlQcm92aWRlchIRCgRjb2RlGAMgASgJQgPgQQISGQoMcmVkaXJlY3RfdXJpGAQgASgJQgPgQQISGgoNY29kZV92ZXJpZmllchgFIAEoCUID4EEBIk0KGEdldExpbmtlZElkZW50aXR5UmVxdWVzdBIxCgRuYW1lGAEgASgJQiPgQQL6QR0KG21lbW9zLmFwaS52MS9MaW5rZWRJZGVudGl0eSJQChtEZWxldGVMaW5rZWRJZGVudGl0eVJlcXVlc3QSMQoEbmFtZRgBIAEoCUIj4EEC+kEdChttZW1vcy5hcGkudjEvTGlua2VkSWRlbnRpdHki8gIKE1BlcnNvbmFsQWNjZXNzVG9rZW4SEQoEbmFtZRgBIAEoCUID4EEIEhgKC2Rlc2NyaXB0aW9uGAIgASgJQgPgQQESMwoKY3JlYXRlZF9hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAxIzCgpleHBpcmVzX2F0GAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEBEjUKDGxhc3RfdXNlZF9hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXBCA+BBAzqMAepBiAEKIG1lbW9zLmFwaS52MS9QZXJzb25hbEFjY2Vzc1Rva2VuEjl1c2Vycy97dXNlcn0vcGVyc29uYWxBY2Nlc3NUb2tlbnMve3BlcnNvbmFsX2FjY2Vzc190b2tlbn0qFHBlcnNvbmFsQWNjZXNzVG9rZW5zMhNwZXJzb25hbEFjY2Vzc1Rva2VuIn0KH0xpc3RQZXJzb25hbEFjY2Vzc1Rva2Vuc1JlcXVlc3QSKQoGcGFyZW50GAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEhYKCXBhZ2Vfc2l6ZRgCIAEoBUID4EEBEhcKCnBhZ2VfdG9rZW4YAyABKAlCA+BBASKSAQogTGlzdFBlcnNvbmFsQWNjZXNzVG9rZW5zUmVzcG9uc2USQQoWcGVyc29uYWxfYWNjZXNzX3Rva2VucxgBIAMoCzIhLm1lbW9zLmFwaS52MS5QZXJzb25hbEFjY2Vzc1Rva2VuEhcKD25leHRfcGFnZV90b2tlbhgCIAEoCRISCgp0b3RhbF9zaXplGAMgASgFIoUBCiBDcmVhdGVQZXJzb25hbEFjY2Vzc1Rva2VuUmVxdWVzdBIpCgZwYXJlbnQYASABKAlCGeBBAvpBEwoRbWVtb3MuYXBpLnYxL1VzZXISGAoLZGVzY3JpcHRpb24YAiABKAlCA+BBARIcCg9leHBpcmVzX2luX2RheXMYAyABKAVCA+BBASJ0CiFDcmVhdGVQZXJzb25hbEFjY2Vzc1Rva2VuUmVzcG9uc2USQAoVcGVyc29uYWxfYWNjZXNzX3Rva2VuGAEgASgLMiEubWVtb3MuYXBpLnYxLlBlcnNvbmFsQWNjZXNzVG9rZW4SDQoFdG9rZW4YAiABKAkiWgogRGVsZXRlUGVyc29uYWxBY2Nlc3NUb2tlblJlcXVlc3QSNgoEbmFtZRgBIAEoCUIo4EEC+kEiCiBtZW1vcy5hcGkudjEvUGVyc29uYWxBY2Nlc3NUb2tlbiKqAQoLVXNlcldlYmhvb2sSDAoEbmFtZRgBIAEoCRILCgN1cmwYAiABKAkSFAoMZGlzcGxheV9uYW1lGAMgASgJEjQKC2NyZWF0ZV90aW1lGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEDEjQKC3VwZGF0ZV90aW1lGAUgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcEID4EEDIi4KF0xpc3RVc2VyV2ViaG9va3NSZXF1ZXN0EhMKBnBhcmVudBgBIAEoCUID4EECIkcKGExpc3RVc2VyV2ViaG9va3NSZXNwb25zZRIrCgh3ZWJob29rcxgBIAMoCzIZLm1lbW9zLmFwaS52MS5Vc2VyV2ViaG9vayJgChhDcmVhdGVVc2VyV2ViaG9va1JlcXVlc3QSEwoGcGFyZW50GAEgASgJQgPgQQISLwoHd2ViaG9vaxgCIAEoCzIZLm1lbW9zLmFwaS52MS5Vc2VyV2ViaG9va0ID4EECInwKGFVwZGF0ZVVzZXJXZWJob29rUmVxdWVzdBIvCgd3ZWJob29rGAEgASgLMhkubWVtb3MuYXBpLnYxLlVzZXJXZWJob29rQgPgQQISLwoLdXBkYXRlX21hc2sYAiABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrIi0KGERlbGV0ZVVzZXJXZWJob29rUmVxdWVzdBIRCgRuYW1lGAEgASgJQgPgQQIiogcKEFVzZXJOb3RpZmljYXRpb24SFAoEbmFtZRgBIAEoCUIG4EED4EEIEikKBnNlbmRlchgCIAEoCUIZ4EED+kETChFtZW1vcy5hcGkudjEvVXNlchIsCgtzZW5kZXJfdXNlchgIIAEoCzISLm1lbW9zLmFwaS52MS5Vc2VyQgPgQQMSOgoGc3RhdHVzGAMgASgOMiUubWVtb3MuYXBpLnYxLlVzZXJOb3RpZmljYXRpb24uU3RhdHVzQgPgQQESNAoLY3JlYXRlX3RpbWUYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wQgPgQQMSNgoEdHlwZRgFIAEoDjIjLm1lbW9zLmFwaS52MS5Vc2VyTm90aWZpY2F0aW9uLlR5cGVCA+BBAxJOCgxtZW1vX2NvbW1lbnQYBiABKAsyMS5tZW1vcy5hcGkudjEuVXNlck5vdGlmaWNhdGlvbi5NZW1vQ29tbWVudFBheWxvYWRCA+BBA0gAEk4KDG1lbW9fbWVudGlvbhgHIAEoCzIxLm1lbW9zLmFwaS52MS5Vc2VyTm90aWZpY2F0aW9uLk1lbW9NZW50aW9uUGF5bG9hZEID4EEDSAAabAoSTWVtb0NvbW1lbnRQYXlsb2FkEgwKBG1lbW8YASABKAkSFAoMcmVsYXRlZF9tZW1vGAIgASgJEhQKDG1lbW9fc25pcHBldBgDIAEoCRIcChRyZWxhdGVkX21lbW9fc25pcHBldBgEIAEoCRpsChJNZW1vTWVudGlvblBheWxvYWQSDAoEbWVtbxgBIAEoCRIUCgxyZWxhdGVkX21lbW8YAiABKAkSFAoMbWVtb19zbmlwcGV0GAMgASgJEhwKFHJlbGF0ZWRfbWVtb19zbmlwcGV0GAQgASgJIjoKBlN0YXR1cxIWChJTVEFUVVNfVU5TUEVDSUZJRUQQABIKCgZVTlJFQUQQARIMCghBUkNISVZFRBACIkAKBFR5cGUSFAoQVFlQRV9VTlNQRUNJRklFRBAAEhAKDE1FTU9fQ09NTUVOVBABEhAKDE1FTU9fTUVOVElPThACOnDqQW0KHW1lbW9zLmFwaS52MS9Vc2VyTm90aWZpY2F0aW9uEil1c2Vycy97dXNlcn0vbm90aWZpY2F0aW9ucy97bm90aWZpY2F0aW9ufRoEbmFtZSoNbm90aWZpY2F0aW9uczIMbm90aWZpY2F0aW9uQgkKB3BheWxvYWQijwEKHExpc3RVc2VyTm90aWZpY2F0aW9uc1JlcXVlc3QSKQoGcGFyZW50GAEgASgJQhngQQL6QRMKEW1lbW9zLmFwaS52MS9Vc2VyEhYKCXBhZ2Vfc2l6ZRgCIAEoBUID4EEBEhcKCnBhZ2VfdG9rZW4YAyABKAlCA+BBARITCgZmaWx0ZXIYBCABKAlCA+BBASJvCh1MaXN0VXNlck5vdGlmaWNhdGlvbnNSZXNwb25zZRI1Cg1ub3RpZmljYXRpb25zGAEgAygLMh4ubWVtb3MuYXBpLnYxLlVzZXJOb3RpZmljYXRpb24SFwoPbmV4dF9wYWdlX3Rva2VuGAIgASgJIpABCh1VcGRhdGVVc2VyTm90aWZpY2F0aW9uUmVxdWVzdBI5Cgxub3RpZmljYXRpb24YASABKAsyHi5tZW1vcy5hcGkudjEuVXNlck5vdGlmaWNhdGlvbkID4EECEjQKC3VwZGF0ZV9tYXNrGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLkZpZWxkTWFza0ID4EECIlQKHURlbGV0ZVVzZXJOb3RpZmljYXRpb25SZXF1ZXN0EjMKBG5hbWUYASABKAlCJeBBAvpBHwodbWVtb3MuYXBpLnYxL1VzZXJOb3RpZmljYXRpb24ygh0KC1VzZXJTZXJ2aWNlEmMKCUxpc3RVc2VycxIeLm1lbW9zLmFwaS52MS5MaXN0VXNlcnNSZXF1ZXN0Gh8ubWVtb3MuYXBpLnYxLkxpc3RVc2Vyc1Jlc3BvbnNlIhWC0+STAg8SDS9hcGkvdjEvdXNlcnMSewoNQmF0Y2hHZXRVc2VycxIiLm1lbW9zLmFwaS52MS5CYXRjaEdldFVzZXJzUmVxdWVzdBojLm1lbW9zLmFwaS52MS5CYXRjaEdldFVzZXJzUmVzcG9uc2UiIYLT5JMCGzoBKiIWL2FwaS92MS91c2VyczpiYXRjaEdldBJiCgdHZXRVc2VyEhwubWVtb3MuYXBpLnYxLkdldFVzZXJSZXF1ZXN0GhIubWVtb3MuYXBpLnYxLlVzZXIiJdpBBG5hbWWC0+STAhgSFi9hcGkvdjEve25hbWU9dXNlcnMvKn0SZQoKQ3JlYXRlVXNlchIfLm1lbW9zLmFwaS52MS5DcmVhdGVVc2VyUmVxdWVzdBoSLm1lbW9zLmFwaS52MS5Vc2VyIiLaQQR1c2VygtPkkwIVOgR1c2VyIg0vYXBpL3YxL3VzZXJzEn8KClVwZGF0ZVVzZXISHy5tZW1vcy5hcGkudjEuVXBkYXRlVXNlclJlcXVlc3QaEi5tZW1vcy5hcGkudjEuVXNlciI82kEQdXNlcix1cGRhdGVfbWFza4LT5JMCIzoEdXNlcjIbL2FwaS92MS97dXNlci5uYW1lPXVzZXJzLyp9EmwKCkRlbGV0ZVVzZXISHy5tZW1vcy5hcGkudjEuRGVsZXRlVXNlclJlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiJdpBBG5hbWWC0+STAhgqFi9hcGkvdjEve25hbWU9dXNlcnMvKn0SfgoQTGlzdEFsbFVzZXJTdGF0cxIlLm1lbW9zLmFwaS52MS5MaXN0QWxsVXNlclN0YXRzUmVxdWVzdBomLm1lbW9zLmFwaS52MS5MaXN0QWxsVXNlclN0YXRzUmVzcG9uc2UiG4LT5JMCFRITL2FwaS92MS91c2VyczpzdGF0cxJ6CgxHZXRVc2VyU3RhdHMSIS5tZW1vcy5hcGkudjEuR2V0VXNlclN0YXRzUmVxdWVzdBoXLm1lbW9zLmFwaS52MS5Vc2VyU3RhdHMiLtpBBG5hbWWC0+STAiESHy9hcGkvdjEve25hbWU9dXNlcnMvKn06Z2V0U3RhdHMSggEKDkdldFVzZXJTZXR0aW5nEiMubWVtb3MuYXBpLnYxLkdldFVzZXJTZXR0aW5nUmVxdWVzdBoZLm1lbW9zLmFwaS52MS5Vc2VyU2V0dGluZyIw2kEEbmFtZYLT5JMCIxIhL2FwaS92MS97bmFtZT11c2Vycy8qL3NldHRpbmdzLyp9EqgBChFVcGRhdGVVc2VyU2V0dGluZxImLm1lbW9zLmFwaS52MS5VcGRhdGVVc2VyU2V0dGluZ1JlcXVlc3QaGS5tZW1vcy5hcGkudjEuVXNlclNldHRpbmciUNpBE3NldHRpbmcsdXBkYXRlX21hc2uC0+STAjQ6B3NldHRpbmcyKS9hcGkvdjEve3NldHRpbmcubmFtZT11c2Vycy8qL3NldHRpbmdzLyp9EpUBChBMaXN0VXNlclNldHRpbmdzEiUubWVtb3MuYXBpLnYxLkxpc3RVc2VyU2V0dGluZ3NSZXF1ZXN0GiYubWVtb3MuYXBpLnYxLkxpc3RVc2VyU2V0dGluZ3NSZXNwb25zZSIy2kEGcGFyZW50gtPkkwIjEiEvYXBpL3YxL3twYXJlbnQ9dXNlcnMvKn0vc2V0dGluZ3MSqQEKFExpc3RMaW5rZWRJZGVudGl0aWVzEikubWVtb3MuYXBpLnYxLkxpc3RMaW5rZWRJZGVudGl0aWVzUmVxdWVzdBoqLm1lbW9zLmFwaS52MS5MaXN0TGlua2VkSWRlbnRpdGllc1Jlc3BvbnNlIjraQQZwYXJlbnSC0+STAisSKS9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9saW5rZWRJZGVudGl0aWVzEqcBChRDcmVhdGVMaW5rZWRJZGVudGl0eRIpLm1lbW9zLmFwaS52MS5DcmVhdGVMaW5rZWRJZGVudGl0eVJlcXVlc3QaHC5tZW1vcy5hcGkudjEuTGlua2VkSWRlbnRpdHkiRtpBD3BhcmVudCxpZHBfbmFtZYLT5JMCLjoBKiIpL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L2xpbmtlZElkZW50aXRpZXMSkwEKEUdldExpbmtlZElkZW50aXR5EiYubWVtb3MuYXBpLnYxLkdldExpbmtlZElkZW50aXR5UmVxdWVzdBocLm1lbW9zLmFwaS52MS5MaW5rZWRJZGVudGl0eSI42kEEbmFtZYLT5JMCKxIpL2FwaS92MS97bmFtZT11c2Vycy8qL2xpbmtlZElkZW50aXRpZXMvKn0SkwEKFERlbGV0ZUxpbmtlZElkZW50aXR5EikubWVtb3MuYXBpLnYxLkRlbGV0ZUxpbmtlZElkZW50aXR5UmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSI42kEEbmFtZYLT5JMCKyopL2FwaS92MS97bmFtZT11c2Vycy8qL2xpbmtlZElkZW50aXRpZXMvKn0SuQEKGExpc3RQZXJzb25hbEFjY2Vzc1Rva2VucxItLm1lbW9zLmFwaS52MS5MaXN0UGVyc29uYWxBY2Nlc3NUb2tlbnNSZXF1ZXN0Gi4ubWVtb3MuYXBpLnYxLkxpc3RQZXJzb25hbEFjY2Vzc1Rva2Vuc1Jlc3BvbnNlIj7aQQZwYXJlbnSC0+STAi8SLS9hcGkvdjEve3BhcmVudD11c2Vycy8qfS9wZXJzb25hbEFjY2Vzc1Rva2VucxK2AQoZQ3JlYXRlUGVyc29uYWxBY2Nlc3NUb2tlbhIuLm1lbW9zLmFwaS52MS5DcmVhdGVQZXJzb25hbEFjY2Vzc1Rva2VuUmVxdWVzdBovLm1lbW9zLmFwaS52MS5DcmVhdGVQZXJzb25hbEFjY2Vzc1Rva2VuUmVzcG9uc2UiOILT5JMCMjoBKiItL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L3BlcnNvbmFsQWNjZXNzVG9rZW5zEqEBChlEZWxldGVQZXJzb25hbEFjY2Vzc1Rva2VuEi4ubWVtb3MuYXBpLnYxLkRlbGV0ZVBlcnNvbmFsQWNjZXNzVG9rZW5SZXF1ZXN0GhYuZ29vZ2xlLnByb3RvYnVmLkVtcHR5IjzaQQRuYW1lgtPkkwIvKi0vYXBpL3YxL3tuYW1lPXVzZXJzLyovcGVyc29uYWxBY2Nlc3NUb2tlbnMvKn0SlQEKEExpc3RVc2VyV2ViaG9va3MSJS5tZW1vcy5hcGkudjEuTGlzdFVzZXJXZWJob29rc1JlcXVlc3QaJi5tZW1vcy5hcGkudjEuTGlzdFVzZXJXZWJob29rc1Jlc3BvbnNlIjLaQQZwYXJlbnSC0+STAiMSIS9hcGkvdjEve3BhcmVudD11c2Vycy8qfS93ZWJob29rcxKbAQoRQ3JlYXRlVXNlcldlYmhvb2sSJi5tZW1vcy5hcGkudjEuQ3JlYXRlVXNlcldlYmhvb2tSZXF1ZXN0GhkubWVtb3MuYXBpLnYxLlVzZXJXZWJob29rIkPaQQ5wYXJlbnQsd2ViaG9va4LT5JMCLDoHd2ViaG9vayIhL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L3dlYmhvb2tzEqgBChFVcGRhdGVVc2VyV2ViaG9vaxImLm1lbW9zLmFwaS52MS5VcGRhdGVVc2VyV2ViaG9va1JlcXVlc3QaGS5tZW1vcy5hcGkudjEuVXNlcldlYmhvb2siUNpBE3dlYmhvb2ssdXBkYXRlX21hc2uC0+STAjQ6B3dlYmhvb2syKS9hcGkvdjEve3dlYmhvb2submFtZT11c2Vycy8qL3dlYmhvb2tzLyp9EoUBChFEZWxldGVVc2VyV2ViaG9vaxImLm1lbW9zLmFwaS52MS5EZWxldGVVc2VyV2ViaG9va1JlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkiMNpBBG5hbWWC0+STAiMqIS9hcGkvdjEve25hbWU9dXNlcnMvKi93ZWJob29rcy8qfRKpAQoVTGlzdFVzZXJOb3RpZmljYXRpb25zEioubWVtb3MuYXBpLnYxLkxpc3RVc2VyTm90aWZpY2F0aW9uc1JlcXVlc3QaKy5tZW1vcy5hcGkudjEuTGlzdFVzZXJOb3RpZmljYXRpb25zUmVzcG9uc2UiN9pBBnBhcmVudILT5JMCKBImL2FwaS92MS97cGFyZW50PXVzZXJzLyp9L25vdGlmaWNhdGlvbnMSywEKFlVwZGF0ZVVzZXJOb3RpZmljYXRpb24SKy5tZW1vcy5hcGkudjEuVXBkYXRlVXNlck5vdGlmaWNhdGlvblJlcXVlc3QaHi5tZW1vcy5hcGkudjEuVXNlck5vdGlmaWNhdGlvbiJk2kEYbm90aWZpY2F0aW9uLHVwZGF0ZV9tYXNrgtPkkwJDOgxub3RpZmljYXRpb24yMy9hcGkvdjEve25vdGlmaWNhdGlvbi5uYW1lPXVzZXJzLyovbm90aWZpY2F0aW9ucy8qfRKUAQoWRGVsZXRlVXNlck5vdGlmaWNhdGlvbhIrLm1lbW9zLmFwaS52MS5EZWxldGVVc2VyTm90aWZpY2F0aW9uUmVxdWVzdBoWLmdvb2dsZS5wcm90b2J1Zi5FbXB0eSI12kEEbmFtZYLT5JMCKComL2FwaS92MS97bmFtZT11c2Vycy8qL25vdGlmaWNhdGlvbnMvKn1CqAEKEGNvbS5tZW1vcy5hcGkudjFCEFVzZXJTZXJ2aWNlUHJvdG9QAVowZ2l0aHViLmNvbS91c2VtZW1vcy9tZW1vcy9wcm90by9nZW4vYXBpL3YxO2FwaXYxogIDTUFYqgIMTWVtb3MuQXBpLlYxygIMTWVtb3NcQXBpXFYx4gIYTWVtb3NcQXBpXFYxXEdQQk1ldGFkYXRh6gIOTWVtb3M6OkFwaTo6VjFiBnByb3RvMw", [file_api_v1_common, file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_empty, file_google_protobuf_field_mask, file_google_protobuf_timestamp]); /** * @generated from message memos.api.v1.User @@ -509,11 +509,23 @@ export const GetUserStatsRequestSchema: GenMessage = /*@__P messageDesc(file_api_v1_user_service, 10); /** - * This endpoint doesn't take any parameters. - * * @generated from message memos.api.v1.ListAllUserStatsRequest */ export type ListAllUserStatsRequest = Message<"memos.api.v1.ListAllUserStatsRequest"> & { + /** + * Optional. The state of memos to include. Defaults to NORMAL. + * + * @generated from field: memos.api.v1.State state = 1; + */ + state: State; + + /** + * Optional. Filter to apply to memo stats. + * Uses the same filter syntax as ListMemos. + * + * @generated from field: string filter = 2; + */ + filter: string; }; /** diff --git a/web/tests/memo-content-paragraph.test.tsx b/web/tests/memo-content-paragraph.test.tsx new file mode 100644 index 000000000..52e4c6fff --- /dev/null +++ b/web/tests/memo-content-paragraph.test.tsx @@ -0,0 +1,34 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { describe, expect, it } from "vitest"; +import { getSingleLinkHref } from "@/components/MemoContent/markdown/Paragraph"; + +const collectSingleLinkHrefs = (content: string): Array => { + const hrefs: Array = []; + + renderToStaticMarkup( + { + hrefs.push(getSingleLinkHref(node)); + return

{children}

; + }, + }} + > + {content} +
, + ); + + return hrefs; +}; + +describe("memo content paragraph links", () => { + it("treats only bare single-link paragraphs as single link hrefs", () => { + expect(collectSingleLinkHrefs("https://www.bilibili.com/\n\n[bilibili](https://www.bilibili.com/)")).toEqual([ + "https://www.bilibili.com/", + undefined, + ]); + }); +});