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, } }