Skip to content
This repository has been archived by the owner on Jan 2, 2025. It is now read-only.

Commit

Permalink
Implement the Commentary API
Browse files Browse the repository at this point in the history
  • Loading branch information
burdiyan committed Jan 2, 2024
1 parent 51a4e0b commit 665b43b
Show file tree
Hide file tree
Showing 20 changed files with 1,315 additions and 234 deletions.
279 changes: 279 additions & 0 deletions backend/daemon/api/documents/v1alpha/comments.go
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
}
98 changes: 98 additions & 0 deletions backend/daemon/api/documents/v1alpha/comments_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package documents

import (
"context"
. "mintter/backend/genproto/documents/v1alpha"

Check warning on line 5 in backend/daemon/api/documents/v1alpha/comments_test.go

View workflow job for this annotation

GitHub Actions / lint-go

dot-imports: should not use dot imports (revive)
"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")
}
1 change: 1 addition & 0 deletions backend/daemon/api/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func (s Server) Register(srv *grpc.Server) {
documents.RegisterDraftsServer(srv, s.Documents)
documents.RegisterPublicationsServer(srv, s.Documents)
documents.RegisterChangesServer(srv, s.Documents)
documents.RegisterCommentsServer(srv, s.Documents)

networking.RegisterNetworkingServer(srv, s.Networking)
entities.RegisterEntitiesServer(srv, s.Entities)
Expand Down
Loading

0 comments on commit 665b43b

Please sign in to comment.