From ee96465be06f0e69880ab5443676928cb8cb4bd2 Mon Sep 17 00:00:00 2001 From: johnnyjoy Date: Mon, 13 Jan 2025 23:14:44 +0800 Subject: [PATCH] feat: list user stats --- proto/api/v1/user_service.proto | 5 +- proto/gen/api/v1/user_service.pb.go | 38 ++++-- proto/gen/apidocs.swagger.yaml | 7 +- server/router/api/v1/acl_config.go | 2 +- server/router/api/v1/user_service.go | 94 ------------- server/router/api/v1/user_service_stats.go | 124 ++++++++++++++++++ .../ExploreSidebar/ExploreSidebar.tsx | 17 +-- .../components/HomeSidebar/HomeSidebar.tsx | 17 ++- .../components/HomeSidebar/TagsSection.tsx | 12 +- .../MemoEditor/ActionButton/TagSelector.tsx | 4 +- .../MemoEditor/Editor/TagSuggestions.tsx | 4 +- .../PagedMemoList/PagedMemoList.tsx | 14 +- web/src/components/RenameTagDialog.tsx | 6 +- ...rStatisticsView.tsx => StatisticsView.tsx} | 66 ++++------ web/src/store/v1/index.ts | 2 +- web/src/store/v1/memoMetadata.ts | 76 ----------- web/src/store/v1/userStats.ts | 48 +++++++ 17 files changed, 266 insertions(+), 270 deletions(-) create mode 100644 server/router/api/v1/user_service_stats.go rename web/src/components/{UserStatisticsView.tsx => StatisticsView.tsx} (74%) delete mode 100644 web/src/store/v1/memoMetadata.ts create mode 100644 web/src/store/v1/userStats.ts diff --git a/proto/api/v1/user_service.proto b/proto/api/v1/user_service.proto index b5b21df3..882766a6 100644 --- a/proto/api/v1/user_service.proto +++ b/proto/api/v1/user_service.proto @@ -192,8 +192,9 @@ message UserStats { message MemoTypeStats { int32 link_count = 1; - int32 task_count = 2; - int32 code_count = 3; + int32 code_count = 2; + int32 todo_count = 3; + int32 undo_count = 4; } } diff --git a/proto/gen/api/v1/user_service.pb.go b/proto/gen/api/v1/user_service.pb.go index 8f6be34a..fec598b4 100644 --- a/proto/gen/api/v1/user_service.pb.go +++ b/proto/gen/api/v1/user_service.pb.go @@ -1286,8 +1286,9 @@ func (x *DeleteUserAccessTokenRequest) GetAccessToken() string { type UserStats_MemoTypeStats struct { state protoimpl.MessageState `protogen:"open.v1"` 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,3,opt,name=code_count,json=codeCount,proto3" json:"code_count,omitempty"` + CodeCount int32 `protobuf:"varint,2,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 sizeCache protoimpl.SizeCache } @@ -1329,16 +1330,23 @@ func (x *UserStats_MemoTypeStats) GetLinkCount() int32 { return 0 } -func (x *UserStats_MemoTypeStats) GetTaskCount() int32 { +func (x *UserStats_MemoTypeStats) GetCodeCount() int32 { if x != nil { - return x.TaskCount + return x.CodeCount } return 0 } -func (x *UserStats_MemoTypeStats) GetCodeCount() int32 { +func (x *UserStats_MemoTypeStats) GetTodoCount() int32 { 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 } @@ -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, 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, - 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, 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, @@ -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, 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, - 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x6c, 0x0a, 0x0d, 0x4d, 0x65, 0x6d, 0x6f, 0x54, - 0x79, 0x70, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6c, 0x69, 0x6e, 0x6b, - 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x6c, 0x69, - 0x6e, 0x6b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x61, 0x73, 0x6b, 0x5f, - 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x61, 0x73, - 0x6b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x64, 0x65, 0x5f, 0x63, - 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x63, 0x6f, 0x64, 0x65, + 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x1a, 0x8b, 0x01, 0x0a, 0x0d, 0x4d, 0x65, 0x6d, 0x6f, + 0x54, 0x79, 0x70, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6c, 0x69, 0x6e, + 0x6b, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x6c, + 0x69, 0x6e, 0x6b, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x64, 0x65, + 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x63, 0x6f, + 0x64, 0x65, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x6f, 0x64, 0x6f, 0x5f, + 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, 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, diff --git a/proto/gen/apidocs.swagger.yaml b/proto/gen/apidocs.swagger.yaml index 5f180fff..4b7fc7a2 100644 --- a/proto/gen/apidocs.swagger.yaml +++ b/proto/gen/apidocs.swagger.yaml @@ -1868,10 +1868,13 @@ definitions: linkCount: type: integer format: int32 - taskCount: + codeCount: type: integer format: int32 - codeCount: + todoCount: + type: integer + format: int32 + undoCount: type: integer format: int32 WorkspaceStorageSettingS3Config: diff --git a/server/router/api/v1/acl_config.go b/server/router/api/v1/acl_config.go index a3332002..7ec9685e 100644 --- a/server/router/api/v1/acl_config.go +++ b/server/router/api/v1/acl_config.go @@ -13,7 +13,7 @@ var authenticationAllowlistMethods = map[string]bool{ "/memos.api.v1.AuthService/SignUp": true, "/memos.api.v1.UserService/GetUser": 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.MemoService/GetMemo": true, "/memos.api.v1.MemoService/GetMemoByUid": true, diff --git a/server/router/api/v1/user_service.go b/server/router/api/v1/user_service.go index d635e015..b2e68a01 100644 --- a/server/router/api/v1/user_service.go +++ b/server/router/api/v1/user_service.go @@ -275,100 +275,6 @@ func (s *APIV1Service) DeleteUser(ctx context.Context, request *v1pb.DeleteUserR 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 { defaultVisibility := "PRIVATE" if workspaceMemoRelatedSetting.DefaultVisibility != "" { diff --git a/server/router/api/v1/user_service_stats.go b/server/router/api/v1/user_service_stats.go new file mode 100644 index 00000000..b17c00d4 --- /dev/null +++ b/server/router/api/v1/user_service_stats.go @@ -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 +} diff --git a/web/src/components/ExploreSidebar/ExploreSidebar.tsx b/web/src/components/ExploreSidebar/ExploreSidebar.tsx index a550d5db..b6beceae 100644 --- a/web/src/components/ExploreSidebar/ExploreSidebar.tsx +++ b/web/src/components/ExploreSidebar/ExploreSidebar.tsx @@ -1,26 +1,26 @@ import clsx from "clsx"; -import { useLocation } from "react-router-dom"; import useDebounce from "react-use/lib/useDebounce"; 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 StatisticsView from "../StatisticsView"; interface Props { className?: string; } const ExploreSidebar = (props: Props) => { - const location = useLocation(); - const memoList = useMemoList(); - const memoMetadataStore = useMemoMetadataStore(); + const currentUser = useCurrentUser(); + const userStatsStore = useUserStatsStore(); useDebounce( async () => { - if (memoList.size() === 0) return; - await memoMetadataStore.fetchMemoMetadata({ location }); + const filters = [`state == "NORMAL"`, `visibilities == [${currentUser ? "'PUBLIC', 'PROTECTED'" : "'PUBLIC'"}]`]; + userStatsStore.listUserStats(undefined, filters.join(" && ")); }, 300, - [memoList.size(), location.pathname], + [], ); return ( @@ -31,6 +31,7 @@ const ExploreSidebar = (props: Props) => { )} > + ); diff --git a/web/src/components/HomeSidebar/HomeSidebar.tsx b/web/src/components/HomeSidebar/HomeSidebar.tsx index 11a9d292..48de9f36 100644 --- a/web/src/components/HomeSidebar/HomeSidebar.tsx +++ b/web/src/components/HomeSidebar/HomeSidebar.tsx @@ -1,10 +1,9 @@ import clsx from "clsx"; -import { useLocation } from "react-router-dom"; import useDebounce from "react-use/lib/useDebounce"; import SearchBar from "@/components/SearchBar"; -import UserStatisticsView from "@/components/UserStatisticsView"; +import StatisticsView from "@/components/StatisticsView"; import useCurrentUser from "@/hooks/useCurrentUser"; -import { useMemoList, useMemoMetadataStore } from "@/store/v1"; +import { useMemoList, useUserStatsStore } from "@/store/v1"; import TagsSection from "./TagsSection"; interface Props { @@ -12,17 +11,17 @@ interface Props { } const HomeSidebar = (props: Props) => { - const location = useLocation(); - const user = useCurrentUser(); + const currentUser = useCurrentUser(); const memoList = useMemoList(); - const memoMetadataStore = useMemoMetadataStore(); + const userStatsStore = useUserStatsStore(); useDebounce( async () => { - await memoMetadataStore.fetchMemoMetadata({ user, location }); + const filters = [`state == "NORMAL"`]; + await userStatsStore.listUserStats(currentUser.name, filters.join(" && ")); }, 300, - [memoList.size(), user, location.pathname], + [memoList.size(), currentUser], ); return ( @@ -33,7 +32,7 @@ const HomeSidebar = (props: Props) => { )} > - + ); diff --git a/web/src/components/HomeSidebar/TagsSection.tsx b/web/src/components/HomeSidebar/TagsSection.tsx index 7a078cf9..eda92789 100644 --- a/web/src/components/HomeSidebar/TagsSection.tsx +++ b/web/src/components/HomeSidebar/TagsSection.tsx @@ -2,11 +2,10 @@ import { Dropdown, Menu, MenuButton, MenuItem, Switch } from "@mui/joy"; import clsx from "clsx"; import { Edit3Icon, HashIcon, MoreVerticalIcon, TagsIcon, TrashIcon } from "lucide-react"; import toast from "react-hot-toast"; -import { useLocation } from "react-router-dom"; import useLocalStorage from "react-use/lib/useLocalStorage"; import { memoServiceClient } from "@/grpcweb"; 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 showRenameTagDialog from "../RenameTagDialog"; import TagTree from "../TagTree"; @@ -18,12 +17,11 @@ interface Props { const TagsSection = (props: Props) => { const t = useTranslate(); - const location = useLocation(); - const user = useCurrentUser(); + const currentUser = useCurrentUser(); const memoFilterStore = useMemoFilterStore(); - const memoMetadataStore = useMemoMetadataStore(); + const userStatsStore = useUserStatsStore(); const [treeMode, setTreeMode] = useLocalStorage("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) => b[1] - a[1]); @@ -46,7 +44,7 @@ const TagsSection = (props: Props) => { parent: "memos/-", tag: tag, }); - await memoMetadataStore.fetchMemoMetadata({ user, location }); + await userStatsStore.listUserStats(currentUser.name); toast.success(t("message.deleted-successfully")); } }; diff --git a/web/src/components/MemoEditor/ActionButton/TagSelector.tsx b/web/src/components/MemoEditor/ActionButton/TagSelector.tsx index 2408e21a..1810dc60 100644 --- a/web/src/components/MemoEditor/ActionButton/TagSelector.tsx +++ b/web/src/components/MemoEditor/ActionButton/TagSelector.tsx @@ -4,7 +4,7 @@ import { HashIcon } from "lucide-react"; import { useRef, useState } from "react"; import useClickAway from "react-use/lib/useClickAway"; import OverflowTip from "@/components/kit/OverflowTip"; -import { useMemoTagList } from "@/store/v1"; +import { useUserStatsTags } from "@/store/v1"; import { useTranslate } from "@/utils/i18n"; import { EditorRefActions } from "../Editor"; @@ -17,7 +17,7 @@ const TagSelector = (props: Props) => { const { editorRef } = props; const [open, setOpen] = useState(false); const containerRef = useRef(null); - const tags = Object.entries(useMemoTagList()) + const tags = Object.entries(useUserStatsTags()) .sort((a, b) => a[0].localeCompare(b[0])) .sort((a, b) => b[1] - a[1]) .map(([tag]) => tag); diff --git a/web/src/components/MemoEditor/Editor/TagSuggestions.tsx b/web/src/components/MemoEditor/Editor/TagSuggestions.tsx index cd0a5740..9ac212e1 100644 --- a/web/src/components/MemoEditor/Editor/TagSuggestions.tsx +++ b/web/src/components/MemoEditor/Editor/TagSuggestions.tsx @@ -3,7 +3,7 @@ import Fuse from "fuse.js"; import { useEffect, useRef, useState } from "react"; import getCaretCoordinates from "textarea-caret"; import OverflowTip from "@/components/kit/OverflowTip"; -import { useMemoTagList } from "@/store/v1"; +import { useUserStatsTags } from "@/store/v1"; import { EditorRefActions } from "."; type Props = { @@ -18,7 +18,7 @@ const TagSuggestions = ({ editorRef, editorActions }: Props) => { const [selected, select] = useState(0); const selectedRef = useRef(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) => b[1] - a[1]) .map(([tag]) => tag); diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index e12c8a95..360e8ffe 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -1,10 +1,9 @@ import { Button } from "@usememos/mui"; 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 { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts"; import useResponsiveWidth from "@/hooks/useResponsiveWidth"; -import { Routes } from "@/router"; import { useMemoList, useMemoStore } from "@/store/v1"; import { Memo } from "@/types/proto/api/v1/memo_service"; import { useTranslate } from "@/utils/i18n"; @@ -27,15 +26,10 @@ const PagedMemoList = (props: Props) => { const { md } = useResponsiveWidth(); const memoStore = useMemoStore(); const memoList = useMemoList(); - const containerRef = useRef(null); const [state, setState] = useState({ isRequesting: true, // Initial request 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 fetchMoreMemos = async (nextPageToken: string) => { @@ -62,7 +56,7 @@ const PagedMemoList = (props: Props) => { }, [props.filter, props.pageSize]); const children = ( -
+
{sortedMemoList.map((memo) => props.renderer(memo))} {state.isRequesting && (
@@ -84,10 +78,10 @@ const PagedMemoList = (props: Props) => { {t("memo.load-more")} - {shouldShowBackToTop && } + )} - {shouldShowBackToTop && } +
)} diff --git a/web/src/components/RenameTagDialog.tsx b/web/src/components/RenameTagDialog.tsx index 3e14d95b..dbb1a89d 100644 --- a/web/src/components/RenameTagDialog.tsx +++ b/web/src/components/RenameTagDialog.tsx @@ -6,7 +6,7 @@ import { toast } from "react-hot-toast"; import { memoServiceClient } from "@/grpcweb"; import useCurrentUser from "@/hooks/useCurrentUser"; import useLoading from "@/hooks/useLoading"; -import { useMemoMetadataStore } from "@/store/v1"; +import { useUserStatsStore } from "@/store/v1"; import { useTranslate } from "@/utils/i18n"; import { generateDialog } from "./Dialog"; @@ -17,7 +17,7 @@ interface Props extends DialogProps { const RenameTagDialog: React.FC = (props: Props) => { const { tag, destroy } = props; const t = useTranslate(); - const memoMetadataStore = useMemoMetadataStore(); + const userStatsStore = useUserStatsStore(); const [newName, setNewName] = useState(tag); const requestState = useLoading(false); const user = useCurrentUser(); @@ -43,7 +43,7 @@ const RenameTagDialog: React.FC = (props: Props) => { newTag: newName, }); toast.success("Rename tag successfully"); - memoMetadataStore.fetchMemoMetadata({ user }); + userStatsStore.listUserStats(user.name); } catch (error: any) { console.error(error); toast.error(error.details); diff --git a/web/src/components/UserStatisticsView.tsx b/web/src/components/StatisticsView.tsx similarity index 74% rename from web/src/components/UserStatisticsView.tsx rename to web/src/components/StatisticsView.tsx index 4a8cb872..bbd69191 100644 --- a/web/src/components/UserStatisticsView.tsx +++ b/web/src/components/StatisticsView.tsx @@ -7,25 +7,18 @@ import { useState } from "react"; import useAsyncEffect from "@/hooks/useAsyncEffect"; import useCurrentUser from "@/hooks/useCurrentUser"; 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 ActivityCalendar from "./ActivityCalendar"; -interface UserMemoStats { - link: number; - taskList: number; - code: number; - incompleteTasks: number; -} - -const UserStatisticsView = () => { +const StatisticsView = () => { const t = useTranslate(); const currentUser = useCurrentUser(); const memoFilterStore = useMemoFilterStore(); - const memoMetadataStore = useMemoMetadataStore(); - const metadataList = Object.values(memoMetadataStore.getState().dataMapByName); + const userStatsStore = useUserStatsStore(); const [memoAmount, setMemoAmount] = useState(0); - const [memoStats, setMemoStats] = useState({ link: 0, taskList: 0, code: 0, incompleteTasks: 0 }); + const [memoTypeStats, setMemoTypeStats] = useState(UserStats_MemoTypeStats.fromPartial({})); const [activityStats, setActivityStats] = useState>({}); const [selectedDate] = useState(new Date()); 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(); useAsyncEffect(async () => { - const memoStats: UserMemoStats = { link: 0, taskList: 0, code: 0, incompleteTasks: 0 }; - metadataList.forEach((memo) => { - const { property } = memo; - if (property?.hasLink) { - memoStats.link += 1; - } - if (property?.hasTaskList) { - memoStats.taskList += 1; - } - if (property?.hasCode) { - memoStats.code += 1; - } - if (property?.hasIncompleteTasks) { - memoStats.incompleteTasks += 1; + const memoTypeStats = UserStats_MemoTypeStats.fromPartial({}); + const displayTimeList: Date[] = []; + for (const stats of Object.values(userStatsStore.userStatsByName)) { + displayTimeList.push(...stats.memoDisplayTimestamps); + if (stats.memoTypeStats) { + memoTypeStats.codeCount += stats.memoTypeStats.codeCount; + memoTypeStats.linkCount += stats.memoTypeStats.linkCount; + memoTypeStats.todoCount += stats.memoTypeStats.todoCount; + memoTypeStats.undoCount += stats.memoTypeStats.undoCount; } - }); - setMemoStats(memoStats); - setMemoAmount(metadataList.length); - setActivityStats(countBy(metadataList.map((memo) => dayjs(memo.displayTime).format("YYYY-MM-DD")))); - }, [memoMetadataStore.stateId]); + } + setMemoTypeStats(memoTypeStats); + setMemoAmount(displayTimeList.length); + setActivityStats(countBy(displayTimeList.map((date) => dayjs(date).format("YYYY-MM-DD")))); + }, [userStatsStore.stateId]); const onCalendarClick = (date: string) => { memoFilterStore.removeFilter((f) => f.factor === "displayTime"); @@ -110,26 +98,26 @@ const UserStatisticsView = () => { {t("memo.links")}
- {memoStats.link} + {memoTypeStats.linkCount}
memoFilterStore.addFilter({ factor: "property.hasTaskList", value: "" })} >
- {memoStats.incompleteTasks > 0 ? : } + {memoTypeStats.undoCount > 0 ? : } {t("memo.to-do")}
- {memoStats.incompleteTasks > 0 ? ( + {memoTypeStats.undoCount > 0 ? (
- {memoStats.taskList - memoStats.incompleteTasks} + {memoTypeStats.todoCount - memoTypeStats.undoCount} / - {memoStats.taskList} + {memoTypeStats.todoCount}
) : ( - {memoStats.taskList} + {memoTypeStats.todoCount} )}
{ {t("memo.code")}
- {memoStats.code} + {memoTypeStats.codeCount} ); }; -export default UserStatisticsView; +export default StatisticsView; diff --git a/web/src/store/v1/index.ts b/web/src/store/v1/index.ts index 9cc46daa..5ca084fb 100644 --- a/web/src/store/v1/index.ts +++ b/web/src/store/v1/index.ts @@ -5,4 +5,4 @@ export * from "./resourceName"; export * from "./resource"; export * from "./workspaceSetting"; export * from "./memoFilter"; -export * from "./memoMetadata"; +export * from "./userStats"; diff --git a/web/src/store/v1/memoMetadata.ts b/web/src/store/v1/memoMetadata.ts deleted file mode 100644 index 22b88f20..00000000 --- a/web/src/store/v1/memoMetadata.ts +++ /dev/null @@ -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; -} - -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 }) => { - 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>( - (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 = {}; - memos.forEach((memo) => { - const tagSet = new Set(); - 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; -}; diff --git a/web/src/store/v1/userStats.ts b/web/src/store/v1/userStats.ts new file mode 100644 index 00000000..8fc93bf1 --- /dev/null +++ b/web/src/store/v1/userStats.ts @@ -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; +} + +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 = {}; + 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 = {}; + 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; +};