This repository has been archived by the owner on Jan 2, 2025. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 11
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
20 changed files
with
1,315 additions
and
234 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,279 @@ | ||
package documents | ||
|
||
import ( | ||
"context" | ||
"fmt" | ||
documents "mintter/backend/genproto/documents/v1alpha" | ||
"mintter/backend/hlc" | ||
"mintter/backend/hyper" | ||
"mintter/backend/pkg/errutil" | ||
"net/url" | ||
"strings" | ||
|
||
"github.com/ipfs/go-cid" | ||
"google.golang.org/grpc/codes" | ||
"google.golang.org/grpc/status" | ||
"google.golang.org/protobuf/types/known/timestamppb" | ||
) | ||
|
||
// CreateComment creates a new comment. | ||
func (srv *Server) CreateComment(ctx context.Context, in *documents.CreateCommentRequest) (*documents.Comment, error) { | ||
if in.Target == "" { | ||
return nil, errutil.MissingArgument("target") | ||
} | ||
|
||
if in.Content == nil { | ||
return nil, errutil.MissingArgument("content") | ||
} | ||
|
||
u, err := url.Parse(in.Target) | ||
if err != nil { | ||
return nil, status.Errorf(codes.InvalidArgument, "failed to parse target %s as a URL: %v", in.Target, err) | ||
} | ||
|
||
if u.Host != "d" { | ||
return nil, status.Errorf(codes.InvalidArgument, "target must be a document URL, got %s", in.Target) | ||
} | ||
|
||
if u.Query().Get("v") == "" { | ||
return nil, status.Errorf(codes.InvalidArgument, "initial comments must use versioned URLs, got %s", in.Target) | ||
} | ||
|
||
me, err := srv.getMe() | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
del, err := srv.getDelegation(ctx) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
ts := hlc.NewClock().Now() | ||
|
||
hb, err := hyper.NewComment(in.Target, cid.Undef, ts, me.DeviceKey(), del, commentContentFromProto(in.Content)) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
if err := srv.blobs.SaveBlob(ctx, hb); err != nil { | ||
return nil, fmt.Errorf("failed to save comment: %w", err) | ||
} | ||
|
||
return commentToProto(ctx, srv.blobs, hb.CID, hb.Decoded.(hyper.Comment)) | ||
} | ||
|
||
// CreateReply creates a new reply to a comment. | ||
func (srv *Server) CreateReply(ctx context.Context, in *documents.CreateReplyRequest) (*documents.Comment, error) { | ||
if in.RepliedComment == "" { | ||
return nil, errutil.MissingArgument("replied_comment") | ||
} | ||
|
||
if in.Content == nil { | ||
return nil, errutil.MissingArgument("content") | ||
} | ||
|
||
if !strings.HasPrefix(in.RepliedComment, "hm://c/") { | ||
return nil, status.Errorf(codes.InvalidArgument, "replied_comment must be a comment ID, got %s", in.RepliedComment) | ||
} | ||
|
||
repliedCID, err := cid.Decode(strings.TrimPrefix(in.RepliedComment, "hm://c/")) | ||
if err != nil { | ||
return nil, status.Errorf(codes.InvalidArgument, "failed to parse CID from %s: %v", in.RepliedComment, err) | ||
} | ||
|
||
repliedBlock, err := srv.blobs.IPFSBlockstore().Get(ctx, repliedCID) | ||
if err != nil { | ||
return nil, fmt.Errorf("replied comment %s not found: %w", in.RepliedComment, err) | ||
} | ||
|
||
replied, err := hyper.DecodeBlob(repliedBlock.Cid(), repliedBlock.RawData()) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
repliedCmt, ok := replied.Decoded.(hyper.Comment) | ||
if !ok { | ||
return nil, status.Errorf(codes.InvalidArgument, "replied comment %s is not a comment", in.RepliedComment) | ||
} | ||
|
||
// If replied comment is an initial comment, we use its ID as target. | ||
target := repliedCmt.Target | ||
if !strings.HasPrefix(target, "hm://c/") { | ||
target = in.RepliedComment | ||
} | ||
|
||
me, err := srv.getMe() | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
del, err := srv.getDelegation(ctx) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
clock := hlc.NewClock() | ||
clock.Track(repliedCmt.HLCTime) | ||
|
||
hb, err := hyper.NewComment(target, repliedCID, clock.Now(), me.DeviceKey(), del, commentContentFromProto(in.Content)) | ||
if err != nil { | ||
return nil, fmt.Errorf("failed to create reply: %w", err) | ||
} | ||
|
||
if err := srv.blobs.SaveBlob(ctx, hb); err != nil { | ||
return nil, fmt.Errorf("failed to save reply: %w", err) | ||
} | ||
|
||
return commentToProto(ctx, srv.blobs, hb.CID, hb.Decoded.(hyper.Comment)) | ||
} | ||
|
||
// GetComment gets a comment by ID. | ||
func (srv *Server) GetComment(ctx context.Context, in *documents.GetCommentRequest) (*documents.Comment, error) { | ||
if !strings.HasPrefix(in.Id, "hm://c/") { | ||
return nil, status.Errorf(codes.InvalidArgument, "comment ID must start with hm://c/, got '%s'", in.Id) | ||
} | ||
|
||
cid, err := cid.Decode(strings.TrimPrefix(in.Id, "hm://c/")) | ||
if err != nil { | ||
return nil, status.Errorf(codes.InvalidArgument, "failed to parse comment CID from %s: %v", in.Id, err) | ||
} | ||
|
||
block, err := srv.blobs.IPFSBlockstore().Get(ctx, cid) | ||
if err != nil { | ||
return nil, fmt.Errorf("comment %s not found: %w", in.Id, err) | ||
} | ||
|
||
hb, err := hyper.DecodeBlob(block.Cid(), block.RawData()) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
return commentToProto(ctx, srv.blobs, hb.CID, hb.Decoded.(hyper.Comment)) | ||
} | ||
|
||
// ListComments lists comments and replies for a given target. | ||
func (srv *Server) ListComments(ctx context.Context, in *documents.ListCommentsRequest) (*documents.ListCommentsResponse, error) { | ||
if in.Target == "" { | ||
return nil, errutil.MissingArgument("target") | ||
} | ||
|
||
resp := &documents.ListCommentsResponse{} | ||
if err := srv.blobs.ForEachComment(ctx, in.Target, func(c cid.Cid, cmt hyper.Comment) error { | ||
pb, err := commentToProto(ctx, srv.blobs, c, cmt) | ||
if err != nil { | ||
return fmt.Errorf("failed to convert comment %s to proto", c.String()) | ||
} | ||
resp.Comments = append(resp.Comments, pb) | ||
return nil | ||
}); err != nil { | ||
return nil, err | ||
} | ||
|
||
return resp, nil | ||
} | ||
|
||
func commentToProto(ctx context.Context, blobs *hyper.Storage, c cid.Cid, cmt hyper.Comment) (*documents.Comment, error) { | ||
author, err := blobs.GetDelegationIssuer(ctx, cmt.Delegation) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
pb := &documents.Comment{ | ||
Id: "hm://c/" + c.String(), | ||
Target: cmt.Target, | ||
Author: author.String(), | ||
Content: commentContentToProto(cmt.Body), | ||
CreateTime: timestamppb.New(cmt.HLCTime.Time()), | ||
} | ||
if cmt.RepliedComment.Defined() { | ||
pb.RepliedComment = "hm://c/" + cmt.RepliedComment.String() | ||
} | ||
|
||
return pb, nil | ||
} | ||
|
||
func commentContentToProto(in []hyper.CommentBlock) []*documents.BlockNode { | ||
if in == nil { | ||
return nil | ||
} | ||
|
||
out := make([]*documents.BlockNode, len(in)) | ||
for i, b := range in { | ||
out[i] = &documents.BlockNode{ | ||
Block: &documents.Block{ | ||
Id: b.ID, | ||
Type: b.Type, | ||
Text: b.Text, | ||
Ref: b.Ref, | ||
Attributes: b.Attributes, | ||
Annotations: annotationsToProto(b.Annotations), | ||
}, | ||
Children: commentContentToProto(b.Children), | ||
} | ||
} | ||
|
||
return out | ||
} | ||
|
||
func annotationsToProto(in []hyper.Annotation) []*documents.Annotation { | ||
if in == nil { | ||
return nil | ||
} | ||
|
||
out := make([]*documents.Annotation, len(in)) | ||
for i, a := range in { | ||
out[i] = &documents.Annotation{ | ||
Type: a.Type, | ||
Ref: a.Ref, | ||
Attributes: a.Attributes, | ||
Starts: a.Starts, | ||
Ends: a.Ends, | ||
} | ||
} | ||
|
||
return out | ||
} | ||
|
||
func commentContentFromProto(in []*documents.BlockNode) []hyper.CommentBlock { | ||
if in == nil { | ||
return nil | ||
} | ||
|
||
out := make([]hyper.CommentBlock, len(in)) | ||
|
||
for i, n := range in { | ||
out[i] = hyper.CommentBlock{ | ||
Block: hyper.Block{ | ||
ID: n.Block.Id, | ||
Type: n.Block.Type, | ||
Text: n.Block.Text, | ||
Ref: n.Block.Ref, | ||
Attributes: n.Block.Attributes, | ||
Annotations: annotationsFromProto(n.Block.Annotations), | ||
}, | ||
Children: commentContentFromProto(n.Children), | ||
} | ||
} | ||
|
||
return out | ||
} | ||
|
||
func annotationsFromProto(in []*documents.Annotation) []hyper.Annotation { | ||
if in == nil { | ||
return nil | ||
} | ||
|
||
out := make([]hyper.Annotation, len(in)) | ||
for i, a := range in { | ||
out[i] = hyper.Annotation{ | ||
Type: a.Type, | ||
Ref: a.Ref, | ||
Attributes: a.Attributes, | ||
Starts: a.Starts, | ||
Ends: a.Ends, | ||
} | ||
} | ||
|
||
return out | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
package documents | ||
|
||
import ( | ||
"context" | ||
. "mintter/backend/genproto/documents/v1alpha" | ||
"mintter/backend/testutil" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/require" | ||
) | ||
|
||
func TestCommentsSmoke(t *testing.T) { | ||
t.Parallel() | ||
|
||
api := newTestDocsAPI(t, "alice") | ||
ctx := context.Background() | ||
|
||
draft, err := api.CreateDraft(ctx, &CreateDraftRequest{}) | ||
require.NoError(t, err) | ||
|
||
_, err = api.UpdateDraft(ctx, &UpdateDraftRequest{ | ||
DocumentId: draft.Id, | ||
Changes: []*DocumentChange{ | ||
{Op: &DocumentChange_SetTitle{SetTitle: "Document title"}}, | ||
}, | ||
}) | ||
require.NoError(t, err) | ||
|
||
pub, err := api.PublishDraft(ctx, &PublishDraftRequest{DocumentId: draft.Id}) | ||
require.NoError(t, err) | ||
|
||
cmt, err := api.CreateComment(ctx, &CreateCommentRequest{ | ||
Target: pub.Document.Id + "?v=" + pub.Version, | ||
Content: []*BlockNode{{ | ||
Block: &Block{ | ||
Id: "b1", | ||
Type: "paragraph", | ||
Text: "Hello World", | ||
}, | ||
}}, | ||
}) | ||
require.NoError(t, err) | ||
|
||
require.Equal(t, pub.Document.Id+"?v="+pub.Version, cmt.Target, "comment target must match") | ||
require.NotNil(t, cmt.CreateTime, "create time must be set") | ||
require.Equal(t, api.me.MustGet().Account().String(), cmt.Author, "comment author must match my node") | ||
|
||
reply, err := api.CreateReply(ctx, &CreateReplyRequest{ | ||
RepliedComment: cmt.Id, | ||
Content: []*BlockNode{{ | ||
Block: &Block{ | ||
Id: "b1", | ||
Type: "paragraph", | ||
Text: "This is a reply", | ||
}, | ||
}}, | ||
}) | ||
require.NoError(t, err) | ||
|
||
require.Equal(t, cmt.Id, reply.Target, "reply must target thread root") | ||
require.Equal(t, cmt.Id, reply.RepliedComment, "reply must point to the replied comment") | ||
require.True(t, reply.CreateTime.AsTime().After(cmt.CreateTime.AsTime()), "reply time must be after replied comment time") | ||
|
||
reply2, err := api.CreateReply(ctx, &CreateReplyRequest{ | ||
RepliedComment: reply.Id, | ||
Content: []*BlockNode{{ | ||
Block: &Block{ | ||
Id: "b1", | ||
Type: "paragraph", | ||
Text: "This is another reply", | ||
}, | ||
}}, | ||
}) | ||
require.NoError(t, err) | ||
|
||
require.Equal(t, cmt.Id, reply2.Target, "reply must target thread root") | ||
require.Equal(t, reply.Id, reply2.RepliedComment, "reply must point to the replied comment") | ||
|
||
gotReply2, err := api.GetComment(ctx, &GetCommentRequest{Id: reply2.Id}) | ||
require.NoError(t, err) | ||
testutil.ProtoEqual(t, reply2, gotReply2, "reply must match when doing Get") | ||
|
||
list, err := api.ListComments(ctx, &ListCommentsRequest{ | ||
Target: pub.Document.Id + "?v=" + pub.Version, | ||
}) | ||
require.Error(t, err, "listing for pinned target must fail") | ||
require.Nil(t, list) | ||
|
||
list, err = api.ListComments(ctx, &ListCommentsRequest{ | ||
Target: pub.Document.Id, | ||
}) | ||
require.NoError(t, err) | ||
|
||
want := &ListCommentsResponse{ | ||
Comments: []*Comment{cmt, reply, reply2}, | ||
} | ||
testutil.ProtoEqual(t, want, list, "list must match") | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.