mirror of https://github.com/usememos/memos
feat: list user stats
parent
cde058c72a
commit
ee96465be0
@ -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,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;
|
||||
};
|
@ -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;
|
||||
};
|
Loading…
Reference in New Issue