~bigbes/confluence-md-utilities

ref: f6c846a1f16d1f6d0045c92a1c0f24429e6b901f confluence-md-utilities/api/comments.go -rw-r--r-- 6.2 KiB
f6c846a1 — Eugene Blikh fix: preserve inline spacing in fmt and code-block round-trip a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
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,
	}
}