docspan 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- docspan/__init__.py +3 -0
- docspan/__main__.py +0 -0
- docspan/backends/__init__.py +19 -0
- docspan/backends/base.py +85 -0
- docspan/backends/confluence/__init__.py +0 -0
- docspan/backends/confluence/adf/__init__.py +14 -0
- docspan/backends/confluence/adf/comparator.py +427 -0
- docspan/backends/confluence/adf/converter.py +119 -0
- docspan/backends/confluence/adf/converters.py +1449 -0
- docspan/backends/confluence/adf/interfaces.py +191 -0
- docspan/backends/confluence/adf/nodes.py +2085 -0
- docspan/backends/confluence/adf/parser.py +400 -0
- docspan/backends/confluence/adf/validators.py +161 -0
- docspan/backends/confluence/adf/visitors.py +495 -0
- docspan/backends/confluence/backend.py +227 -0
- docspan/backends/confluence/client.py +44 -0
- docspan/backends/confluence/config/__init__.py +21 -0
- docspan/backends/confluence/config/loader.py +107 -0
- docspan/backends/confluence/config/models.py +167 -0
- docspan/backends/confluence/config/validation.py +297 -0
- docspan/backends/confluence/markdown/__init__.py +22 -0
- docspan/backends/confluence/markdown/ast.py +819 -0
- docspan/backends/confluence/markdown/extensions/__init__.py +5 -0
- docspan/backends/confluence/markdown/extensions/frontmatter.py +80 -0
- docspan/backends/confluence/markdown/extensions/mermaid.py +64 -0
- docspan/backends/confluence/markdown/extensions/wikilinks.py +179 -0
- docspan/backends/confluence/markdown/inline_parser.py +495 -0
- docspan/backends/confluence/markdown/parser.py +1006 -0
- docspan/backends/confluence/models/__init__.py +18 -0
- docspan/backends/confluence/models/markdown_file.py +402 -0
- docspan/backends/confluence/models/page.py +212 -0
- docspan/backends/confluence/models/path_utils.py +34 -0
- docspan/backends/confluence/models/results.py +28 -0
- docspan/backends/confluence/models/sync_status.py +382 -0
- docspan/backends/confluence/services/__init__.py +0 -0
- docspan/backends/confluence/services/confluence/__init__.py +40 -0
- docspan/backends/confluence/services/confluence/attachment_client.py +147 -0
- docspan/backends/confluence/services/confluence/base_client.py +420 -0
- docspan/backends/confluence/services/confluence/client.py +376 -0
- docspan/backends/confluence/services/confluence/comment_client.py +682 -0
- docspan/backends/confluence/services/confluence/crawler.py +587 -0
- docspan/backends/confluence/services/confluence/label_client.py +130 -0
- docspan/backends/confluence/services/confluence/page_client.py +1288 -0
- docspan/backends/confluence/services/confluence/space_client.py +179 -0
- docspan/backends/confluence/services/confluence/url_parser.py +106 -0
- docspan/backends/google_docs/__init__.py +0 -0
- docspan/backends/google_docs/auth.py +143 -0
- docspan/backends/google_docs/backend.py +140 -0
- docspan/backends/google_docs/client.py +665 -0
- docspan/backends/google_docs/converter.py +471 -0
- docspan/backends/google_docs/docs_request_builder.py +232 -0
- docspan/backends/google_docs/docs_structure_parser.py +120 -0
- docspan/backends/google_docs/markdown_to_paragraph_parser.py +145 -0
- docspan/cli/__init__.py +0 -0
- docspan/cli/main.py +408 -0
- docspan/config.py +62 -0
- docspan/core/__init__.py +49 -0
- docspan/core/merge.py +30 -0
- docspan/core/orchestrator.py +332 -0
- docspan/core/paths.py +8 -0
- docspan/core/state.py +53 -0
- docspan-0.1.0.dist-info/METADATA +273 -0
- docspan-0.1.0.dist-info/RECORD +65 -0
- docspan-0.1.0.dist-info/WHEEL +4 -0
- docspan-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,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
|
+
)
|