memos/server/router/api/v1/memo_service.go

885 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}