mirror of
				https://github.com/usememos/memos.git
				synced 2025-10-31 16:59:30 +08:00 
			
		
		
		
	
		
			
				
	
	
		
			905 lines
		
	
	
	
		
			29 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			905 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"
 | |
| 
 | |
| 	"github.com/usememos/memos/plugin/webhook"
 | |
| 	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 {
 | |
| 	creatorID, err := ExtractUserIDFromName(memo.Creator)
 | |
| 	if err != nil {
 | |
| 		return status.Errorf(codes.InvalidArgument, "invalid memo creator")
 | |
| 	}
 | |
| 	webhooks, err := s.Store.GetUserWebhooks(ctx, creatorID)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	for _, hook := range webhooks {
 | |
| 		payload, err := convertMemoToWebhookPayload(memo)
 | |
| 		if err != nil {
 | |
| 			return errors.Wrap(err, "failed to convert memo to webhook payload")
 | |
| 		}
 | |
| 		payload.ActivityType = activityType
 | |
| 		payload.URL = hook.Url
 | |
| 
 | |
| 		// Use asynchronous webhook dispatch
 | |
| 		webhook.PostAsync(payload)
 | |
| 	}
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func convertMemoToWebhookPayload(memo *v1pb.Memo) (*webhook.WebhookRequestPayload, error) {
 | |
| 	creatorID, err := ExtractUserIDFromName(memo.Creator)
 | |
| 	if err != nil {
 | |
| 		return nil, errors.Wrap(err, "invalid memo creator")
 | |
| 	}
 | |
| 	return &webhook.WebhookRequestPayload{
 | |
| 		Creator: fmt.Sprintf("%s%d", UserNamePrefix, creatorID),
 | |
| 		Memo:    memo,
 | |
| 	}, nil
 | |
| }
 | |
| 
 | |
| 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
 | |
| }
 |