package api
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
)
// Comment is the public representation of a Confluence comment thread node.
//
// BodyStorage is the raw Confluence storage XML for the comment body; it is not
// converted to Markdown. Replies are nested up to two levels deep (the depth
// supported by the children.comment expand chain); deeper threads are not
// captured.
type Comment struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
Status string `json:"status,omitempty"`
Location string `json:"location"`
MarkerRef string `json:"marker_ref,omitempty"`
Resolved bool `json:"resolved,omitempty"`
Resolution string `json:"resolution,omitempty"`
Author User `json:"author"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated,omitempty"`
BodyStorage string `json:"body_storage"`
Replies []*Comment `json:"replies,omitempty"`
}
// User identifies a Confluence user attached to a comment.
type User struct {
Username string `json:"username,omitempty"`
DisplayName string `json:"display_name,omitempty"`
UserKey string `json:"user_key,omitempty"`
AccountID string `json:"account_id,omitempty"`
}
const (
commentLimit = 200
commentExpand = "body.storage,version,history,history.createdBy," +
"extensions.location,extensions.inlineProperties,extensions.resolution," +
"children.comment.body.storage,children.comment.version,children.comment.history,children.comment.history.createdBy," +
"children.comment.extensions.location,children.comment.extensions.inlineProperties,children.comment.extensions.resolution," +
"children.comment.children.comment.body.storage,children.comment.children.comment.version," +
"children.comment.children.comment.history,children.comment.children.comment.history.createdBy," +
"children.comment.children.comment.extensions.location," +
"children.comment.children.comment.extensions.inlineProperties," +
"children.comment.children.comment.extensions.resolution"
)
type apiCommentList struct {
Results []apiComment `json:"results"`
Size int `json:"size"`
Limit int `json:"limit"`
Start int `json:"start"`
Links apiLinks `json:"_links"`
}
type apiLinks struct {
Next string `json:"next"`
Base string `json:"base"`
Context string `json:"context"`
}
type apiComment struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
Title string `json:"title"`
Body Body `json:"body"`
Version apiVersionMeta `json:"version"`
History apiHistory `json:"history"`
Extensions apiExtensions `json:"extensions"`
Children apiChildren `json:"children"`
}
type apiVersionMeta struct {
Number int `json:"number"`
When time.Time `json:"when"`
By apiUser `json:"by"`
}
type apiHistory struct {
CreatedDate time.Time `json:"createdDate"`
CreatedBy apiUser `json:"createdBy"`
}
type apiUser struct {
Username string `json:"username"`
DisplayName string `json:"displayName"`
UserKey string `json:"userKey"`
AccountID string `json:"accountId"`
}
type apiExtensions struct {
Location string `json:"location"`
InlineProperties apiInlineProperties `json:"inlineProperties"`
Resolution apiResolution `json:"resolution"`
}
type apiInlineProperties struct {
MarkerRef string `json:"markerRef"`
OriginalSelection string `json:"originalSelection,omitempty"`
}
type apiResolution struct {
Status string `json:"status"`
LastModifier apiUser `json:"lastModifier"`
LastModified time.Time `json:"lastModified"`
}
type apiChildren struct {
Comment apiCommentList `json:"comment"`
}
// GetComments fetches all comments (inline + footer) for the given page,
// following pagination and converting nested reply trees.
func (c *Client) GetComments(pageID string) ([]*Comment, error) {
params := url.Values{
"depth": {"all"},
"limit": {fmt.Sprintf("%d", commentLimit)},
"expand": {commentExpand},
}
path := fmt.Sprintf("/rest/api/content/%s/child/comment?%s", pageID, params.Encode())
var out []*Comment
for path != "" {
page, err := c.fetchCommentPage(path, pageID)
if err != nil {
return nil, err
}
for i := range page.Results {
out = append(out, convertComment(&page.Results[i]))
}
path = c.nextLinkPath(page.Links.Next)
}
return out, nil
}
func (c *Client) fetchCommentPage(path, pageID string) (*apiCommentList, error) {
req, err := c.newRequest(http.MethodGet, path)
if err != nil {
return nil, err
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching comments for page %s: %w", pageID, err)
}
defer resp.Body.Close()
if err := checkResponse(resp); err != nil {
return nil, err
}
var list apiCommentList
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
return nil, fmt.Errorf("decoding comments response: %w", err)
}
return &list, nil
}
// nextLinkPath normalizes the _links.next value into a path the client can
// pass to newRequest. Confluence DC sometimes returns it as a relative path
// ("/rest/api/...") and sometimes prefixed with the base URL.
func (c *Client) nextLinkPath(next string) string {
if next == "" {
return ""
}
if strings.HasPrefix(next, c.BaseURL) {
return strings.TrimPrefix(next, c.BaseURL)
}
return next
}
func convertComment(in *apiComment) *Comment {
out := &Comment{
ID: in.ID,
Title: in.Title,
Status: in.Status,
Location: in.Extensions.Location,
MarkerRef: in.Extensions.InlineProperties.MarkerRef,
Resolution: in.Extensions.Resolution.Status,
Resolved: in.Extensions.Resolution.Status == "resolved",
Author: convertUser(in.History.CreatedBy),
Created: in.History.CreatedDate,
Updated: in.Version.When,
BodyStorage: in.Body.Storage.Value,
}
for i := range in.Children.Comment.Results {
out.Replies = append(out.Replies, convertComment(&in.Children.Comment.Results[i]))
}
return out
}
func convertUser(in apiUser) User {
return User{
Username: in.Username,
DisplayName: in.DisplayName,
UserKey: in.UserKey,
AccountID: in.AccountID,
}
}