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,18 @@
1
+ """
2
+ Data models module.
3
+
4
+ This module provides data models/DTOs for the markdown-confluence package.
5
+ """
6
+
7
+ from docspan.backends.confluence.models.markdown_file import MarkdownFile
8
+ from docspan.backends.confluence.models.page import ConfluencePage
9
+ from docspan.backends.confluence.models.results import PublishResult
10
+ from docspan.backends.confluence.models.sync_status import FileSyncRecord, SyncStatusTracker
11
+
12
+ __all__ = [
13
+ "MarkdownFile",
14
+ "ConfluencePage",
15
+ "PublishResult",
16
+ "FileSyncRecord",
17
+ "SyncStatusTracker",
18
+ ]
@@ -0,0 +1,402 @@
1
+ """
2
+ Models for Markdown files and their contents.
3
+ """
4
+
5
+ import logging
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Set
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Known frontmatter keys that markdown_confluence understands
14
+ KNOWN_CONNIE_KEYS: Set[str] = {
15
+ "connie-title", # Page title override
16
+ "connie-page-id", # Confluence page ID
17
+ "connie-parent-id", # Parent page ID (legacy)
18
+ "connie-parent-page-id", # Parent page ID (preferred)
19
+ "connie-publish", # Whether to publish this file
20
+ "connie-visibility", # Page visibility (private/public)
21
+ "connie-last-remote-version", # Last known remote version
22
+ "connie-last-sync-timestamp", # Last sync timestamp
23
+ }
24
+
25
+ # Common typos/mistakes mapped to the correct key
26
+ CONNIE_KEY_SUGGESTIONS: Dict[str, str] = {
27
+ "connie-page-title": "connie-title",
28
+ "connie-pagetitle": "connie-title",
29
+ "connie-name": "connie-title",
30
+ "connie-id": "connie-page-id",
31
+ "connie-pageid": "connie-page-id",
32
+ "connie-parent": "connie-parent-id",
33
+ "connie-parentid": "connie-parent-id",
34
+ "connie-parent-page": "connie-parent-id",
35
+ }
36
+
37
+
38
+ @dataclass
39
+ class MarkdownFile:
40
+ """
41
+ Represents a Markdown file with its content and metadata.
42
+
43
+ Attributes:
44
+ path: Path to the file
45
+ content: Raw content of the file
46
+ frontmatter: Extracted frontmatter as a dictionary
47
+ title: Title for the page (from filename or frontmatter)
48
+ title_from_h1: Whether the title was extracted from a first-level heading
49
+ """
50
+
51
+ path: Path
52
+ content: str
53
+ frontmatter: Dict[str, Any] = field(default_factory=dict)
54
+ title: Optional[str] = None
55
+ title_from_h1: bool = False
56
+
57
+ @classmethod
58
+ def from_file(cls, file_path: Path) -> "MarkdownFile":
59
+ """
60
+ Create a MarkdownFile instance from a file.
61
+
62
+ Args:
63
+ file_path: Path to the Markdown file
64
+
65
+ Returns:
66
+ MarkdownFile instance
67
+
68
+ Raises:
69
+ FileNotFoundError: If the file doesn't exist
70
+ UnicodeDecodeError: If the file can't be read as UTF-8
71
+ """
72
+ with open(file_path, "r", encoding="utf-8") as f:
73
+ content = f.read()
74
+
75
+ instance = cls(path=file_path, content=content)
76
+ instance.extract_frontmatter()
77
+ instance.validate_frontmatter()
78
+ instance.determine_title()
79
+
80
+ return instance
81
+
82
+ def extract_frontmatter(self) -> None:
83
+ """
84
+ Extract YAML frontmatter from content and update the instance.
85
+ """
86
+ frontmatter_pattern = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
87
+ match = frontmatter_pattern.match(self.content)
88
+
89
+ if match:
90
+ try:
91
+ import yaml
92
+
93
+ yaml_content = match.group(1)
94
+ self.frontmatter = yaml.safe_load(yaml_content) or {}
95
+
96
+ # Remove frontmatter from content
97
+ self.content = self.content[match.end() :]
98
+ except ImportError:
99
+ # If pyyaml is not available, try simple key-value parsing
100
+ self._parse_frontmatter_simple(match.group(1))
101
+ self.content = self.content[match.end() :]
102
+ except Exception:
103
+ logger.warning("Failed to parse frontmatter; keeping raw content", exc_info=True)
104
+
105
+ @staticmethod
106
+ def _levenshtein_distance(s1: str, s2: str) -> int:
107
+ """
108
+ Calculate the Levenshtein distance between two strings.
109
+
110
+ Args:
111
+ s1: First string
112
+ s2: Second string
113
+
114
+ Returns:
115
+ Edit distance between the strings
116
+ """
117
+ if len(s1) < len(s2):
118
+ return MarkdownFile._levenshtein_distance(s2, s1)
119
+
120
+ if len(s2) == 0:
121
+ return len(s1)
122
+
123
+ previous_row = range(len(s2) + 1)
124
+ for i, c1 in enumerate(s1):
125
+ current_row = [i + 1]
126
+ for j, c2 in enumerate(s2):
127
+ # Cost is 0 if characters match, 1 otherwise
128
+ insertions = previous_row[j + 1] + 1
129
+ deletions = current_row[j] + 1
130
+ substitutions = previous_row[j] + (c1 != c2)
131
+ current_row.append(min(insertions, deletions, substitutions))
132
+ previous_row = current_row
133
+
134
+ return previous_row[-1]
135
+
136
+ def _find_closest_key(self, unknown_key: str, max_distance: int = 5) -> Optional[str]:
137
+ """
138
+ Find the closest known key to an unknown key using Levenshtein distance.
139
+
140
+ Args:
141
+ unknown_key: The unrecognized frontmatter key
142
+ max_distance: Maximum edit distance to consider a suggestion
143
+
144
+ Returns:
145
+ The closest known key if within max_distance, None otherwise
146
+ """
147
+ # First check explicit suggestions mapping
148
+ if unknown_key in CONNIE_KEY_SUGGESTIONS:
149
+ return CONNIE_KEY_SUGGESTIONS[unknown_key]
150
+
151
+ # Otherwise, find closest by Levenshtein distance
152
+ best_match = None
153
+ best_distance = max_distance + 1
154
+
155
+ for known_key in KNOWN_CONNIE_KEYS:
156
+ distance = self._levenshtein_distance(unknown_key, known_key)
157
+ if distance < best_distance:
158
+ best_distance = distance
159
+ best_match = known_key
160
+
161
+ return best_match if best_distance <= max_distance else None
162
+
163
+ def validate_frontmatter(self) -> List[str]:
164
+ """
165
+ Validate frontmatter keys and warn about unrecognized connie-* keys.
166
+
167
+ Uses Levenshtein distance to suggest the closest known key for typos.
168
+
169
+ Returns:
170
+ List of warning messages for unrecognized keys
171
+ """
172
+ warnings = []
173
+
174
+ for key in self.frontmatter.keys():
175
+ # Only validate keys that start with "connie-"
176
+ if not key.startswith("connie-"):
177
+ continue
178
+
179
+ if key in KNOWN_CONNIE_KEYS:
180
+ continue
181
+
182
+ # Find the closest known key
183
+ suggestion = self._find_closest_key(key)
184
+
185
+ if suggestion:
186
+ msg = (
187
+ f"Unrecognized frontmatter key '{key}' in {self.path}. "
188
+ f"Did you mean '{suggestion}'?"
189
+ )
190
+ else:
191
+ msg = (
192
+ f"Unrecognized frontmatter key '{key}' in {self.path}. "
193
+ f"Known keys are: {', '.join(sorted(KNOWN_CONNIE_KEYS))}"
194
+ )
195
+
196
+ logger.warning(msg)
197
+ warnings.append(msg)
198
+
199
+ return warnings
200
+
201
+ def _parse_frontmatter_simple(self, yaml_content: str) -> None:
202
+ """
203
+ Simple YAML parser for frontmatter (no pyyaml dependency).
204
+
205
+ Args:
206
+ yaml_content: YAML content to parse
207
+ """
208
+ for line in yaml_content.split("\n"):
209
+ if ":" in line:
210
+ key, value = line.split(":", 1)
211
+ key = key.strip()
212
+ value = value.strip()
213
+
214
+ # Handle quoted values
215
+ if value.startswith('"') and value.endswith('"'):
216
+ value = value[1:-1]
217
+ elif value.startswith("'") and value.endswith("'"):
218
+ value = value[1:-1]
219
+
220
+ self.frontmatter[key] = value
221
+
222
+ def determine_title(self) -> None:
223
+ """
224
+ Determine the title for the page.
225
+
226
+ Sets title_from_h1=True if the title is extracted from a first-level heading,
227
+ which allows the ADF converter to skip the duplicate heading in content.
228
+ """
229
+ # First, try to get the title from frontmatter
230
+ title = self.frontmatter.get("connie-title")
231
+ if title:
232
+ self.title = title
233
+ self.title_from_h1 = False
234
+ return
235
+
236
+ # Otherwise, extract the first header from the content
237
+ header_pattern = re.compile(r"^#\s+(.+?)$", re.MULTILINE)
238
+ match = header_pattern.search(self.content)
239
+ if match:
240
+ self.title = match.group(1)
241
+ self.title_from_h1 = True # Mark that title came from H1
242
+ else:
243
+ # If no header found, use the filename
244
+ self.title = self.path.stem
245
+ self.title_from_h1 = False
246
+
247
+ def save(self) -> None:
248
+ """
249
+ Save changes back to the file.
250
+ """
251
+ try:
252
+ # Generate frontmatter
253
+ import yaml
254
+
255
+ frontmatter_str = yaml.dump(self.frontmatter, default_flow_style=False)
256
+ output = f"---\n{frontmatter_str}---\n\n{self.content}"
257
+ except ImportError:
258
+ # Fallback if pyyaml is not available
259
+ frontmatter_lines = []
260
+ for key, value in self.frontmatter.items():
261
+ if isinstance(value, str) and " " in value:
262
+ frontmatter_lines.append(f"{key}: \"{value}\"")
263
+ else:
264
+ frontmatter_lines.append(f"{key}: {value}")
265
+ frontmatter_str = "\n".join(frontmatter_lines)
266
+ output = f"---\n{frontmatter_str}\n---\n\n{self.content}"
267
+
268
+ with open(self.path, "w", encoding="utf-8") as f:
269
+ f.write(output)
270
+
271
+ def get_confluence_page_id(self) -> Optional[str]:
272
+ """
273
+ Get Confluence page ID from frontmatter.
274
+
275
+ Returns:
276
+ Page ID or None if not found
277
+ """
278
+ return self.frontmatter.get("connie-page-id")
279
+
280
+ def get_parent_id(self) -> Optional[str]:
281
+ """
282
+ Get parent page ID from frontmatter.
283
+
284
+ Checks both 'connie-parent-page-id' (preferred) and 'connie-parent-id' (legacy)
285
+ for backward compatibility.
286
+
287
+ Returns:
288
+ Parent page ID or None if not found
289
+ """
290
+ # Check preferred field name first
291
+ parent_id = self.frontmatter.get("connie-parent-page-id")
292
+ if parent_id:
293
+ return parent_id
294
+ # Fall back to legacy field name
295
+ return self.frontmatter.get("connie-parent-id")
296
+
297
+ def remove_confluence_page_id(self) -> None:
298
+ """
299
+ Remove Confluence page ID from frontmatter.
300
+ """
301
+ if "connie-page-id" in self.frontmatter:
302
+ del self.frontmatter["connie-page-id"]
303
+
304
+ def get_tags(self) -> List[str]:
305
+ """
306
+ Get tags from frontmatter.
307
+
308
+ Returns:
309
+ List of tags
310
+ """
311
+ tags = self.frontmatter.get("tags", [])
312
+ if isinstance(tags, str):
313
+ return [tag.strip() for tag in tags.split(",")]
314
+ elif isinstance(tags, list):
315
+ return tags
316
+ return []
317
+
318
+ def should_publish(self) -> bool:
319
+ """
320
+ Check if this file should be published based on frontmatter.
321
+
322
+ Returns:
323
+ True if file should be published, False otherwise
324
+ """
325
+ return self.frontmatter.get("connie-publish", True)
326
+
327
+ # Sync metadata methods
328
+
329
+ def get_remote_version(self) -> Optional[int]:
330
+ """
331
+ Get last known remote version from frontmatter.
332
+
333
+ Returns:
334
+ Remote version number or None if not tracked
335
+ """
336
+ version = self.frontmatter.get("connie-last-remote-version")
337
+ return int(version) if version is not None else None
338
+
339
+ def set_remote_version(self, version: int) -> None:
340
+ """
341
+ Set last known remote version in frontmatter.
342
+
343
+ Args:
344
+ version: Remote version number
345
+ """
346
+ self.frontmatter["connie-last-remote-version"] = version
347
+
348
+ def get_last_sync_timestamp(self) -> Optional[str]:
349
+ """
350
+ Get last sync timestamp from frontmatter.
351
+
352
+ Returns:
353
+ ISO 8601 timestamp or None if never synced
354
+ """
355
+ return self.frontmatter.get("connie-last-sync-timestamp")
356
+
357
+ def set_last_sync_timestamp(self, timestamp: str) -> None:
358
+ """
359
+ Set last sync timestamp in frontmatter.
360
+
361
+ Args:
362
+ timestamp: ISO 8601 timestamp
363
+ """
364
+ self.frontmatter["connie-last-sync-timestamp"] = timestamp
365
+
366
+ def get_local_modified_timestamp(self) -> Optional[float]:
367
+ """
368
+ Get file's last modification timestamp.
369
+
370
+ Returns:
371
+ Unix timestamp of last modification
372
+ """
373
+ if self.path.exists():
374
+ return self.path.stat().st_mtime
375
+ return None
376
+
377
+ def was_modified_since_sync(self) -> bool:
378
+ """
379
+ Check if local file was modified since last sync.
380
+
381
+ Returns:
382
+ True if file was modified after last sync, False otherwise
383
+ """
384
+ last_sync = self.get_last_sync_timestamp()
385
+ if not last_sync:
386
+ # Never synced, consider it modified
387
+ return True
388
+
389
+ try:
390
+ from datetime import datetime
391
+ last_sync_time = datetime.fromisoformat(last_sync.replace('Z', '+00:00'))
392
+ file_mtime = self.get_local_modified_timestamp()
393
+
394
+ if file_mtime is None:
395
+ return False
396
+
397
+ file_time = datetime.fromtimestamp(file_mtime)
398
+ # Allow 2 second grace period for file system timestamp precision
399
+ return file_time.timestamp() > last_sync_time.timestamp() + 2
400
+ except Exception:
401
+ logger.warning("Could not determine file modification time; assuming modified", exc_info=True)
402
+ return True
@@ -0,0 +1,212 @@
1
+ """
2
+ Models for Confluence pages.
3
+
4
+ This module enforces ADF (Atlassian Document Format) as the exclusive format
5
+ for Confluence Cloud pages, with no fallback to legacy storage format.
6
+ """
7
+
8
+ import json
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Dict, List, Optional, Union
11
+
12
+
13
+ @dataclass
14
+ class ConfluencePage:
15
+ """
16
+ Represents a Confluence page using ADF (Atlassian Document Format).
17
+
18
+ This class enforces ADF as the exclusive format for Confluence Cloud.
19
+ Storage format is not supported and will raise errors.
20
+
21
+ Attributes:
22
+ title: Page title
23
+ content: Page content in ADF format (must be dict)
24
+ space_key: Confluence space key
25
+ parent_id: ID of the parent page
26
+ id: Confluence page ID (for updating existing pages)
27
+ version: Current page version (for updates)
28
+ labels: List of labels to apply to the page
29
+ force_update: Flag to force an update even if content appears unchanged
30
+ restrictions: List of restriction objects defining read/update permissions
31
+ """
32
+
33
+ title: str
34
+ content: Union[str, Dict[str, Any]] # Should be ADF dict; string will raise error
35
+ parent_id: str
36
+ space_key: Optional[str] = None # Can be derived from parent_id if not specified
37
+ id: Optional[str] = None
38
+ version: Optional[int] = None
39
+ labels: List[str] = field(default_factory=list)
40
+ force_update: bool = False
41
+ restrictions: Optional[List[Dict[str, Any]]] = None
42
+
43
+ def to_api_data(self, for_update: bool = False) -> Dict[str, Any]:
44
+ """
45
+ Convert to data suitable for Confluence API using ADF format.
46
+
47
+ This method enforces ADF (Atlassian Document Format) as the exclusive format.
48
+ Storage format is not supported and will raise errors.
49
+
50
+ Args:
51
+ for_update: Whether this is for an update operation
52
+
53
+ Returns:
54
+ Dictionary with API data in ADF format
55
+
56
+ Raises:
57
+ ValueError: If required fields are missing for update
58
+ UnsupportedADFFeatureError: If content contains unsupported features
59
+ ADFConversionError: If content cannot be converted to valid ADF
60
+ """
61
+ if for_update:
62
+ if not self.id:
63
+ raise ValueError("Page ID is required for update")
64
+ if self.version is None:
65
+ raise ValueError("Page version is required for update")
66
+
67
+ data = {
68
+ "id": self.id,
69
+ "type": "page",
70
+ "title": self.title,
71
+ "version": {"number": self.version + 1},
72
+ }
73
+ else:
74
+ data = {
75
+ "type": "page",
76
+ "title": self.title,
77
+ "ancestors": [{"id": self.parent_id}] if self.parent_id else [],
78
+ }
79
+
80
+ # Add space key if provided
81
+ if self.space_key:
82
+ data["space"] = {"key": self.space_key}
83
+
84
+ # Add restrictions if provided (works for both create and update)
85
+ if self.restrictions:
86
+ data["restrictions"] = self.restrictions
87
+
88
+ # Handle content - always use ADF format
89
+ if isinstance(self.content, dict):
90
+ # Content is already ADF document
91
+ self._validate_adf(self.content)
92
+ data["body"] = {"atlas_doc_format": self.content}
93
+ elif isinstance(self.content, str) and self.content.startswith("{") and self.content.endswith("}"):
94
+ # Content looks like JSON string - parse and validate
95
+ try:
96
+ adf_content = json.loads(self.content)
97
+ self._validate_adf(adf_content)
98
+ data["body"] = {"atlas_doc_format": adf_content}
99
+ except json.JSONDecodeError as e:
100
+ from docspan.backends.confluence.services.confluence.base_client import (
101
+ ADFConversionError,
102
+ )
103
+ raise ADFConversionError(
104
+ f"Failed to parse JSON content: {e}",
105
+ markdown_content=self.content[:200]
106
+ )
107
+ else:
108
+ # String content is not supported - must be ADF dict
109
+ from docspan.backends.confluence.services.confluence.base_client import (
110
+ ADFConversionError,
111
+ )
112
+ raise ADFConversionError(
113
+ "Content must be ADF dictionary format. String/storage format is not supported.",
114
+ markdown_content=str(self.content)[:200] if self.content else None
115
+ )
116
+
117
+ return data
118
+
119
+ def _validate_adf(self, adf_content: Dict[str, Any]) -> None:
120
+ """
121
+ Validate ADF content for unsupported features.
122
+
123
+ Args:
124
+ adf_content: ADF content to validate
125
+
126
+ Raises:
127
+ UnsupportedADFFeatureError: If content contains unsupported features
128
+ InvalidADFError: If ADF structure is invalid
129
+ """
130
+ from docspan.backends.confluence.services.confluence.base_client import (
131
+ InvalidADFError,
132
+ )
133
+
134
+ # Check basic structure
135
+ if not isinstance(adf_content, dict):
136
+ raise InvalidADFError(
137
+ "ADF content must be a dictionary",
138
+ adf_content=adf_content
139
+ )
140
+
141
+ # Check for storage_format_html (indicates legacy format)
142
+ # NOTE: Disabled this validation because storage_format_html is legitimately
143
+ # used by the Mermaid plugin to embed rendered diagrams.
144
+ # The original validation was too strict and rejected valid ADF content.
145
+ # if self._has_storage_format_html(adf_content):
146
+ # raise UnsupportedADFFeatureError(
147
+ # "Content contains 'storage_format_html' attribute. "
148
+ # "This indicates legacy storage format which is no longer supported. "
149
+ # "Content must be converted to pure ADF format.",
150
+ # feature_name="storage_format_html"
151
+ # )
152
+
153
+ # Validate node types are supported
154
+ self._validate_node_types(adf_content)
155
+
156
+ def _has_storage_format_html(self, adf_content: Dict[str, Any]) -> bool:
157
+ """
158
+ Check if any node in the ADF content has storage_format_html.
159
+
160
+ Args:
161
+ adf_content: ADF content to check
162
+
163
+ Returns:
164
+ True if any node has storage_format_html, False otherwise
165
+ """
166
+ # Check current node
167
+ if "attrs" in adf_content and "storage_format_html" in adf_content["attrs"]:
168
+ return True
169
+
170
+ # Check content nodes recursively
171
+ if "content" in adf_content and isinstance(adf_content["content"], list):
172
+ for node in adf_content["content"]:
173
+ if isinstance(node, dict) and self._has_storage_format_html(node):
174
+ return True
175
+
176
+ return False
177
+
178
+ def _validate_node_types(self, node: Dict[str, Any]) -> None:
179
+ """
180
+ Recursively validate that all node types are supported.
181
+
182
+ Args:
183
+ node: ADF node to validate
184
+
185
+ Raises:
186
+ UnsupportedADFFeatureError: If node contains unsupported types
187
+ """
188
+ from docspan.backends.confluence.services.confluence.base_client import (
189
+ UnsupportedADFFeatureError,
190
+ )
191
+
192
+ node_type = node.get("type")
193
+
194
+ # List of known unsupported features (expand as we discover them)
195
+ # Note: This is intentionally minimal - we discover unsupported features through errors
196
+ unsupported_types = {
197
+ # Add types here as we discover them
198
+ # Example: "customPanel", "unknownExtension"
199
+ }
200
+
201
+ if node_type in unsupported_types:
202
+ raise UnsupportedADFFeatureError(
203
+ f"ADF node type '{node_type}' is not yet supported. "
204
+ f"Please implement ADF conversion for this type.",
205
+ feature_name=node_type
206
+ )
207
+
208
+ # Recursively check children
209
+ if "content" in node and isinstance(node["content"], list):
210
+ for child in node["content"]:
211
+ if isinstance(child, dict):
212
+ self._validate_node_types(child)
@@ -0,0 +1,34 @@
1
+ """
2
+ Path utilities for handling relative paths.
3
+ """
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Union
8
+
9
+
10
+ def safe_relative_path(path: Union[str, Path], base_path: Union[str, Path]) -> str:
11
+ """
12
+ Safely get a relative path without raising ValueError for paths outside the base.
13
+
14
+ This function handles cases where the target path is not a subpath of the base path,
15
+ which would normally cause Path.relative_to() to raise a ValueError.
16
+
17
+ Args:
18
+ path: Path to convert to relative
19
+ base_path: Base path to make relative to
20
+
21
+ Returns:
22
+ String representation of the relative path or the original path if outside base
23
+ """
24
+ path_obj = Path(path).resolve()
25
+ base_path_obj = Path(base_path).resolve()
26
+
27
+ try:
28
+ # Try standard relative_to
29
+ return str(path_obj.relative_to(base_path_obj))
30
+ except ValueError:
31
+ # Path is outside base directory
32
+ # Use os.path.relpath which handles paths outside the base
33
+ rel_path = os.path.relpath(path_obj, base_path_obj)
34
+ return rel_path
@@ -0,0 +1,28 @@
1
+ """
2
+ Result models for operations.
3
+ """
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import List, Optional
7
+
8
+
9
+ @dataclass
10
+ class PublishResult:
11
+ """
12
+ Result of a publish operation.
13
+
14
+ Attributes:
15
+ success: Whether the operation was successful
16
+ page_id: ID of the published page
17
+ page_url: URL of the published page
18
+ errors: List of error messages
19
+ deferred: Whether the operation was deferred for later processing
20
+ requires_parent: ID of the parent page that needs to be created first
21
+ """
22
+
23
+ success: bool
24
+ page_id: Optional[str] = None
25
+ page_url: Optional[str] = None
26
+ errors: List[str] = field(default_factory=list)
27
+ deferred: bool = False
28
+ requires_parent: Optional[str] = None