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