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.
Files changed (65) hide show
  1. docspan/__init__.py +3 -0
  2. docspan/__main__.py +0 -0
  3. docspan/backends/__init__.py +19 -0
  4. docspan/backends/base.py +85 -0
  5. docspan/backends/confluence/__init__.py +0 -0
  6. docspan/backends/confluence/adf/__init__.py +14 -0
  7. docspan/backends/confluence/adf/comparator.py +427 -0
  8. docspan/backends/confluence/adf/converter.py +119 -0
  9. docspan/backends/confluence/adf/converters.py +1449 -0
  10. docspan/backends/confluence/adf/interfaces.py +191 -0
  11. docspan/backends/confluence/adf/nodes.py +2085 -0
  12. docspan/backends/confluence/adf/parser.py +400 -0
  13. docspan/backends/confluence/adf/validators.py +161 -0
  14. docspan/backends/confluence/adf/visitors.py +495 -0
  15. docspan/backends/confluence/backend.py +227 -0
  16. docspan/backends/confluence/client.py +44 -0
  17. docspan/backends/confluence/config/__init__.py +21 -0
  18. docspan/backends/confluence/config/loader.py +107 -0
  19. docspan/backends/confluence/config/models.py +167 -0
  20. docspan/backends/confluence/config/validation.py +297 -0
  21. docspan/backends/confluence/markdown/__init__.py +22 -0
  22. docspan/backends/confluence/markdown/ast.py +819 -0
  23. docspan/backends/confluence/markdown/extensions/__init__.py +5 -0
  24. docspan/backends/confluence/markdown/extensions/frontmatter.py +80 -0
  25. docspan/backends/confluence/markdown/extensions/mermaid.py +64 -0
  26. docspan/backends/confluence/markdown/extensions/wikilinks.py +179 -0
  27. docspan/backends/confluence/markdown/inline_parser.py +495 -0
  28. docspan/backends/confluence/markdown/parser.py +1006 -0
  29. docspan/backends/confluence/models/__init__.py +18 -0
  30. docspan/backends/confluence/models/markdown_file.py +402 -0
  31. docspan/backends/confluence/models/page.py +212 -0
  32. docspan/backends/confluence/models/path_utils.py +34 -0
  33. docspan/backends/confluence/models/results.py +28 -0
  34. docspan/backends/confluence/models/sync_status.py +382 -0
  35. docspan/backends/confluence/services/__init__.py +0 -0
  36. docspan/backends/confluence/services/confluence/__init__.py +40 -0
  37. docspan/backends/confluence/services/confluence/attachment_client.py +147 -0
  38. docspan/backends/confluence/services/confluence/base_client.py +420 -0
  39. docspan/backends/confluence/services/confluence/client.py +376 -0
  40. docspan/backends/confluence/services/confluence/comment_client.py +682 -0
  41. docspan/backends/confluence/services/confluence/crawler.py +587 -0
  42. docspan/backends/confluence/services/confluence/label_client.py +130 -0
  43. docspan/backends/confluence/services/confluence/page_client.py +1288 -0
  44. docspan/backends/confluence/services/confluence/space_client.py +179 -0
  45. docspan/backends/confluence/services/confluence/url_parser.py +106 -0
  46. docspan/backends/google_docs/__init__.py +0 -0
  47. docspan/backends/google_docs/auth.py +143 -0
  48. docspan/backends/google_docs/backend.py +140 -0
  49. docspan/backends/google_docs/client.py +665 -0
  50. docspan/backends/google_docs/converter.py +471 -0
  51. docspan/backends/google_docs/docs_request_builder.py +232 -0
  52. docspan/backends/google_docs/docs_structure_parser.py +120 -0
  53. docspan/backends/google_docs/markdown_to_paragraph_parser.py +145 -0
  54. docspan/cli/__init__.py +0 -0
  55. docspan/cli/main.py +408 -0
  56. docspan/config.py +62 -0
  57. docspan/core/__init__.py +49 -0
  58. docspan/core/merge.py +30 -0
  59. docspan/core/orchestrator.py +332 -0
  60. docspan/core/paths.py +8 -0
  61. docspan/core/state.py +53 -0
  62. docspan-0.1.0.dist-info/METADATA +273 -0
  63. docspan-0.1.0.dist-info/RECORD +65 -0
  64. docspan-0.1.0.dist-info/WHEEL +4 -0
  65. 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}")