mirror of https://github.com/usememos/memos.git
885 lines
29 KiB
Go
885 lines
29 KiB
Go
package v1
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/lithammer/shortuuid/v4"
|
||
"github.com/pkg/errors"
|
||
"github.com/usememos/gomark"
|
||
"github.com/usememos/gomark/ast"
|
||
"github.com/usememos/gomark/renderer"
|
||
"google.golang.org/grpc/codes"
|
||
"google.golang.org/grpc/status"
|
||
"google.golang.org/protobuf/types/known/emptypb"
|
||
|
||
v1pb "github.com/usememos/memos/proto/gen/api/v1"
|
||
storepb "github.com/usememos/memos/proto/gen/store"
|
||
"github.com/usememos/memos/server/runner/memopayload"
|
||
"github.com/usememos/memos/store"
|
||
)
|
||
|
||
func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoRequest) (*v1pb.Memo, error) {
|
||
user, err := s.GetCurrentUser(ctx)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get user")
|
||
}
|
||
|
||
create := &store.Memo{
|
||
UID: shortuuid.New(),
|
||
CreatorID: user.ID,
|
||
Content: request.Memo.Content,
|
||
Visibility: convertVisibilityToStore(request.Memo.Visibility),
|
||
}
|
||
workspaceMemoRelatedSetting, err := s.Store.GetWorkspaceMemoRelatedSetting(ctx)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get workspace memo related setting")
|
||
}
|
||
if workspaceMemoRelatedSetting.DisallowPublicVisibility && create.Visibility == store.Public {
|
||
return nil, status.Errorf(codes.PermissionDenied, "disable public memos system setting is enabled")
|
||
}
|
||
contentLengthLimit, err := s.getContentLengthLimit(ctx)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get content length limit")
|
||
}
|
||
if len(create.Content) > contentLengthLimit {
|
||
return nil, status.Errorf(codes.InvalidArgument, "content too long (max %d characters)", contentLengthLimit)
|
||
}
|
||
if err := memopayload.RebuildMemoPayload(create); err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err)
|
||
}
|
||
if request.Memo.Location != nil {
|
||
create.Payload.Location = convertLocationToStore(request.Memo.Location)
|
||
}
|
||
|
||
memo, err := s.Store.CreateMemo(ctx, create)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
attachments := []*store.Attachment{}
|
||
|
||
if len(request.Memo.Attachments) > 0 {
|
||
_, err := s.SetMemoAttachments(ctx, &v1pb.SetMemoAttachmentsRequest{
|
||
Name: fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID),
|
||
Attachments: request.Memo.Attachments,
|
||
})
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "failed to set memo attachments")
|
||
}
|
||
|
||
a, err := s.Store.ListAttachments(ctx, &store.FindAttachment{
|
||
MemoID: &memo.ID,
|
||
})
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "failed to get memo attachments")
|
||
}
|
||
attachments = a
|
||
}
|
||
if len(request.Memo.Relations) > 0 {
|
||
_, err := s.SetMemoRelations(ctx, &v1pb.SetMemoRelationsRequest{
|
||
Name: fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID),
|
||
Relations: request.Memo.Relations,
|
||
})
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "failed to set memo relations")
|
||
}
|
||
}
|
||
|
||
memoMessage, err := s.convertMemoFromStore(ctx, memo, nil, attachments)
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "failed to convert memo")
|
||
}
|
||
// Try to dispatch webhook when memo is created.
|
||
if err := s.DispatchMemoCreatedWebhook(ctx, memoMessage); err != nil {
|
||
slog.Warn("Failed to dispatch memo created webhook", slog.Any("err", err))
|
||
}
|
||
|
||
return memoMessage, nil
|
||
}
|
||
|
||
func (s *APIV1Service) ListMemos(ctx context.Context, request *v1pb.ListMemosRequest) (*v1pb.ListMemosResponse, error) {
|
||
memoFind := &store.FindMemo{
|
||
// Exclude comments by default.
|
||
ExcludeComments: true,
|
||
}
|
||
if request.State == v1pb.State_ARCHIVED {
|
||
state := store.Archived
|
||
memoFind.RowStatus = &state
|
||
} else {
|
||
state := store.Normal
|
||
memoFind.RowStatus = &state
|
||
}
|
||
|
||
// Parse order_by field (replaces the old sort and direction fields)
|
||
if request.OrderBy != "" {
|
||
if err := s.parseMemoOrderBy(request.OrderBy, memoFind); err != nil {
|
||
return nil, status.Errorf(codes.InvalidArgument, "invalid order_by: %v", err)
|
||
}
|
||
} else {
|
||
// Default ordering by display_time desc
|
||
memoFind.OrderByTimeAsc = false
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
currentUser, err := s.GetCurrentUser(ctx)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get user")
|
||
}
|
||
if currentUser == nil {
|
||
memoFind.VisibilityList = []store.Visibility{store.Public}
|
||
} else {
|
||
if memoFind.CreatorID == nil {
|
||
filter := fmt.Sprintf(`creator_id == %d || visibility in ["PUBLIC", "PROTECTED"]`, currentUser.ID)
|
||
memoFind.Filters = append(memoFind.Filters, filter)
|
||
} else if *memoFind.CreatorID != currentUser.ID {
|
||
memoFind.VisibilityList = []store.Visibility{store.Public, store.Protected}
|
||
}
|
||
}
|
||
|
||
workspaceMemoRelatedSetting, err := s.Store.GetWorkspaceMemoRelatedSetting(ctx)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get workspace memo related setting")
|
||
}
|
||
if workspaceMemoRelatedSetting.DisplayWithUpdateTime {
|
||
memoFind.OrderByUpdatedTs = true
|
||
}
|
||
|
||
var limit, offset int
|
||
if request.PageToken != "" {
|
||
var pageToken v1pb.PageToken
|
||
if err := unmarshalPageToken(request.PageToken, &pageToken); err != nil {
|
||
return nil, status.Errorf(codes.InvalidArgument, "invalid page token: %v", err)
|
||
}
|
||
limit = int(pageToken.Limit)
|
||
offset = int(pageToken.Offset)
|
||
} else {
|
||
limit = int(request.PageSize)
|
||
}
|
||
if limit <= 0 {
|
||
limit = DefaultPageSize
|
||
}
|
||
limitPlusOne := limit + 1
|
||
memoFind.Limit = &limitPlusOne
|
||
memoFind.Offset = &offset
|
||
memos, err := s.Store.ListMemos(ctx, memoFind)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to list memos: %v", err)
|
||
}
|
||
|
||
memoMessages := []*v1pb.Memo{}
|
||
nextPageToken := ""
|
||
if len(memos) == limitPlusOne {
|
||
memos = memos[:limit]
|
||
nextPageToken, err = getPageToken(limit, offset+limit)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get next page token, error: %v", err)
|
||
}
|
||
}
|
||
|
||
if len(memos) == 0 {
|
||
response := &v1pb.ListMemosResponse{
|
||
Memos: memoMessages,
|
||
NextPageToken: nextPageToken,
|
||
}
|
||
return response, nil
|
||
}
|
||
|
||
reactionMap := make(map[string][]*store.Reaction)
|
||
memoNames := make([]string, 0, len(memos))
|
||
|
||
attachmentMap := make(map[int32][]*store.Attachment)
|
||
memoIDs := make([]string, 0, len(memos))
|
||
|
||
for _, m := range memos {
|
||
memoNames = append(memoNames, fmt.Sprintf("'%s%s'", MemoNamePrefix, m.UID))
|
||
memoIDs = append(memoIDs, fmt.Sprintf("'%d'", m.ID))
|
||
}
|
||
|
||
// REACTIONS
|
||
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{
|
||
Filters: []string{fmt.Sprintf("content_id in [%s]", strings.Join(memoNames, ", "))},
|
||
})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to list reactions")
|
||
}
|
||
for _, reaction := range reactions {
|
||
reactionMap[reaction.ContentID] = append(reactionMap[reaction.ContentID], reaction)
|
||
}
|
||
|
||
// ATTACHMENTS
|
||
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{
|
||
Filters: []string{fmt.Sprintf("memo_id in [%s]", strings.Join(memoIDs, ", "))},
|
||
})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to list attachments")
|
||
}
|
||
for _, attachment := range attachments {
|
||
attachmentMap[*attachment.MemoID] = append(attachmentMap[*attachment.MemoID], attachment)
|
||
}
|
||
|
||
for _, memo := range memos {
|
||
memoName := fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID)
|
||
reactions := reactionMap[memoName]
|
||
attachments := attachmentMap[memo.ID]
|
||
|
||
memoMessage, err := s.convertMemoFromStore(ctx, memo, reactions, attachments)
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "failed to convert memo")
|
||
}
|
||
|
||
memoMessages = append(memoMessages, memoMessage)
|
||
}
|
||
|
||
response := &v1pb.ListMemosResponse{
|
||
Memos: memoMessages,
|
||
NextPageToken: nextPageToken,
|
||
}
|
||
return response, nil
|
||
}
|
||
|
||
func (s *APIV1Service) GetMemo(ctx context.Context, request *v1pb.GetMemoRequest) (*v1pb.Memo, error) {
|
||
memoUID, err := ExtractMemoUIDFromName(request.Name)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
|
||
}
|
||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||
UID: &memoUID,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if memo == nil {
|
||
return nil, status.Errorf(codes.NotFound, "memo not found")
|
||
}
|
||
if memo.Visibility != store.Public {
|
||
user, err := s.GetCurrentUser(ctx)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get user")
|
||
}
|
||
if user == nil {
|
||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||
}
|
||
if memo.Visibility == store.Private && memo.CreatorID != user.ID {
|
||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||
}
|
||
}
|
||
|
||
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{
|
||
ContentID: &request.Name,
|
||
})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to list reactions")
|
||
}
|
||
|
||
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{
|
||
MemoID: &memo.ID,
|
||
})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to list attachments")
|
||
}
|
||
|
||
memoMessage, err := s.convertMemoFromStore(ctx, memo, reactions, attachments)
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "failed to convert memo")
|
||
}
|
||
return memoMessage, nil
|
||
}
|
||
|
||
func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoRequest) (*v1pb.Memo, error) {
|
||
memoUID, err := ExtractMemoUIDFromName(request.Memo.Name)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
|
||
}
|
||
if request.UpdateMask == nil || len(request.UpdateMask.Paths) == 0 {
|
||
return nil, status.Errorf(codes.InvalidArgument, "update mask is required")
|
||
}
|
||
|
||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if memo == nil {
|
||
return nil, status.Errorf(codes.NotFound, "memo not found")
|
||
}
|
||
|
||
user, err := s.GetCurrentUser(ctx)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get current user")
|
||
}
|
||
// Only the creator or admin can update the memo.
|
||
if memo.CreatorID != user.ID && !isSuperUser(user) {
|
||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||
}
|
||
|
||
update := &store.UpdateMemo{
|
||
ID: memo.ID,
|
||
}
|
||
for _, path := range request.UpdateMask.Paths {
|
||
if path == "content" {
|
||
contentLengthLimit, err := s.getContentLengthLimit(ctx)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get content length limit")
|
||
}
|
||
if len(request.Memo.Content) > contentLengthLimit {
|
||
return nil, status.Errorf(codes.InvalidArgument, "content too long (max %d characters)", contentLengthLimit)
|
||
}
|
||
memo.Content = request.Memo.Content
|
||
if err := memopayload.RebuildMemoPayload(memo); err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err)
|
||
}
|
||
update.Content = &memo.Content
|
||
update.Payload = memo.Payload
|
||
} else if path == "visibility" {
|
||
workspaceMemoRelatedSetting, err := s.Store.GetWorkspaceMemoRelatedSetting(ctx)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get workspace memo related setting")
|
||
}
|
||
visibility := convertVisibilityToStore(request.Memo.Visibility)
|
||
if workspaceMemoRelatedSetting.DisallowPublicVisibility && visibility == store.Public {
|
||
return nil, status.Errorf(codes.PermissionDenied, "disable public memos system setting is enabled")
|
||
}
|
||
update.Visibility = &visibility
|
||
} else if path == "pinned" {
|
||
update.Pinned = &request.Memo.Pinned
|
||
} else if path == "state" {
|
||
rowStatus := convertStateToStore(request.Memo.State)
|
||
update.RowStatus = &rowStatus
|
||
} else if path == "create_time" {
|
||
createdTs := request.Memo.CreateTime.AsTime().Unix()
|
||
update.CreatedTs = &createdTs
|
||
} else if path == "update_time" {
|
||
updatedTs := time.Now().Unix()
|
||
if request.Memo.UpdateTime != nil {
|
||
updatedTs = request.Memo.UpdateTime.AsTime().Unix()
|
||
}
|
||
update.UpdatedTs = &updatedTs
|
||
} else if path == "display_time" {
|
||
displayTs := request.Memo.DisplayTime.AsTime().Unix()
|
||
memoRelatedSetting, err := s.Store.GetWorkspaceMemoRelatedSetting(ctx)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get workspace memo related setting")
|
||
}
|
||
if memoRelatedSetting.DisplayWithUpdateTime {
|
||
update.UpdatedTs = &displayTs
|
||
} else {
|
||
update.CreatedTs = &displayTs
|
||
}
|
||
} else if path == "location" {
|
||
payload := memo.Payload
|
||
payload.Location = convertLocationToStore(request.Memo.Location)
|
||
update.Payload = payload
|
||
} else if path == "attachments" {
|
||
_, err := s.SetMemoAttachments(ctx, &v1pb.SetMemoAttachmentsRequest{
|
||
Name: request.Memo.Name,
|
||
Attachments: request.Memo.Attachments,
|
||
})
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "failed to set memo attachments")
|
||
}
|
||
} else if path == "relations" {
|
||
_, err := s.SetMemoRelations(ctx, &v1pb.SetMemoRelationsRequest{
|
||
Name: request.Memo.Name,
|
||
Relations: request.Memo.Relations,
|
||
})
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "failed to set memo relations")
|
||
}
|
||
}
|
||
}
|
||
|
||
if err = s.Store.UpdateMemo(ctx, update); err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to update memo")
|
||
}
|
||
|
||
memo, err = s.Store.GetMemo(ctx, &store.FindMemo{
|
||
ID: &memo.ID,
|
||
})
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "failed to get memo")
|
||
}
|
||
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{
|
||
ContentID: &request.Memo.Name,
|
||
})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to list reactions")
|
||
}
|
||
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{
|
||
MemoID: &memo.ID,
|
||
})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to list attachments")
|
||
}
|
||
|
||
memoMessage, err := s.convertMemoFromStore(ctx, memo, reactions, attachments)
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "failed to convert memo")
|
||
}
|
||
// Try to dispatch webhook when memo is updated.
|
||
if err := s.DispatchMemoUpdatedWebhook(ctx, memoMessage); err != nil {
|
||
slog.Warn("Failed to dispatch memo updated webhook", slog.Any("err", err))
|
||
}
|
||
|
||
return memoMessage, nil
|
||
}
|
||
|
||
func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoRequest) (*emptypb.Empty, error) {
|
||
memoUID, err := ExtractMemoUIDFromName(request.Name)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
|
||
}
|
||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{
|
||
UID: &memoUID,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if memo == nil {
|
||
return nil, status.Errorf(codes.NotFound, "memo not found")
|
||
}
|
||
|
||
user, err := s.GetCurrentUser(ctx)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get current user")
|
||
}
|
||
// Only the creator or admin can update the memo.
|
||
if memo.CreatorID != user.ID && !isSuperUser(user) {
|
||
return nil, status.Errorf(codes.PermissionDenied, "permission denied")
|
||
}
|
||
|
||
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{
|
||
ContentID: &request.Name,
|
||
})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to list reactions")
|
||
}
|
||
|
||
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{
|
||
MemoID: &memo.ID,
|
||
})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to list attachments")
|
||
}
|
||
|
||
if memoMessage, err := s.convertMemoFromStore(ctx, memo, reactions, attachments); err == nil {
|
||
// Try to dispatch webhook when memo is deleted.
|
||
if err := s.DispatchMemoDeletedWebhook(ctx, memoMessage); err != nil {
|
||
slog.Warn("Failed to dispatch memo deleted webhook", slog.Any("err", err))
|
||
}
|
||
}
|
||
|
||
if err = s.Store.DeleteMemo(ctx, &store.DeleteMemo{ID: memo.ID}); err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to delete memo")
|
||
}
|
||
|
||
// Delete memo relation
|
||
if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{MemoID: &memo.ID}); err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to delete memo relations")
|
||
}
|
||
|
||
// Delete related attachments.
|
||
for _, attachment := range attachments {
|
||
if err := s.Store.DeleteAttachment(ctx, &store.DeleteAttachment{ID: attachment.ID}); err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to delete attachment")
|
||
}
|
||
}
|
||
|
||
// Delete memo comments
|
||
commentType := store.MemoRelationComment
|
||
relations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{RelatedMemoID: &memo.ID, Type: &commentType})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to list memo comments")
|
||
}
|
||
for _, relation := range relations {
|
||
if err := s.Store.DeleteMemo(ctx, &store.DeleteMemo{ID: relation.MemoID}); err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to delete memo comment")
|
||
}
|
||
}
|
||
|
||
// Delete memo references
|
||
referenceType := store.MemoRelationReference
|
||
if err := s.Store.DeleteMemoRelation(ctx, &store.DeleteMemoRelation{RelatedMemoID: &memo.ID, Type: &referenceType}); err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to delete memo references")
|
||
}
|
||
|
||
return &emptypb.Empty{}, nil
|
||
}
|
||
|
||
func (s *APIV1Service) CreateMemoComment(ctx context.Context, request *v1pb.CreateMemoCommentRequest) (*v1pb.Memo, error) {
|
||
memoUID, err := ExtractMemoUIDFromName(request.Name)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
|
||
}
|
||
relatedMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get memo")
|
||
}
|
||
|
||
// Create the memo comment first.
|
||
memoComment, err := s.CreateMemo(ctx, &v1pb.CreateMemoRequest{Memo: request.Comment})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to create memo")
|
||
}
|
||
memoUID, err = ExtractMemoUIDFromName(memoComment.Name)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
|
||
}
|
||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get memo")
|
||
}
|
||
|
||
// Build the relation between the comment memo and the original memo.
|
||
_, err = s.Store.UpsertMemoRelation(ctx, &store.MemoRelation{
|
||
MemoID: memo.ID,
|
||
RelatedMemoID: relatedMemo.ID,
|
||
Type: store.MemoRelationComment,
|
||
})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to create memo relation")
|
||
}
|
||
creatorID, err := ExtractUserIDFromName(memoComment.Creator)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.InvalidArgument, "invalid memo creator")
|
||
}
|
||
if memoComment.Visibility != v1pb.Visibility_PRIVATE && creatorID != relatedMemo.CreatorID {
|
||
activity, err := s.Store.CreateActivity(ctx, &store.Activity{
|
||
CreatorID: creatorID,
|
||
Type: store.ActivityTypeMemoComment,
|
||
Level: store.ActivityLevelInfo,
|
||
Payload: &storepb.ActivityPayload{
|
||
MemoComment: &storepb.ActivityMemoCommentPayload{
|
||
MemoId: memo.ID,
|
||
RelatedMemoId: relatedMemo.ID,
|
||
},
|
||
},
|
||
})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to create activity")
|
||
}
|
||
if _, err := s.Store.CreateInbox(ctx, &store.Inbox{
|
||
SenderID: creatorID,
|
||
ReceiverID: relatedMemo.CreatorID,
|
||
Status: store.UNREAD,
|
||
Message: &storepb.InboxMessage{
|
||
Type: storepb.InboxMessage_MEMO_COMMENT,
|
||
ActivityId: &activity.ID,
|
||
},
|
||
}); err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to create inbox")
|
||
}
|
||
}
|
||
|
||
return memoComment, nil
|
||
}
|
||
|
||
func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListMemoCommentsRequest) (*v1pb.ListMemoCommentsResponse, error) {
|
||
memoUID, err := ExtractMemoUIDFromName(request.Name)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
|
||
}
|
||
memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &memoUID})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get memo")
|
||
}
|
||
|
||
currentUser, err := s.GetCurrentUser(ctx)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get user")
|
||
}
|
||
var memoFilter string
|
||
if currentUser == nil {
|
||
memoFilter = `visibility == "PUBLIC"`
|
||
} else {
|
||
memoFilter = fmt.Sprintf(`creator_id == %d || visibility in ["PUBLIC", "PROTECTED"]`, currentUser.ID)
|
||
}
|
||
memoRelationComment := store.MemoRelationComment
|
||
memoRelations, err := s.Store.ListMemoRelations(ctx, &store.FindMemoRelation{
|
||
RelatedMemoID: &memo.ID,
|
||
Type: &memoRelationComment,
|
||
MemoFilter: &memoFilter,
|
||
})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to list memo relations")
|
||
}
|
||
|
||
if len(memoRelations) == 0 {
|
||
response := &v1pb.ListMemoCommentsResponse{
|
||
Memos: []*v1pb.Memo{},
|
||
}
|
||
return response, nil
|
||
}
|
||
|
||
memoRelationIDs := make([]string, 0, len(memoRelations))
|
||
for _, m := range memoRelations {
|
||
memoRelationIDs = append(memoRelationIDs, fmt.Sprintf("%d", m.MemoID))
|
||
}
|
||
memos, err := s.Store.ListMemos(ctx, &store.FindMemo{
|
||
Filters: []string{fmt.Sprintf("id in [%s]", strings.Join(memoRelationIDs, ", "))},
|
||
})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to list memos")
|
||
}
|
||
|
||
memoIDToNameMap := make(map[int32]string)
|
||
memoNamesForQuery := make([]string, 0, len(memos))
|
||
memoIDsForQuery := make([]string, 0, len(memos))
|
||
|
||
for _, memo := range memos {
|
||
memoName := fmt.Sprintf("%s%s", MemoNamePrefix, memo.UID)
|
||
memoIDToNameMap[memo.ID] = memoName
|
||
memoNamesForQuery = append(memoNamesForQuery, fmt.Sprintf("'%s'", memoName))
|
||
memoIDsForQuery = append(memoIDsForQuery, fmt.Sprintf("'%d'", memo.ID))
|
||
}
|
||
reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{
|
||
Filters: []string{fmt.Sprintf("content_id in [%s]", strings.Join(memoNamesForQuery, ", "))},
|
||
})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to list reactions")
|
||
}
|
||
|
||
memoReactionsMap := make(map[string][]*store.Reaction)
|
||
for _, reaction := range reactions {
|
||
memoReactionsMap[reaction.ContentID] = append(memoReactionsMap[reaction.ContentID], reaction)
|
||
}
|
||
|
||
attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{
|
||
Filters: []string{fmt.Sprintf("memo_id in [%s]", strings.Join(memoIDsForQuery, ", "))},
|
||
})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to list attachments")
|
||
}
|
||
attachmentMap := make(map[int32][]*store.Attachment)
|
||
for _, attachment := range attachments {
|
||
attachmentMap[*attachment.MemoID] = append(attachmentMap[*attachment.MemoID], attachment)
|
||
}
|
||
|
||
var memosResponse []*v1pb.Memo
|
||
for _, m := range memos {
|
||
memoName := memoIDToNameMap[m.ID]
|
||
reactions := memoReactionsMap[memoName]
|
||
attachments := attachmentMap[m.ID]
|
||
|
||
memoMessage, err := s.convertMemoFromStore(ctx, m, reactions, attachments)
|
||
if err != nil {
|
||
return nil, errors.Wrap(err, "failed to convert memo")
|
||
}
|
||
memosResponse = append(memosResponse, memoMessage)
|
||
}
|
||
|
||
response := &v1pb.ListMemoCommentsResponse{
|
||
Memos: memosResponse,
|
||
}
|
||
return response, nil
|
||
}
|
||
|
||
func (s *APIV1Service) RenameMemoTag(ctx context.Context, request *v1pb.RenameMemoTagRequest) (*emptypb.Empty, error) {
|
||
user, err := s.GetCurrentUser(ctx)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get current user")
|
||
}
|
||
|
||
memoFind := &store.FindMemo{
|
||
CreatorID: &user.ID,
|
||
Filters: []string{fmt.Sprintf("tag in [\"%s\"]", request.OldTag)},
|
||
ExcludeComments: true,
|
||
}
|
||
if (request.Parent) != "memos/-" {
|
||
memoUID, err := ExtractMemoUIDFromName(request.Parent)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
|
||
}
|
||
memoFind.UID = &memoUID
|
||
}
|
||
|
||
memos, err := s.Store.ListMemos(ctx, memoFind)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to list memos")
|
||
}
|
||
|
||
for _, memo := range memos {
|
||
doc, err := gomark.Parse(memo.Content)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to parse memo: %v", err)
|
||
}
|
||
memopayload.TraverseASTDocument(doc, func(node ast.Node) {
|
||
if tag, ok := node.(*ast.Tag); ok && tag.Content == request.OldTag {
|
||
tag.Content = request.NewTag
|
||
}
|
||
})
|
||
memo.Content = gomark.Restore(doc)
|
||
if err := memopayload.RebuildMemoPayload(memo); err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err)
|
||
}
|
||
if err := s.Store.UpdateMemo(ctx, &store.UpdateMemo{
|
||
ID: memo.ID,
|
||
Content: &memo.Content,
|
||
Payload: memo.Payload,
|
||
}); err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to update memo: %v", err)
|
||
}
|
||
}
|
||
|
||
return &emptypb.Empty{}, nil
|
||
}
|
||
|
||
func (s *APIV1Service) DeleteMemoTag(ctx context.Context, request *v1pb.DeleteMemoTagRequest) (*emptypb.Empty, error) {
|
||
user, err := s.GetCurrentUser(ctx)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to get current user")
|
||
}
|
||
|
||
memoFind := &store.FindMemo{
|
||
CreatorID: &user.ID,
|
||
Filters: []string{fmt.Sprintf("tag in [\"%s\"]", request.Tag)},
|
||
ExcludeContent: true,
|
||
ExcludeComments: true,
|
||
}
|
||
if request.Parent != "memos/-" {
|
||
memoUID, err := ExtractMemoUIDFromName(request.Parent)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.InvalidArgument, "invalid memo name: %v", err)
|
||
}
|
||
memoFind.UID = &memoUID
|
||
}
|
||
|
||
memos, err := s.Store.ListMemos(ctx, memoFind)
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to list memos")
|
||
}
|
||
|
||
for _, memo := range memos {
|
||
if request.DeleteRelatedMemos {
|
||
err := s.Store.DeleteMemo(ctx, &store.DeleteMemo{ID: memo.ID})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to delete memo")
|
||
}
|
||
} else {
|
||
archived := store.Archived
|
||
err := s.Store.UpdateMemo(ctx, &store.UpdateMemo{
|
||
ID: memo.ID,
|
||
RowStatus: &archived,
|
||
})
|
||
if err != nil {
|
||
return nil, status.Errorf(codes.Internal, "failed to update memo")
|
||
}
|
||
}
|
||
}
|
||
|
||
return &emptypb.Empty{}, nil
|
||
}
|
||
|
||
func (s *APIV1Service) getContentLengthLimit(ctx context.Context) (int, error) {
|
||
workspaceMemoRelatedSetting, err := s.Store.GetWorkspaceMemoRelatedSetting(ctx)
|
||
if err != nil {
|
||
return 0, status.Errorf(codes.Internal, "failed to get workspace memo related setting")
|
||
}
|
||
return int(workspaceMemoRelatedSetting.ContentLengthLimit), nil
|
||
}
|
||
|
||
// DispatchMemoCreatedWebhook dispatches webhook when memo is created.
|
||
func (s *APIV1Service) DispatchMemoCreatedWebhook(ctx context.Context, memo *v1pb.Memo) error {
|
||
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.created")
|
||
}
|
||
|
||
// DispatchMemoUpdatedWebhook dispatches webhook when memo is updated.
|
||
func (s *APIV1Service) DispatchMemoUpdatedWebhook(ctx context.Context, memo *v1pb.Memo) error {
|
||
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.updated")
|
||
}
|
||
|
||
// DispatchMemoDeletedWebhook dispatches webhook when memo is deleted.
|
||
func (s *APIV1Service) DispatchMemoDeletedWebhook(ctx context.Context, memo *v1pb.Memo) error {
|
||
return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.deleted")
|
||
}
|
||
|
||
func (s *APIV1Service) dispatchMemoRelatedWebhook(ctx context.Context, memo *v1pb.Memo, activityType string) error {
|
||
// 改造:通过集中式通知服务分发(支持 RAW/WeCom/Bark,内置基础防护)。
|
||
// 在测试环境或未初始化情况下,Notification 可能为 nil,需容错。
|
||
if s.Notification == nil {
|
||
return nil
|
||
}
|
||
if err := s.Notification.DispatchMemoWebhooks(ctx, memo, activityType); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 旧的 payload 转换函数已由 server/notification/service.go 中的实现取代。
|
||
|
||
func getMemoContentSnippet(content string) (string, error) {
|
||
doc, err := gomark.Parse(content)
|
||
if err != nil {
|
||
return "", errors.Wrap(err, "failed to parse content")
|
||
}
|
||
|
||
plainText := renderer.NewStringRenderer().RenderDocument(doc)
|
||
if len(plainText) > 64 {
|
||
return substring(plainText, 64) + "...", nil
|
||
}
|
||
return plainText, nil
|
||
}
|
||
|
||
func substring(s string, length int) string {
|
||
if length <= 0 {
|
||
return ""
|
||
}
|
||
|
||
runeCount := 0
|
||
byteIndex := 0
|
||
for byteIndex < len(s) {
|
||
_, size := utf8.DecodeRuneInString(s[byteIndex:])
|
||
byteIndex += size
|
||
runeCount++
|
||
if runeCount == length {
|
||
break
|
||
}
|
||
}
|
||
|
||
return s[:byteIndex]
|
||
}
|
||
|
||
// parseMemoOrderBy parses the order_by field and sets the appropriate ordering in memoFind.
|
||
func (*APIV1Service) parseMemoOrderBy(orderBy string, memoFind *store.FindMemo) error {
|
||
// Parse order_by field like "display_time desc" or "create_time asc"
|
||
parts := strings.Fields(strings.TrimSpace(orderBy))
|
||
if len(parts) == 0 {
|
||
return errors.New("empty order_by")
|
||
}
|
||
|
||
field := parts[0]
|
||
direction := "desc" // default
|
||
if len(parts) > 1 {
|
||
direction = strings.ToLower(parts[1])
|
||
if direction != "asc" && direction != "desc" {
|
||
return errors.Errorf("invalid order direction: %s, must be 'asc' or 'desc'", parts[1])
|
||
}
|
||
}
|
||
|
||
switch field {
|
||
case "display_time":
|
||
memoFind.OrderByTimeAsc = direction == "asc"
|
||
case "create_time":
|
||
memoFind.OrderByTimeAsc = direction == "asc"
|
||
case "update_time":
|
||
memoFind.OrderByUpdatedTs = true
|
||
memoFind.OrderByTimeAsc = direction == "asc"
|
||
case "name":
|
||
// For ordering by memo name/id - not commonly used but supported
|
||
memoFind.OrderByTimeAsc = direction == "asc"
|
||
default:
|
||
return errors.Errorf("unsupported order field: %s, supported fields are: display_time, create_time, update_time, name", field)
|
||
}
|
||
|
||
return nil
|
||
}
|