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,1449 @@
1
+ """
2
+ Converters for transforming Markdown nodes to ADF nodes.
3
+
4
+ This module implements the converter pattern for transforming
5
+ Markdown nodes to Atlassian Document Format (ADF) nodes.
6
+ """
7
+
8
+ import logging
9
+ import re as _re
10
+ from typing import Any, Dict, List
11
+
12
+ # Matches Jira issue URLs: .../browse/PROJ-123
13
+ _JIRA_ISSUE_URL_RE = _re.compile(
14
+ r'https?://[^/]+/browse/([A-Z][A-Z0-9_]+-\d+)',
15
+ _re.IGNORECASE,
16
+ )
17
+ # Matches a bare issue key like "INCDNT-1" or "PROJ-42"
18
+ _JIRA_ISSUE_KEY_RE = _re.compile(r'^[A-Z][A-Z0-9_]+-\d+$', _re.IGNORECASE)
19
+
20
+ from docspan.backends.confluence.adf.interfaces import AdfDocumentBuilder, TypedNodeConverter
21
+ from docspan.backends.confluence.adf.nodes import AdfBuilder, AdfNode
22
+ from docspan.backends.confluence.adf.visitors import AdfNodeVisitor, NodeVisitorRegistry
23
+ from docspan.backends.confluence.markdown.ast import (
24
+ BlockquoteNode,
25
+ BulletListNode,
26
+ CodeBlockMacroNode,
27
+ CodeBlockNode,
28
+ ColoredTextNode,
29
+ DateNode,
30
+ EmojiNode,
31
+ ExcerptNode,
32
+ ExpandMacroNode,
33
+ ExpandNode,
34
+ HeadingNode,
35
+ HighlightedTextNode,
36
+ HorizontalRuleNode,
37
+ ImageNode,
38
+ InfoNode,
39
+ InlineCodeNode,
40
+ LayoutColumnNode,
41
+ LayoutSectionNode,
42
+ LinkNode,
43
+ ListItemNode,
44
+ MarkdownNode,
45
+ MediaGroupNode,
46
+ MentionNode,
47
+ MermaidNode,
48
+ NoteNode,
49
+ OrderedListNode,
50
+ ParagraphNode,
51
+ StatusBadgeNode,
52
+ StatusMacroNode,
53
+ TableNode,
54
+ TaskItemNode,
55
+ TaskListNode,
56
+ TextNode,
57
+ TocNode,
58
+ URLEmbedNode,
59
+ WarningNode,
60
+ WikiLinkNode,
61
+ )
62
+
63
+
64
+ class AdfDocumentBuilderImpl(AdfDocumentBuilder):
65
+ """
66
+ Implementation of ADF document builder.
67
+
68
+ This class is responsible for building ADF documents from ADF nodes.
69
+
70
+ Attributes:
71
+ builder: ADF node builder
72
+ logger: Logger instance
73
+ """
74
+
75
+ def __init__(self, builder: AdfBuilder):
76
+ """
77
+ Initialize the document builder.
78
+
79
+ Args:
80
+ builder: ADF node builder
81
+ """
82
+ self.builder = builder
83
+ self.logger = logging.getLogger(__name__)
84
+
85
+ def build_document(self, nodes: List[AdfNode]) -> Dict[str, Any]:
86
+ """
87
+ Build an ADF document from a list of ADF nodes.
88
+
89
+ Args:
90
+ nodes: List of ADF nodes
91
+
92
+ Returns:
93
+ ADF document as a dictionary
94
+ """
95
+ result = self.builder.document(nodes)
96
+ self.logger.debug(f"Built ADF document with {len(nodes)} top-level nodes")
97
+ return result
98
+
99
+
100
+ class MarkdownToAdfConverter:
101
+ """
102
+ Converter for transforming Markdown AST to ADF.
103
+
104
+ This class orchestrates the conversion of Markdown nodes to ADF nodes
105
+ using visitors and builds the final ADF document.
106
+
107
+ Attributes:
108
+ visitor: ADF node visitor
109
+ document_builder: ADF document builder
110
+ logger: Logger instance
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ visitor: AdfNodeVisitor,
116
+ document_builder: AdfDocumentBuilder
117
+ ):
118
+ """
119
+ Initialize the converter.
120
+
121
+ Args:
122
+ visitor: ADF node visitor
123
+ document_builder: ADF document builder
124
+ """
125
+ self.visitor = visitor
126
+ self.document_builder = document_builder
127
+ self.logger = logging.getLogger(__name__)
128
+
129
+ def convert(self, nodes: List[MarkdownNode]) -> Dict[str, Any]:
130
+ """
131
+ Convert Markdown nodes to ADF.
132
+
133
+ Args:
134
+ nodes: List of Markdown AST nodes
135
+
136
+ Returns:
137
+ ADF document as a dictionary
138
+ """
139
+ self.logger.debug(f"Converting {len(nodes)} Markdown nodes to ADF")
140
+ adf_nodes = []
141
+
142
+ for node in nodes:
143
+ try:
144
+ adf_node = self.visitor.visit(node)
145
+ adf_nodes.append(adf_node)
146
+ except Exception as e:
147
+ self.logger.error(f"Error converting node of type {node.type}: {str(e)}")
148
+ # Continue with other nodes, providing resilience to conversion errors
149
+
150
+ return self.document_builder.build_document(adf_nodes)
151
+
152
+
153
+ class ConverterFactory:
154
+ """
155
+ Factory for creating Markdown to ADF converters.
156
+
157
+ This class creates and configures all the components needed for
158
+ Markdown to ADF conversion.
159
+ """
160
+
161
+ @staticmethod
162
+ def create_converter() -> MarkdownToAdfConverter:
163
+ """
164
+ Create a fully configured Markdown to ADF converter.
165
+
166
+ Returns:
167
+ Configured converter
168
+ """
169
+ # Create the builder
170
+ builder = AdfBuilder()
171
+
172
+ # Create the registry and visitor
173
+ registry = NodeVisitorRegistry()
174
+ visitor = AdfNodeVisitor(registry, builder)
175
+
176
+ # Create and register all node visitors
177
+ registry.register("text", TextNodeConverter(builder))
178
+ registry.register("heading", HeadingNodeConverter(builder, visitor))
179
+ registry.register("paragraph", ParagraphNodeConverter(builder, visitor))
180
+ registry.register("listItem", ListItemNodeConverter(builder, visitor))
181
+ registry.register("bulletList", BulletListNodeConverter(builder, visitor))
182
+ registry.register("orderedList", OrderedListNodeConverter(builder, visitor))
183
+ registry.register("codeBlock", CodeBlockNodeConverter(builder))
184
+ registry.register("inlineCode", InlineCodeNodeConverter())
185
+ registry.register("blockquote", BlockquoteNodeConverter(builder, visitor))
186
+ registry.register("table", TableNodeConverter(builder, visitor))
187
+ registry.register("link", LinkNodeConverter(builder, visitor))
188
+ registry.register("image", ImageNodeConverter(builder))
189
+ registry.register("mediaGroup", MediaGroupNodeConverter(builder, visitor))
190
+ registry.register("wikiLink", WikiLinkNodeConverter(builder))
191
+ registry.register("mention", MentionNodeConverter(builder))
192
+ registry.register("emoji", EmojiNodeConverter(builder))
193
+ registry.register("expand", ExpandNodeConverter(builder, visitor))
194
+ registry.register("horizontalRule", HorizontalRuleNodeConverter(builder))
195
+ registry.register("mermaid", MermaidNodeConverter(builder))
196
+ registry.register("taskList", TaskListNodeConverter(builder, visitor))
197
+ registry.register("taskItem", TaskItemNodeConverter(builder, visitor))
198
+ registry.register("statusBadge", StatusBadgeNodeConverter(builder))
199
+ registry.register("date", DateNodeConverter(builder))
200
+ registry.register("layoutSection", LayoutSectionNodeConverter(builder, visitor))
201
+ registry.register("layoutColumn", LayoutColumnNodeConverter(builder, visitor))
202
+ registry.register("highlightedText", HighlightedTextNodeConverter(builder))
203
+ registry.register("coloredText", ColoredTextNodeConverter(builder))
204
+ registry.register("urlEmbed", URLEmbedNodeConverter(builder))
205
+
206
+ # Extension/macro converters
207
+ registry.register("toc", TocNodeConverter(builder))
208
+ registry.register("statusMacro", StatusMacroNodeConverter(builder))
209
+ registry.register("info", InfoNodeConverter(builder, visitor))
210
+ registry.register("warning", WarningNodeConverter(builder, visitor))
211
+ registry.register("note", NoteNodeConverter(builder, visitor))
212
+ registry.register("excerpt", ExcerptNodeConverter(builder, visitor))
213
+ registry.register("expandMacro", ExpandMacroNodeConverter(builder, visitor))
214
+ registry.register("codeBlockMacro", CodeBlockMacroNodeConverter(builder))
215
+
216
+ # Create the document builder
217
+ document_builder = AdfDocumentBuilderImpl(builder)
218
+
219
+ # Create and return the converter
220
+ return MarkdownToAdfConverter(visitor, document_builder)
221
+
222
+
223
+ # Node converters
224
+
225
+ class TextNodeConverter(TypedNodeConverter):
226
+ """Converter for text nodes."""
227
+
228
+ node_type = "text"
229
+
230
+ def __init__(self, builder: AdfBuilder):
231
+ """Initialize the converter."""
232
+ self.builder = builder
233
+
234
+ def convert_typed(self, node: TextNode) -> AdfNode:
235
+ """Convert a text node to ADF."""
236
+ # If the node has no content, return an empty text node
237
+ if not node.content:
238
+ return self.builder.text("")
239
+
240
+ # Process marks if they exist
241
+ marks = list(node.marks)
242
+
243
+ return self.builder.text(node.content, marks)
244
+
245
+
246
+ class HeadingNodeConverter(TypedNodeConverter):
247
+ """Converter for heading nodes."""
248
+
249
+ node_type = "heading"
250
+
251
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
252
+ """Initialize the converter."""
253
+ self.builder = builder
254
+ self.visitor = visitor
255
+
256
+ def convert_typed(self, node: HeadingNode) -> AdfNode:
257
+ """Convert a heading node to ADF."""
258
+ children = [self.visitor.visit(child) for child in node.children]
259
+ return self.builder.heading(children, node.level)
260
+
261
+
262
+ class ParagraphNodeConverter(TypedNodeConverter):
263
+ """Converter for paragraph nodes."""
264
+
265
+ node_type = "paragraph"
266
+
267
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
268
+ """Initialize the converter."""
269
+ self.builder = builder
270
+ self.visitor = visitor
271
+
272
+ def convert_typed(self, node: ParagraphNode) -> AdfNode:
273
+ """Convert a paragraph node to ADF."""
274
+ children = [self.visitor.visit(child) for child in node.children]
275
+ return self.builder.paragraph(children)
276
+
277
+
278
+ class ListItemNodeConverter(TypedNodeConverter):
279
+ """Converter for list item nodes."""
280
+
281
+ node_type = "listItem"
282
+
283
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
284
+ """Initialize the converter."""
285
+ self.builder = builder
286
+ self.visitor = visitor
287
+
288
+ def convert_typed(self, node: ListItemNode) -> AdfNode:
289
+ """Convert a list item node to ADF."""
290
+ children = [self.visitor.visit(child) for child in node.children]
291
+ return self.builder.list_item(children)
292
+
293
+
294
+ class BulletListNodeConverter(TypedNodeConverter):
295
+ """Converter for bullet list nodes."""
296
+
297
+ node_type = "bulletList"
298
+
299
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
300
+ """Initialize the converter."""
301
+ self.builder = builder
302
+ self.visitor = visitor
303
+
304
+ def convert_typed(self, node: BulletListNode) -> AdfNode:
305
+ """Convert a bullet list node to ADF."""
306
+ children = [self.visitor.visit(child) for child in node.children]
307
+ return self.builder.bullet_list(children)
308
+
309
+
310
+ class OrderedListNodeConverter(TypedNodeConverter):
311
+ """Converter for ordered list nodes."""
312
+
313
+ node_type = "orderedList"
314
+
315
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
316
+ """Initialize the converter."""
317
+ self.builder = builder
318
+ self.visitor = visitor
319
+
320
+ def convert_typed(self, node: OrderedListNode) -> AdfNode:
321
+ """Convert an ordered list node to ADF."""
322
+ children = [self.visitor.visit(child) for child in node.children]
323
+ return self.builder.ordered_list(children)
324
+
325
+
326
+ class CodeBlockNodeConverter(TypedNodeConverter):
327
+ """Converter for code block nodes."""
328
+
329
+ node_type = "codeBlock"
330
+
331
+ def __init__(self, builder: AdfBuilder):
332
+ """Initialize the converter."""
333
+ self.builder = builder
334
+
335
+ def convert_typed(self, node: CodeBlockNode) -> AdfNode:
336
+ """Convert a code block node to ADF."""
337
+ return self.builder.code_block(node.content or "", node.language)
338
+
339
+
340
+ class InlineCodeNodeConverter(TypedNodeConverter):
341
+ """Converter for inline code nodes."""
342
+
343
+ node_type = "inlineCode"
344
+
345
+ def convert_typed(self, node: InlineCodeNode) -> AdfNode:
346
+ """Convert an inline code node to ADF."""
347
+ return AdfNode(type="text", text=node.content or "", marks=[{"type": "code"}])
348
+
349
+
350
+ class BlockquoteNodeConverter(TypedNodeConverter):
351
+ """Converter for blockquote nodes."""
352
+
353
+ node_type = "blockquote"
354
+
355
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
356
+ """Initialize the converter."""
357
+ self.builder = builder
358
+ self.visitor = visitor
359
+
360
+ def convert_typed(self, node: BlockquoteNode) -> AdfNode:
361
+ """Convert a blockquote node to ADF."""
362
+ children = [self.visitor.visit(child) for child in node.children]
363
+ return self.builder.blockquote(children)
364
+
365
+
366
+ class TableNodeConverter(TypedNodeConverter):
367
+ """Converter for table nodes."""
368
+
369
+ node_type = "table"
370
+
371
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
372
+ """Initialize the converter."""
373
+ self.builder = builder
374
+ self.visitor = visitor
375
+
376
+ def convert_typed(self, node: TableNode) -> AdfNode:
377
+ """Convert a table node to ADF."""
378
+ # Convert rows to ADF nodes
379
+ adf_rows = []
380
+
381
+ # Handle headers if present
382
+ has_headers = bool(node.headers)
383
+
384
+ if has_headers:
385
+ header_cells = []
386
+ for header in node.headers:
387
+ header_content = self.builder.paragraph([self.builder.text(header)])
388
+ header_cells.append(header_content)
389
+ adf_rows.append(header_cells)
390
+
391
+ # Handle data rows
392
+ for row in node.rows:
393
+ row_cells = []
394
+ for cell in row:
395
+ if isinstance(cell, MarkdownNode):
396
+ row_cells.append(self.visitor.visit(cell))
397
+ else:
398
+ # Handle case where cell is raw content
399
+ cell_content = self.builder.paragraph([self.builder.text(str(cell))])
400
+ row_cells.append(cell_content)
401
+ adf_rows.append(row_cells)
402
+
403
+ return self.builder.table(adf_rows, has_headers)
404
+
405
+
406
+ class LinkNodeConverter(TypedNodeConverter):
407
+ """Converter for link nodes."""
408
+
409
+ node_type = "link"
410
+
411
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
412
+ """Initialize the converter."""
413
+ self.builder = builder
414
+ self.visitor = visitor
415
+
416
+ def convert_typed(self, node: LinkNode) -> AdfNode:
417
+ """Convert a link node to ADF.
418
+
419
+ Jira issue URLs (*/browse/PROJ-NNN) are rendered as inlineCard smart links
420
+ when the display text is the issue key, the full URL, or empty — i.e. when
421
+ the author hasn't chosen custom display text. Custom text like
422
+ '[see the ticket](jira-url)' keeps the linked text format.
423
+ """
424
+ # Extract text content from child nodes or use link content
425
+ text_content = " ".join(
426
+ child.content or ""
427
+ for child in node.children if hasattr(child, 'content')
428
+ ) or node.content or ""
429
+
430
+ # Smart link detection: Jira issue URLs → inlineCard when display text is
431
+ # the issue key, the bare URL, or absent.
432
+ jira_match = _JIRA_ISSUE_URL_RE.match(node.url)
433
+ if jira_match:
434
+ jira_match.group(1).upper()
435
+ text_is_key = bool(_JIRA_ISSUE_KEY_RE.match(text_content))
436
+ text_is_url = (not text_content or text_content == node.url)
437
+ if text_is_key or text_is_url:
438
+ return self.builder.inline_card(node.url)
439
+
440
+ # Extract marks from child text nodes (e.g., strong, em)
441
+ additional_marks = []
442
+ for child in node.children:
443
+ if hasattr(child, 'marks') and child.marks:
444
+ additional_marks.extend(child.marks)
445
+
446
+ return self.builder.link(text_content, node.url, node.title, additional_marks=additional_marks)
447
+
448
+
449
+ class ImageNodeConverter(TypedNodeConverter):
450
+ """
451
+ Converter for image nodes to ADF media nodes.
452
+
453
+ Supports:
454
+ - External images with dimensions
455
+ - Confluence attachments
456
+ - Layout wrapping in mediaSingle
457
+ - Width and height attributes
458
+ """
459
+
460
+ node_type = "image"
461
+
462
+ def __init__(self, builder: AdfBuilder):
463
+ """Initialize the converter."""
464
+ self.builder = builder
465
+ self.logger = logging.getLogger(__name__)
466
+
467
+ def convert_typed(self, node: ImageNode) -> AdfNode:
468
+ """
469
+ Convert an image node to ADF media node.
470
+
471
+ Args:
472
+ node: ImageNode with src, alt, title, width, height, layout attributes
473
+
474
+ Returns:
475
+ AdfNode - either media or mediaSingle (if layout specified)
476
+
477
+ Notes:
478
+ - Handles both external URLs and Confluence attachments
479
+ - Wraps in mediaSingle if layout attribute is specified
480
+ - Passes width and height to builder
481
+ """
482
+ # Determine if this is a Confluence attachment or external image
483
+ if node.is_confluence_attachment:
484
+ self.logger.debug(f"Converting Confluence attachment image: {node.src}")
485
+ media_node = self.builder.confluence_image(
486
+ file_id=node.src,
487
+ alt=node.alt,
488
+ title=node.title,
489
+ width=node.width,
490
+ height=node.height,
491
+ collection=node.collection or "contentId",
492
+ occurrence_key=node.occurrence_key
493
+ )
494
+ else:
495
+ self.logger.debug(f"Converting external image: {node.src}")
496
+ media_node = self.builder.image(
497
+ url=node.src,
498
+ alt=node.alt,
499
+ title=node.title,
500
+ width=node.width,
501
+ height=node.height
502
+ )
503
+
504
+ # Wrap in mediaSingle if layout is specified
505
+ if node.layout:
506
+ self.logger.debug(f"Wrapping image in mediaSingle with layout: {node.layout}")
507
+ return self.builder.media_single(media_node, layout=node.layout)
508
+
509
+ return media_node
510
+
511
+
512
+ class MediaGroupNodeConverter(TypedNodeConverter):
513
+ """
514
+ Converter for media group nodes to ADF.
515
+
516
+ Converts MediaGroupNode (image galleries) to ADF mediaGroup nodes
517
+ containing multiple media items.
518
+ """
519
+
520
+ node_type = "mediaGroup"
521
+
522
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
523
+ """
524
+ Initialize the converter.
525
+
526
+ Args:
527
+ builder: ADF node builder
528
+ visitor: Node visitor for recursively converting child nodes
529
+ """
530
+ self.builder = builder
531
+ self.visitor = visitor
532
+ self.logger = logging.getLogger(__name__)
533
+
534
+ def convert_typed(self, node: MediaGroupNode) -> AdfNode:
535
+ """
536
+ Convert a media group node to ADF mediaGroup node.
537
+
538
+ Args:
539
+ node: MediaGroupNode with children (ImageNode items)
540
+
541
+ Returns:
542
+ AdfNode representing a mediaGroup
543
+
544
+ Notes:
545
+ - Recursively converts all child image nodes
546
+ - Children should be ImageNode items
547
+ - Creates ADF mediaGroup structure for galleries
548
+ """
549
+ self.logger.debug(f"Converting media group with {len(node.children)} items")
550
+
551
+ # Convert child nodes (should be ImageNode items)
552
+ media_items = []
553
+ for child in node.children:
554
+ try:
555
+ converted_child = self.visitor.visit(child)
556
+ if converted_child:
557
+ media_items.append(converted_child)
558
+ except Exception as e:
559
+ self.logger.warning(
560
+ f"Failed to convert child node {child.type} in media group: {e}"
561
+ )
562
+
563
+ if not media_items:
564
+ self.logger.warning("Media group has no items, creating empty group")
565
+
566
+ return self.builder.media_group(media_items)
567
+
568
+
569
+ class WikiLinkNodeConverter(TypedNodeConverter):
570
+ """
571
+ Converter for wiki link nodes to ADF smart cards.
572
+
573
+ Converts [[Wiki Link]] syntax to Confluence inlineCard nodes for
574
+ native smart link rendering with page metadata and icons.
575
+ """
576
+
577
+ node_type = "wikiLink"
578
+
579
+ def __init__(self, builder: AdfBuilder):
580
+ """Initialize the converter."""
581
+ self.builder = builder
582
+ self.logger = logging.getLogger(__name__)
583
+
584
+ def convert_typed(self, node: WikiLinkNode) -> AdfNode:
585
+ """
586
+ Convert a wiki link node to ADF inline card.
587
+
588
+ Args:
589
+ node: WikiLinkNode with target and optional display text
590
+
591
+ Returns:
592
+ AdfNode representing an inlineCard
593
+
594
+ Notes:
595
+ - Uses anchor-style URLs for now (#target)
596
+ - TODO: Implement Confluence page ID resolution service
597
+ - Falls back to text link if conversion fails
598
+ """
599
+ try:
600
+ # Get target and display text
601
+ target = node.target
602
+ display = node.display or target
603
+
604
+ # For now, use anchor-style URLs until we implement page ID resolution
605
+ # Format: #PageName (Confluence handles these as internal page refs)
606
+ url = f"#{target}"
607
+
608
+ # Create Confluence metadata for better smart card rendering
609
+ confluence_metadata = {
610
+ "linkType": "page",
611
+ "contentTitle": display,
612
+ "isRenamedTitle": bool(node.display) # True if custom display text provided
613
+ }
614
+
615
+ self.logger.debug(
616
+ f"Converting wiki link [[{target}]] to inline card with URL: {url}"
617
+ )
618
+
619
+ # Create inline card using the builder method
620
+ return self.builder.inline_card(
621
+ url=url,
622
+ title=display,
623
+ confluence_metadata=confluence_metadata
624
+ )
625
+
626
+ except Exception as e:
627
+ # Fallback to simple link if inline card creation fails
628
+ self.logger.warning(
629
+ f"Failed to convert wiki link [[{node.target}]] to inline card: {e}. "
630
+ f"Falling back to simple link."
631
+ )
632
+ display = node.display or node.target
633
+ return self.builder.link(display, f"#{node.target}")
634
+
635
+
636
+ class MentionNodeConverter(TypedNodeConverter):
637
+ """
638
+ Converter for user mention nodes to ADF.
639
+
640
+ Converts @username syntax to Confluence mention nodes with user ID resolution.
641
+ """
642
+
643
+ node_type = "mention"
644
+
645
+ def __init__(self, builder: AdfBuilder, user_resolver=None):
646
+ """
647
+ Initialize the converter.
648
+
649
+ Args:
650
+ builder: ADF node builder
651
+ user_resolver: Optional UserResolver instance for username resolution
652
+ """
653
+ self.builder = builder
654
+ self.user_resolver = user_resolver
655
+ self.logger = logging.getLogger(__name__)
656
+
657
+ def convert_typed(self, node: MentionNode) -> AdfNode:
658
+ """
659
+ Convert a mention node to ADF mention node.
660
+
661
+ Args:
662
+ node: MentionNode with username and optional user_id
663
+
664
+ Returns:
665
+ AdfNode representing a mention
666
+
667
+ Notes:
668
+ - If user_id not provided, attempts resolution via UserResolver
669
+ - Falls back to placeholder ID if resolution fails
670
+ - Falls back to text node if conversion fails
671
+ """
672
+ try:
673
+ username = node.username
674
+ user_id = node.user_id
675
+ display_text = node.text or f"@{username}"
676
+
677
+ # If no user_id provided, try to resolve it
678
+ if not user_id and self.user_resolver:
679
+ self.logger.debug(f"Attempting to resolve user ID for @{username}")
680
+ try:
681
+ user_id = self.user_resolver.resolve_username(username)
682
+ if user_id:
683
+ self.logger.info(f"Resolved @{username} to user ID: {user_id}")
684
+ else:
685
+ self.logger.warning(f"Could not resolve user ID for @{username}")
686
+ except Exception as e:
687
+ self.logger.error(f"Error resolving user @{username}: {e}")
688
+
689
+ # If still no user_id, use placeholder
690
+ if not user_id:
691
+ self.logger.warning(
692
+ f"Mention for @{username} has no user_id. "
693
+ f"Using placeholder ID. User may not be notified."
694
+ )
695
+ # Use username as temporary user_id
696
+ user_id = f"unresolved-{username}"
697
+
698
+ self.logger.debug(
699
+ f"Converting mention @{username} to ADF mention node with user_id: {user_id}"
700
+ )
701
+
702
+ # Create mention node using the builder method
703
+ return self.builder.mention(
704
+ user_id=user_id,
705
+ text=display_text
706
+ )
707
+
708
+ except Exception as e:
709
+ # Fallback to text node if mention creation fails
710
+ self.logger.warning(
711
+ f"Failed to convert mention @{node.username} to ADF: {e}. "
712
+ f"Falling back to text node."
713
+ )
714
+ display_text = node.text or f"@{node.username}"
715
+ return self.builder.text(display_text)
716
+
717
+
718
+ class EmojiNodeConverter(TypedNodeConverter):
719
+ """
720
+ Converter for emoji nodes to ADF.
721
+
722
+ Converts :emoji_name: syntax to Confluence emoji nodes with Unicode mapping.
723
+ """
724
+
725
+ node_type = "emoji"
726
+
727
+ def __init__(self, builder: AdfBuilder, emoji_mapper=None):
728
+ """
729
+ Initialize the converter.
730
+
731
+ Args:
732
+ builder: ADF node builder
733
+ emoji_mapper: Optional EmojiMapper instance for emoji resolution
734
+ """
735
+ self.builder = builder
736
+ self.emoji_mapper = emoji_mapper
737
+ self.logger = logging.getLogger(__name__)
738
+
739
+ def convert_typed(self, node: EmojiNode) -> AdfNode:
740
+ """
741
+ Convert an emoji node to ADF emoji node.
742
+
743
+ Args:
744
+ node: EmojiNode with short_name and optional emoji_id
745
+
746
+ Returns:
747
+ AdfNode representing an emoji
748
+
749
+ Notes:
750
+ - If emoji_id not provided, attempts resolution via EmojiMapper
751
+ - Falls back to text node if emoji not supported
752
+ - Uses Unicode codepoint and character from mapping
753
+ """
754
+ try:
755
+ short_name = node.short_name
756
+ emoji_id = node.emoji_id
757
+ text = node.text
758
+
759
+ # If no emoji_id provided, try to resolve it
760
+ if not emoji_id and self.emoji_mapper:
761
+ self.logger.debug(f"Attempting to resolve emoji :{short_name}:")
762
+ try:
763
+ result = self.emoji_mapper.get_emoji(short_name)
764
+ if result:
765
+ emoji_id, character = result
766
+ # Use the character from mapping if not provided
767
+ if not text or text == f":{short_name}:":
768
+ text = character
769
+ self.logger.info(f"Resolved :{short_name}: to emoji ID: {emoji_id}")
770
+ else:
771
+ self.logger.warning(f"Could not resolve emoji :{short_name}:")
772
+ except Exception as e:
773
+ self.logger.error(f"Error resolving emoji :{short_name}: {e}")
774
+
775
+ # If still no emoji_id, check if mapper says it's supported
776
+ if not emoji_id:
777
+ if self.emoji_mapper and not self.emoji_mapper.is_supported(short_name):
778
+ self.logger.warning(
779
+ f"Emoji :{short_name}: is not supported. "
780
+ f"Falling back to text node."
781
+ )
782
+ fallback_text = node.text or f":{short_name}:"
783
+ return self.builder.text(fallback_text)
784
+ else:
785
+ # No mapper available, use placeholder
786
+ self.logger.warning(
787
+ f"Emoji :{short_name}: has no emoji_id and no mapper available. "
788
+ f"Falling back to text node."
789
+ )
790
+ fallback_text = node.text or f":{short_name}:"
791
+ return self.builder.text(fallback_text)
792
+
793
+ # Ensure text is set
794
+ if not text:
795
+ text = f":{short_name}:"
796
+
797
+ self.logger.debug(
798
+ f"Converting emoji :{short_name}: to ADF emoji node with ID: {emoji_id}"
799
+ )
800
+
801
+ # Create emoji node using the builder method
802
+ # Include short_name with colons for consistency
803
+ short_name_with_colons = f":{short_name}:"
804
+ return self.builder.emoji(
805
+ short_name=short_name_with_colons,
806
+ emoji_id=emoji_id,
807
+ text=text
808
+ )
809
+
810
+ except Exception as e:
811
+ # Fallback to text node if emoji creation fails
812
+ self.logger.warning(
813
+ f"Failed to convert emoji :{node.short_name}: to ADF: {e}. "
814
+ f"Falling back to text node."
815
+ )
816
+ fallback_text = node.text or f":{node.short_name}:"
817
+ return self.builder.text(fallback_text)
818
+
819
+
820
+ class ExpandNodeConverter(TypedNodeConverter):
821
+ """
822
+ Converter for expand (collapsible) nodes to ADF.
823
+
824
+ Converts HTML <details><summary> blocks to Confluence expand nodes.
825
+ """
826
+
827
+ node_type = "expand"
828
+
829
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
830
+ """
831
+ Initialize the converter.
832
+
833
+ Args:
834
+ builder: ADF node builder
835
+ visitor: Node visitor for recursively converting child nodes
836
+ """
837
+ self.builder = builder
838
+ self.visitor = visitor
839
+ self.logger = logging.getLogger(__name__)
840
+
841
+ def convert_typed(self, node: ExpandNode) -> AdfNode:
842
+ """
843
+ Convert an expand node to ADF expand node.
844
+
845
+ Args:
846
+ node: ExpandNode with title and children
847
+
848
+ Returns:
849
+ AdfNode representing an expand section
850
+
851
+ Notes:
852
+ - Recursively converts all child nodes
853
+ - If no children, creates empty paragraph
854
+ - Title can be empty string
855
+ """
856
+ try:
857
+ title = node.title or ""
858
+ self.logger.debug(f"Converting expand node with title: {title!r}")
859
+
860
+ # Convert child nodes
861
+ content_nodes = []
862
+ for child in node.children:
863
+ try:
864
+ converted_child = self.visitor.visit(child)
865
+ if converted_child:
866
+ content_nodes.append(converted_child)
867
+ except Exception as e:
868
+ self.logger.warning(
869
+ f"Failed to convert child node {child.type} in expand: {e}"
870
+ )
871
+
872
+ # Ensure at least one content node for valid ADF
873
+ if not content_nodes:
874
+ self.logger.debug("No content in expand, adding empty paragraph")
875
+ content_nodes.append(self.builder.paragraph([]))
876
+
877
+ self.logger.debug(
878
+ f"Converting expand '{title}' with {len(content_nodes)} child nodes"
879
+ )
880
+
881
+ # Create expand node using the builder method
882
+ return self.builder.expand(title=title, content=content_nodes)
883
+
884
+ except Exception as e:
885
+ # Fallback: convert children as normal paragraphs
886
+ self.logger.warning(
887
+ f"Failed to convert expand node: {e}. "
888
+ f"Converting children as normal content."
889
+ )
890
+ # Return children as a list or wrap in paragraph
891
+ if node.children:
892
+ return self.visitor.visit(node.children[0])
893
+ else:
894
+ return self.builder.paragraph([])
895
+
896
+
897
+ class HorizontalRuleNodeConverter(TypedNodeConverter):
898
+ """Converter for horizontal rule nodes."""
899
+
900
+ node_type = "horizontalRule"
901
+
902
+ def __init__(self, builder: AdfBuilder):
903
+ """Initialize the converter."""
904
+ self.builder = builder
905
+
906
+ def convert_typed(self, node: HorizontalRuleNode) -> AdfNode:
907
+ """Convert a horizontal rule node to ADF."""
908
+ return self.builder.horizontal_rule()
909
+
910
+
911
+ class MermaidNodeConverter(TypedNodeConverter):
912
+ """Converter for mermaid diagram nodes."""
913
+
914
+ node_type = "mermaid"
915
+
916
+ def __init__(self, builder: AdfBuilder):
917
+ """Initialize the converter."""
918
+ self.builder = builder
919
+ self.logger = logging.getLogger(__name__)
920
+
921
+ def convert_typed(self, node: MermaidNode) -> AdfNode:
922
+ """Convert a mermaid diagram node to ADF."""
923
+ self.logger.info("Processing Mermaid diagram for ADF conversion")
924
+ self.logger.debug(f"Mermaid node attributes: {node.attrs}")
925
+
926
+ # First, check if the node has storage_format_html attribute (highest priority)
927
+ if hasattr(node, 'storage_format_html') and node.storage_format_html:
928
+ self.logger.info("Using node's storage_format_html for direct HTML embedding")
929
+
930
+ # Create a paragraph node that will be replaced with HTML content
931
+ note_paragraph = self.builder.paragraph([
932
+ self.builder.text("")
933
+ ])
934
+
935
+ # Transfer the HTML content to the paragraph node
936
+ # This will be included in the JSON output via the AdfNode.to_dict method
937
+ note_paragraph.storage_format_html = node.storage_format_html
938
+
939
+ return note_paragraph
940
+
941
+ # Check if we have an iframe HTML for direct embedding
942
+ elif "iframe_html" in node.attrs:
943
+ # Create a paragraph with HTML content using the raw macro
944
+ self.logger.info("Using iframe HTML embedding for diagram")
945
+ html_content = node.attrs["iframe_html"]
946
+
947
+ # Create a paragraph node that will be replaced with HTML content
948
+ note_paragraph = self.builder.paragraph([
949
+ self.builder.text("")
950
+ ])
951
+
952
+ # Store HTML content in the storage_format_html attribute
953
+ # This will be included in the JSON output via the AdfNode.to_dict method
954
+ note_paragraph.storage_format_html = html_content
955
+
956
+ return note_paragraph
957
+
958
+ # Check if the diagram has been rendered as image
959
+ elif "rendered_url" in node.attrs:
960
+ # Get the rendered URL
961
+ image_url = node.attrs["rendered_url"]
962
+ self.logger.info(f"Found rendered URL for Mermaid diagram: {image_url}")
963
+
964
+ # Create a more descriptive alt text if possible
965
+ alt_text = "Mermaid diagram"
966
+ if node.code.strip().startswith("flowchart") or node.code.strip().startswith("graph"):
967
+ alt_text = "Flowchart diagram"
968
+ elif node.code.strip().startswith("sequenceDiagram"):
969
+ alt_text = "Sequence diagram"
970
+ elif node.code.strip().startswith("classDiagram"):
971
+ alt_text = "Class diagram"
972
+
973
+ self.logger.info(f"Converting Mermaid diagram to ADF image with alt text: {alt_text}")
974
+
975
+ # Create the image node
976
+ image_node = self.builder.image(image_url, alt_text)
977
+
978
+ # Add attachment ID if available, as this is crucial for proper rendering
979
+ if "attachment_id" in node.attrs:
980
+ attachment_id = node.attrs["attachment_id"]
981
+ self.logger.info(f"Adding attachment ID {attachment_id} to image node")
982
+
983
+ # Override attributes with file-based reference that Confluence understands better
984
+ image_node.attrs = {
985
+ "type": "file",
986
+ "id": attachment_id,
987
+ "collection": "contentId"
988
+ }
989
+
990
+ if alt_text:
991
+ image_node.attrs["alt"] = alt_text
992
+
993
+ self.logger.debug(f"Updated image node with attachment ID: {image_node.to_dict()}")
994
+
995
+ # Wrap the image in media_single for better layout control
996
+ # Extract layout and width from node attributes if specified
997
+ layout = node.attrs.get("layout", "center")
998
+ width = node.attrs.get("width", 800) # Default to 800px for diagrams
999
+
1000
+ self.logger.debug(f"Wrapping image in media_single with layout={layout}, width={width}")
1001
+ return self.builder.media_single(
1002
+ image_node,
1003
+ layout=layout,
1004
+ width=width,
1005
+ width_type="pixel"
1006
+ )
1007
+
1008
+ # Check if the diagram has a live link
1009
+ elif "live_link" in node.attrs:
1010
+ # Get the live link
1011
+ live_link = node.attrs["live_link"]
1012
+ self.logger.info(f"Found Mermaid Live link: {live_link}")
1013
+
1014
+ # Create a link with the mermaid diagram
1015
+ return self.builder.link("View Mermaid diagram", live_link, "Mermaid Live diagram")
1016
+
1017
+ # Log the issue and fall back to code block
1018
+ self.logger.warning("Mermaid diagram could not be rendered as image or embedded, using code block fallback")
1019
+ self.logger.warning(f"Available attributes: {list(node.attrs.keys() if hasattr(node, 'attrs') else [])}")
1020
+
1021
+ # Fallback to a code block if rendering failed
1022
+ return self.builder.code_block(node.code, "mermaid")
1023
+
1024
+
1025
+ class TaskListNodeConverter(TypedNodeConverter):
1026
+ """Converter for task list nodes."""
1027
+
1028
+ node_type = "taskList"
1029
+
1030
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
1031
+ """Initialize the converter."""
1032
+ self.builder = builder
1033
+ self.visitor = visitor
1034
+
1035
+ def convert_typed(self, node: "TaskListNode") -> AdfNode:
1036
+ """Convert a task list node to ADF."""
1037
+ items = [self.visitor.visit(child) for child in node.children]
1038
+ return self.builder.task_list(items)
1039
+
1040
+
1041
+ class TaskItemNodeConverter(TypedNodeConverter):
1042
+ """Converter for task item nodes."""
1043
+
1044
+ node_type = "taskItem"
1045
+
1046
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
1047
+ """Initialize the converter."""
1048
+ self.builder = builder
1049
+ self.visitor = visitor
1050
+
1051
+ def convert_typed(self, node: "TaskItemNode") -> AdfNode:
1052
+ """
1053
+ Convert a task item node to ADF.
1054
+
1055
+ Confluence's taskItem nodes don't support paragraph children - they only
1056
+ accept inline content. This method flattens paragraph nodes to extract
1057
+ their inline content.
1058
+ """
1059
+ content = []
1060
+ for child in node.children:
1061
+ visited = self.visitor.visit(child)
1062
+ # If child is a paragraph, extract its inline content instead
1063
+ # Confluence taskItem nodes don't support paragraph children
1064
+ if visited.type == "paragraph" and visited.content:
1065
+ content.extend(visited.content)
1066
+ else:
1067
+ content.append(visited)
1068
+
1069
+ state = "DONE" if node.checked else "TODO"
1070
+ return self.builder.task_item(content, state)
1071
+
1072
+
1073
+ class StatusBadgeNodeConverter(TypedNodeConverter):
1074
+ """Converter for status badge nodes."""
1075
+
1076
+ node_type = "statusBadge"
1077
+
1078
+ def __init__(self, builder: AdfBuilder):
1079
+ """Initialize the converter."""
1080
+ self.builder = builder
1081
+
1082
+ def convert_typed(self, node: "StatusBadgeNode") -> AdfNode:
1083
+ """Convert a status badge node to ADF."""
1084
+ return self.builder.status(node.text, node.color)
1085
+
1086
+
1087
+ class DateNodeConverter(TypedNodeConverter):
1088
+ """Converter for date nodes."""
1089
+
1090
+ node_type = "date"
1091
+
1092
+ def __init__(self, builder: AdfBuilder):
1093
+ """Initialize the converter."""
1094
+ self.builder = builder
1095
+
1096
+ def convert_typed(self, node: "DateNode") -> AdfNode:
1097
+ """Convert a date node to ADF."""
1098
+ return self.builder.date(node.timestamp)
1099
+
1100
+
1101
+ class LayoutSectionNodeConverter(TypedNodeConverter):
1102
+ """Converter for layout section nodes."""
1103
+
1104
+ node_type = "layoutSection"
1105
+
1106
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
1107
+ """Initialize the converter."""
1108
+ self.builder = builder
1109
+ self.visitor = visitor
1110
+
1111
+ def convert_typed(self, node: "LayoutSectionNode") -> AdfNode:
1112
+ """Convert a layout section node to ADF."""
1113
+ columns = [self.visitor.visit(child) for child in node.children]
1114
+ return self.builder.layout_section(columns)
1115
+
1116
+
1117
+ class LayoutColumnNodeConverter(TypedNodeConverter):
1118
+ """Converter for layout column nodes."""
1119
+
1120
+ node_type = "layoutColumn"
1121
+
1122
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
1123
+ """Initialize the converter."""
1124
+ self.builder = builder
1125
+ self.visitor = visitor
1126
+
1127
+ def convert_typed(self, node: "LayoutColumnNode") -> AdfNode:
1128
+ """Convert a layout column node to ADF."""
1129
+ content = [self.visitor.visit(child) for child in node.children]
1130
+ return self.builder.layout_column(content, node.width)
1131
+
1132
+
1133
+ class HighlightedTextNodeConverter(TypedNodeConverter):
1134
+ """Converter for highlighted text nodes."""
1135
+
1136
+ node_type = "highlightedText"
1137
+
1138
+ def __init__(self, builder: AdfBuilder):
1139
+ """Initialize the converter."""
1140
+ self.builder = builder
1141
+
1142
+ def convert_typed(self, node: "HighlightedTextNode") -> AdfNode:
1143
+ """Convert a highlighted text node to ADF."""
1144
+ return self.builder.highlighted_text(node.content or "", node.bg_color, node.marks)
1145
+
1146
+
1147
+ class ColoredTextNodeConverter(TypedNodeConverter):
1148
+ """Converter for colored text nodes."""
1149
+
1150
+ node_type = "coloredText"
1151
+
1152
+ def __init__(self, builder: AdfBuilder):
1153
+ """Initialize the converter."""
1154
+ self.builder = builder
1155
+
1156
+ def convert_typed(self, node: "ColoredTextNode") -> AdfNode:
1157
+ """Convert a colored text node to ADF."""
1158
+ text = node.content or ""
1159
+
1160
+ # If both color and bg_color are specified, combine them
1161
+ if node.color and node.bg_color:
1162
+ # Create text with both marks
1163
+ marks = list(node.marks) if node.marks else []
1164
+ marks.append({"type": "textColor", "attrs": {"color": node.color}})
1165
+ marks.append({"type": "backgroundColor", "attrs": {"color": node.bg_color}})
1166
+ return self.builder.text(text, marks)
1167
+ elif node.color:
1168
+ return self.builder.colored_text(text, node.color, node.marks)
1169
+ elif node.bg_color:
1170
+ return self.builder.highlighted_text(text, node.bg_color, node.marks)
1171
+ else:
1172
+ # Fallback to plain text
1173
+ return self.builder.text(text, node.marks)
1174
+
1175
+
1176
+ class URLEmbedNodeConverter(TypedNodeConverter):
1177
+ """Converter for URL embed nodes."""
1178
+
1179
+ node_type = "urlEmbed"
1180
+
1181
+ def __init__(self, builder: AdfBuilder):
1182
+ """Initialize the converter."""
1183
+ self.builder = builder
1184
+ self.logger = logging.getLogger(__name__)
1185
+
1186
+ def convert_typed(self, node: "URLEmbedNode") -> AdfNode:
1187
+ """Convert a URL embed node to ADF."""
1188
+ if node.embed_type == "video":
1189
+ # Use rich_media for video embeds
1190
+ return self.builder.rich_media(
1191
+ node.url,
1192
+ layout=node.layout,
1193
+ width=node.width,
1194
+ height=node.height
1195
+ )
1196
+ else:
1197
+ # Use embed_card for other URL embeds
1198
+ return self.builder.embed_card(node.url, layout=node.layout)
1199
+
1200
+
1201
+ class TocNodeConverter(TypedNodeConverter):
1202
+ """Converter for Table of Contents nodes."""
1203
+
1204
+ node_type = "toc"
1205
+
1206
+ def __init__(self, builder: AdfBuilder):
1207
+ """Initialize the converter."""
1208
+ self.builder = builder
1209
+ self.logger = logging.getLogger(__name__)
1210
+
1211
+ def convert_typed(self, node: "TocNode") -> AdfNode:
1212
+ """Convert a TOC node to ADF."""
1213
+ self.logger.debug(f"Converting TOC node with max_level={node.max_level}")
1214
+
1215
+ return self.builder.toc_macro(
1216
+ max_level=node.max_level,
1217
+ min_level=node.min_level,
1218
+ include=node.include
1219
+ )
1220
+
1221
+
1222
+ class StatusMacroNodeConverter(TypedNodeConverter):
1223
+ """Converter for status macro nodes."""
1224
+
1225
+ node_type = "statusMacro"
1226
+
1227
+ def __init__(self, builder: AdfBuilder):
1228
+ """Initialize the converter."""
1229
+ self.builder = builder
1230
+ self.logger = logging.getLogger(__name__)
1231
+
1232
+ def convert_typed(self, node: "StatusMacroNode") -> AdfNode:
1233
+ """Convert a status macro node to ADF."""
1234
+ self.logger.debug(f"Converting status macro: {node.status_text} ({node.color})")
1235
+
1236
+ return self.builder.status_macro(
1237
+ status_text=node.status_text,
1238
+ color=node.color,
1239
+ subtle=node.subtle
1240
+ )
1241
+
1242
+
1243
+ class InfoNodeConverter(TypedNodeConverter):
1244
+ """Converter for info panel nodes."""
1245
+
1246
+ node_type = "info"
1247
+
1248
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
1249
+ """Initialize the converter."""
1250
+ self.builder = builder
1251
+ self.visitor = visitor
1252
+ self.logger = logging.getLogger(__name__)
1253
+
1254
+ def convert_typed(self, node: "InfoNode") -> AdfNode:
1255
+ """Convert an info panel node to ADF."""
1256
+ self.logger.debug(f"Converting info panel with title: {node.title}")
1257
+
1258
+ # Convert child nodes
1259
+ content_nodes = []
1260
+ for child in node.children:
1261
+ try:
1262
+ converted_child = self.visitor.visit(child)
1263
+ if converted_child:
1264
+ content_nodes.append(converted_child)
1265
+ except Exception as e:
1266
+ self.logger.warning(
1267
+ f"Failed to convert child node {child.type} in info panel: {e}"
1268
+ )
1269
+
1270
+ # Ensure at least one content node for valid ADF
1271
+ if not content_nodes:
1272
+ self.logger.debug("No content in info panel, adding empty paragraph")
1273
+ content_nodes.append(self.builder.paragraph([]))
1274
+
1275
+ return self.builder.info_panel(content=content_nodes, title=node.title)
1276
+
1277
+
1278
+ class WarningNodeConverter(TypedNodeConverter):
1279
+ """Converter for warning panel nodes."""
1280
+
1281
+ node_type = "warning"
1282
+
1283
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
1284
+ """Initialize the converter."""
1285
+ self.builder = builder
1286
+ self.visitor = visitor
1287
+ self.logger = logging.getLogger(__name__)
1288
+
1289
+ def convert_typed(self, node: "WarningNode") -> AdfNode:
1290
+ """Convert a warning panel node to ADF."""
1291
+ self.logger.debug(f"Converting warning panel with title: {node.title}")
1292
+
1293
+ # Convert child nodes
1294
+ content_nodes = []
1295
+ for child in node.children:
1296
+ try:
1297
+ converted_child = self.visitor.visit(child)
1298
+ if converted_child:
1299
+ content_nodes.append(converted_child)
1300
+ except Exception as e:
1301
+ self.logger.warning(
1302
+ f"Failed to convert child node {child.type} in warning panel: {e}"
1303
+ )
1304
+
1305
+ # Ensure at least one content node for valid ADF
1306
+ if not content_nodes:
1307
+ self.logger.debug("No content in warning panel, adding empty paragraph")
1308
+ content_nodes.append(self.builder.paragraph([]))
1309
+
1310
+ return self.builder.warning_panel(
1311
+ content=content_nodes,
1312
+ title=node.title,
1313
+ icon=node.icon
1314
+ )
1315
+
1316
+
1317
+ class NoteNodeConverter(TypedNodeConverter):
1318
+ """Converter for note panel nodes."""
1319
+
1320
+ node_type = "note"
1321
+
1322
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
1323
+ """Initialize the converter."""
1324
+ self.builder = builder
1325
+ self.visitor = visitor
1326
+ self.logger = logging.getLogger(__name__)
1327
+
1328
+ def convert_typed(self, node: "NoteNode") -> AdfNode:
1329
+ """Convert a note panel node to ADF."""
1330
+ self.logger.debug(f"Converting note panel with title: {node.title}")
1331
+
1332
+ # Convert child nodes
1333
+ content_nodes = []
1334
+ for child in node.children:
1335
+ try:
1336
+ converted_child = self.visitor.visit(child)
1337
+ if converted_child:
1338
+ content_nodes.append(converted_child)
1339
+ except Exception as e:
1340
+ self.logger.warning(
1341
+ f"Failed to convert child node {child.type} in note panel: {e}"
1342
+ )
1343
+
1344
+ # Ensure at least one content node for valid ADF
1345
+ if not content_nodes:
1346
+ self.logger.debug("No content in note panel, adding empty paragraph")
1347
+ content_nodes.append(self.builder.paragraph([]))
1348
+
1349
+ return self.builder.note_panel(
1350
+ content=content_nodes,
1351
+ title=node.title,
1352
+ icon=node.icon
1353
+ )
1354
+
1355
+
1356
+ class ExcerptNodeConverter(TypedNodeConverter):
1357
+ """Converter for excerpt nodes."""
1358
+
1359
+ node_type = "excerpt"
1360
+
1361
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
1362
+ """Initialize the converter."""
1363
+ self.builder = builder
1364
+ self.visitor = visitor
1365
+ self.logger = logging.getLogger(__name__)
1366
+
1367
+ def convert_typed(self, node: "ExcerptNode") -> AdfNode:
1368
+ """Convert an excerpt node to ADF."""
1369
+ self.logger.debug(f"Converting excerpt (hidden={node.hidden})")
1370
+
1371
+ # Convert child nodes
1372
+ content_nodes = []
1373
+ for child in node.children:
1374
+ try:
1375
+ converted_child = self.visitor.visit(child)
1376
+ if converted_child:
1377
+ content_nodes.append(converted_child)
1378
+ except Exception as e:
1379
+ self.logger.warning(
1380
+ f"Failed to convert child node {child.type} in excerpt: {e}"
1381
+ )
1382
+
1383
+ # Ensure at least one content node for valid ADF
1384
+ if not content_nodes:
1385
+ self.logger.debug("No content in excerpt, adding empty paragraph")
1386
+ content_nodes.append(self.builder.paragraph([]))
1387
+
1388
+ return self.builder.excerpt_macro(content=content_nodes, hidden=node.hidden)
1389
+
1390
+
1391
+ class ExpandMacroNodeConverter(TypedNodeConverter):
1392
+ """Converter for expand macro nodes."""
1393
+
1394
+ node_type = "expandMacro"
1395
+
1396
+ def __init__(self, builder: AdfBuilder, visitor: AdfNodeVisitor):
1397
+ """Initialize the converter."""
1398
+ self.builder = builder
1399
+ self.visitor = visitor
1400
+ self.logger = logging.getLogger(__name__)
1401
+
1402
+ def convert_typed(self, node: "ExpandMacroNode") -> AdfNode:
1403
+ """Convert an expand macro node to ADF."""
1404
+ self.logger.debug(f"Converting expand macro with title: {node.title}")
1405
+
1406
+ # Convert child nodes
1407
+ content_nodes = []
1408
+ for child in node.children:
1409
+ try:
1410
+ converted_child = self.visitor.visit(child)
1411
+ if converted_child:
1412
+ content_nodes.append(converted_child)
1413
+ except Exception as e:
1414
+ self.logger.warning(
1415
+ f"Failed to convert child node {child.type} in expand macro: {e}"
1416
+ )
1417
+
1418
+ # Ensure at least one content node for valid ADF
1419
+ if not content_nodes:
1420
+ self.logger.debug("No content in expand macro, adding empty paragraph")
1421
+ content_nodes.append(self.builder.paragraph([]))
1422
+
1423
+ return self.builder.expand_macro(content=content_nodes, title=node.title)
1424
+
1425
+
1426
+ class CodeBlockMacroNodeConverter(TypedNodeConverter):
1427
+ """Converter for enhanced code block macro nodes."""
1428
+
1429
+ node_type = "codeBlockMacro"
1430
+
1431
+ def __init__(self, builder: AdfBuilder):
1432
+ """Initialize the converter."""
1433
+ self.builder = builder
1434
+ self.logger = logging.getLogger(__name__)
1435
+
1436
+ def convert_typed(self, node: "CodeBlockMacroNode") -> AdfNode:
1437
+ """Convert a code block macro node to ADF."""
1438
+ self.logger.debug(
1439
+ f"Converting code block macro (language={node.language}, title={node.title})"
1440
+ )
1441
+
1442
+ return self.builder.code_block_macro(
1443
+ code=node.content or "",
1444
+ language=node.language,
1445
+ title=node.title,
1446
+ linenumbers=node.linenumbers,
1447
+ theme=node.theme,
1448
+ collapse=node.collapse
1449
+ )