diff --git a/README.md b/README.md index 906b49c0..415efcaf 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,24 @@ type Locator struct { } ``` -- `GET /api/v1/find?url=post-url` - find all comments for given post, returns flat list of `Comment` +- `GET /api/v1/find?url=post-url&sort=fld&format=tree` - find all comments for given post + +This is the primary call used by UI to show comments for given post. It can return two formats - `plain` and `tree`. In plain +format result will be sorted list of `Comment`. In tree format this is going to be tree-like structure with this structure: + +```go +type Tree struct { + Nodes []Node `json:"comments"` +} + +type Node struct { + Comment store.Comment `json:"comment"` + Replies []Node `json:"replies,omitempty"` +} +``` + +Sort can be `time` or `score`. Supported sort order with prefix -/+, i.e. `-time`. For `tree` mode sort will be applied to top-level comments only and all replies always sorted by time. + - `GET /api/v1/last/{max}` - get up to `{max}` last comments - `GET /api/v1/id/{id}` - get comment by `id` - `GET /api/v1/count?url=post-url` - get comment's count for `{url}` diff --git a/app/rest/format/tree.go b/app/rest/format/tree.go new file mode 100644 index 00000000..9d0440e4 --- /dev/null +++ b/app/rest/format/tree.go @@ -0,0 +1,76 @@ +package format + +import ( + "sort" + "strings" + + "github.com/umputun/remark/app/store" +) + +// Tree is formatter as comment tree list of comments +type Tree struct { + Nodes []*Node `json:"comments"` +} + +// Node is a comment with optional replies +type Node struct { + Comment store.Comment `json:"comment"` + Replies []*Node `json:"replies,omitempty"` +} + +// MakeTree gets unsorted list of comments and produces Tree +func MakeTree(comments []store.Comment, sortType string) (res Tree) { + res = Tree{} + + repComments := res.filter(comments, func(c store.Comment) bool { return c.ParentID == "" }) + for _, rc := range repComments { + node := Node{Comment: rc} + res.Nodes = append(res.Nodes, res.proc(comments, &node, rc.ID)) + } + + // sort result according to sortType + sort.Slice(res.Nodes, func(i, j int) bool { + switch sortType { + case "+time", "-time", "time": + if strings.HasPrefix(sortType, "-") { + return res.Nodes[i].Comment.Timestamp.After(res.Nodes[j].Comment.Timestamp) + } + return res.Nodes[i].Comment.Timestamp.Before(res.Nodes[j].Comment.Timestamp) + + case "+score", "-score", "score": + if strings.HasPrefix(sortType, "-") { + return res.Nodes[i].Comment.Score > res.Nodes[j].Comment.Score + } + return res.Nodes[i].Comment.Score < res.Nodes[j].Comment.Score + + default: + return res.Nodes[i].Comment.Timestamp.Before(res.Nodes[j].Comment.Timestamp) + } + }) + + return res +} + +func (t *Tree) proc(comments []store.Comment, node *Node, parentID string) *Node { + repComments := t.filter(comments, func(c store.Comment) bool { return c.ParentID == parentID }) + for _, rc := range repComments { + rnode := &Node{Comment: rc, Replies: []*Node{}} + node.Replies = append(node.Replies, rnode) + + // replies always sorted by time + sort.Slice(node.Replies, func(i, j int) bool { + return node.Replies[i].Comment.Timestamp.Before(node.Replies[j].Comment.Timestamp) + }) + t.proc(comments, rnode, rc.ID) + } + return node +} + +func (t *Tree) filter(comments []store.Comment, fn func(c store.Comment) bool) (f []store.Comment) { + for _, c := range comments { + if fn(c) { + f = append(f, c) + } + } + return f +} diff --git a/app/rest/format/tree_test.go b/app/rest/format/tree_test.go new file mode 100644 index 00000000..0aaf7dac --- /dev/null +++ b/app/rest/format/tree_test.go @@ -0,0 +1,292 @@ +package format + +import ( + "bytes" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/umputun/remark/app/store" +) + +func TestStore_MakeTree(t *testing.T) { + + // unsorted by purpose + comments := []store.Comment{ + {ID: "14", ParentID: "1", Timestamp: time.Date(2017, 12, 25, 19, 46, 14, 0, time.UTC)}, + {ID: "1", Timestamp: time.Date(2017, 12, 25, 19, 46, 1, 0, time.UTC)}, + {ID: "2", Timestamp: time.Date(2017, 12, 25, 19, 47, 2, 0, time.UTC)}, + {ID: "11", ParentID: "1", Timestamp: time.Date(2017, 12, 25, 19, 46, 11, 0, time.UTC)}, + {ID: "13", ParentID: "1", Timestamp: time.Date(2017, 12, 25, 19, 46, 13, 0, time.UTC)}, + {ID: "12", ParentID: "1", Timestamp: time.Date(2017, 12, 25, 19, 46, 12, 0, time.UTC)}, + {ID: "131", ParentID: "13", Timestamp: time.Date(2017, 12, 25, 19, 46, 31, 0, time.UTC)}, + {ID: "132", ParentID: "13", Timestamp: time.Date(2017, 12, 25, 19, 46, 32, 0, time.UTC)}, + {ID: "21", ParentID: "2", Timestamp: time.Date(2017, 12, 25, 19, 47, 21, 0, time.UTC)}, + {ID: "22", ParentID: "2", Timestamp: time.Date(2017, 12, 25, 19, 47, 22, 0, time.UTC)}, + {ID: "4", Timestamp: time.Date(2017, 12, 25, 19, 47, 22, 0, time.UTC)}, + {ID: "3", Timestamp: time.Date(2017, 12, 25, 19, 47, 22, 0, time.UTC)}, + } + + res := MakeTree(comments, "time") + + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetIndent("", " ") + err := enc.Encode(res) + assert.Nil(t, err) + assert.Equal(t, expJSON, string(buf.Bytes())) + t.Log(string(buf.Bytes())) +} + +const expJSON = `{ + "comments": [ + { + "comment": { + "id": "1", + "pid": "", + "text": "", + "user": { + "name": "", + "id": "", + "picture": "", + "profile": "", + "admin": false + }, + "locator": { + "url": "" + }, + "score": 0, + "votes": null, + "time": "2017-12-25T19:46:01Z" + }, + "replies": [ + { + "comment": { + "id": "11", + "pid": "1", + "text": "", + "user": { + "name": "", + "id": "", + "picture": "", + "profile": "", + "admin": false + }, + "locator": { + "url": "" + }, + "score": 0, + "votes": null, + "time": "2017-12-25T19:46:11Z" + } + }, + { + "comment": { + "id": "12", + "pid": "1", + "text": "", + "user": { + "name": "", + "id": "", + "picture": "", + "profile": "", + "admin": false + }, + "locator": { + "url": "" + }, + "score": 0, + "votes": null, + "time": "2017-12-25T19:46:12Z" + } + }, + { + "comment": { + "id": "13", + "pid": "1", + "text": "", + "user": { + "name": "", + "id": "", + "picture": "", + "profile": "", + "admin": false + }, + "locator": { + "url": "" + }, + "score": 0, + "votes": null, + "time": "2017-12-25T19:46:13Z" + }, + "replies": [ + { + "comment": { + "id": "131", + "pid": "13", + "text": "", + "user": { + "name": "", + "id": "", + "picture": "", + "profile": "", + "admin": false + }, + "locator": { + "url": "" + }, + "score": 0, + "votes": null, + "time": "2017-12-25T19:46:31Z" + } + }, + { + "comment": { + "id": "132", + "pid": "13", + "text": "", + "user": { + "name": "", + "id": "", + "picture": "", + "profile": "", + "admin": false + }, + "locator": { + "url": "" + }, + "score": 0, + "votes": null, + "time": "2017-12-25T19:46:32Z" + } + } + ] + }, + { + "comment": { + "id": "14", + "pid": "1", + "text": "", + "user": { + "name": "", + "id": "", + "picture": "", + "profile": "", + "admin": false + }, + "locator": { + "url": "" + }, + "score": 0, + "votes": null, + "time": "2017-12-25T19:46:14Z" + } + } + ] + }, + { + "comment": { + "id": "2", + "pid": "", + "text": "", + "user": { + "name": "", + "id": "", + "picture": "", + "profile": "", + "admin": false + }, + "locator": { + "url": "" + }, + "score": 0, + "votes": null, + "time": "2017-12-25T19:47:02Z" + }, + "replies": [ + { + "comment": { + "id": "21", + "pid": "2", + "text": "", + "user": { + "name": "", + "id": "", + "picture": "", + "profile": "", + "admin": false + }, + "locator": { + "url": "" + }, + "score": 0, + "votes": null, + "time": "2017-12-25T19:47:21Z" + } + }, + { + "comment": { + "id": "22", + "pid": "2", + "text": "", + "user": { + "name": "", + "id": "", + "picture": "", + "profile": "", + "admin": false + }, + "locator": { + "url": "" + }, + "score": 0, + "votes": null, + "time": "2017-12-25T19:47:22Z" + } + } + ] + }, + { + "comment": { + "id": "4", + "pid": "", + "text": "", + "user": { + "name": "", + "id": "", + "picture": "", + "profile": "", + "admin": false + }, + "locator": { + "url": "" + }, + "score": 0, + "votes": null, + "time": "2017-12-25T19:47:22Z" + } + }, + { + "comment": { + "id": "3", + "pid": "", + "text": "", + "user": { + "name": "", + "id": "", + "picture": "", + "profile": "", + "admin": false + }, + "locator": { + "url": "" + }, + "score": 0, + "votes": null, + "time": "2017-12-25T19:47:22Z" + } + } + ] +} +` diff --git a/app/rest/server.go b/app/rest/server.go index b5844a10..91d3ebab 100644 --- a/app/rest/server.go +++ b/app/rest/server.go @@ -18,6 +18,7 @@ import ( "github.com/umputun/remark/app/migrator" "github.com/umputun/remark/app/rest/auth" + "github.com/umputun/remark/app/rest/format" "github.com/umputun/remark/app/store" ) @@ -159,7 +160,7 @@ func (s *Server) deleteCommentCtrl(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, JSON{"id": id, "url": url}) } -// GET /find?url=post-url&sort=-time +// GET /find?url=post-url&format=tree&sort=-time func (s *Server) findCommentsCtrl(w http.ResponseWriter, r *http.Request) { url := r.URL.Query().Get("url") log.Printf("[DEBUG] get comments for %s", url) @@ -170,7 +171,10 @@ func (s *Server) findCommentsCtrl(w http.ResponseWriter, r *http.Request) { httpError(w, r, http.StatusInternalServerError, err, "can't load comments comment") return } - render.Status(r, http.StatusOK) + if r.URL.Query().Get("format") == "tree" { + renderJSONWithHTML(w, r, format.MakeTree(comments, r.URL.Query().Get("sort"))) + return + } renderJSONWithHTML(w, r, comments) }