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,1288 @@
1
+ """
2
+ Client for managing Confluence pages.
3
+
4
+ This module provides specialized functionality for working with Confluence pages,
5
+ including creating, updating, getting, and deleting pages.
6
+ """
7
+
8
+ import json
9
+ from typing import Any, Dict, List, Literal, Optional
10
+
11
+ from docspan.backends.confluence.config.models import ConfluenceConfig
12
+ from docspan.backends.confluence.models.page import ConfluencePage
13
+ from docspan.backends.confluence.services.confluence.base_client import (
14
+ ArchivedPageError,
15
+ BaseConfluenceClient,
16
+ ConfluenceApiError,
17
+ PageNotFoundError,
18
+ )
19
+
20
+
21
+ class PageClient(BaseConfluenceClient):
22
+ """
23
+ Client for managing Confluence pages.
24
+
25
+ Provides methods for creating, updating, retrieving, and deleting pages.
26
+ """
27
+
28
+ def __init__(self, config: ConfluenceConfig):
29
+ """
30
+ Initialize the page client.
31
+
32
+ Args:
33
+ config: Confluence configuration
34
+ """
35
+ super().__init__(config)
36
+
37
+ # Cache for parent page information
38
+ self._parent_page_info = None
39
+
40
+ def get_page(self, page_id: str, detect_editor: bool = True) -> Dict[str, Any]:
41
+ """
42
+ Get a page from Confluence by ID.
43
+
44
+ Args:
45
+ page_id: Confluence page ID
46
+ detect_editor: Whether to detect and include editor type information
47
+
48
+ Returns:
49
+ Page data with optional editor type detection
50
+
51
+ Raises:
52
+ PageNotFoundError: If the page doesn't exist
53
+ ConfluenceApiError: For other API errors
54
+ """
55
+ # Expand metadata.properties to detect editor type
56
+ expand_params = "body.atlas_doc_format,body.storage,version,ancestors,space"
57
+ if detect_editor:
58
+ expand_params += ",metadata.properties"
59
+
60
+ page_data = self._make_request(
61
+ method="GET",
62
+ endpoint=f"content/{page_id}",
63
+ params={"expand": expand_params}
64
+ )
65
+
66
+ # Detect and annotate editor type
67
+ if detect_editor:
68
+ page_data['_editor_type'] = self._detect_editor_type(page_data)
69
+
70
+ return page_data
71
+
72
+ def get_page_by_id(self, page_id: str) -> Dict[str, Any]:
73
+ """
74
+ Alternative method to get a page by ID with more detailed error handling.
75
+
76
+ This method is optimized for page validation and provides more graceful error handling.
77
+
78
+ Args:
79
+ page_id: Confluence page ID
80
+
81
+ Returns:
82
+ Page data or empty dict if page not found
83
+ """
84
+ try:
85
+ # Try standard get_page first
86
+ return self.get_page(page_id)
87
+ except (PageNotFoundError, ArchivedPageError):
88
+ # Try with alternate parameters that might work for archived pages
89
+ try:
90
+ return self._make_request(
91
+ method="GET",
92
+ endpoint=f"content/{page_id}",
93
+ params={"expand": "version,ancestors,space", "status": "any"},
94
+ handle_errors=False
95
+ )
96
+ except Exception:
97
+ # Final attempt with minimal parameters
98
+ try:
99
+ return self._make_request(
100
+ method="GET",
101
+ endpoint=f"content/{page_id}",
102
+ params={"status": "any"},
103
+ handle_errors=False
104
+ )
105
+ except Exception:
106
+ return {}
107
+ except Exception:
108
+ # Return empty dict for any other errors
109
+ return {}
110
+
111
+ def get_page_version(self, page_id: str, version_number: int) -> Dict[str, Any]:
112
+ """
113
+ Get a specific version of a page from Confluence.
114
+
115
+ Args:
116
+ page_id: Confluence page ID
117
+ version_number: Version number to retrieve
118
+
119
+ Returns:
120
+ Page data for the specified version
121
+
122
+ Raises:
123
+ PageNotFoundError: If the page or version doesn't exist
124
+ ConfluenceApiError: For other API errors
125
+ """
126
+ page_data = self._make_request(
127
+ method="GET",
128
+ endpoint=f"content/{page_id}",
129
+ params={
130
+ "status": "historical",
131
+ "version": version_number,
132
+ "expand": "body.atlas_doc_format,body.storage,version,ancestors,space"
133
+ }
134
+ )
135
+ return page_data
136
+
137
+ def get_all_page_versions(self, page_id: str) -> List[Dict[str, Any]]:
138
+ """
139
+ Get all versions of a page from Confluence.
140
+
141
+ Args:
142
+ page_id: Confluence page ID
143
+
144
+ Returns:
145
+ List of all page versions
146
+
147
+ Raises:
148
+ PageNotFoundError: If the page doesn't exist
149
+ ConfluenceApiError: For other API errors
150
+ """
151
+ response = self._make_request(
152
+ method="GET",
153
+ endpoint=f"content/{page_id}/version"
154
+ )
155
+ return response.get("results", [])
156
+
157
+ def find_page_by_title(self, space_key: str, title: str) -> Optional[Dict[str, Any]]:
158
+ """
159
+ Find a page by title within a space.
160
+
161
+ Args:
162
+ space_key: Confluence space key
163
+ title: Page title to find
164
+
165
+ Returns:
166
+ Page data or None if not found
167
+ """
168
+ response = self._make_request(
169
+ method="GET",
170
+ endpoint="content",
171
+ params={"spaceKey": space_key, "title": title, "expand": "version"}
172
+ )
173
+
174
+ results = response.get("results", [])
175
+ return results[0] if results else None
176
+
177
+ def create_page(self, page: ConfluencePage) -> Dict[str, Any]:
178
+ """
179
+ Create a new page in Confluence.
180
+
181
+ Args:
182
+ page: Page data to create
183
+
184
+ Returns:
185
+ Created page data
186
+
187
+ Raises:
188
+ ValueError: If required fields are missing
189
+ ConfluenceApiError: For API errors
190
+ """
191
+ # Try to get space key from different sources in priority order:
192
+ # 1. Page's space_key if set
193
+ # 2. Parent page if parent_id is set
194
+ # 3. Config's space_key as default fallback
195
+
196
+ space_key = page.space_key
197
+ parent_id = page.parent_id
198
+
199
+ # Validate parent page exists if specified
200
+ if parent_id:
201
+ try:
202
+ # Try to get the parent page to verify it exists
203
+ parent_exists = False
204
+ try:
205
+ parent_page = self.get_page(parent_id)
206
+ parent_exists = True
207
+ self.logger.debug(f"Parent page exists: {parent_id}")
208
+
209
+ # If we don't have a space key yet, get it from the parent
210
+ if not space_key and parent_page and "space" in parent_page:
211
+ space_key = parent_page.get("space", {}).get("key")
212
+ if space_key:
213
+ self.logger.debug(f"Using space key '{space_key}' from parent page {parent_id}")
214
+ except Exception as e:
215
+ self.logger.warning(f"Failed to verify parent page {parent_id}: {e}")
216
+ parent_exists = False
217
+
218
+ # If parent doesn't exist, raise a specific exception to handle this case properly
219
+ # rather than silently creating at root level
220
+ if not parent_exists:
221
+ self.logger.warning(f"Parent page {parent_id} does not exist or is not accessible.")
222
+ # We'll raise an error but keep the parent_id value in the file
223
+ # This allows the hierarchical structure to be preserved even when parent pages don't exist yet
224
+ raise ValueError(f"Parent page {parent_id} does not exist or is not accessible. Hierarchical publishing may require processing parent pages first.")
225
+
226
+ except Exception as e:
227
+ # Any error here means the parent page likely doesn't exist
228
+ self.logger.warning(f"Error checking parent page {parent_id}, assuming not available: {e}")
229
+ parent_id = None
230
+
231
+ # Try parent if no space_key specified (as a backup if direct check failed)
232
+ if not space_key and parent_id:
233
+ space_key = self.get_space_key_from_parent(parent_id)
234
+ if space_key:
235
+ self.logger.debug(f"Using space key '{space_key}' from parent page {parent_id}")
236
+
237
+ # Fall back to config's default space key if available
238
+ if not space_key and hasattr(self.config, 'space_key') and self.config.space_key:
239
+ space_key = self.config.space_key
240
+ self.logger.debug(f"Using default space key '{space_key}' from configuration")
241
+
242
+ # If we still don't have a space key, we can't create the page
243
+ if not space_key:
244
+ raise ValueError("Space key is required for creating pages. Either specify it directly, provide a valid parent page ID, or set a default space key in configuration.")
245
+
246
+ # Build request data
247
+ data = {
248
+ "type": "page",
249
+ "title": page.title,
250
+ "space": {"key": space_key},
251
+ "ancestors": [{"id": parent_id}] if parent_id else [],
252
+ }
253
+
254
+ # SECURITY: If restrictions will be applied, create as draft first to avoid public exposure
255
+ # Confluence Cloud doesn't support restrictions in create payload, so we use draft status
256
+ # to keep the page hidden until restrictions are set
257
+ create_as_draft = bool(page.restrictions)
258
+ if create_as_draft:
259
+ data["status"] = "draft"
260
+ self.logger.info(f"Creating page '{page.title}' as draft (restrictions will be applied before publishing)")
261
+
262
+ # Add content based on type
263
+ data["body"] = self._format_page_content(page.content)
264
+
265
+ # Create the page
266
+ self.logger.debug(f"Creating page '{page.title}' in space {space_key} with parent ID {parent_id}")
267
+ result = self._make_request(
268
+ method="POST",
269
+ endpoint="content",
270
+ json_data=data
271
+ )
272
+
273
+ # Set restrictions after page creation if provided
274
+ if page.restrictions and result.get("id"):
275
+ page_id = result["id"]
276
+ self.logger.info(f"Setting restrictions on newly created page {page_id}")
277
+ self.set_page_restrictions(page_id, page.restrictions)
278
+
279
+ # Now publish the page (transition from draft to current)
280
+ if create_as_draft:
281
+ self.logger.info(f"Publishing page {page_id} after restrictions applied")
282
+ self._publish_draft_page(page_id, result.get("version", {}).get("number", 1))
283
+
284
+ return result
285
+
286
+ def update_page(self, page: ConfluencePage) -> Dict[str, Any]:
287
+ """
288
+ Update an existing page in Confluence.
289
+
290
+ Args:
291
+ page: Page data to update
292
+
293
+ Returns:
294
+ Updated page data
295
+
296
+ Raises:
297
+ ValueError: If page ID is missing
298
+ ArchivedPageError: If the page is archived
299
+ PageNotFoundError: If the page doesn't exist
300
+ ConfluenceApiError: For other API errors
301
+ """
302
+ if not page.id:
303
+ raise ValueError("Page ID is required for update")
304
+
305
+ # Get current version and parent if not specified
306
+ page_data = None
307
+ if page.version is None or page.parent_id:
308
+ try:
309
+ page_data = self.get_page(page.id)
310
+ page.version = page_data.get("version", {}).get("number", 0)
311
+ except (PageNotFoundError, ArchivedPageError):
312
+ # These errors should propagate up
313
+ raise
314
+ except Exception as e:
315
+ self.logger.warning(f"Couldn't get current page version: {e}")
316
+ raise
317
+
318
+ # Check if parent needs to be changed
319
+ if page.parent_id and page_data:
320
+ current_parent_id = None
321
+ if "ancestors" in page_data and page_data["ancestors"]:
322
+ current_parent_id = page_data["ancestors"][-1]["id"]
323
+
324
+ # If parent is different, move the page first
325
+ if current_parent_id != page.parent_id:
326
+ # Guard: a page cannot be its own parent (e.g. space landing pages whose
327
+ # connie-parent-id matches their own page ID). Skip the move silently.
328
+ if page.id == page.parent_id:
329
+ self.logger.debug(
330
+ f"Skipping move for page {page.id}: target parent is the page itself "
331
+ "(expected for root/landing pages)"
332
+ )
333
+ else:
334
+ self.logger.info(
335
+ f"Parent changed from {current_parent_id} to {page.parent_id} for page {page.id}. "
336
+ "Moving page before updating content."
337
+ )
338
+ try:
339
+ self.move_page(page.id, page.parent_id, position='append')
340
+ self.logger.info(f"Successfully moved page {page.id} to new parent {page.parent_id}")
341
+ except Exception as move_error:
342
+ self.logger.error(f"Failed to move page to new parent: {move_error}")
343
+ # Continue with content update even if move fails
344
+
345
+ # Build request data
346
+ data = {
347
+ "type": "page",
348
+ "title": page.title,
349
+ "version": {"number": page.version + 1},
350
+ }
351
+
352
+ # Add space key if provided
353
+ if page.space_key:
354
+ data["space"] = {"key": page.space_key}
355
+
356
+ # Note: Don't add restrictions to update payload - will be set separately after update
357
+
358
+ # Add comment if force update is enabled
359
+ if hasattr(page, 'force_update') and page.force_update:
360
+ import time
361
+ timestamp = str(int(time.time()))
362
+ data["metadata"] = {
363
+ "comment": f"Forced update at {timestamp}"
364
+ }
365
+
366
+ # Add content
367
+ data["body"] = self._format_page_content(page.content)
368
+
369
+ # Update the page
370
+ self.logger.debug(f"Updating page '{page.title}' (ID: {page.id}, version: {page.version})")
371
+
372
+ try:
373
+ result = self._make_request(
374
+ method="PUT",
375
+ endpoint=f"content/{page.id}",
376
+ json_data=data
377
+ )
378
+
379
+ # Check if version actually incremented
380
+ new_version = result.get('version', {}).get('number')
381
+ if new_version <= page.version:
382
+ self.logger.warning(
383
+ f"Version didn't increment (was: {page.version}, now: {new_version}). "
384
+ f"Content may not have changed enough for Confluence to create a new version."
385
+ )
386
+ else:
387
+ self.logger.debug(f"Version successfully incremented to {new_version}")
388
+
389
+ # Set restrictions after page update if provided
390
+ if page.restrictions and page.id:
391
+ self.logger.debug(f"Setting restrictions on updated page {page.id}")
392
+ self.set_page_restrictions(page.id, page.restrictions)
393
+
394
+ return result
395
+ except ArchivedPageError:
396
+ # Return structured error info for archived pages
397
+ return self._create_archived_page_error(page.id, page.title)
398
+ except Exception as e:
399
+ # No fallback format - ADF is the only supported format
400
+ # Any errors should be raised directly for proper handling
401
+ self.logger.error(f"Failed to update page {page.id}: {e}")
402
+ raise
403
+
404
+ def set_page_restrictions(self, page_id: str, restrictions: List[Dict[str, Any]]) -> None:
405
+ """
406
+ Set restrictions on a Confluence page.
407
+
408
+ This must be called AFTER page creation, as Confluence Cloud doesn't support
409
+ setting restrictions during page creation.
410
+
411
+ Args:
412
+ page_id: Confluence page ID
413
+ restrictions: List of restriction objects with format:
414
+ [
415
+ {
416
+ "operation": "read",
417
+ "restrictions": {
418
+ "user": [{"accountId": "123"}],
419
+ "group": [{"name": "developers"}]
420
+ }
421
+ },
422
+ {
423
+ "operation": "update",
424
+ "restrictions": {
425
+ "user": [{"accountId": "123"}]
426
+ }
427
+ }
428
+ ]
429
+
430
+ Raises:
431
+ ConfluenceApiError: If the API request fails
432
+ """
433
+ if not restrictions:
434
+ return
435
+
436
+ self.logger.debug(f"Setting restrictions on page {page_id}: {restrictions}")
437
+
438
+ # Update restrictions for each operation type
439
+ for restriction in restrictions:
440
+ operation = restriction.get("operation")
441
+ restriction_data = restriction.get("restrictions", {})
442
+
443
+ if not operation or not restriction_data:
444
+ self.logger.warning(f"Invalid restriction format, skipping: {restriction}")
445
+ continue
446
+
447
+ # PUT to /rest/api/content/{id}/restriction/{operation}
448
+ endpoint = f"content/{page_id}/restriction/{operation}"
449
+
450
+ try:
451
+ # The API expects the restriction data directly (not wrapped in array)
452
+ payload = restriction_data
453
+
454
+ self.logger.debug(f"Setting {operation} restriction on page {page_id}")
455
+ self._make_request(
456
+ method="PUT",
457
+ endpoint=endpoint,
458
+ json_data=payload
459
+ )
460
+ self.logger.info(f"Successfully set {operation} restriction on page {page_id}")
461
+ except Exception as e:
462
+ self.logger.error(f"Failed to set {operation} restriction on page {page_id}: {e}")
463
+ # Raise exception - restrictions are security-critical when explicitly requested
464
+ raise ConfluenceApiError(f"Failed to set {operation} restriction on page {page_id}: {e}")
465
+
466
+ def _publish_draft_page(self, page_id: str, current_version: int) -> Dict[str, Any]:
467
+ """
468
+ Publish a draft page (transition from draft to current status).
469
+
470
+ Args:
471
+ page_id: Confluence page ID
472
+ current_version: Current version number of the draft page
473
+
474
+ Returns:
475
+ Updated page result
476
+
477
+ Raises:
478
+ ConfluenceApiError: If the API request fails
479
+ """
480
+ self.logger.debug(f"Publishing draft page {page_id} (version {current_version})")
481
+
482
+ # Get the current page to retrieve its content and other properties
483
+ current_page = self._make_request(
484
+ method="GET",
485
+ endpoint=f"content/{page_id}",
486
+ params={"expand": "body.storage,version"}
487
+ )
488
+
489
+ # Update the page with status="current" to publish it
490
+ update_data = {
491
+ "version": {
492
+ "number": current_version + 1
493
+ },
494
+ "title": current_page["title"],
495
+ "type": "page",
496
+ "status": "current",
497
+ "body": current_page["body"]
498
+ }
499
+
500
+ result = self._make_request(
501
+ method="PUT",
502
+ endpoint=f"content/{page_id}",
503
+ json_data=update_data
504
+ )
505
+
506
+ self.logger.info(f"Successfully published draft page {page_id}")
507
+ return result
508
+
509
+ def delete_page(self, page_id: str, trash: bool = True, current_status: str = "current") -> Dict[str, Any]:
510
+ """
511
+ Delete a page from Confluence.
512
+
513
+ Args:
514
+ page_id: Confluence page ID to delete
515
+ trash: Whether to move the page to trash or permanently delete it
516
+ current_status: The current status of the page ("current", "archived", "draft", etc.)
517
+
518
+ Returns:
519
+ Deletion result
520
+
521
+ Notes:
522
+ Confluence API deletion rules:
523
+ - If page status is "current" -> use status=trashed to move to trash
524
+ - If page status is "trashed" -> use status=deleted to permanently delete
525
+ - If page status is "archived" -> page must be unarchived first
526
+ """
527
+ try:
528
+ if current_status == "archived":
529
+ # For archived pages, we need to unarchive first, then delete
530
+ self.logger.info(f"Handling archived page deletion for {page_id}")
531
+
532
+ # Step 1: Unarchive the page
533
+ try:
534
+ self.unarchive_page(page_id)
535
+ self.logger.info(f"Successfully unarchived page {page_id}")
536
+ # Update status for next steps
537
+ current_status = "current"
538
+ except Exception as e:
539
+ self.logger.error(f"Failed to unarchive page {page_id}: {e}")
540
+ # We can't proceed with standard deletion if unarchiving failed
541
+ raise ConfluenceApiError(
542
+ message=f"Cannot delete archived page {page_id}: Failed to unarchive first: {e}",
543
+ status_code=400
544
+ )
545
+
546
+ # Step 2: Delete the page using two-step process per Confluence API v1
547
+ # Only "current", "draft", or "trashed" are valid statuses for deletion
548
+ if current_status not in ["current", "draft", "trashed"]:
549
+ current_status = "current" # Default to current if unknown status
550
+
551
+ # Two-step deletion process:
552
+ # Step 2a: Move to trash (if not already trashed)
553
+ if current_status != "trashed":
554
+ self.logger.info(f"Step 1: Moving page {page_id} to trash (current status: {current_status})")
555
+ trash_result = self._make_request(
556
+ method="DELETE",
557
+ endpoint=f"content/{page_id}",
558
+ params={"status": "current"}, # Use "current" to move to trash
559
+ error_handlers={
560
+ 400: lambda r: self._handle_delete_error(r, page_id),
561
+ 403: lambda r: self._handle_delete_error(r, page_id)
562
+ }
563
+ )
564
+ self.logger.info(f"Successfully moved page {page_id} to trash")
565
+
566
+ # If only moving to trash (not permanent delete), return now
567
+ if trash:
568
+ if not trash_result:
569
+ trash_result = {"status": "success", "id": page_id, "trashed": True}
570
+ return trash_result
571
+
572
+ # Step 2b: Purge from trash (permanent deletion)
573
+ if not trash:
574
+ self.logger.info(f"Step 2: Purging page {page_id} from trash")
575
+ purge_result = self._make_request(
576
+ method="DELETE",
577
+ endpoint=f"content/{page_id}",
578
+ params={"status": "trashed"}, # Use "trashed" to purge
579
+ error_handlers={
580
+ 400: lambda r: self._handle_delete_error(r, page_id),
581
+ 403: lambda r: self._handle_delete_error(r, page_id)
582
+ }
583
+ )
584
+ if not purge_result:
585
+ purge_result = {"status": "success", "id": page_id, "deleted": True}
586
+ self.logger.info(f"Successfully purged page {page_id}")
587
+ return purge_result
588
+
589
+ # Should not reach here
590
+ return {"status": "success", "id": page_id, "deleted": True}
591
+
592
+ except Exception as e:
593
+ self.logger.error(f"Error deleting page {page_id}: {e}")
594
+ # Don't mask the error, propagate it
595
+ raise
596
+
597
+ def unarchive_page(self, page_id: str) -> Dict[str, Any]:
598
+ """
599
+ Unarchive a page in Confluence by updating its status.
600
+
601
+ Args:
602
+ page_id: Confluence page ID to unarchive
603
+
604
+ Returns:
605
+ API response
606
+
607
+ Raises:
608
+ ConfluenceApiError: If the unarchive operation fails
609
+ """
610
+ self.logger.info(f"Attempting to unarchive page {page_id}")
611
+
612
+ # First, get the current page info to retrieve its version and content
613
+ try:
614
+ # Get current page info with archived status
615
+ page_info = None
616
+ try:
617
+ # Try with standard endpoint first
618
+ page_info = self._make_request(
619
+ method="GET",
620
+ endpoint=f"content/{page_id}",
621
+ params={"expand": "body.atlas_doc_format,version,ancestors,space,status", "status": "archived"},
622
+ handle_errors=False
623
+ )
624
+ except Exception as e:
625
+ self.logger.warning(f"Failed to get archived page with standard parameters: {e}")
626
+ # Try other status parameters
627
+ try:
628
+ page_info = self._make_request(
629
+ method="GET",
630
+ endpoint=f"content/{page_id}",
631
+ params={"expand": "body.atlas_doc_format,version,ancestors,space,status"},
632
+ handle_errors=False
633
+ )
634
+ except Exception as e2:
635
+ self.logger.error(f"Failed to get page info: {e2}")
636
+ raise ConfluenceApiError(
637
+ message=f"Cannot retrieve page information for {page_id}",
638
+ status_code=404
639
+ )
640
+
641
+ if not page_info:
642
+ raise ConfluenceApiError(
643
+ message=f"Failed to retrieve page information for {page_id}",
644
+ status_code=404
645
+ )
646
+
647
+ # Extract current version and content (ADF format)
648
+ current_version = page_info.get("version", {}).get("number", 0)
649
+ space_key = page_info.get("space", {}).get("key")
650
+ title = page_info.get("title", "")
651
+
652
+ # Get ADF content - may be nested in "value" field as JSON string
653
+ adf_body_raw = page_info.get("body", {}).get("atlas_doc_format", {})
654
+ if isinstance(adf_body_raw, dict) and "value" in adf_body_raw:
655
+ # Parse nested JSON string
656
+ try:
657
+ adf_content = json.loads(adf_body_raw["value"])
658
+ except json.JSONDecodeError:
659
+ # If parsing fails, use the raw value
660
+ adf_content = adf_body_raw
661
+ else:
662
+ adf_content = adf_body_raw
663
+
664
+ self.logger.info(f"Retrieved page info: version={current_version}, space={space_key}, title={title}")
665
+
666
+ # Create update payload with status=current
667
+ update_data = {
668
+ "id": page_id,
669
+ "type": "page",
670
+ "title": title,
671
+ "space": {"key": space_key},
672
+ "version": {"number": current_version + 1},
673
+ "body": {
674
+ "editor": {
675
+ "value": json.dumps(adf_content),
676
+ "representation": "atlas_doc_format"
677
+ }
678
+ },
679
+ "status": "current"
680
+ }
681
+
682
+ # Update the page to change its status
683
+ self.logger.info(f"Updating page {page_id} with status=current")
684
+ result = self._make_request(
685
+ method="PUT",
686
+ endpoint=f"content/{page_id}",
687
+ json_data=update_data,
688
+ handle_errors=True
689
+ )
690
+
691
+ self.logger.info(f"Successfully unarchived page {page_id} by updating its status")
692
+ return {"status": "success", "id": page_id, "unarchived": True, "data": result}
693
+
694
+ except Exception as e:
695
+ self.logger.error(f"Failed to unarchive page {page_id}: {e}")
696
+ raise ConfluenceApiError(
697
+ message=f"Failed to unarchive page {page_id}: {e}",
698
+ response=getattr(e, "response", None)
699
+ )
700
+
701
+ def _handle_delete_error(self, response, page_id: str) -> Dict[str, Any]:
702
+ """
703
+ Handle errors during page deletion by trying alternative methods.
704
+
705
+ Args:
706
+ response: Error response
707
+ page_id: ID of page being deleted
708
+
709
+ Returns:
710
+ Result of alternative deletion attempt
711
+ """
712
+ # Log the detailed error information
713
+ try:
714
+ error_body = response.json() if response.content else None
715
+ self.logger.error(f"Delete failed for page {page_id}: status={response.status_code}, body={error_body}")
716
+ except Exception:
717
+ self.logger.error(f"Delete failed for page {page_id}: status={response.status_code}, text={response.text[:200]}")
718
+
719
+ # Attempt to use archive endpoint for archived pages
720
+ self.logger.info(f"Standard deletion failed for page {page_id}, trying archive endpoint")
721
+
722
+ try:
723
+ # Try to use the archive endpoint instead
724
+ archive_response = self.session.post(f"{self.rest_api_url}/content/{page_id}/archive")
725
+ archive_response.raise_for_status()
726
+ return {"status": "success", "id": page_id, "archived": True}
727
+ except Exception as e:
728
+ self.logger.warning(f"Archive endpoint also failed for page {page_id}: {e}")
729
+ if hasattr(archive_response, 'text'):
730
+ self.logger.warning(f"Archive response: {archive_response.text[:200]}")
731
+ # Re-raise the original error by raising a new exception with the original status code
732
+ raise ConfluenceApiError(
733
+ message=f"Failed to delete page {page_id}",
734
+ status_code=response.status_code,
735
+ response=response
736
+ )
737
+
738
+ def get_space_key_from_parent(self, parent_id: str) -> Optional[str]:
739
+ """
740
+ Retrieve the space key from a parent page ID.
741
+
742
+ Args:
743
+ parent_id: Parent page ID
744
+
745
+ Returns:
746
+ Space key if found, None otherwise
747
+ """
748
+ # Check cache first
749
+ if self._parent_page_info and self._parent_page_info.get("id") == parent_id:
750
+ return self._parent_page_info.get("space", {}).get("key")
751
+
752
+ try:
753
+ # Get parent page information
754
+ parent_data = self.get_page(parent_id)
755
+ self._parent_page_info = parent_data
756
+
757
+ # Extract space key
758
+ space = parent_data.get("space", {})
759
+ return space.get("key")
760
+ except Exception as e:
761
+ self.logger.warning(f"Failed to retrieve space key from parent page {parent_id}: {e}")
762
+
763
+ # Try to infer space key from parent ID if it might contain space info
764
+ if parent_id and any(space_id in parent_id for space_id in ["BYOADMIN", "DEV", "TEAM", "DOC"]):
765
+ for space_id in ["BYOADMIN", "DEV", "TEAM", "DOC"]:
766
+ if space_id in parent_id:
767
+ self.logger.info(f"Using inferred space key '{space_id}' based on parent ID")
768
+ return space_id
769
+ return None
770
+
771
+ def move_page(self, page_id: str, target_id: str, position: Literal['before', 'after', 'append'] = 'append') -> Dict[str, Any]:
772
+ """
773
+ Move a page to a new location relative to a target page.
774
+
775
+ Args:
776
+ page_id: ID of the page to move
777
+ target_id: ID of the target page
778
+ position: Position relative to the target page:
779
+ - 'before': Move page before target page (same level)
780
+ - 'after': Move page after target page (same level)
781
+ - 'append': Move page as a child of target page (recommended)
782
+
783
+ Note:
784
+ This method uses the dedicated Move Page API endpoint for moving pages.
785
+ https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content---children-and-descendants/#api-content-id-move-position-targetid-put
786
+
787
+ Returns:
788
+ API response
789
+
790
+ Raises:
791
+ ConfluenceApiError: For API errors
792
+ """
793
+ self.logger.info(f"Moving page {page_id} to parent {target_id}")
794
+
795
+ try:
796
+ # First, make sure the target page exists
797
+ try:
798
+ target_page = self.get_page(target_id)
799
+ target_title = target_page.get("title", "Unknown")
800
+ self.logger.info(f"Target parent page {target_id} exists: '{target_title}'")
801
+ except Exception as e:
802
+ self.logger.error(f"Target parent page {target_id} does not exist: {e}")
803
+ raise ValueError(f"Target parent page {target_id} does not exist or is not accessible")
804
+
805
+ # Then, get the current page to obtain its version and other details
806
+ try:
807
+ page_data = self.get_page(page_id)
808
+ except Exception as e:
809
+ self.logger.error(f"Source page {page_id} does not exist: {e}")
810
+ raise ValueError(f"Source page {page_id} does not exist or is not accessible")
811
+
812
+ page_data.get("version", {}).get("number", 0)
813
+ page_data.get("space", {}).get("key")
814
+ title = page_data.get("title")
815
+
816
+ # Check if page already has the correct parent
817
+ current_parent_id = None
818
+ if "ancestors" in page_data and page_data["ancestors"]:
819
+ current_parent_id = page_data["ancestors"][-1]["id"]
820
+ self.logger.info(f"Current parent of page {page_id} is {current_parent_id}")
821
+
822
+ if current_parent_id == target_id:
823
+ self.logger.info(f"Page {page_id} already has parent {target_id}, no update needed")
824
+ return {"id": page_id, "title": title, "status": "current", "no_change": True}
825
+
826
+ # Use the dedicated move page API endpoint
827
+ self.logger.info(f"📄➡️ Moving page '{title}' (ID: {page_id}) to parent '{target_title}' (ID: {target_id}) with position {position}")
828
+
829
+ try:
830
+ # Call the dedicated move page endpoint
831
+ # Reference: https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-content---children-and-descendants/#api-content-id-move-position-targetid-put
832
+ result = self._make_request(
833
+ method="PUT",
834
+ endpoint=f"content/{page_id}/move/{position}/{target_id}",
835
+ params={"expand": "ancestors,version"}
836
+ )
837
+
838
+ # Log more detailed information about the response
839
+ resp_id = result.get("id", "unknown")
840
+ resp_title = result.get("title", "unknown")
841
+ resp_version = result.get("version", {}).get("number", "unknown")
842
+ resp_type = result.get("type", "unknown")
843
+ self.logger.info(f"✅ Move API successful: {resp_title} (ID: {resp_id}, ver: {resp_version}, type: {resp_type})")
844
+ except Exception as move_error:
845
+ self.logger.error(f"❌ Move API failed: {move_error}")
846
+ # Re-raise to be handled by the outer exception handler
847
+ raise
848
+
849
+ # 7. Verify the parent was updated correctly
850
+ try:
851
+ verification = self.get_page(page_id)
852
+ final_parent_id = None
853
+ if "ancestors" in verification and verification["ancestors"]:
854
+ final_parent_id = verification["ancestors"][-1]["id"]
855
+
856
+ # Always log the verification result
857
+ self.logger.info(f"Verification - Page '{title}' (ID: {page_id}) current parent: {final_parent_id}")
858
+
859
+ if final_parent_id == target_id:
860
+ self.logger.info(f"✅ Successfully moved page '{title}' (ID: {page_id}) to parent '{target_title}' (ID: {target_id})")
861
+ else:
862
+ self.logger.warning(f"⚠️ Page move may have failed. Page '{title}' (ID: {page_id}) has parent: {final_parent_id}, Expected parent: {target_id}")
863
+ except Exception as e:
864
+ self.logger.warning(f"Could not verify page move: {e}")
865
+
866
+ return result
867
+ except ValueError as ve:
868
+ # Re-raise value errors for better handling
869
+ self.logger.error(f"Value error moving page: {ve}")
870
+ raise
871
+ except Exception as e:
872
+ self.logger.error(f"Failed to move page {page_id} to parent {target_id}: {e}")
873
+ raise
874
+
875
+ def _detect_editor_type(self, page_data: Dict[str, Any]) -> str:
876
+ """
877
+ Detect which editor was used to create/edit a page.
878
+
879
+ Args:
880
+ page_data: Page data from Confluence API
881
+
882
+ Returns:
883
+ Editor type: 'v2' (new editor/ADF), 'v1' (legacy editor/storage), or 'unknown'
884
+ """
885
+ # Check metadata.properties.editor first (most reliable)
886
+ metadata = page_data.get("metadata", {})
887
+ properties = metadata.get("properties", {})
888
+
889
+ # The editor property might be nested
890
+ if "editor" in properties:
891
+ editor_info = properties["editor"]
892
+ if isinstance(editor_info, dict):
893
+ editor_value = editor_info.get("value", "")
894
+ else:
895
+ editor_value = str(editor_info)
896
+
897
+ if editor_value == "v2":
898
+ return "v2"
899
+ elif editor_value == "v1":
900
+ return "v1"
901
+
902
+ # Fallback: Check body format
903
+ body = page_data.get("body", {})
904
+
905
+ # If has atlas_doc_format but no storage, it's new editor
906
+ has_adf = "atlas_doc_format" in body and body["atlas_doc_format"]
907
+ has_storage = "storage" in body and body["storage"]
908
+
909
+ if has_adf and not has_storage:
910
+ return "v2"
911
+ elif has_storage and not has_adf:
912
+ return "v1"
913
+ elif has_storage and has_adf:
914
+ # Both formats present - prefer the one with actual content
915
+ storage_value = body.get("storage", {}).get("value", "")
916
+ adf_value = body.get("atlas_doc_format", {})
917
+
918
+ if adf_value and isinstance(adf_value, dict) and adf_value.get("version") == 1:
919
+ return "v2"
920
+ elif storage_value:
921
+ return "v1"
922
+
923
+ return "unknown"
924
+
925
+ def migrate_to_new_editor(self, page_id: str, dry_run: bool = False) -> Dict[str, Any]:
926
+ """
927
+ Migrate a legacy editor page to the new editor (ADF format).
928
+
929
+ This method:
930
+ 1. Fetches the page and detects its editor type
931
+ 2. If it's using the legacy editor, converts storage format to ADF
932
+ 3. Updates the page with ADF content and sets editor metadata to v2
933
+
934
+ Args:
935
+ page_id: Confluence page ID to migrate
936
+ dry_run: If True, only detect and report without making changes
937
+
938
+ Returns:
939
+ Dictionary with migration results including:
940
+ - current_editor: The detected editor type
941
+ - needs_migration: Whether migration is needed
942
+ - migrated: Whether migration was performed (False for dry_run)
943
+ - page_data: Updated page data if migrated
944
+
945
+ Raises:
946
+ ConfluenceApiError: For API errors
947
+ """
948
+ self.logger.info(f"{'[DRY RUN] ' if dry_run else ''}Checking page {page_id} for legacy editor migration")
949
+
950
+ # Get page with editor detection
951
+ page_data = self.get_page(page_id, detect_editor=True)
952
+ editor_type = page_data.get("_editor_type", "unknown")
953
+ title = page_data.get("title", "Unknown")
954
+
955
+ result = {
956
+ "page_id": page_id,
957
+ "title": title,
958
+ "current_editor": editor_type,
959
+ "needs_migration": editor_type == "v1",
960
+ "migrated": False
961
+ }
962
+
963
+ if editor_type == "v2":
964
+ self.logger.info(f"Page '{title}' (ID: {page_id}) is already using new editor (v2)")
965
+ return result
966
+
967
+ if editor_type == "unknown":
968
+ self.logger.debug(f"Page '{title}' (ID: {page_id}) has unknown editor type (likely transitional state with both formats but no metadata)")
969
+ self.logger.debug("Treating as legacy page and attempting migration...")
970
+
971
+ self.logger.debug(f"Page '{title}' (ID: {page_id}) is using legacy editor — attempting auto-migration")
972
+
973
+ if dry_run:
974
+ self.logger.info(f"[DRY RUN] Would migrate page '{title}' from legacy editor to new editor")
975
+ return result
976
+
977
+ # Perform migration
978
+ try:
979
+ body = page_data.get("body", {})
980
+
981
+ # Check if page already has ADF content (transitional state)
982
+ existing_adf = body.get("atlas_doc_format")
983
+ if existing_adf and isinstance(existing_adf, dict):
984
+ self.logger.info("Page already has ADF content, will update metadata only")
985
+ adf_content = existing_adf
986
+ else:
987
+ # Get storage format content and convert to ADF
988
+ storage_content = body.get("storage", {}).get("value", "")
989
+
990
+ if not storage_content:
991
+ raise ValueError("No storage content found in legacy page")
992
+
993
+ # Convert storage format HTML to ADF
994
+ # Note: This is a simplified conversion - in reality, you may need
995
+ # a more sophisticated HTML to ADF converter
996
+ try:
997
+ from docspan.backends.confluence.adf.converter import storage_to_adf
998
+ adf_content = storage_to_adf(storage_content)
999
+ except (ImportError, AttributeError):
1000
+ # Fallback: Create a legacy content macro wrapper
1001
+ self.logger.info("storage_to_adf not available, using legacy content wrapper")
1002
+ adf_content = self._wrap_legacy_content_in_adf(storage_content)
1003
+
1004
+ # Get current version
1005
+ current_version = page_data.get("version", {}).get("number", 0)
1006
+ space_key = page_data.get("space", {}).get("key")
1007
+
1008
+ # Update page with ADF content
1009
+ update_data = {
1010
+ "id": page_id,
1011
+ "type": "page",
1012
+ "title": title,
1013
+ "space": {"key": space_key},
1014
+ "version": {"number": current_version + 1},
1015
+ "body": {
1016
+ "editor": {
1017
+ "value": json.dumps(adf_content),
1018
+ "representation": "atlas_doc_format"
1019
+ }
1020
+ }
1021
+ }
1022
+
1023
+ self.logger.info(f"Migrating page '{title}' (ID: {page_id}) to new editor...")
1024
+ updated_page = self._make_request(
1025
+ method="PUT",
1026
+ endpoint=f"content/{page_id}",
1027
+ json_data=update_data
1028
+ )
1029
+
1030
+ # Set editor property via separate API endpoint (properties must be set this way)
1031
+ self.logger.info(f"Setting editor property to v2 for page '{title}'...")
1032
+ property_data = {
1033
+ "key": "editor",
1034
+ "value": "v2"
1035
+ }
1036
+
1037
+ # Try to update existing property first, if that fails, create it
1038
+ try:
1039
+ self._make_request(
1040
+ method="PUT",
1041
+ endpoint=f"content/{page_id}/property/editor",
1042
+ json_data=property_data
1043
+ )
1044
+ self.logger.info("✅ Updated editor property to v2")
1045
+ except Exception as prop_error:
1046
+ # Property doesn't exist, create it
1047
+ self.logger.debug(f"Property doesn't exist, creating it: {prop_error}")
1048
+ self._make_request(
1049
+ method="POST",
1050
+ endpoint=f"content/{page_id}/property",
1051
+ json_data=property_data
1052
+ )
1053
+ self.logger.info("✅ Created editor property with value v2")
1054
+
1055
+ result["migrated"] = True
1056
+ result["page_data"] = updated_page
1057
+ self.logger.info(f"✅ Successfully migrated page '{title}' to new editor (ADF format)")
1058
+
1059
+ return result
1060
+
1061
+ except Exception as e:
1062
+ # Log at DEBUG: migration failure is expected for many legacy pages
1063
+ # (e.g. 400 from the ADF-conversion endpoint). Callers that want to
1064
+ # surface this to the user can do so at a higher level.
1065
+ self.logger.debug(f"Failed to migrate page {page_id}: {e}")
1066
+ result["error"] = str(e)
1067
+ raise
1068
+
1069
+ def _wrap_legacy_content_in_adf(self, storage_html: str) -> Dict[str, Any]:
1070
+ """
1071
+ Wrap legacy storage format HTML in an ADF legacy content macro.
1072
+
1073
+ This is a fallback method when proper HTML->ADF conversion is not available.
1074
+ It wraps the legacy content in a legacy content macro so it renders correctly.
1075
+
1076
+ Args:
1077
+ storage_html: Legacy storage format HTML
1078
+
1079
+ Returns:
1080
+ ADF document with legacy content macro
1081
+ """
1082
+ return {
1083
+ "version": 1,
1084
+ "type": "doc",
1085
+ "content": [
1086
+ {
1087
+ "type": "paragraph",
1088
+ "content": [
1089
+ {
1090
+ "type": "text",
1091
+ "text": "⚠️ This page was migrated from the legacy editor. Some formatting may appear in a legacy content block below."
1092
+ }
1093
+ ]
1094
+ },
1095
+ {
1096
+ "type": "bodiedExtension",
1097
+ "attrs": {
1098
+ "extensionType": "com.atlassian.confluence.macro.core",
1099
+ "extensionKey": "legacy-content",
1100
+ "parameters": {
1101
+ "macroParams": {},
1102
+ "macroMetadata": {
1103
+ "macroId": {"value": "legacy-content"},
1104
+ "schemaVersion": {"value": "1"},
1105
+ "title": "Legacy Content"
1106
+ }
1107
+ },
1108
+ "layout": "default"
1109
+ },
1110
+ "content": [
1111
+ {
1112
+ "type": "paragraph",
1113
+ "content": [
1114
+ {
1115
+ "type": "text",
1116
+ "text": storage_html,
1117
+ "marks": [{"type": "code"}]
1118
+ }
1119
+ ]
1120
+ }
1121
+ ]
1122
+ }
1123
+ ]
1124
+ }
1125
+
1126
+ def _format_page_content(self, content: Any) -> Dict[str, Any]:
1127
+ """
1128
+ Format page content for the Confluence API using ADF format.
1129
+
1130
+ This method enforces ADF (Atlassian Document Format) as the exclusive format.
1131
+ Storage format is not supported and will raise errors.
1132
+
1133
+ For REST API v1 (/rest/api/content), the body must use the "editor" format with
1134
+ the ADF content as a JSON-encoded string.
1135
+
1136
+ References:
1137
+ - https://community.developer.atlassian.com/t/can-i-create-content-in-confluence-cloud-using-atlassian-document-format-adf-rather-than-storage-format/30720
1138
+ - https://community.developer.atlassian.com/t/confluence-rest-api-v2-create-page-with-atlas-doc-format-representation/67565
1139
+
1140
+ Args:
1141
+ content: Page content (must be ADF dict)
1142
+
1143
+ Returns:
1144
+ Formatted content dictionary with editor format for REST API v1:
1145
+ {
1146
+ "editor": {
1147
+ "value": "<JSON-encoded ADF string>",
1148
+ "representation": "atlas_doc_format"
1149
+ }
1150
+ }
1151
+
1152
+ Raises:
1153
+ UnsupportedADFFeatureError: If content contains unsupported features
1154
+ ADFConversionError: If content cannot be converted to valid ADF
1155
+ """
1156
+ from docspan.backends.confluence.services.confluence.base_client import (
1157
+ ADFConversionError,
1158
+ )
1159
+
1160
+ if isinstance(content, dict):
1161
+ # Content is already ADF document
1162
+ self._validate_adf_content(content)
1163
+ # For REST API v1, we need to use "editor" with a JSON-encoded string
1164
+ return {
1165
+ "editor": {
1166
+ "value": json.dumps(content),
1167
+ "representation": "atlas_doc_format"
1168
+ }
1169
+ }
1170
+
1171
+ elif isinstance(content, str) and content.startswith("{") and content.endswith("}"):
1172
+ # Content looks like JSON string - parse and validate
1173
+ try:
1174
+ adf_content = json.loads(content)
1175
+ self._validate_adf_content(adf_content)
1176
+ # For REST API v1, we need to use "editor" with a JSON-encoded string
1177
+ return {
1178
+ "editor": {
1179
+ "value": json.dumps(adf_content),
1180
+ "representation": "atlas_doc_format"
1181
+ }
1182
+ }
1183
+ except json.JSONDecodeError as e:
1184
+ raise ADFConversionError(
1185
+ f"Failed to parse JSON content: {e}",
1186
+ markdown_content=content[:200]
1187
+ )
1188
+ else:
1189
+ # String content is not supported - must be ADF dict
1190
+ raise ADFConversionError(
1191
+ "Content must be ADF dictionary format. String/storage format is not supported.",
1192
+ markdown_content=str(content)[:200] if content else None
1193
+ )
1194
+
1195
+ def _validate_adf_content(self, adf_content: Dict[str, Any]) -> None:
1196
+ """
1197
+ Validate ADF content for unsupported features.
1198
+
1199
+ Args:
1200
+ adf_content: ADF content to validate
1201
+
1202
+ Raises:
1203
+ UnsupportedADFFeatureError: If content contains unsupported features
1204
+ InvalidADFError: If ADF structure is invalid
1205
+ """
1206
+ from docspan.backends.confluence.services.confluence.base_client import (
1207
+ InvalidADFError,
1208
+ )
1209
+
1210
+ # Check basic structure
1211
+ if not isinstance(adf_content, dict):
1212
+ raise InvalidADFError(
1213
+ "ADF content must be a dictionary",
1214
+ adf_content=adf_content
1215
+ )
1216
+
1217
+ # Check for storage_format_html (indicates legacy format)
1218
+ # NOTE: Disabled this validation because storage_format_html is legitimately
1219
+ # used by the Mermaid plugin to embed rendered diagrams.
1220
+ # The original validation was too strict and rejected valid ADF content.
1221
+ # if self._has_storage_format_html(adf_content):
1222
+ # raise UnsupportedADFFeatureError(
1223
+ # "Content contains 'storage_format_html' attribute. "
1224
+ # "This indicates legacy storage format which is no longer supported. "
1225
+ # "Content must be converted to pure ADF format.",
1226
+ # feature_name="storage_format_html"
1227
+ # )
1228
+
1229
+ # Validate node types are supported
1230
+ self._validate_node_types(adf_content)
1231
+
1232
+ def _has_storage_format_html(self, adf_content: Dict[str, Any]) -> bool:
1233
+ """
1234
+ Check if any node in the ADF content has storage_format_html.
1235
+
1236
+ Args:
1237
+ adf_content: ADF content to check
1238
+
1239
+ Returns:
1240
+ True if any node has storage_format_html, False otherwise
1241
+ """
1242
+ # Check current node
1243
+ if "attrs" in adf_content and "storage_format_html" in adf_content["attrs"]:
1244
+ return True
1245
+
1246
+ # Check content nodes recursively
1247
+ if "content" in adf_content and isinstance(adf_content["content"], list):
1248
+ for node in adf_content["content"]:
1249
+ if isinstance(node, dict) and self._has_storage_format_html(node):
1250
+ return True
1251
+
1252
+ return False
1253
+
1254
+ def _validate_node_types(self, node: Dict[str, Any]) -> None:
1255
+ """
1256
+ Recursively validate that all node types are supported.
1257
+
1258
+ Args:
1259
+ node: ADF node to validate
1260
+
1261
+ Raises:
1262
+ UnsupportedADFFeatureError: If node contains unsupported types
1263
+ """
1264
+ from docspan.backends.confluence.services.confluence.base_client import (
1265
+ UnsupportedADFFeatureError,
1266
+ )
1267
+
1268
+ node_type = node.get("type")
1269
+
1270
+ # List of known unsupported features (expand as we discover them)
1271
+ # Note: This is intentionally minimal - we discover unsupported features through errors
1272
+ unsupported_types = {
1273
+ # Add types here as we discover them
1274
+ # Example: "customPanel", "unknownExtension"
1275
+ }
1276
+
1277
+ if node_type in unsupported_types:
1278
+ raise UnsupportedADFFeatureError(
1279
+ f"ADF node type '{node_type}' is not yet supported. "
1280
+ f"Please implement ADF conversion for this type.",
1281
+ feature_name=node_type
1282
+ )
1283
+
1284
+ # Recursively check children
1285
+ if "content" in node and isinstance(node["content"], list):
1286
+ for child in node["content"]:
1287
+ if isinstance(child, dict):
1288
+ self._validate_node_types(child)