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.
pull/5947/head
boojack 3 weeks ago
parent c49e75f91f
commit 88ac3ec31e

@ -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 {

@ -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() }

@ -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
}

@ -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

@ -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")
}

@ -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 = &currentUser.ID
} else if currentUser == nil {
memoFind.VisibilityList = []store.Visibility{store.Public}
} else {
if memoFind.CreatorID == nil {

@ -8,7 +8,7 @@ interface ParagraphProps extends React.HTMLAttributes<HTMLParagraphElement>, 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) => {

@ -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<Shortcut | undefined>();
useEffect(() => {
refetchSettings();
}, [refetchSettings]);
const handleDeleteShortcut = async (shortcut: Shortcut) => {
setDeleteTarget(shortcut);
};

@ -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(

@ -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<string, number> = {};
let tagCount: Record<string, number> = {};
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;
};

@ -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;
}

@ -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<ListAllUserStatsRequest, "state" | "filter">;
// 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<ListAllUserStatsQuery>) => [...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<ListAllUserStatsQuery> = {}, 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<UserStats | Record<string, number>, Error, Record<string, number>>({
queryKey:
forCurrentUser && currentUser?.name
? userKeys.userStats(currentUser.name)
: [...userKeys.stats(), "tagCounts", forCurrentUser ? "current" : "all"],
queryFn: async (): Promise<UserStats | Record<string, number>> => {
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<string, number>;
},
enabled: !forCurrentUser || !!currentUser?.name,
staleTime: 1000 * 60 * 2, // 2 minutes - tags don't change frequently
});

@ -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;

File diff suppressed because one or more lines are too long

@ -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<string | undefined> => {
const hrefs: Array<string | undefined> = [];
renderToStaticMarkup(
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children, node }) => {
hrefs.push(getSingleLinkHref(node));
return <p>{children}</p>;
},
}}
>
{content}
</ReactMarkdown>,
);
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,
]);
});
});
Loading…
Cancel
Save