mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
feat: list user stats
This commit is contained in:
@@ -192,8 +192,9 @@ message UserStats {
|
|||||||
|
|
||||||
message MemoTypeStats {
|
message MemoTypeStats {
|
||||||
int32 link_count = 1;
|
int32 link_count = 1;
|
||||||
int32 task_count = 2;
|
int32 code_count = 2;
|
||||||
int32 code_count = 3;
|
int32 todo_count = 3;
|
||||||
|
int32 undo_count = 4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1286,8 +1286,9 @@ func (x *DeleteUserAccessTokenRequest) GetAccessToken() string {
|
|||||||
type UserStats_MemoTypeStats struct {
|
type UserStats_MemoTypeStats struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
LinkCount int32 `protobuf:"varint,1,opt,name=link_count,json=linkCount,proto3" json:"link_count,omitempty"`
|
LinkCount int32 `protobuf:"varint,1,opt,name=link_count,json=linkCount,proto3" json:"link_count,omitempty"`
|
||||||
TaskCount int32 `protobuf:"varint,2,opt,name=task_count,json=taskCount,proto3" json:"task_count,omitempty"`
|
CodeCount int32 `protobuf:"varint,2,opt,name=code_count,json=codeCount,proto3" json:"code_count,omitempty"`
|
||||||
CodeCount int32 `protobuf:"varint,3,opt,name=code_count,json=codeCount,proto3" json:"code_count,omitempty"`
|
TodoCount int32 `protobuf:"varint,3,opt,name=todo_count,json=todoCount,proto3" json:"todo_count,omitempty"`
|
||||||
|
UndoCount int32 `protobuf:"varint,4,opt,name=undo_count,json=undoCount,proto3" json:"undo_count,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -1329,16 +1330,23 @@ func (x *UserStats_MemoTypeStats) GetLinkCount() int32 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *UserStats_MemoTypeStats) GetTaskCount() int32 {
|
func (x *UserStats_MemoTypeStats) GetCodeCount() int32 {
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.TaskCount
|
return x.CodeCount
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *UserStats_MemoTypeStats) GetCodeCount() int32 {
|
func (x *UserStats_MemoTypeStats) GetTodoCount() int32 {
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.CodeCount
|
return x.TodoCount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *UserStats_MemoTypeStats) GetUndoCount() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.UndoCount
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -1428,7 +1436,7 @@ var file_api_v1_user_service_proto_rawDesc = []byte{
|
|||||||
0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x73, 0x6b, 0x22, 0x27, 0x0a, 0x11, 0x44, 0x65, 0x6c, 0x65,
|
0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x73, 0x6b, 0x22, 0x27, 0x0a, 0x11, 0x44, 0x65, 0x6c, 0x65,
|
||||||
0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a,
|
0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a,
|
||||||
0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d,
|
0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d,
|
||||||
0x65, 0x22, 0xb1, 0x03, 0x0a, 0x09, 0x55, 0x73, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12,
|
0x65, 0x22, 0xd1, 0x03, 0x0a, 0x09, 0x55, 0x73, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12,
|
||||||
0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e,
|
0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e,
|
||||||
0x61, 0x6d, 0x65, 0x12, 0x52, 0x0a, 0x17, 0x6d, 0x65, 0x6d, 0x6f, 0x5f, 0x64, 0x69, 0x73, 0x70,
|
0x61, 0x6d, 0x65, 0x12, 0x52, 0x0a, 0x17, 0x6d, 0x65, 0x6d, 0x6f, 0x5f, 0x64, 0x69, 0x73, 0x70,
|
||||||
0x6c, 0x61, 0x79, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x18, 0x02,
|
0x6c, 0x61, 0x79, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x18, 0x02,
|
||||||
@@ -1448,13 +1456,15 @@ var file_api_v1_user_service_proto_rawDesc = []byte{
|
|||||||
0x67, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b,
|
0x67, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b,
|
||||||
0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a,
|
0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a,
|
||||||
0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x76, 0x61,
|
0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x76, 0x61,
|
||||||
0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x6c, 0x0a, 0x0d, 0x4d, 0x65, 0x6d, 0x6f, 0x54,
|
0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x8b, 0x01, 0x0a, 0x0d, 0x4d, 0x65, 0x6d, 0x6f,
|
||||||
0x79, 0x70, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6c, 0x69, 0x6e, 0x6b,
|
0x54, 0x79, 0x70, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6c, 0x69, 0x6e,
|
||||||
0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x6c, 0x69,
|
0x6b, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x6c,
|
||||||
0x6e, 0x6b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x61, 0x73, 0x6b, 0x5f,
|
0x69, 0x6e, 0x6b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x64, 0x65,
|
||||||
0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x61, 0x73,
|
0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x63, 0x6f,
|
||||||
0x6b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x64, 0x65, 0x5f, 0x63,
|
0x64, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x6f, 0x64, 0x6f, 0x5f,
|
||||||
0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x63, 0x6f, 0x64, 0x65,
|
0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x6f, 0x64,
|
||||||
|
0x6f, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x6e, 0x64, 0x6f, 0x5f, 0x63,
|
||||||
|
0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x75, 0x6e, 0x64, 0x6f,
|
||||||
0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x31, 0x0a, 0x17, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x6c, 0x6c,
|
0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x31, 0x0a, 0x17, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x6c, 0x6c,
|
||||||
0x55, 0x73, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
0x55, 0x73, 0x65, 0x72, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||||
0x12, 0x16, 0x0a, 0x06, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
0x12, 0x16, 0x0a, 0x06, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||||
|
@@ -1868,10 +1868,13 @@ definitions:
|
|||||||
linkCount:
|
linkCount:
|
||||||
type: integer
|
type: integer
|
||||||
format: int32
|
format: int32
|
||||||
taskCount:
|
codeCount:
|
||||||
type: integer
|
type: integer
|
||||||
format: int32
|
format: int32
|
||||||
codeCount:
|
todoCount:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
undoCount:
|
||||||
type: integer
|
type: integer
|
||||||
format: int32
|
format: int32
|
||||||
WorkspaceStorageSettingS3Config:
|
WorkspaceStorageSettingS3Config:
|
||||||
|
@@ -13,7 +13,7 @@ var authenticationAllowlistMethods = map[string]bool{
|
|||||||
"/memos.api.v1.AuthService/SignUp": true,
|
"/memos.api.v1.AuthService/SignUp": true,
|
||||||
"/memos.api.v1.UserService/GetUser": true,
|
"/memos.api.v1.UserService/GetUser": true,
|
||||||
"/memos.api.v1.UserService/GetUserAvatarBinary": true,
|
"/memos.api.v1.UserService/GetUserAvatarBinary": true,
|
||||||
"/memos.api.v1.UserService/ListUserStats": true,
|
"/memos.api.v1.UserService/ListAllUserStats": true,
|
||||||
"/memos.api.v1.UserService/SearchUsers": true,
|
"/memos.api.v1.UserService/SearchUsers": true,
|
||||||
"/memos.api.v1.MemoService/GetMemo": true,
|
"/memos.api.v1.MemoService/GetMemo": true,
|
||||||
"/memos.api.v1.MemoService/GetMemoByUid": true,
|
"/memos.api.v1.MemoService/GetMemoByUid": true,
|
||||||
|
@@ -275,100 +275,6 @@ func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserR
|
|||||||
return &emptypb.Empty{}, nil
|
return &emptypb.Empty{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIV1Service) ListAllUserStats(ctx context.Context, request *v1pb.ListAllUserStatsRequest) (*v1pb.ListAllUserStatsResponse, error) {
|
|
||||||
users, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
|
|
||||||
}
|
|
||||||
userStatsList := []*v1pb.UserStats{}
|
|
||||||
for _, user := range users {
|
|
||||||
userStats, err := s.GetUserStats(ctx, &v1pb.GetUserStatsRequest{
|
|
||||||
Name: fmt.Sprintf("%s%d", UserNamePrefix, user.ID),
|
|
||||||
Filter: request.Filter,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get user stats: %v", err)
|
|
||||||
}
|
|
||||||
userStatsList = append(userStatsList, userStats)
|
|
||||||
}
|
|
||||||
return &v1pb.ListAllUserStatsResponse{
|
|
||||||
UserStats: userStatsList,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserStatsRequest) (*v1pb.UserStats, error) {
|
|
||||||
userID, err := ExtractUserIDFromName(request.Name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
|
|
||||||
}
|
|
||||||
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUser, err := s.GetCurrentUser(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
|
||||||
}
|
|
||||||
// For unauthenticated users, only public memos are visible.
|
|
||||||
visibilities := []store.Visibility{store.Public}
|
|
||||||
if currentUser != nil {
|
|
||||||
// For authenticated users, protected memos are also visible.
|
|
||||||
visibilities = append(visibilities, store.Protected)
|
|
||||||
if currentUser.ID == user.ID {
|
|
||||||
// For the current user, show all memos including private ones.
|
|
||||||
visibilities = []store.Visibility{store.Public, store.Protected, store.Private}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
workspaceMemoRelatedSetting, err := s.Store.GetWorkspaceMemoRelatedSetting(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.Wrap(err, "failed to get workspace memo related setting")
|
|
||||||
}
|
|
||||||
userStats := &v1pb.UserStats{
|
|
||||||
Name: fmt.Sprintf("%s%d", UserNamePrefix, user.ID),
|
|
||||||
MemoDisplayTimestamps: []*timestamppb.Timestamp{},
|
|
||||||
MemoTypeStats: &v1pb.UserStats_MemoTypeStats{},
|
|
||||||
TagCount: map[string]int32{},
|
|
||||||
}
|
|
||||||
memoFind := &store.FindMemo{
|
|
||||||
// Exclude comments by default.
|
|
||||||
ExcludeComments: true,
|
|
||||||
ExcludeContent: true,
|
|
||||||
}
|
|
||||||
if err := s.buildMemoFindWithFilter(ctx, memoFind, request.Filter); err != nil {
|
|
||||||
return nil, status.Errorf(codes.InvalidArgument, "failed to build find memos with filter: %v", err)
|
|
||||||
}
|
|
||||||
// Override the creator ID and visibility list.
|
|
||||||
memoFind.CreatorID = &user.ID
|
|
||||||
memoFind.VisibilityList = visibilities
|
|
||||||
memos, err := s.Store.ListMemos(ctx, memoFind)
|
|
||||||
if err != nil {
|
|
||||||
return nil, status.Errorf(codes.Internal, "failed to list memos: %v", err)
|
|
||||||
}
|
|
||||||
for _, memo := range memos {
|
|
||||||
displayTs := memo.CreatedTs
|
|
||||||
if workspaceMemoRelatedSetting.DisplayWithUpdateTime {
|
|
||||||
displayTs = memo.UpdatedTs
|
|
||||||
}
|
|
||||||
userStats.MemoDisplayTimestamps = append(userStats.MemoDisplayTimestamps, timestamppb.New(time.Unix(displayTs, 0)))
|
|
||||||
// Handle duplicated tags.
|
|
||||||
for _, tag := range memo.Payload.Tags {
|
|
||||||
userStats.TagCount[tag]++
|
|
||||||
}
|
|
||||||
if memo.Payload.Property.GetHasLink() {
|
|
||||||
userStats.MemoTypeStats.LinkCount++
|
|
||||||
}
|
|
||||||
if memo.Payload.Property.GetHasTaskList() {
|
|
||||||
userStats.MemoTypeStats.TaskCount++
|
|
||||||
}
|
|
||||||
if memo.Payload.Property.GetHasCode() {
|
|
||||||
userStats.MemoTypeStats.CodeCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return userStats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getDefaultUserSetting(workspaceMemoRelatedSetting *storepb.WorkspaceMemoRelatedSetting) *v1pb.UserSetting {
|
func getDefaultUserSetting(workspaceMemoRelatedSetting *storepb.WorkspaceMemoRelatedSetting) *v1pb.UserSetting {
|
||||||
defaultVisibility := "PRIVATE"
|
defaultVisibility := "PRIVATE"
|
||||||
if workspaceMemoRelatedSetting.DefaultVisibility != "" {
|
if workspaceMemoRelatedSetting.DefaultVisibility != "" {
|
||||||
|
124
server/router/api/v1/user_service_stats.go
Normal file
124
server/router/api/v1/user_service_stats.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
|
|
||||||
|
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||||||
|
"github.com/usememos/memos/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *APIV1Service) ListAllUserStats(ctx context.Context, request *v1pb.ListAllUserStatsRequest) (*v1pb.ListAllUserStatsResponse, error) {
|
||||||
|
users, err := s.Store.ListUsers(ctx, &store.FindUser{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to list users: %v", err)
|
||||||
|
}
|
||||||
|
userStatsList := []*v1pb.UserStats{}
|
||||||
|
for _, user := range users {
|
||||||
|
userStats, err := s.GetUserStats(ctx, &v1pb.GetUserStatsRequest{
|
||||||
|
Name: fmt.Sprintf("%s%d", UserNamePrefix, user.ID),
|
||||||
|
Filter: request.Filter,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get user stats: %v", err)
|
||||||
|
}
|
||||||
|
userStatsList = append(userStatsList, userStats)
|
||||||
|
}
|
||||||
|
return &v1pb.ListAllUserStatsResponse{
|
||||||
|
UserStats: userStatsList,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIV1Service) GetUserStats(ctx context.Context, request *v1pb.GetUserStatsRequest) (*v1pb.UserStats, error) {
|
||||||
|
userID, err := ExtractUserIDFromName(request.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "invalid user name: %v", err)
|
||||||
|
}
|
||||||
|
user, err := s.Store.GetUser(ctx, &store.FindUser{ID: &userID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceMemoRelatedSetting, err := s.Store.GetWorkspaceMemoRelatedSetting(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to get workspace memo related setting")
|
||||||
|
}
|
||||||
|
userStats := &v1pb.UserStats{
|
||||||
|
Name: fmt.Sprintf("%s%d", UserNamePrefix, user.ID),
|
||||||
|
MemoDisplayTimestamps: []*timestamppb.Timestamp{},
|
||||||
|
MemoTypeStats: &v1pb.UserStats_MemoTypeStats{},
|
||||||
|
TagCount: map[string]int32{},
|
||||||
|
}
|
||||||
|
memoFind := &store.FindMemo{
|
||||||
|
// Exclude comments by default.
|
||||||
|
ExcludeComments: true,
|
||||||
|
ExcludeContent: true,
|
||||||
|
}
|
||||||
|
if err := s.buildMemoFindWithFilter(ctx, memoFind, request.Filter); err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "failed to build find memos with filter: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser, err := s.GetCurrentUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||||
|
}
|
||||||
|
if len(memoFind.VisibilityList) == 0 {
|
||||||
|
visibilities := []store.Visibility{store.Public}
|
||||||
|
if currentUser != nil {
|
||||||
|
visibilities = append(visibilities, store.Protected)
|
||||||
|
if currentUser.ID == user.ID {
|
||||||
|
visibilities = append(visibilities, store.Private)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
memoFind.VisibilityList = visibilities
|
||||||
|
} else {
|
||||||
|
if slices.Contains(memoFind.VisibilityList, store.Private) {
|
||||||
|
if currentUser == nil || currentUser.ID != user.ID {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if slices.Contains(memoFind.VisibilityList, store.Protected) {
|
||||||
|
if currentUser == nil {
|
||||||
|
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override the creator ID.
|
||||||
|
memoFind.CreatorID = &user.ID
|
||||||
|
memos, err := s.Store.ListMemos(ctx, memoFind)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "failed to list memos: %v", err)
|
||||||
|
}
|
||||||
|
for _, memo := range memos {
|
||||||
|
displayTs := memo.CreatedTs
|
||||||
|
if workspaceMemoRelatedSetting.DisplayWithUpdateTime {
|
||||||
|
displayTs = memo.UpdatedTs
|
||||||
|
}
|
||||||
|
userStats.MemoDisplayTimestamps = append(userStats.MemoDisplayTimestamps, timestamppb.New(time.Unix(displayTs, 0)))
|
||||||
|
// Handle duplicated tags.
|
||||||
|
for _, tag := range memo.Payload.Tags {
|
||||||
|
userStats.TagCount[tag]++
|
||||||
|
}
|
||||||
|
if memo.Payload.Property.GetHasLink() {
|
||||||
|
userStats.MemoTypeStats.LinkCount++
|
||||||
|
}
|
||||||
|
if memo.Payload.Property.GetHasCode() {
|
||||||
|
userStats.MemoTypeStats.CodeCount++
|
||||||
|
}
|
||||||
|
if memo.Payload.Property.GetHasTaskList() {
|
||||||
|
userStats.MemoTypeStats.TodoCount++
|
||||||
|
}
|
||||||
|
if memo.Payload.Property.GetHasIncompleteTasks() {
|
||||||
|
userStats.MemoTypeStats.UndoCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return userStats, nil
|
||||||
|
}
|
@@ -1,26 +1,26 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import useDebounce from "react-use/lib/useDebounce";
|
import useDebounce from "react-use/lib/useDebounce";
|
||||||
import SearchBar from "@/components/SearchBar";
|
import SearchBar from "@/components/SearchBar";
|
||||||
import { useMemoList, useMemoMetadataStore } from "@/store/v1";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
|
import { useUserStatsStore } from "@/store/v1";
|
||||||
import TagsSection from "../HomeSidebar/TagsSection";
|
import TagsSection from "../HomeSidebar/TagsSection";
|
||||||
|
import StatisticsView from "../StatisticsView";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ExploreSidebar = (props: Props) => {
|
const ExploreSidebar = (props: Props) => {
|
||||||
const location = useLocation();
|
const currentUser = useCurrentUser();
|
||||||
const memoList = useMemoList();
|
const userStatsStore = useUserStatsStore();
|
||||||
const memoMetadataStore = useMemoMetadataStore();
|
|
||||||
|
|
||||||
useDebounce(
|
useDebounce(
|
||||||
async () => {
|
async () => {
|
||||||
if (memoList.size() === 0) return;
|
const filters = [`state == "NORMAL"`, `visibilities == [${currentUser ? "'PUBLIC', 'PROTECTED'" : "'PUBLIC'"}]`];
|
||||||
await memoMetadataStore.fetchMemoMetadata({ location });
|
userStatsStore.listUserStats(undefined, filters.join(" && "));
|
||||||
},
|
},
|
||||||
300,
|
300,
|
||||||
[memoList.size(), location.pathname],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -31,6 +31,7 @@ const ExploreSidebar = (props: Props) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
|
<StatisticsView />
|
||||||
<TagsSection readonly={true} />
|
<TagsSection readonly={true} />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
@@ -1,10 +1,9 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import useDebounce from "react-use/lib/useDebounce";
|
import useDebounce from "react-use/lib/useDebounce";
|
||||||
import SearchBar from "@/components/SearchBar";
|
import SearchBar from "@/components/SearchBar";
|
||||||
import UserStatisticsView from "@/components/UserStatisticsView";
|
import StatisticsView from "@/components/StatisticsView";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import { useMemoList, useMemoMetadataStore } from "@/store/v1";
|
import { useMemoList, useUserStatsStore } from "@/store/v1";
|
||||||
import TagsSection from "./TagsSection";
|
import TagsSection from "./TagsSection";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -12,17 +11,17 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const HomeSidebar = (props: Props) => {
|
const HomeSidebar = (props: Props) => {
|
||||||
const location = useLocation();
|
const currentUser = useCurrentUser();
|
||||||
const user = useCurrentUser();
|
|
||||||
const memoList = useMemoList();
|
const memoList = useMemoList();
|
||||||
const memoMetadataStore = useMemoMetadataStore();
|
const userStatsStore = useUserStatsStore();
|
||||||
|
|
||||||
useDebounce(
|
useDebounce(
|
||||||
async () => {
|
async () => {
|
||||||
await memoMetadataStore.fetchMemoMetadata({ user, location });
|
const filters = [`state == "NORMAL"`];
|
||||||
|
await userStatsStore.listUserStats(currentUser.name, filters.join(" && "));
|
||||||
},
|
},
|
||||||
300,
|
300,
|
||||||
[memoList.size(), user, location.pathname],
|
[memoList.size(), currentUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,7 +32,7 @@ const HomeSidebar = (props: Props) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
<UserStatisticsView />
|
<StatisticsView />
|
||||||
<TagsSection />
|
<TagsSection />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
@@ -2,11 +2,10 @@ import { Dropdown, Menu, MenuButton, MenuItem, Switch } from "@mui/joy";
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { Edit3Icon, HashIcon, MoreVerticalIcon, TagsIcon, TrashIcon } from "lucide-react";
|
import { Edit3Icon, HashIcon, MoreVerticalIcon, TagsIcon, TrashIcon } from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useLocation } from "react-router-dom";
|
|
||||||
import useLocalStorage from "react-use/lib/useLocalStorage";
|
import useLocalStorage from "react-use/lib/useLocalStorage";
|
||||||
import { memoServiceClient } from "@/grpcweb";
|
import { memoServiceClient } from "@/grpcweb";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import { useMemoFilterStore, useMemoMetadataStore, useMemoTagList } from "@/store/v1";
|
import { useMemoFilterStore, useUserStatsStore, useUserStatsTags } from "@/store/v1";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import showRenameTagDialog from "../RenameTagDialog";
|
import showRenameTagDialog from "../RenameTagDialog";
|
||||||
import TagTree from "../TagTree";
|
import TagTree from "../TagTree";
|
||||||
@@ -18,12 +17,11 @@ interface Props {
|
|||||||
|
|
||||||
const TagsSection = (props: Props) => {
|
const TagsSection = (props: Props) => {
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const location = useLocation();
|
const currentUser = useCurrentUser();
|
||||||
const user = useCurrentUser();
|
|
||||||
const memoFilterStore = useMemoFilterStore();
|
const memoFilterStore = useMemoFilterStore();
|
||||||
const memoMetadataStore = useMemoMetadataStore();
|
const userStatsStore = useUserStatsStore();
|
||||||
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
|
const [treeMode, setTreeMode] = useLocalStorage<boolean>("tag-view-as-tree", false);
|
||||||
const tags = Object.entries(useMemoTagList())
|
const tags = Object.entries(useUserStatsTags())
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
.sort((a, b) => b[1] - a[1]);
|
.sort((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
@@ -46,7 +44,7 @@ const TagsSection = (props: Props) => {
|
|||||||
parent: "memos/-",
|
parent: "memos/-",
|
||||||
tag: tag,
|
tag: tag,
|
||||||
});
|
});
|
||||||
await memoMetadataStore.fetchMemoMetadata({ user, location });
|
await userStatsStore.listUserStats(currentUser.name);
|
||||||
toast.success(t("message.deleted-successfully"));
|
toast.success(t("message.deleted-successfully"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@@ -4,7 +4,7 @@ import { HashIcon } from "lucide-react";
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import useClickAway from "react-use/lib/useClickAway";
|
import useClickAway from "react-use/lib/useClickAway";
|
||||||
import OverflowTip from "@/components/kit/OverflowTip";
|
import OverflowTip from "@/components/kit/OverflowTip";
|
||||||
import { useMemoTagList } from "@/store/v1";
|
import { useUserStatsTags } from "@/store/v1";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import { EditorRefActions } from "../Editor";
|
import { EditorRefActions } from "../Editor";
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ const TagSelector = (props: Props) => {
|
|||||||
const { editorRef } = props;
|
const { editorRef } = props;
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const tags = Object.entries(useMemoTagList())
|
const tags = Object.entries(useUserStatsTags())
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
.map(([tag]) => tag);
|
.map(([tag]) => tag);
|
||||||
|
@@ -3,7 +3,7 @@ import Fuse from "fuse.js";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import getCaretCoordinates from "textarea-caret";
|
import getCaretCoordinates from "textarea-caret";
|
||||||
import OverflowTip from "@/components/kit/OverflowTip";
|
import OverflowTip from "@/components/kit/OverflowTip";
|
||||||
import { useMemoTagList } from "@/store/v1";
|
import { useUserStatsTags } from "@/store/v1";
|
||||||
import { EditorRefActions } from ".";
|
import { EditorRefActions } from ".";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -18,7 +18,7 @@ const TagSuggestions = ({ editorRef, editorActions }: Props) => {
|
|||||||
const [selected, select] = useState(0);
|
const [selected, select] = useState(0);
|
||||||
const selectedRef = useRef(selected);
|
const selectedRef = useRef(selected);
|
||||||
selectedRef.current = selected;
|
selectedRef.current = selected;
|
||||||
const tags = Object.entries(useMemoTagList())
|
const tags = Object.entries(useUserStatsTags())
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
.map(([tag]) => tag);
|
.map(([tag]) => tag);
|
||||||
|
@@ -1,10 +1,9 @@
|
|||||||
import { Button } from "@usememos/mui";
|
import { Button } from "@usememos/mui";
|
||||||
import { ArrowDownIcon, ArrowUpIcon, LoaderIcon, SlashIcon } from "lucide-react";
|
import { ArrowDownIcon, ArrowUpIcon, LoaderIcon, SlashIcon } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import PullToRefresh from "react-simple-pull-to-refresh";
|
import PullToRefresh from "react-simple-pull-to-refresh";
|
||||||
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
|
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
|
||||||
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
import useResponsiveWidth from "@/hooks/useResponsiveWidth";
|
||||||
import { Routes } from "@/router";
|
|
||||||
import { useMemoList, useMemoStore } from "@/store/v1";
|
import { useMemoList, useMemoStore } from "@/store/v1";
|
||||||
import { Memo } from "@/types/proto/api/v1/memo_service";
|
import { Memo } from "@/types/proto/api/v1/memo_service";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
@@ -27,15 +26,10 @@ const PagedMemoList = (props: Props) => {
|
|||||||
const { md } = useResponsiveWidth();
|
const { md } = useResponsiveWidth();
|
||||||
const memoStore = useMemoStore();
|
const memoStore = useMemoStore();
|
||||||
const memoList = useMemoList();
|
const memoList = useMemoList();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [state, setState] = useState<State>({
|
const [state, setState] = useState<State>({
|
||||||
isRequesting: true, // Initial request
|
isRequesting: true, // Initial request
|
||||||
nextPageToken: "",
|
nextPageToken: "",
|
||||||
});
|
});
|
||||||
const shouldShowBackToTop = useMemo(
|
|
||||||
() => [Routes.ROOT, Routes.EXPLORE, Routes.ARCHIVED].includes(location.pathname as Routes) || location.pathname.startsWith("/u/"),
|
|
||||||
[location.pathname],
|
|
||||||
);
|
|
||||||
const sortedMemoList = props.listSort ? props.listSort(memoList.value) : memoList.value;
|
const sortedMemoList = props.listSort ? props.listSort(memoList.value) : memoList.value;
|
||||||
|
|
||||||
const fetchMoreMemos = async (nextPageToken: string) => {
|
const fetchMoreMemos = async (nextPageToken: string) => {
|
||||||
@@ -62,7 +56,7 @@ const PagedMemoList = (props: Props) => {
|
|||||||
}, [props.filter, props.pageSize]);
|
}, [props.filter, props.pageSize]);
|
||||||
|
|
||||||
const children = (
|
const children = (
|
||||||
<div ref={containerRef} className="flex flex-col justify-start items-start w-full max-w-full">
|
<div className="flex flex-col justify-start items-start w-full max-w-full">
|
||||||
{sortedMemoList.map((memo) => props.renderer(memo))}
|
{sortedMemoList.map((memo) => props.renderer(memo))}
|
||||||
{state.isRequesting && (
|
{state.isRequesting && (
|
||||||
<div className="w-full flex flex-row justify-center items-center my-4">
|
<div className="w-full flex flex-row justify-center items-center my-4">
|
||||||
@@ -84,10 +78,10 @@ const PagedMemoList = (props: Props) => {
|
|||||||
{t("memo.load-more")}
|
{t("memo.load-more")}
|
||||||
<ArrowDownIcon className="ml-1 w-4 h-auto" />
|
<ArrowDownIcon className="ml-1 w-4 h-auto" />
|
||||||
</Button>
|
</Button>
|
||||||
{shouldShowBackToTop && <SlashIcon className="mx-1 w-4 h-auto opacity-40" />}
|
<SlashIcon className="mx-1 w-4 h-auto opacity-40" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{shouldShowBackToTop && <BackToTop />}
|
<BackToTop />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
@@ -6,7 +6,7 @@ import { toast } from "react-hot-toast";
|
|||||||
import { memoServiceClient } from "@/grpcweb";
|
import { memoServiceClient } from "@/grpcweb";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import useLoading from "@/hooks/useLoading";
|
import useLoading from "@/hooks/useLoading";
|
||||||
import { useMemoMetadataStore } from "@/store/v1";
|
import { useUserStatsStore } from "@/store/v1";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import { generateDialog } from "./Dialog";
|
import { generateDialog } from "./Dialog";
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ interface Props extends DialogProps {
|
|||||||
const RenameTagDialog: React.FC<Props> = (props: Props) => {
|
const RenameTagDialog: React.FC<Props> = (props: Props) => {
|
||||||
const { tag, destroy } = props;
|
const { tag, destroy } = props;
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const memoMetadataStore = useMemoMetadataStore();
|
const userStatsStore = useUserStatsStore();
|
||||||
const [newName, setNewName] = useState(tag);
|
const [newName, setNewName] = useState(tag);
|
||||||
const requestState = useLoading(false);
|
const requestState = useLoading(false);
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
@@ -43,7 +43,7 @@ const RenameTagDialog: React.FC<Props> = (props: Props) => {
|
|||||||
newTag: newName,
|
newTag: newName,
|
||||||
});
|
});
|
||||||
toast.success("Rename tag successfully");
|
toast.success("Rename tag successfully");
|
||||||
memoMetadataStore.fetchMemoMetadata({ user });
|
userStatsStore.listUserStats(user.name);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(error.details);
|
toast.error(error.details);
|
||||||
|
@@ -7,25 +7,18 @@ import { useState } from "react";
|
|||||||
import useAsyncEffect from "@/hooks/useAsyncEffect";
|
import useAsyncEffect from "@/hooks/useAsyncEffect";
|
||||||
import useCurrentUser from "@/hooks/useCurrentUser";
|
import useCurrentUser from "@/hooks/useCurrentUser";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
import { useMemoFilterStore, useMemoMetadataStore } from "@/store/v1";
|
import { useMemoFilterStore, useUserStatsStore } from "@/store/v1";
|
||||||
|
import { UserStats_MemoTypeStats } from "@/types/proto/api/v1/user_service";
|
||||||
import { useTranslate } from "@/utils/i18n";
|
import { useTranslate } from "@/utils/i18n";
|
||||||
import ActivityCalendar from "./ActivityCalendar";
|
import ActivityCalendar from "./ActivityCalendar";
|
||||||
|
|
||||||
interface UserMemoStats {
|
const StatisticsView = () => {
|
||||||
link: number;
|
|
||||||
taskList: number;
|
|
||||||
code: number;
|
|
||||||
incompleteTasks: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserStatisticsView = () => {
|
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const memoFilterStore = useMemoFilterStore();
|
const memoFilterStore = useMemoFilterStore();
|
||||||
const memoMetadataStore = useMemoMetadataStore();
|
const userStatsStore = useUserStatsStore();
|
||||||
const metadataList = Object.values(memoMetadataStore.getState().dataMapByName);
|
|
||||||
const [memoAmount, setMemoAmount] = useState(0);
|
const [memoAmount, setMemoAmount] = useState(0);
|
||||||
const [memoStats, setMemoStats] = useState<UserMemoStats>({ link: 0, taskList: 0, code: 0, incompleteTasks: 0 });
|
const [memoTypeStats, setMemoTypeStats] = useState<UserStats_MemoTypeStats>(UserStats_MemoTypeStats.fromPartial({}));
|
||||||
const [activityStats, setActivityStats] = useState<Record<string, number>>({});
|
const [activityStats, setActivityStats] = useState<Record<string, number>>({});
|
||||||
const [selectedDate] = useState(new Date());
|
const [selectedDate] = useState(new Date());
|
||||||
const [visibleMonthString, setVisibleMonthString] = useState(dayjs(selectedDate.toDateString()).format("YYYY-MM"));
|
const [visibleMonthString, setVisibleMonthString] = useState(dayjs(selectedDate.toDateString()).format("YYYY-MM"));
|
||||||
@@ -35,26 +28,21 @@ const UserStatisticsView = () => {
|
|||||||
const singularOrPluralDay = (days > 0 ? t("common.days") : t("common.day")).toLowerCase();
|
const singularOrPluralDay = (days > 0 ? t("common.days") : t("common.day")).toLowerCase();
|
||||||
|
|
||||||
useAsyncEffect(async () => {
|
useAsyncEffect(async () => {
|
||||||
const memoStats: UserMemoStats = { link: 0, taskList: 0, code: 0, incompleteTasks: 0 };
|
const memoTypeStats = UserStats_MemoTypeStats.fromPartial({});
|
||||||
metadataList.forEach((memo) => {
|
const displayTimeList: Date[] = [];
|
||||||
const { property } = memo;
|
for (const stats of Object.values(userStatsStore.userStatsByName)) {
|
||||||
if (property?.hasLink) {
|
displayTimeList.push(...stats.memoDisplayTimestamps);
|
||||||
memoStats.link += 1;
|
if (stats.memoTypeStats) {
|
||||||
|
memoTypeStats.codeCount += stats.memoTypeStats.codeCount;
|
||||||
|
memoTypeStats.linkCount += stats.memoTypeStats.linkCount;
|
||||||
|
memoTypeStats.todoCount += stats.memoTypeStats.todoCount;
|
||||||
|
memoTypeStats.undoCount += stats.memoTypeStats.undoCount;
|
||||||
}
|
}
|
||||||
if (property?.hasTaskList) {
|
}
|
||||||
memoStats.taskList += 1;
|
setMemoTypeStats(memoTypeStats);
|
||||||
}
|
setMemoAmount(displayTimeList.length);
|
||||||
if (property?.hasCode) {
|
setActivityStats(countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD"))));
|
||||||
memoStats.code += 1;
|
}, [userStatsStore.stateId]);
|
||||||
}
|
|
||||||
if (property?.hasIncompleteTasks) {
|
|
||||||
memoStats.incompleteTasks += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setMemoStats(memoStats);
|
|
||||||
setMemoAmount(metadataList.length);
|
|
||||||
setActivityStats(countBy(metadataList.map((memo) => dayjs(memo.displayTime).format("YYYY-MM-DD"))));
|
|
||||||
}, [memoMetadataStore.stateId]);
|
|
||||||
|
|
||||||
const onCalendarClick = (date: string) => {
|
const onCalendarClick = (date: string) => {
|
||||||
memoFilterStore.removeFilter((f) => f.factor === "displayTime");
|
memoFilterStore.removeFilter((f) => f.factor === "displayTime");
|
||||||
@@ -110,26 +98,26 @@ const UserStatisticsView = () => {
|
|||||||
<LinkIcon className="w-4 h-auto mr-1" />
|
<LinkIcon className="w-4 h-auto mr-1" />
|
||||||
<span className="block text-sm">{t("memo.links")}</span>
|
<span className="block text-sm">{t("memo.links")}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm truncate">{memoStats.link}</span>
|
<span className="text-sm truncate">{memoTypeStats.linkCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={clsx("w-auto border dark:border-zinc-800 pl-1 pr-1.5 rounded-md flex justify-between items-center")}
|
className={clsx("w-auto border dark:border-zinc-800 pl-1 pr-1.5 rounded-md flex justify-between items-center")}
|
||||||
onClick={() => memoFilterStore.addFilter({ factor: "property.hasTaskList", value: "" })}
|
onClick={() => memoFilterStore.addFilter({ factor: "property.hasTaskList", value: "" })}
|
||||||
>
|
>
|
||||||
<div className="w-auto flex justify-start items-center mr-1">
|
<div className="w-auto flex justify-start items-center mr-1">
|
||||||
{memoStats.incompleteTasks > 0 ? <ListTodoIcon className="w-4 h-auto mr-1" /> : <CheckCircleIcon className="w-4 h-auto mr-1" />}
|
{memoTypeStats.undoCount > 0 ? <ListTodoIcon className="w-4 h-auto mr-1" /> : <CheckCircleIcon className="w-4 h-auto mr-1" />}
|
||||||
<span className="block text-sm">{t("memo.to-do")}</span>
|
<span className="block text-sm">{t("memo.to-do")}</span>
|
||||||
</div>
|
</div>
|
||||||
{memoStats.incompleteTasks > 0 ? (
|
{memoTypeStats.undoCount > 0 ? (
|
||||||
<Tooltip title={"Done / Total"} placement="top" arrow>
|
<Tooltip title={"Done / Total"} placement="top" arrow>
|
||||||
<div className="text-sm flex flex-row items-start justify-center">
|
<div className="text-sm flex flex-row items-start justify-center">
|
||||||
<span className="truncate">{memoStats.taskList - memoStats.incompleteTasks}</span>
|
<span className="truncate">{memoTypeStats.todoCount - memoTypeStats.undoCount}</span>
|
||||||
<span className="font-mono opacity-50">/</span>
|
<span className="font-mono opacity-50">/</span>
|
||||||
<span className="truncate">{memoStats.taskList}</span>
|
<span className="truncate">{memoTypeStats.todoCount}</span>
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm truncate">{memoStats.taskList}</span>
|
<span className="text-sm truncate">{memoTypeStats.todoCount}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -140,11 +128,11 @@ const UserStatisticsView = () => {
|
|||||||
<Code2Icon className="w-4 h-auto mr-1" />
|
<Code2Icon className="w-4 h-auto mr-1" />
|
||||||
<span className="block text-sm">{t("memo.code")}</span>
|
<span className="block text-sm">{t("memo.code")}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm truncate">{memoStats.code}</span>
|
<span className="text-sm truncate">{memoTypeStats.codeCount}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default UserStatisticsView;
|
export default StatisticsView;
|
@@ -5,4 +5,4 @@ export * from "./resourceName";
|
|||||||
export * from "./resource";
|
export * from "./resource";
|
||||||
export * from "./workspaceSetting";
|
export * from "./workspaceSetting";
|
||||||
export * from "./memoFilter";
|
export * from "./memoFilter";
|
||||||
export * from "./memoMetadata";
|
export * from "./userStats";
|
||||||
|
@@ -1,76 +0,0 @@
|
|||||||
import { uniqueId } from "lodash-es";
|
|
||||||
import { Location } from "react-router-dom";
|
|
||||||
import { create } from "zustand";
|
|
||||||
import { combine } from "zustand/middleware";
|
|
||||||
import { memoServiceClient } from "@/grpcweb";
|
|
||||||
import { Routes } from "@/router";
|
|
||||||
import { Memo, MemoView } from "@/types/proto/api/v1/memo_service";
|
|
||||||
import { User } from "@/types/proto/api/v1/user_service";
|
|
||||||
|
|
||||||
// Set the maximum number of memos to fetch.
|
|
||||||
const DEFAULT_MEMO_PAGE_SIZE = 1000000;
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
// stateId is used to identify the store instance state.
|
|
||||||
// It should be update when any state change.
|
|
||||||
stateId: string;
|
|
||||||
dataMapByName: Record<string, Memo>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDefaultState = (): State => ({
|
|
||||||
stateId: uniqueId(),
|
|
||||||
dataMapByName: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useMemoMetadataStore = create(
|
|
||||||
combine(getDefaultState(), (set, get) => ({
|
|
||||||
setState: (state: State) => set(state),
|
|
||||||
getState: () => get(),
|
|
||||||
fetchMemoMetadata: async (params: { user?: User; location?: Location<any> }) => {
|
|
||||||
const filters = [`state == "NORMAL"`];
|
|
||||||
if (params.user) {
|
|
||||||
if (params.location?.pathname === Routes.EXPLORE) {
|
|
||||||
filters.push(`visibilities == ["PUBLIC", "PROTECTED"]`);
|
|
||||||
}
|
|
||||||
filters.push(`creator == "${params.user.name}"`);
|
|
||||||
} else {
|
|
||||||
filters.push(`visibilities == ["PUBLIC"]`);
|
|
||||||
}
|
|
||||||
const { memos, nextPageToken } = await memoServiceClient.listMemos({
|
|
||||||
filter: filters.join(" && "),
|
|
||||||
view: MemoView.MEMO_VIEW_METADATA_ONLY,
|
|
||||||
pageSize: DEFAULT_MEMO_PAGE_SIZE,
|
|
||||||
});
|
|
||||||
const memoMap = memos.reduce<Record<string, Memo>>(
|
|
||||||
(acc, memo) => ({
|
|
||||||
...acc,
|
|
||||||
[memo.name]: memo,
|
|
||||||
}),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
set({ stateId: uniqueId(), dataMapByName: memoMap });
|
|
||||||
return { memos, nextPageToken };
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const useMemoTagList = () => {
|
|
||||||
const memoStore = useMemoMetadataStore();
|
|
||||||
const memos = Object.values(memoStore.getState().dataMapByName);
|
|
||||||
const tagAmounts: Record<string, number> = {};
|
|
||||||
memos.forEach((memo) => {
|
|
||||||
const tagSet = new Set<string>();
|
|
||||||
for (const tag of memo.tags) {
|
|
||||||
const parts = tag.split("/");
|
|
||||||
let currentTag = "";
|
|
||||||
for (const part of parts) {
|
|
||||||
currentTag = currentTag ? `${currentTag}/${part}` : part;
|
|
||||||
tagSet.add(currentTag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Array.from(tagSet).forEach((tag) => {
|
|
||||||
tagAmounts[tag] = tagAmounts[tag] ? tagAmounts[tag] + 1 : 1;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return tagAmounts;
|
|
||||||
};
|
|
48
web/src/store/v1/userStats.ts
Normal file
48
web/src/store/v1/userStats.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { uniqueId } from "lodash-es";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import { combine } from "zustand/middleware";
|
||||||
|
import { userServiceClient } from "@/grpcweb";
|
||||||
|
import { UserStats } from "@/types/proto/api/v1/user_service";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
// stateId is used to identify the store instance state.
|
||||||
|
// It should be update when any state change.
|
||||||
|
stateId: string;
|
||||||
|
userStatsByName: Record<string, UserStats>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDefaultState = (): State => ({
|
||||||
|
stateId: uniqueId(),
|
||||||
|
userStatsByName: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useUserStatsStore = create(
|
||||||
|
combine(getDefaultState(), (set, get) => ({
|
||||||
|
setState: (state: State) => set(state),
|
||||||
|
getState: () => get(),
|
||||||
|
listUserStats: async (user?: string, filter?: string) => {
|
||||||
|
const userStatsByName: Record<string, UserStats> = {};
|
||||||
|
if (!user) {
|
||||||
|
const { userStats } = await userServiceClient.listAllUserStats({ filter });
|
||||||
|
for (const stats of userStats) {
|
||||||
|
userStatsByName[stats.name] = stats;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const userStats = await userServiceClient.getUserStats({ name: user, filter });
|
||||||
|
userStatsByName[user] = userStats;
|
||||||
|
}
|
||||||
|
set({ stateId: uniqueId(), userStatsByName });
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useUserStatsTags = () => {
|
||||||
|
const userStatsStore = useUserStatsStore();
|
||||||
|
const tagAmounts: Record<string, number> = {};
|
||||||
|
for (const userStats of Object.values(userStatsStore.getState().userStatsByName)) {
|
||||||
|
for (const tag of Object.keys(userStats.tagCount)) {
|
||||||
|
tagAmounts[tag] = (tagAmounts[tag] || 0) + userStats.tagCount[tag];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tagAmounts;
|
||||||
|
};
|
Reference in New Issue
Block a user