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,495 @@
1
+ """
2
+ Inline element parser for Markdown.
3
+ """
4
+
5
+ import re
6
+ from typing import Any, Dict, List, Match
7
+
8
+ from docspan.backends.confluence.markdown.ast import (
9
+ ColoredTextNode,
10
+ DateNode,
11
+ EmojiNode,
12
+ HighlightedTextNode,
13
+ ImageNode,
14
+ InlineCodeNode,
15
+ LinkNode,
16
+ MarkdownNode,
17
+ MentionNode,
18
+ StatusBadgeNode,
19
+ StatusMacroNode,
20
+ TextNode,
21
+ TocNode,
22
+ )
23
+
24
+
25
+ class InlineParser:
26
+ """
27
+ Parser for inline Markdown elements.
28
+
29
+ This parser handles inline formatting like bold, italic, links,
30
+ inline code, and other elements that appear within block-level elements.
31
+ """
32
+
33
+ # Regex patterns for inline elements
34
+ PATTERNS = {
35
+ "link": r'\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)', # [text](url "title")
36
+ "image": r'!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]+)")?(?:\s+=(\d+)(?:x(\d+))?)?\)', # ![alt](url "title" =WxH)
37
+ "bold": r"\*\*([^*]+)\*\*|\b__([^_]+)__\b", # **text** or __text__
38
+ "italic": r"\*([^*]+)\*|\b_([^_]+)_\b", # *text* or _text_
39
+ "bold_italic": r"\*\*\*([^*]+)\*\*\*|\b___([^_]+)___\b", # ***text*** or ___text___
40
+ "inline_code": r"`([^`]+)`", # `code`
41
+ "strikethrough": r"~~([^~]+)~~", # ~~text~~
42
+ "emoji": r":([a-z0-9_+-]+):", # :emoji_name:
43
+ "mention": r"@([a-zA-Z0-9._-]+)", # @username
44
+ "status_badge": r"\[!([A-Z_]+)\]", # [!STATUS]
45
+ "highlighted": r"==([^=]+)==", # ==text==
46
+ "colored_text": r'<mark[^>]*style="color:([^"]+)"[^>]*>([^<]+)</mark>', # <mark style="color:#ff0000">text</mark>
47
+ "iso_date": r"\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2})?", # 2025-10-29 or 2025-10-29T14:00:00
48
+ "inline_macro": r"\{([a-z][a-z0-9-]*)(?::([^}]+))?\}", # {toc} or {toc:maxLevel=3} or {status:colour=Green|title=Active}
49
+ }
50
+
51
+ def __init__(self):
52
+ """Initialize the parser."""
53
+ self.compiled_patterns = {
54
+ name: re.compile(pattern) for name, pattern in self.PATTERNS.items()
55
+ }
56
+
57
+ def parse(self, text: str) -> List[MarkdownNode]:
58
+ """
59
+ Parse inline elements in text.
60
+
61
+ Args:
62
+ text: Text content to parse
63
+
64
+ Returns:
65
+ List of MarkdownNode objects
66
+ """
67
+ if not text:
68
+ return []
69
+
70
+ # Find all inline element matches
71
+ matches = []
72
+ for elem_type, pattern in self.compiled_patterns.items():
73
+ for match in pattern.finditer(text):
74
+ start, end = match.span()
75
+ matches.append((start, end, elem_type, match))
76
+
77
+ # If no matches, return a single text node
78
+ if not matches:
79
+ return [TextNode(type="text", content=text)]
80
+
81
+ # Sort matches by start position
82
+ matches.sort(key=lambda x: x[0])
83
+
84
+ # Create result nodes
85
+ result = []
86
+ last_end = 0
87
+
88
+ for start, end, elem_type, match in matches:
89
+ # Handle overlapping matches by checking if this match starts
90
+ # after the end of the previous match
91
+ if start < last_end:
92
+ continue
93
+
94
+ # Add text before the match
95
+ if start > last_end:
96
+ result.append(TextNode(type="text", content=text[last_end:start]))
97
+
98
+ # Process the match based on its type
99
+ if elem_type == "link":
100
+ node = self._create_link_node(match)
101
+ result.append(node)
102
+ elif elem_type == "image":
103
+ node = self._create_image_node(match)
104
+ result.append(node)
105
+ elif elem_type == "inline_code":
106
+ node = self._create_inline_code_node(match)
107
+ result.append(node)
108
+ elif elem_type == "emoji":
109
+ node = self._create_emoji_node(match)
110
+ result.append(node)
111
+ elif elem_type == "mention":
112
+ node = self._create_mention_node(match)
113
+ result.append(node)
114
+ elif elem_type == "status_badge":
115
+ node = self._create_status_badge_node(match)
116
+ result.append(node)
117
+ elif elem_type == "highlighted":
118
+ node = self._create_highlighted_text_node(match)
119
+ result.append(node)
120
+ elif elem_type == "colored_text":
121
+ node = self._create_colored_text_node(match)
122
+ result.append(node)
123
+ elif elem_type == "iso_date":
124
+ node = self._create_date_node(match)
125
+ result.append(node)
126
+ elif elem_type == "inline_macro":
127
+ node = self._create_inline_macro_node(match)
128
+ result.append(node)
129
+ else:
130
+ # Handle formatting marks (bold, italic, etc.)
131
+ # This returns a list of nodes (to support nested elements like links in bold)
132
+ nodes = self._create_formatted_text_node(match, elem_type)
133
+ result.extend(nodes)
134
+
135
+ last_end = end
136
+
137
+ # Add any remaining text
138
+ if last_end < len(text):
139
+ result.append(TextNode(type="text", content=text[last_end:]))
140
+
141
+ return result
142
+
143
+ def _create_link_node(self, match: Match) -> LinkNode:
144
+ """
145
+ Create a link node from a match.
146
+
147
+ Args:
148
+ match: Regex match for a link
149
+
150
+ Returns:
151
+ LinkNode
152
+ """
153
+ text = match.group(1)
154
+ url = match.group(2)
155
+ title = match.group(3) if match.lastindex >= 3 else None
156
+
157
+ link = LinkNode(type="link", url=url, title=title)
158
+ link.children.append(TextNode(type="text", content=text))
159
+
160
+ return link
161
+
162
+ def _create_image_node(self, match: Match) -> ImageNode:
163
+ """
164
+ Create an image node from a match.
165
+
166
+ Supports extended syntax for image dimensions:
167
+ - ![alt](url) - basic image
168
+ - ![alt](url "title") - with title
169
+ - ![alt](url =W) - with width only
170
+ - ![alt](url =WxH) - with width and height
171
+ - ![alt](url "title" =WxH) - with title and dimensions
172
+
173
+ Args:
174
+ match: Regex match for an image
175
+
176
+ Returns:
177
+ ImageNode with optional width and height
178
+ """
179
+ alt = match.group(1) or ""
180
+ src = match.group(2)
181
+ title = match.group(3) if match.lastindex >= 3 and match.group(3) else None
182
+
183
+ # Parse dimensions if present (groups 4 and 5)
184
+ width = None
185
+ height = None
186
+
187
+ if match.lastindex >= 4 and match.group(4):
188
+ try:
189
+ width = int(match.group(4))
190
+ except (ValueError, TypeError):
191
+ pass # Ignore invalid width values
192
+
193
+ if match.lastindex >= 5 and match.group(5):
194
+ try:
195
+ height = int(match.group(5))
196
+ except (ValueError, TypeError):
197
+ pass # Ignore invalid height values
198
+
199
+ return ImageNode(
200
+ type="image",
201
+ src=src,
202
+ alt=alt,
203
+ title=title,
204
+ width=width,
205
+ height=height
206
+ )
207
+
208
+ def _create_inline_code_node(self, match: Match) -> InlineCodeNode:
209
+ """
210
+ Create an inline code node from a match.
211
+
212
+ Args:
213
+ match: Regex match for inline code
214
+
215
+ Returns:
216
+ InlineCodeNode
217
+ """
218
+ code = match.group(1)
219
+ node = InlineCodeNode(type="inlineCode", content=code)
220
+ return node
221
+
222
+ def _create_emoji_node(self, match: Match) -> EmojiNode:
223
+ """
224
+ Create an emoji node from a match.
225
+
226
+ Args:
227
+ match: Regex match for emoji (:emoji_name:)
228
+
229
+ Returns:
230
+ EmojiNode
231
+ """
232
+ short_name = match.group(1)
233
+ # EmojiNode will auto-initialize text with :short_name: format
234
+ node = EmojiNode(short_name=short_name)
235
+ return node
236
+
237
+ def _create_mention_node(self, match: Match) -> MentionNode:
238
+ """
239
+ Create a mention node from a match.
240
+
241
+ Args:
242
+ match: Regex match for mention (@username)
243
+
244
+ Returns:
245
+ MentionNode
246
+ """
247
+ username = match.group(1)
248
+ # MentionNode will auto-initialize text with @username format
249
+ node = MentionNode(username=username)
250
+ return node
251
+
252
+ def _create_status_badge_node(self, match: Match) -> StatusBadgeNode:
253
+ """
254
+ Create a status badge node from a match.
255
+
256
+ Args:
257
+ match: Regex match for status badge ([!STATUS])
258
+
259
+ Returns:
260
+ StatusBadgeNode
261
+ """
262
+ status_text = match.group(1)
263
+
264
+ # Status color mapping
265
+ STATUS_COLOR_MAP = {
266
+ # Green - Success states
267
+ "DONE": "green",
268
+ "COMPLETE": "green",
269
+ "SUCCESS": "green",
270
+ "PASSED": "green",
271
+ "APPROVED": "green",
272
+ # Red - Error states
273
+ "ERROR": "red",
274
+ "FAILED": "red",
275
+ "BLOCKED": "red",
276
+ "REJECTED": "red",
277
+ # Yellow - Warning states
278
+ "WARNING": "yellow",
279
+ "PENDING": "yellow",
280
+ "REVIEW": "yellow",
281
+ # Blue - Info states
282
+ "INFO": "blue",
283
+ "IN_PROGRESS": "blue",
284
+ "RUNNING": "blue",
285
+ # Purple - Special states
286
+ "OPTIONAL": "purple",
287
+ # Neutral - Default
288
+ "TODO": "neutral",
289
+ }
290
+
291
+ color = STATUS_COLOR_MAP.get(status_text, "neutral")
292
+ node = StatusBadgeNode(text=status_text, color=color)
293
+ return node
294
+
295
+ def _create_highlighted_text_node(self, match: Match) -> HighlightedTextNode:
296
+ """
297
+ Create a highlighted text node from a match.
298
+
299
+ Args:
300
+ match: Regex match for highlighted text (==text==)
301
+
302
+ Returns:
303
+ HighlightedTextNode
304
+ """
305
+ text = match.group(1)
306
+ node = HighlightedTextNode(content=text, bg_color="#ffff00")
307
+ return node
308
+
309
+ def _create_colored_text_node(self, match: Match) -> ColoredTextNode:
310
+ """
311
+ Create a colored text node from a match.
312
+
313
+ Args:
314
+ match: Regex match for colored text (<mark style="color:...">text</mark>)
315
+
316
+ Returns:
317
+ ColoredTextNode
318
+ """
319
+ color = match.group(1)
320
+ text = match.group(2)
321
+ node = ColoredTextNode(content=text, color=color)
322
+ return node
323
+
324
+ def _create_date_node(self, match: Match) -> DateNode:
325
+ """
326
+ Create a date node from a match.
327
+
328
+ Args:
329
+ match: Regex match for ISO date (2025-10-29 or 2025-10-29T14:00:00)
330
+
331
+ Returns:
332
+ DateNode
333
+ """
334
+ from datetime import datetime
335
+
336
+ date_string = match.group(0)
337
+
338
+ try:
339
+ # Try parsing as datetime first
340
+ if 'T' in date_string:
341
+ dt = datetime.fromisoformat(date_string)
342
+ else:
343
+ # Parse as date only and set time to midnight
344
+ dt = datetime.strptime(date_string, "%Y-%m-%d")
345
+
346
+ # Convert to milliseconds timestamp
347
+ timestamp = int(dt.timestamp() * 1000)
348
+ node = DateNode(timestamp=timestamp, date_string=date_string)
349
+ except (ValueError, AttributeError):
350
+ # If parsing fails, create a date node with timestamp 0
351
+ node = DateNode(timestamp=0, date_string=date_string)
352
+
353
+ return node
354
+
355
+ def _create_inline_macro_node(self, match: Match) -> MarkdownNode:
356
+ """
357
+ Create a macro node from a match.
358
+
359
+ Supports curly brace macro syntax:
360
+ - {toc} - simple macro
361
+ - {toc:maxLevel=3} - with parameters
362
+ - {status:colour=Green|title=Active} - status with pipe separator
363
+
364
+ Args:
365
+ match: Regex match for macro ({macro-name:params})
366
+
367
+ Returns:
368
+ Appropriate extension node (TocNode, StatusMacroNode, etc.)
369
+ """
370
+ macro_name = match.group(1)
371
+ params_str = match.group(2) if match.lastindex >= 2 and match.group(2) else None
372
+
373
+ # Parse parameters
374
+ params = self._parse_macro_parameters(params_str) if params_str else {}
375
+
376
+ # Create appropriate node based on macro name
377
+ if macro_name == "toc":
378
+ # Parse TOC-specific parameters
379
+ max_level = int(params.get("maxLevel", "7"))
380
+ min_level = int(params.get("minLevel", "1"))
381
+ include = params.get("include")
382
+ exclude = params.get("exclude")
383
+ toc_type = params.get("type", "list")
384
+ printable = params.get("printable", "true").lower() == "true"
385
+ separator = params.get("separator", "dots")
386
+
387
+ return TocNode(
388
+ max_level=max_level,
389
+ min_level=min_level,
390
+ include=include,
391
+ exclude=exclude,
392
+ toc_type=toc_type,
393
+ printable=printable,
394
+ separator=separator
395
+ )
396
+
397
+ elif macro_name == "status":
398
+ # Parse status-specific parameters
399
+ # Support both 'title' and direct text
400
+ status_text = params.get("title", "")
401
+ color = params.get("colour", params.get("color", "Grey")) # Support both spellings
402
+ subtle = params.get("subtle", "false").lower() == "true"
403
+
404
+ return StatusMacroNode(
405
+ status_text=status_text,
406
+ color=color,
407
+ subtle=subtle
408
+ )
409
+
410
+ else:
411
+ # For other macros, create a generic extension node placeholder
412
+ # This will be handled by block parser if it's a block macro
413
+ # For now, treat as text node
414
+ return TextNode(type="text", content=match.group(0))
415
+
416
+ def _parse_macro_parameters(self, params_str: str) -> Dict[str, str]:
417
+ """
418
+ Parse macro parameters from parameter string.
419
+
420
+ Supports both comma-separated and pipe-separated parameters:
421
+ - maxLevel=3,minLevel=1
422
+ - colour=Green|title=Active
423
+
424
+ Args:
425
+ params_str: Parameter string (e.g., "maxLevel=3,minLevel=1")
426
+
427
+ Returns:
428
+ Dictionary of parameter key-value pairs
429
+ """
430
+ params = {}
431
+
432
+ # Determine separator (pipe for status, comma for others)
433
+ if "|" in params_str:
434
+ separator = "|"
435
+ else:
436
+ separator = ","
437
+
438
+ # Split by separator and parse key=value pairs
439
+ for pair in params_str.split(separator):
440
+ pair = pair.strip()
441
+ if "=" in pair:
442
+ key, value = pair.split("=", 1)
443
+ params[key.strip()] = value.strip()
444
+
445
+ return params
446
+
447
+ def _create_formatted_text_node(self, match: Match, format_type: str) -> List[MarkdownNode]:
448
+ """
449
+ Create formatted text nodes from a match, recursively parsing nested inline elements.
450
+
451
+ Args:
452
+ match: Regex match for formatted text
453
+ format_type: Type of formatting (bold, italic, etc.)
454
+
455
+ Returns:
456
+ List of MarkdownNode objects with appropriate marks applied
457
+ """
458
+ # Get the captured text content from the match
459
+ text = match.group(1) if match.group(1) else match.group(2)
460
+
461
+ # Determine which marks to apply
462
+ marks = []
463
+ if format_type == "bold":
464
+ marks.append({"type": "strong"})
465
+ elif format_type == "italic":
466
+ marks.append({"type": "em"})
467
+ elif format_type == "bold_italic":
468
+ marks.append({"type": "strong"})
469
+ marks.append({"type": "em"})
470
+ elif format_type == "strikethrough":
471
+ marks.append({"type": "strike"})
472
+
473
+ # Recursively parse the content for nested inline elements (like links)
474
+ nested_nodes = self.parse(text)
475
+
476
+ # Apply marks to all text nodes in the result
477
+ self._apply_marks_to_nodes(nested_nodes, marks)
478
+
479
+ return nested_nodes
480
+
481
+ def _apply_marks_to_nodes(self, nodes: List[MarkdownNode], marks: List[Dict[str, Any]]) -> None:
482
+ """
483
+ Recursively apply marks to all TextNode objects in a list of nodes.
484
+
485
+ Args:
486
+ nodes: List of nodes to process
487
+ marks: Marks to apply to text nodes
488
+ """
489
+ for node in nodes:
490
+ if isinstance(node, TextNode):
491
+ # Add marks to this text node
492
+ node.marks.extend(marks)
493
+ elif hasattr(node, 'children') and node.children:
494
+ # Recursively apply to children (e.g., text inside links)
495
+ self._apply_marks_to_nodes(node.children, marks)