docspan 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docspan/__init__.py +3 -0
- docspan/__main__.py +0 -0
- docspan/backends/__init__.py +19 -0
- docspan/backends/base.py +85 -0
- docspan/backends/confluence/__init__.py +0 -0
- docspan/backends/confluence/adf/__init__.py +14 -0
- docspan/backends/confluence/adf/comparator.py +427 -0
- docspan/backends/confluence/adf/converter.py +119 -0
- docspan/backends/confluence/adf/converters.py +1449 -0
- docspan/backends/confluence/adf/interfaces.py +191 -0
- docspan/backends/confluence/adf/nodes.py +2085 -0
- docspan/backends/confluence/adf/parser.py +400 -0
- docspan/backends/confluence/adf/validators.py +161 -0
- docspan/backends/confluence/adf/visitors.py +495 -0
- docspan/backends/confluence/backend.py +227 -0
- docspan/backends/confluence/client.py +44 -0
- docspan/backends/confluence/config/__init__.py +21 -0
- docspan/backends/confluence/config/loader.py +107 -0
- docspan/backends/confluence/config/models.py +167 -0
- docspan/backends/confluence/config/validation.py +297 -0
- docspan/backends/confluence/markdown/__init__.py +22 -0
- docspan/backends/confluence/markdown/ast.py +819 -0
- docspan/backends/confluence/markdown/extensions/__init__.py +5 -0
- docspan/backends/confluence/markdown/extensions/frontmatter.py +80 -0
- docspan/backends/confluence/markdown/extensions/mermaid.py +64 -0
- docspan/backends/confluence/markdown/extensions/wikilinks.py +179 -0
- docspan/backends/confluence/markdown/inline_parser.py +495 -0
- docspan/backends/confluence/markdown/parser.py +1006 -0
- docspan/backends/confluence/models/__init__.py +18 -0
- docspan/backends/confluence/models/markdown_file.py +402 -0
- docspan/backends/confluence/models/page.py +212 -0
- docspan/backends/confluence/models/path_utils.py +34 -0
- docspan/backends/confluence/models/results.py +28 -0
- docspan/backends/confluence/models/sync_status.py +382 -0
- docspan/backends/confluence/services/__init__.py +0 -0
- docspan/backends/confluence/services/confluence/__init__.py +40 -0
- docspan/backends/confluence/services/confluence/attachment_client.py +147 -0
- docspan/backends/confluence/services/confluence/base_client.py +420 -0
- docspan/backends/confluence/services/confluence/client.py +376 -0
- docspan/backends/confluence/services/confluence/comment_client.py +682 -0
- docspan/backends/confluence/services/confluence/crawler.py +587 -0
- docspan/backends/confluence/services/confluence/label_client.py +130 -0
- docspan/backends/confluence/services/confluence/page_client.py +1288 -0
- docspan/backends/confluence/services/confluence/space_client.py +179 -0
- docspan/backends/confluence/services/confluence/url_parser.py +106 -0
- docspan/backends/google_docs/__init__.py +0 -0
- docspan/backends/google_docs/auth.py +143 -0
- docspan/backends/google_docs/backend.py +140 -0
- docspan/backends/google_docs/client.py +665 -0
- docspan/backends/google_docs/converter.py +471 -0
- docspan/backends/google_docs/docs_request_builder.py +232 -0
- docspan/backends/google_docs/docs_structure_parser.py +120 -0
- docspan/backends/google_docs/markdown_to_paragraph_parser.py +145 -0
- docspan/cli/__init__.py +0 -0
- docspan/cli/main.py +408 -0
- docspan/config.py +62 -0
- docspan/core/__init__.py +49 -0
- docspan/core/merge.py +30 -0
- docspan/core/orchestrator.py +332 -0
- docspan/core/paths.py +8 -0
- docspan/core/state.py +53 -0
- docspan-0.1.0.dist-info/METADATA +273 -0
- docspan-0.1.0.dist-info/RECORD +65 -0
- docspan-0.1.0.dist-info/WHEEL +4 -0
- docspan-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Confluence Comment Client — full v2 API coverage.
|
|
3
|
+
|
|
4
|
+
Endpoints implemented (all under /wiki/api/v2/):
|
|
5
|
+
|
|
6
|
+
Footer comments:
|
|
7
|
+
GET /footer-comments get_all_footer_comments()
|
|
8
|
+
POST /footer-comments create_footer_comment() [replaces v1]
|
|
9
|
+
GET /footer-comments/{id} get_footer_comment()
|
|
10
|
+
PUT /footer-comments/{id} update_footer_comment()
|
|
11
|
+
DEL /footer-comments/{id} delete_footer_comment()
|
|
12
|
+
GET /footer-comments/{id}/children get_footer_comment_children()
|
|
13
|
+
GET /pages/{id}/footer-comments get_page_footer_comments()
|
|
14
|
+
GET /blogposts/{id}/footer-comments get_blogpost_footer_comments()
|
|
15
|
+
GET /attachments/{id}/footer-comments get_attachment_footer_comments()
|
|
16
|
+
GET /custom-content/{id}/footer-comments get_custom_content_footer_comments()
|
|
17
|
+
|
|
18
|
+
Inline comments:
|
|
19
|
+
GET /inline-comments get_all_inline_comments()
|
|
20
|
+
POST /inline-comments create_inline_comment() [replaces v1]
|
|
21
|
+
GET /inline-comments/{id} get_inline_comment()
|
|
22
|
+
PUT /inline-comments/{id} update_inline_comment() (also resolves)
|
|
23
|
+
DEL /inline-comments/{id} delete_inline_comment()
|
|
24
|
+
GET /pages/{id}/inline-comments get_page_inline_comments()
|
|
25
|
+
GET /blogposts/{id}/inline-comments get_blogpost_inline_comments()
|
|
26
|
+
|
|
27
|
+
Legacy v1 (retained for rendered-HTML access):
|
|
28
|
+
GET /wiki/rest/api/content/{id}/child/comment get_comments()
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import re
|
|
34
|
+
from typing import Any, Dict, List, Optional
|
|
35
|
+
|
|
36
|
+
import requests
|
|
37
|
+
from requests.auth import HTTPBasicAuth
|
|
38
|
+
|
|
39
|
+
from docspan.backends.confluence.config.models import ConfluenceConfig
|
|
40
|
+
from docspan.backends.confluence.services.confluence.base_client import (
|
|
41
|
+
ConfluenceApiError,
|
|
42
|
+
PageNotFoundError,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CommentNotFoundError(ConfluenceApiError):
|
|
49
|
+
"""Raised when a comment cannot be found."""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class InlineCommentNotSupportedError(ConfluenceApiError):
|
|
54
|
+
"""
|
|
55
|
+
Raised when an inline comment operation is not supported.
|
|
56
|
+
|
|
57
|
+
Kept for backwards compatibility. The v2 API now supports creating and
|
|
58
|
+
replying to inline comments, so this should rarely be raised.
|
|
59
|
+
"""
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ConfluenceCommentClient:
|
|
64
|
+
"""
|
|
65
|
+
Full v2 API client for Confluence comments.
|
|
66
|
+
|
|
67
|
+
Footer comments:
|
|
68
|
+
✅ get_all_footer_comments() GET /footer-comments
|
|
69
|
+
✅ create_footer_comment() POST /footer-comments
|
|
70
|
+
✅ get_footer_comment() GET /footer-comments/{id}
|
|
71
|
+
✅ update_footer_comment() PUT /footer-comments/{id}
|
|
72
|
+
✅ delete_footer_comment() DEL /footer-comments/{id}
|
|
73
|
+
✅ get_footer_comment_children() GET /footer-comments/{id}/children
|
|
74
|
+
✅ get_page_footer_comments() GET /pages/{id}/footer-comments
|
|
75
|
+
✅ get_blogpost_footer_comments() GET /blogposts/{id}/footer-comments
|
|
76
|
+
✅ get_attachment_footer_comments() GET /attachments/{id}/footer-comments
|
|
77
|
+
✅ get_custom_content_footer_comments() GET /custom-content/{id}/footer-comments
|
|
78
|
+
|
|
79
|
+
Inline comments:
|
|
80
|
+
✅ get_all_inline_comments() GET /inline-comments
|
|
81
|
+
✅ create_inline_comment() POST /inline-comments
|
|
82
|
+
✅ get_inline_comment() GET /inline-comments/{id}
|
|
83
|
+
✅ update_inline_comment() PUT /inline-comments/{id}
|
|
84
|
+
✅ delete_inline_comment() DEL /inline-comments/{id}
|
|
85
|
+
✅ get_page_inline_comments() GET /pages/{id}/inline-comments
|
|
86
|
+
✅ get_blogpost_inline_comments() GET /blogposts/{id}/inline-comments
|
|
87
|
+
|
|
88
|
+
Reply helpers:
|
|
89
|
+
✅ reply_to_footer_comment() → create_footer_comment(parent_comment_id=...)
|
|
90
|
+
✅ reply_to_inline_comment() → create_inline_comment(parent_comment_id=...)
|
|
91
|
+
|
|
92
|
+
Legacy v1 (for rendered body.view HTML):
|
|
93
|
+
✅ get_comments() GET /wiki/rest/api/content/{id}/child/comment
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, config: ConfluenceConfig):
|
|
97
|
+
self.config = config
|
|
98
|
+
self.base_url = config.base_url.rstrip('/')
|
|
99
|
+
self._v2 = f"{self.base_url}/wiki/api/v2"
|
|
100
|
+
self._v1 = f"{self.base_url}/wiki/rest/api"
|
|
101
|
+
self.auth = HTTPBasicAuth(config.username, config.api_token)
|
|
102
|
+
self.session = requests.Session()
|
|
103
|
+
self.session.auth = self.auth
|
|
104
|
+
|
|
105
|
+
# ──────────────────────────────────────────────
|
|
106
|
+
# Internal helpers
|
|
107
|
+
# ──────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
def _get(self, url: str, params: Optional[Dict] = None) -> Dict:
|
|
110
|
+
"""GET with standard error handling."""
|
|
111
|
+
try:
|
|
112
|
+
response = self.session.get(url, params=params or {})
|
|
113
|
+
response.raise_for_status()
|
|
114
|
+
return response.json()
|
|
115
|
+
except requests.exceptions.HTTPError as e:
|
|
116
|
+
status = e.response.status_code
|
|
117
|
+
if status == 404:
|
|
118
|
+
raise PageNotFoundError(f"Not found: {url}", status_code=404, response=e.response)
|
|
119
|
+
raise ConfluenceApiError(f"GET {url} failed: {e}", status_code=status, response=e.response)
|
|
120
|
+
|
|
121
|
+
def _post(self, url: str, payload: Dict) -> Dict:
|
|
122
|
+
"""POST with standard error handling."""
|
|
123
|
+
try:
|
|
124
|
+
response = self.session.post(url, json=payload, headers={"Content-Type": "application/json"})
|
|
125
|
+
response.raise_for_status()
|
|
126
|
+
return response.json()
|
|
127
|
+
except requests.exceptions.HTTPError as e:
|
|
128
|
+
status = e.response.status_code
|
|
129
|
+
if status == 404:
|
|
130
|
+
raise PageNotFoundError(f"Not found: {url}", status_code=404, response=e.response)
|
|
131
|
+
raise ConfluenceApiError(f"POST {url} failed: {e}", status_code=status, response=e.response)
|
|
132
|
+
|
|
133
|
+
def _put(self, url: str, payload: Dict) -> Dict:
|
|
134
|
+
"""PUT with standard error handling."""
|
|
135
|
+
try:
|
|
136
|
+
response = self.session.put(url, json=payload, headers={"Content-Type": "application/json"})
|
|
137
|
+
response.raise_for_status()
|
|
138
|
+
return response.json()
|
|
139
|
+
except requests.exceptions.HTTPError as e:
|
|
140
|
+
status = e.response.status_code
|
|
141
|
+
if status == 404:
|
|
142
|
+
raise CommentNotFoundError(f"Comment not found: {url}", status_code=404, response=e.response)
|
|
143
|
+
raise ConfluenceApiError(f"PUT {url} failed: {e}", status_code=status, response=e.response)
|
|
144
|
+
|
|
145
|
+
def _delete(self, url: str) -> None:
|
|
146
|
+
"""DELETE with standard error handling."""
|
|
147
|
+
try:
|
|
148
|
+
response = self.session.delete(url)
|
|
149
|
+
response.raise_for_status()
|
|
150
|
+
except requests.exceptions.HTTPError as e:
|
|
151
|
+
status = e.response.status_code
|
|
152
|
+
if status == 404:
|
|
153
|
+
raise CommentNotFoundError(f"Comment not found: {url}", status_code=404, response=e.response)
|
|
154
|
+
raise ConfluenceApiError(f"DELETE {url} failed: {e}", status_code=status, response=e.response)
|
|
155
|
+
|
|
156
|
+
def _collect_pages(self, url: str, params: Optional[Dict] = None) -> List[Dict]:
|
|
157
|
+
"""
|
|
158
|
+
Fetch all results across cursor-based pagination.
|
|
159
|
+
|
|
160
|
+
Follows _links.next until exhausted. Returns flat list of all result objects.
|
|
161
|
+
"""
|
|
162
|
+
params = dict(params or {})
|
|
163
|
+
all_results: List[Dict] = []
|
|
164
|
+
|
|
165
|
+
while True:
|
|
166
|
+
data = self._get(url, params)
|
|
167
|
+
all_results.extend(data.get("results", []))
|
|
168
|
+
|
|
169
|
+
next_cursor = data.get("_links", {}).get("next")
|
|
170
|
+
if not next_cursor:
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
# next_cursor is a relative URL like /wiki/api/v2/...?cursor=xxx&limit=yyy
|
|
174
|
+
# Extract the cursor value from it
|
|
175
|
+
import urllib.parse as _up
|
|
176
|
+
parsed = _up.urlparse(next_cursor)
|
|
177
|
+
qs = _up.parse_qs(parsed.query)
|
|
178
|
+
cursor = qs.get("cursor", [None])[0]
|
|
179
|
+
if not cursor:
|
|
180
|
+
break
|
|
181
|
+
params["cursor"] = cursor
|
|
182
|
+
|
|
183
|
+
return all_results
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _body_payload(body_value: str, representation: str = "storage") -> Dict:
|
|
187
|
+
"""Build body dict for create/update payloads."""
|
|
188
|
+
if representation == "storage" and not body_value.strip().startswith("<"):
|
|
189
|
+
body_value = f"<p>{body_value}</p>"
|
|
190
|
+
return {"representation": representation, "value": body_value}
|
|
191
|
+
|
|
192
|
+
# ──────────────────────────────────────────────
|
|
193
|
+
# Footer comments — READ
|
|
194
|
+
# ──────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
def get_all_footer_comments(
|
|
197
|
+
self,
|
|
198
|
+
body_format: Optional[str] = None,
|
|
199
|
+
sort: Optional[str] = None,
|
|
200
|
+
limit: int = 25,
|
|
201
|
+
) -> List[Dict]:
|
|
202
|
+
"""
|
|
203
|
+
GET /footer-comments — all footer comments (global, paginated).
|
|
204
|
+
|
|
205
|
+
Returns a flat list of all FooterCommentModel objects.
|
|
206
|
+
"""
|
|
207
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
208
|
+
if body_format:
|
|
209
|
+
params["body-format"] = body_format
|
|
210
|
+
if sort:
|
|
211
|
+
params["sort"] = sort
|
|
212
|
+
return self._collect_pages(f"{self._v2}/footer-comments", params)
|
|
213
|
+
|
|
214
|
+
def get_footer_comment(
|
|
215
|
+
self,
|
|
216
|
+
comment_id: str,
|
|
217
|
+
body_format: Optional[str] = None,
|
|
218
|
+
version: Optional[int] = None,
|
|
219
|
+
include_properties: bool = False,
|
|
220
|
+
include_operations: bool = False,
|
|
221
|
+
include_likes: bool = False,
|
|
222
|
+
include_versions: bool = False,
|
|
223
|
+
) -> Dict:
|
|
224
|
+
"""GET /footer-comments/{comment-id} — retrieve a single footer comment."""
|
|
225
|
+
params: Dict[str, Any] = {}
|
|
226
|
+
if body_format:
|
|
227
|
+
params["body-format"] = body_format
|
|
228
|
+
if version is not None:
|
|
229
|
+
params["version"] = version
|
|
230
|
+
if include_properties:
|
|
231
|
+
params["include-properties"] = "true"
|
|
232
|
+
if include_operations:
|
|
233
|
+
params["include-operations"] = "true"
|
|
234
|
+
if include_likes:
|
|
235
|
+
params["include-likes"] = "true"
|
|
236
|
+
if include_versions:
|
|
237
|
+
params["include-versions"] = "true"
|
|
238
|
+
return self._get(f"{self._v2}/footer-comments/{comment_id}", params)
|
|
239
|
+
|
|
240
|
+
def get_footer_comment_children(
|
|
241
|
+
self,
|
|
242
|
+
comment_id: str,
|
|
243
|
+
body_format: Optional[str] = None,
|
|
244
|
+
sort: Optional[str] = None,
|
|
245
|
+
limit: int = 25,
|
|
246
|
+
) -> List[Dict]:
|
|
247
|
+
"""GET /footer-comments/{id}/children — replies to a footer comment (paginated)."""
|
|
248
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
249
|
+
if body_format:
|
|
250
|
+
params["body-format"] = body_format
|
|
251
|
+
if sort:
|
|
252
|
+
params["sort"] = sort
|
|
253
|
+
return self._collect_pages(f"{self._v2}/footer-comments/{comment_id}/children", params)
|
|
254
|
+
|
|
255
|
+
def get_page_footer_comments(
|
|
256
|
+
self,
|
|
257
|
+
page_id: str,
|
|
258
|
+
body_format: Optional[str] = None,
|
|
259
|
+
status: Optional[List[str]] = None,
|
|
260
|
+
sort: Optional[str] = None,
|
|
261
|
+
limit: int = 25,
|
|
262
|
+
) -> List[Dict]:
|
|
263
|
+
"""GET /pages/{id}/footer-comments — root footer comments on a page (paginated)."""
|
|
264
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
265
|
+
if body_format:
|
|
266
|
+
params["body-format"] = body_format
|
|
267
|
+
if status:
|
|
268
|
+
params["status"] = status
|
|
269
|
+
if sort:
|
|
270
|
+
params["sort"] = sort
|
|
271
|
+
return self._collect_pages(f"{self._v2}/pages/{page_id}/footer-comments", params)
|
|
272
|
+
|
|
273
|
+
def get_blogpost_footer_comments(
|
|
274
|
+
self,
|
|
275
|
+
blogpost_id: str,
|
|
276
|
+
body_format: Optional[str] = None,
|
|
277
|
+
status: Optional[List[str]] = None,
|
|
278
|
+
sort: Optional[str] = None,
|
|
279
|
+
limit: int = 25,
|
|
280
|
+
) -> List[Dict]:
|
|
281
|
+
"""GET /blogposts/{id}/footer-comments — root footer comments on a blog post (paginated)."""
|
|
282
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
283
|
+
if body_format:
|
|
284
|
+
params["body-format"] = body_format
|
|
285
|
+
if status:
|
|
286
|
+
params["status"] = status
|
|
287
|
+
if sort:
|
|
288
|
+
params["sort"] = sort
|
|
289
|
+
return self._collect_pages(f"{self._v2}/blogposts/{blogpost_id}/footer-comments", params)
|
|
290
|
+
|
|
291
|
+
def get_attachment_footer_comments(
|
|
292
|
+
self,
|
|
293
|
+
attachment_id: str,
|
|
294
|
+
body_format: Optional[str] = None,
|
|
295
|
+
sort: Optional[str] = None,
|
|
296
|
+
limit: int = 25,
|
|
297
|
+
) -> List[Dict]:
|
|
298
|
+
"""GET /attachments/{id}/footer-comments — footer comments on an attachment (paginated)."""
|
|
299
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
300
|
+
if body_format:
|
|
301
|
+
params["body-format"] = body_format
|
|
302
|
+
if sort:
|
|
303
|
+
params["sort"] = sort
|
|
304
|
+
return self._collect_pages(f"{self._v2}/attachments/{attachment_id}/footer-comments", params)
|
|
305
|
+
|
|
306
|
+
def get_custom_content_footer_comments(
|
|
307
|
+
self,
|
|
308
|
+
content_id: str,
|
|
309
|
+
body_format: Optional[str] = None,
|
|
310
|
+
sort: Optional[str] = None,
|
|
311
|
+
limit: int = 25,
|
|
312
|
+
) -> List[Dict]:
|
|
313
|
+
"""GET /custom-content/{id}/footer-comments — footer comments on custom content (paginated)."""
|
|
314
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
315
|
+
if body_format:
|
|
316
|
+
params["body-format"] = body_format
|
|
317
|
+
if sort:
|
|
318
|
+
params["sort"] = sort
|
|
319
|
+
return self._collect_pages(f"{self._v2}/custom-content/{content_id}/footer-comments", params)
|
|
320
|
+
|
|
321
|
+
# ──────────────────────────────────────────────
|
|
322
|
+
# Footer comments — WRITE
|
|
323
|
+
# ──────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
def create_footer_comment(
|
|
326
|
+
self,
|
|
327
|
+
body_value: str,
|
|
328
|
+
*,
|
|
329
|
+
page_id: Optional[str] = None,
|
|
330
|
+
blogpost_id: Optional[str] = None,
|
|
331
|
+
parent_comment_id: Optional[str] = None,
|
|
332
|
+
attachment_id: Optional[str] = None,
|
|
333
|
+
custom_content_id: Optional[str] = None,
|
|
334
|
+
body_representation: str = "storage",
|
|
335
|
+
) -> Dict:
|
|
336
|
+
"""
|
|
337
|
+
POST /footer-comments — create a footer comment.
|
|
338
|
+
|
|
339
|
+
Exactly one of page_id, blogpost_id, attachment_id, or custom_content_id must be
|
|
340
|
+
provided (or parent_comment_id for a reply).
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
body_value: Comment body text (HTML for storage, plain text auto-wrapped).
|
|
344
|
+
page_id: Target page ID.
|
|
345
|
+
blogpost_id: Target blog post ID.
|
|
346
|
+
parent_comment_id: Parent comment ID for replies.
|
|
347
|
+
attachment_id: Target attachment ID.
|
|
348
|
+
custom_content_id: Target custom content ID.
|
|
349
|
+
body_representation: Body format — "storage" (default) or "atlas_doc_format".
|
|
350
|
+
|
|
351
|
+
Example:
|
|
352
|
+
>>> comment = client.create_footer_comment("Great doc!", page_id="123456")
|
|
353
|
+
>>> reply = client.create_footer_comment("I agree!", parent_comment_id="789")
|
|
354
|
+
"""
|
|
355
|
+
payload: Dict[str, Any] = {"body": self._body_payload(body_value, body_representation)}
|
|
356
|
+
if page_id:
|
|
357
|
+
payload["pageId"] = page_id
|
|
358
|
+
if blogpost_id:
|
|
359
|
+
payload["blogPostId"] = blogpost_id
|
|
360
|
+
if parent_comment_id:
|
|
361
|
+
payload["parentCommentId"] = parent_comment_id
|
|
362
|
+
if attachment_id:
|
|
363
|
+
payload["attachmentId"] = attachment_id
|
|
364
|
+
if custom_content_id:
|
|
365
|
+
payload["customContentId"] = custom_content_id
|
|
366
|
+
return self._post(f"{self._v2}/footer-comments", payload)
|
|
367
|
+
|
|
368
|
+
def reply_to_footer_comment(self, parent_comment_id: str, body_value: str) -> Dict:
|
|
369
|
+
"""Convenience wrapper: create_footer_comment(parent_comment_id=...)."""
|
|
370
|
+
return self.create_footer_comment(body_value, parent_comment_id=parent_comment_id)
|
|
371
|
+
|
|
372
|
+
def update_footer_comment(
|
|
373
|
+
self,
|
|
374
|
+
comment_id: str,
|
|
375
|
+
body_value: str,
|
|
376
|
+
version_number: int,
|
|
377
|
+
version_message: str = "",
|
|
378
|
+
body_representation: str = "storage",
|
|
379
|
+
) -> Dict:
|
|
380
|
+
"""
|
|
381
|
+
PUT /footer-comments/{comment-id} — update a footer comment body.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
comment_id: ID of the comment to update.
|
|
385
|
+
body_value: New body text.
|
|
386
|
+
version_number: Current version number + 1 (Confluence increments versions).
|
|
387
|
+
version_message: Optional change message.
|
|
388
|
+
body_representation: "storage" or "atlas_doc_format".
|
|
389
|
+
"""
|
|
390
|
+
payload: Dict[str, Any] = {
|
|
391
|
+
"version": {"number": version_number, "message": version_message},
|
|
392
|
+
"body": self._body_payload(body_value, body_representation),
|
|
393
|
+
}
|
|
394
|
+
return self._put(f"{self._v2}/footer-comments/{comment_id}", payload)
|
|
395
|
+
|
|
396
|
+
def delete_footer_comment(self, comment_id: str) -> None:
|
|
397
|
+
"""DELETE /footer-comments/{comment-id} — permanently delete a footer comment."""
|
|
398
|
+
self._delete(f"{self._v2}/footer-comments/{comment_id}")
|
|
399
|
+
|
|
400
|
+
# ──────────────────────────────────────────────
|
|
401
|
+
# Inline comments — READ
|
|
402
|
+
# ──────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
def get_all_inline_comments(
|
|
405
|
+
self,
|
|
406
|
+
body_format: Optional[str] = None,
|
|
407
|
+
sort: Optional[str] = None,
|
|
408
|
+
limit: int = 25,
|
|
409
|
+
) -> List[Dict]:
|
|
410
|
+
"""
|
|
411
|
+
GET /inline-comments — all inline comments (global, paginated).
|
|
412
|
+
|
|
413
|
+
Returns a flat list of all InlineCommentModel objects including
|
|
414
|
+
resolutionStatus and inlineOriginalSelection.
|
|
415
|
+
"""
|
|
416
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
417
|
+
if body_format:
|
|
418
|
+
params["body-format"] = body_format
|
|
419
|
+
if sort:
|
|
420
|
+
params["sort"] = sort
|
|
421
|
+
return self._collect_pages(f"{self._v2}/inline-comments", params)
|
|
422
|
+
|
|
423
|
+
def get_inline_comment(
|
|
424
|
+
self,
|
|
425
|
+
comment_id: str,
|
|
426
|
+
body_format: Optional[str] = None,
|
|
427
|
+
version: Optional[int] = None,
|
|
428
|
+
include_properties: bool = False,
|
|
429
|
+
include_operations: bool = False,
|
|
430
|
+
include_likes: bool = False,
|
|
431
|
+
include_versions: bool = False,
|
|
432
|
+
) -> Dict:
|
|
433
|
+
"""GET /inline-comments/{comment-id} — retrieve a single inline comment."""
|
|
434
|
+
params: Dict[str, Any] = {}
|
|
435
|
+
if body_format:
|
|
436
|
+
params["body-format"] = body_format
|
|
437
|
+
if version is not None:
|
|
438
|
+
params["version"] = version
|
|
439
|
+
if include_properties:
|
|
440
|
+
params["include-properties"] = "true"
|
|
441
|
+
if include_operations:
|
|
442
|
+
params["include-operations"] = "true"
|
|
443
|
+
if include_likes:
|
|
444
|
+
params["include-likes"] = "true"
|
|
445
|
+
if include_versions:
|
|
446
|
+
params["include-versions"] = "true"
|
|
447
|
+
return self._get(f"{self._v2}/inline-comments/{comment_id}", params)
|
|
448
|
+
|
|
449
|
+
def get_page_inline_comments(
|
|
450
|
+
self,
|
|
451
|
+
page_id: str,
|
|
452
|
+
body_format: Optional[str] = None,
|
|
453
|
+
status: Optional[List[str]] = None,
|
|
454
|
+
resolution_status: Optional[List[str]] = None,
|
|
455
|
+
sort: Optional[str] = None,
|
|
456
|
+
limit: int = 25,
|
|
457
|
+
) -> List[Dict]:
|
|
458
|
+
"""
|
|
459
|
+
GET /pages/{id}/inline-comments — root inline comments on a page (paginated).
|
|
460
|
+
|
|
461
|
+
Each result includes:
|
|
462
|
+
- resolutionStatus: "open" | "resolved"
|
|
463
|
+
- properties.inlineOriginalSelection: the highlighted text
|
|
464
|
+
- properties.inlineMarkerRef: marker reference for the highlight position
|
|
465
|
+
"""
|
|
466
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
467
|
+
if body_format:
|
|
468
|
+
params["body-format"] = body_format
|
|
469
|
+
if status:
|
|
470
|
+
params["status"] = status
|
|
471
|
+
if resolution_status:
|
|
472
|
+
params["resolution-status"] = resolution_status
|
|
473
|
+
if sort:
|
|
474
|
+
params["sort"] = sort
|
|
475
|
+
return self._collect_pages(f"{self._v2}/pages/{page_id}/inline-comments", params)
|
|
476
|
+
|
|
477
|
+
def get_blogpost_inline_comments(
|
|
478
|
+
self,
|
|
479
|
+
blogpost_id: str,
|
|
480
|
+
body_format: Optional[str] = None,
|
|
481
|
+
status: Optional[List[str]] = None,
|
|
482
|
+
resolution_status: Optional[List[str]] = None,
|
|
483
|
+
sort: Optional[str] = None,
|
|
484
|
+
limit: int = 25,
|
|
485
|
+
) -> List[Dict]:
|
|
486
|
+
"""GET /blogposts/{id}/inline-comments — root inline comments on a blog post (paginated)."""
|
|
487
|
+
params: Dict[str, Any] = {"limit": limit}
|
|
488
|
+
if body_format:
|
|
489
|
+
params["body-format"] = body_format
|
|
490
|
+
if status:
|
|
491
|
+
params["status"] = status
|
|
492
|
+
if resolution_status:
|
|
493
|
+
params["resolution-status"] = resolution_status
|
|
494
|
+
if sort:
|
|
495
|
+
params["sort"] = sort
|
|
496
|
+
return self._collect_pages(f"{self._v2}/blogposts/{blogpost_id}/inline-comments", params)
|
|
497
|
+
|
|
498
|
+
# ──────────────────────────────────────────────
|
|
499
|
+
# Inline comments — WRITE
|
|
500
|
+
# ──────────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
def create_inline_comment(
|
|
503
|
+
self,
|
|
504
|
+
body_value: str,
|
|
505
|
+
*,
|
|
506
|
+
page_id: Optional[str] = None,
|
|
507
|
+
blogpost_id: Optional[str] = None,
|
|
508
|
+
parent_comment_id: Optional[str] = None,
|
|
509
|
+
text_selection: Optional[str] = None,
|
|
510
|
+
text_selection_match_count: int = 1,
|
|
511
|
+
text_selection_match_index: int = 0,
|
|
512
|
+
body_representation: str = "storage",
|
|
513
|
+
) -> Dict:
|
|
514
|
+
"""
|
|
515
|
+
POST /inline-comments — create an inline comment (v2 API).
|
|
516
|
+
|
|
517
|
+
For new inline comments, provide text_selection to anchor the highlight.
|
|
518
|
+
For replies to existing inline comments, provide parent_comment_id.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
body_value: Comment text.
|
|
522
|
+
page_id: Target page ID.
|
|
523
|
+
blogpost_id: Target blog post ID.
|
|
524
|
+
parent_comment_id: Parent inline comment ID for replies.
|
|
525
|
+
text_selection: Exact text to highlight. Required for new (non-reply) comments.
|
|
526
|
+
text_selection_match_count: Total occurrences of text_selection in the page.
|
|
527
|
+
text_selection_match_index: Which occurrence to anchor (0-indexed).
|
|
528
|
+
body_representation: "storage" or "atlas_doc_format".
|
|
529
|
+
|
|
530
|
+
Example:
|
|
531
|
+
# New inline comment on highlighted text
|
|
532
|
+
>>> client.create_inline_comment(
|
|
533
|
+
... "This needs clarification", page_id="123", text_selection="SLA targets"
|
|
534
|
+
... )
|
|
535
|
+
# Reply to existing inline comment
|
|
536
|
+
>>> client.create_inline_comment(
|
|
537
|
+
... "Agreed", parent_comment_id="456"
|
|
538
|
+
... )
|
|
539
|
+
"""
|
|
540
|
+
payload: Dict[str, Any] = {"body": self._body_payload(body_value, body_representation)}
|
|
541
|
+
if page_id:
|
|
542
|
+
payload["pageId"] = page_id
|
|
543
|
+
if blogpost_id:
|
|
544
|
+
payload["blogPostId"] = blogpost_id
|
|
545
|
+
if parent_comment_id:
|
|
546
|
+
payload["parentCommentId"] = parent_comment_id
|
|
547
|
+
if text_selection and not parent_comment_id:
|
|
548
|
+
payload["inlineCommentProperties"] = {
|
|
549
|
+
"textSelection": text_selection,
|
|
550
|
+
"textSelectionMatchCount": text_selection_match_count,
|
|
551
|
+
"textSelectionMatchIndex": text_selection_match_index,
|
|
552
|
+
}
|
|
553
|
+
return self._post(f"{self._v2}/inline-comments", payload)
|
|
554
|
+
|
|
555
|
+
def reply_to_inline_comment(self, parent_comment_id: str, body_value: str) -> Dict:
|
|
556
|
+
"""
|
|
557
|
+
Reply to an inline comment (v2 API).
|
|
558
|
+
|
|
559
|
+
Previously unsupported via v1. Now fully supported via v2
|
|
560
|
+
POST /inline-comments with parentCommentId.
|
|
561
|
+
"""
|
|
562
|
+
return self.create_inline_comment(body_value, parent_comment_id=parent_comment_id)
|
|
563
|
+
|
|
564
|
+
def update_inline_comment(
|
|
565
|
+
self,
|
|
566
|
+
comment_id: str,
|
|
567
|
+
version_number: int,
|
|
568
|
+
body_value: Optional[str] = None,
|
|
569
|
+
resolved: Optional[bool] = None,
|
|
570
|
+
version_message: str = "",
|
|
571
|
+
body_representation: str = "storage",
|
|
572
|
+
) -> Dict:
|
|
573
|
+
"""
|
|
574
|
+
PUT /inline-comments/{comment-id} — update body and/or resolve an inline comment.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
comment_id: ID of the inline comment.
|
|
578
|
+
version_number: New version number (current + 1).
|
|
579
|
+
body_value: Updated body text (omit to keep unchanged, but version still increments).
|
|
580
|
+
resolved: True to resolve the comment thread, False to re-open.
|
|
581
|
+
version_message: Optional change message.
|
|
582
|
+
body_representation: "storage" or "atlas_doc_format".
|
|
583
|
+
|
|
584
|
+
Example:
|
|
585
|
+
# Resolve an inline comment
|
|
586
|
+
>>> client.update_inline_comment("789", version_number=2, resolved=True)
|
|
587
|
+
"""
|
|
588
|
+
payload: Dict[str, Any] = {
|
|
589
|
+
"version": {"number": version_number, "message": version_message},
|
|
590
|
+
}
|
|
591
|
+
if body_value is not None:
|
|
592
|
+
payload["body"] = self._body_payload(body_value, body_representation)
|
|
593
|
+
if resolved is not None:
|
|
594
|
+
payload["resolved"] = resolved
|
|
595
|
+
return self._put(f"{self._v2}/inline-comments/{comment_id}", payload)
|
|
596
|
+
|
|
597
|
+
def resolve_inline_comment(self, comment_id: str, version_number: int) -> Dict:
|
|
598
|
+
"""Convenience wrapper: update_inline_comment(..., resolved=True)."""
|
|
599
|
+
return self.update_inline_comment(comment_id, version_number, resolved=True)
|
|
600
|
+
|
|
601
|
+
def delete_inline_comment(self, comment_id: str) -> None:
|
|
602
|
+
"""DELETE /inline-comments/{comment-id} — permanently delete an inline comment."""
|
|
603
|
+
self._delete(f"{self._v2}/inline-comments/{comment_id}")
|
|
604
|
+
|
|
605
|
+
# ──────────────────────────────────────────────
|
|
606
|
+
# Legacy v1 — retained for rendered body.view
|
|
607
|
+
# ──────────────────────────────────────────────
|
|
608
|
+
|
|
609
|
+
def get_comments(
|
|
610
|
+
self,
|
|
611
|
+
page_id: str,
|
|
612
|
+
expand: str = "body.view,version,extensions",
|
|
613
|
+
) -> Dict:
|
|
614
|
+
"""
|
|
615
|
+
GET /wiki/rest/api/content/{id}/child/comment (v1) — all comments with rendered HTML.
|
|
616
|
+
|
|
617
|
+
Use this when you need body.view.value (rendered HTML for text extraction).
|
|
618
|
+
The v2 list endpoints do not return body.view.
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
Dict with 'results' list. Each comment has body.view.value (rendered HTML).
|
|
622
|
+
|
|
623
|
+
Example:
|
|
624
|
+
>>> comments = client.get_comments("1145569739")
|
|
625
|
+
>>> for c in comments["results"]:
|
|
626
|
+
... print(c["body"]["view"]["value"])
|
|
627
|
+
"""
|
|
628
|
+
return self._get(
|
|
629
|
+
f"{self._v1}/content/{page_id}/child/comment",
|
|
630
|
+
{"expand": expand},
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
# Aliases kept for backwards compatibility
|
|
634
|
+
def get_footer_comments(self, page_id: str) -> Dict:
|
|
635
|
+
"""
|
|
636
|
+
Alias for get_page_footer_comments() returning raw API dict (not flat list).
|
|
637
|
+
|
|
638
|
+
Kept for backwards compatibility with crawler._save_comments().
|
|
639
|
+
"""
|
|
640
|
+
return self._get(f"{self._v2}/pages/{page_id}/footer-comments")
|
|
641
|
+
|
|
642
|
+
def get_inline_comments(self, page_id: str) -> Dict:
|
|
643
|
+
"""
|
|
644
|
+
Alias for get_page_inline_comments() returning raw API dict (not flat list).
|
|
645
|
+
|
|
646
|
+
Kept for backwards compatibility with crawler._save_comments().
|
|
647
|
+
"""
|
|
648
|
+
return self._get(f"{self._v2}/pages/{page_id}/inline-comments")
|
|
649
|
+
|
|
650
|
+
# ──────────────────────────────────────────────
|
|
651
|
+
# Formatting helpers
|
|
652
|
+
# ──────────────────────────────────────────────
|
|
653
|
+
|
|
654
|
+
def format_comment_summary(self, comment: Dict) -> str:
|
|
655
|
+
"""
|
|
656
|
+
Format a v1 API comment as a human-readable string.
|
|
657
|
+
|
|
658
|
+
Works with get_comments() results (has body.view.value for rendered HTML).
|
|
659
|
+
For v2 comments, use crawler._format_comment_md() instead.
|
|
660
|
+
"""
|
|
661
|
+
comment_id = comment.get("id", "unknown")
|
|
662
|
+
author = comment.get("version", {}).get("by", {}).get("displayName", "Unknown")
|
|
663
|
+
date = comment.get("version", {}).get("friendlyWhen", "Unknown date")
|
|
664
|
+
location = comment.get("extensions", {}).get("location", "footer")
|
|
665
|
+
|
|
666
|
+
body_html = comment.get("body", {}).get("view", {}).get("value", "")
|
|
667
|
+
body_text = re.sub("<[^<]+?>", "", body_html)
|
|
668
|
+
body_preview = body_text[:100] + "..." if len(body_text) > 100 else body_text
|
|
669
|
+
|
|
670
|
+
return (
|
|
671
|
+
f"ID: {comment_id}\n"
|
|
672
|
+
f"Author: {author}\n"
|
|
673
|
+
f"Date: {date}\n"
|
|
674
|
+
f"Type: {location}\n"
|
|
675
|
+
f"Content: {body_preview}\n"
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
def save_comments_to_json(self, comments: Dict, output_path: str) -> None:
|
|
679
|
+
"""Save a comments response dict to a JSON file."""
|
|
680
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
681
|
+
json.dump(comments, f, indent=2, ensure_ascii=False)
|
|
682
|
+
logger.info(f"Saved {len(comments.get('results', []))} comments to {output_path}")
|