dataops-sdk 0.3.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.
Potentially problematic release.
This version of dataops-sdk might be problematic. Click here for more details.
- dataops_sdk/__init__.py +84 -0
- dataops_sdk/constants.py +6 -0
- dataops_sdk/exceptions.py +31 -0
- dataops_sdk/runner/__init__.py +46 -0
- dataops_sdk/runner/datasource_factory.py +83 -0
- dataops_sdk/runner/schemas/nacos/__init__.py +5 -0
- dataops_sdk/runner/schemas/nacos/config.py +40 -0
- dataops_sdk/runner/schemas/nacos/constants.py +235 -0
- dataops_sdk/runner/schemas/nacos/instance.py +103 -0
- dataops_sdk/runner/schemas/nacos/nacos_exception.py +44 -0
- dataops_sdk/runner/schemas/nacos/naming_param.py +62 -0
- dataops_sdk/runner/schemas/nacos/naming_request.py +84 -0
- dataops_sdk/runner/schemas/nacos/naming_response.py +50 -0
- dataops_sdk/runner/schemas/nacos/preserved_metadata_key.py +10 -0
- dataops_sdk/runner/schemas/nacos/service.py +119 -0
- dataops_sdk/runner/utils/__init__.py +42 -0
- dataops_sdk/runner/utils/common.py +209 -0
- dataops_sdk/runner/utils/dbConn_base.py +256 -0
- dataops_sdk/runner/utils/dbConn_sync.py +485 -0
- dataops_sdk/runner/utils/duckdb_manager.py +203 -0
- dataops_sdk/runner/utils/file_transfer_base.py +307 -0
- dataops_sdk/runner/utils/ftp_file_transfer.py +242 -0
- dataops_sdk/runner/utils/log.py +50 -0
- dataops_sdk/runner/utils/nacos.py +96 -0
- dataops_sdk/runner/utils/neo4j_sync.py +236 -0
- dataops_sdk/runner/utils/queue_base.py +224 -0
- dataops_sdk/runner/utils/queue_kafka.py +150 -0
- dataops_sdk/runner/utils/sftp_file_transfer.py +243 -0
- dataops_sdk/schemas/__init__.py +32 -0
- dataops_sdk/schemas/alarm.py +38 -0
- dataops_sdk/schemas/api.py +37 -0
- dataops_sdk/schemas/data_table.py +437 -0
- dataops_sdk/schemas/datasource.py +26 -0
- dataops_sdk/schemas/dict_.py +13 -0
- dataops_sdk/schemas/enums.py +120 -0
- dataops_sdk/schemas/group.py +40 -0
- dataops_sdk/schemas/hazard.py +151 -0
- dataops_sdk/schemas/meta.py +175 -0
- dataops_sdk/schemas/query.py +175 -0
- dataops_sdk/schemas/report.py +22 -0
- dataops_sdk/schemas/skill.py +70 -0
- dataops_sdk/schemas/table.py +65 -0
- dataops_sdk/schemas/user.py +52 -0
- dataops_sdk/schemas/widget.py +651 -0
- dataops_sdk/schemas/workflow.py +63 -0
- dataops_sdk/services/__init__.py +110 -0
- dataops_sdk/services/alarm.py +324 -0
- dataops_sdk/services/api.py +28 -0
- dataops_sdk/services/auth.py +120 -0
- dataops_sdk/services/data_table.py +485 -0
- dataops_sdk/services/dataset.py +127 -0
- dataops_sdk/services/datasource.py +57 -0
- dataops_sdk/services/dict_.py +69 -0
- dataops_sdk/services/hazard.py +260 -0
- dataops_sdk/services/meta.py +523 -0
- dataops_sdk/services/metrics.py +42 -0
- dataops_sdk/services/ref.py +66 -0
- dataops_sdk/services/report.py +36 -0
- dataops_sdk/services/skill.py +276 -0
- dataops_sdk/services/table.py +340 -0
- dataops_sdk/services/widget.py +143 -0
- dataops_sdk/services/workflow.py +102 -0
- dataops_sdk/session.py +292 -0
- dataops_sdk/tiptap/__init__.py +30 -0
- dataops_sdk/tiptap/builder.py +75 -0
- dataops_sdk/tiptap/constants.py +133 -0
- dataops_sdk/tiptap/markdown_parser.py +294 -0
- dataops_sdk/tiptap/models.py +43 -0
- dataops_sdk/tiptap/parse_utils.py +889 -0
- dataops_sdk/tiptap/render_utils.py +846 -0
- dataops_sdk/tiptap/renderers.py +51 -0
- dataops_sdk/tiptap/tool.py +629 -0
- dataops_sdk/utils/__init__.py +15 -0
- dataops_sdk/utils/common.py +211 -0
- dataops_sdk/utils/utils.py +120 -0
- dataops_sdk-0.3.0.dist-info/METADATA +25 -0
- dataops_sdk-0.3.0.dist-info/RECORD +79 -0
- dataops_sdk-0.3.0.dist-info/WHEEL +5 -0
- dataops_sdk-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
"""TipTap Markdown parsing utilities.
|
|
2
|
+
|
|
3
|
+
Provides functions to parse Markdown text into TipTap JSON format.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from dataops_sdk.tiptap import constants
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_doc(content: list[dict] | None = None) -> dict:
|
|
13
|
+
"""Create a doc node.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
content: List of content nodes
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
TipTap doc node
|
|
20
|
+
"""
|
|
21
|
+
doc = {"type": constants.NodeTypes.DOC}
|
|
22
|
+
if content:
|
|
23
|
+
doc["content"] = content
|
|
24
|
+
return doc
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_node(
|
|
28
|
+
node_type: str,
|
|
29
|
+
attrs: dict | None = None,
|
|
30
|
+
content: list[dict] | None = None,
|
|
31
|
+
) -> dict:
|
|
32
|
+
"""Create a generic node.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
node_type: Type of the node
|
|
36
|
+
attrs: Node attributes
|
|
37
|
+
content: Child content nodes
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
TipTap node
|
|
41
|
+
"""
|
|
42
|
+
node: dict[str, Any] = {"type": node_type}
|
|
43
|
+
if attrs:
|
|
44
|
+
node["attrs"] = attrs
|
|
45
|
+
if content:
|
|
46
|
+
node["content"] = content
|
|
47
|
+
return node
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def create_text_node(text: str, marks: list[dict] | None = None) -> dict:
|
|
51
|
+
"""Create a text node.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
text: Text content
|
|
55
|
+
marks: List of marks to apply
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
TipTap text node
|
|
59
|
+
"""
|
|
60
|
+
node: dict[str, Any] = {"type": constants.NodeTypes.TEXT, "text": text or ""}
|
|
61
|
+
if marks:
|
|
62
|
+
node["marks"] = marks
|
|
63
|
+
return node
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_paragraph(tokens: list[dict] | None = None) -> dict:
|
|
67
|
+
"""Parse a paragraph node.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
tokens: Child tokens
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
TipTap paragraph node
|
|
74
|
+
"""
|
|
75
|
+
node = create_node(constants.NodeTypes.PARAGRAPH)
|
|
76
|
+
if tokens:
|
|
77
|
+
node["content"] = tokens
|
|
78
|
+
return node
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_heading(tokens: list[dict], level: int) -> dict:
|
|
82
|
+
"""Parse a heading node.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
tokens: Child tokens
|
|
86
|
+
level: Heading level (1-6)
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
TipTap heading node
|
|
90
|
+
"""
|
|
91
|
+
return create_node(
|
|
92
|
+
constants.NodeTypes.HEADING,
|
|
93
|
+
attrs={constants.AttrTypes.LEVEL: level},
|
|
94
|
+
content=tokens,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_text(text: str) -> dict:
|
|
99
|
+
"""Parse a text node.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
text: Text content
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
TipTap text node
|
|
106
|
+
"""
|
|
107
|
+
return create_text_node(text, None)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse_code_block(code: str, language: str | None = None) -> dict:
|
|
111
|
+
"""Parse a code block node.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
code: Code content
|
|
115
|
+
language: Code language
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
TipTap code block node
|
|
119
|
+
"""
|
|
120
|
+
node = create_node(constants.NodeTypes.CODE_BLOCK)
|
|
121
|
+
content = [create_text_node(code)]
|
|
122
|
+
node["content"] = content
|
|
123
|
+
if language:
|
|
124
|
+
node["attrs"] = {constants.AttrTypes.LANGUAGE: language}
|
|
125
|
+
return node
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def parse_blockquote(tokens: list[dict] | None = None) -> dict:
|
|
129
|
+
"""Parse a blockquote node.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
tokens: Child tokens
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
TipTap blockquote node
|
|
136
|
+
"""
|
|
137
|
+
node = create_node(constants.NodeTypes.BLOCKQUOTE)
|
|
138
|
+
if tokens:
|
|
139
|
+
node["content"] = tokens
|
|
140
|
+
return node
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def parse_bullet_list(items: list[dict] | None = None) -> dict:
|
|
144
|
+
"""Parse a bullet list node.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
items: List items
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
TipTap bullet list node
|
|
151
|
+
"""
|
|
152
|
+
node = create_node(constants.NodeTypes.BULLET_LIST)
|
|
153
|
+
if items:
|
|
154
|
+
node["content"] = items
|
|
155
|
+
return node
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_ordered_list(items: list[dict] | None = None) -> dict:
|
|
159
|
+
"""Parse an ordered list node.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
items: List items
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
TipTap ordered list node
|
|
166
|
+
"""
|
|
167
|
+
node = create_node(constants.NodeTypes.ORDERED_LIST)
|
|
168
|
+
if items:
|
|
169
|
+
node["content"] = items
|
|
170
|
+
return node
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def parse_task_list(items: list[dict] | None = None) -> dict:
|
|
174
|
+
"""Parse a task list node.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
items: Task items
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
TipTap task list node
|
|
181
|
+
"""
|
|
182
|
+
node = create_node(constants.NodeTypes.TASK_LIST)
|
|
183
|
+
if items:
|
|
184
|
+
node["content"] = items
|
|
185
|
+
return node
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def parse_task_item(text: str, checked: bool) -> dict:
|
|
189
|
+
"""Parse a task item node.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
text: Task item text
|
|
193
|
+
checked: Whether the task is checked
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
TipTap task item node
|
|
197
|
+
"""
|
|
198
|
+
node = create_node(
|
|
199
|
+
constants.NodeTypes.TASK_ITEM,
|
|
200
|
+
attrs={constants.AttrTypes.CHECKED: checked},
|
|
201
|
+
content=[create_text_node(text)],
|
|
202
|
+
)
|
|
203
|
+
return node
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def parse_list_item(tokens: list[dict] | None = None) -> dict:
|
|
207
|
+
"""Parse a list item node.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
tokens: Child tokens
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
TipTap list item node
|
|
214
|
+
"""
|
|
215
|
+
node = create_node(constants.NodeTypes.LIST_ITEM)
|
|
216
|
+
if tokens:
|
|
217
|
+
node["content"] = tokens
|
|
218
|
+
return node
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def parse_horizontal_rule() -> dict:
|
|
222
|
+
"""Parse a horizontal rule node.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
TipTap horizontal rule node
|
|
226
|
+
"""
|
|
227
|
+
return create_node(constants.NodeTypes.HORIZONTAL_RULE)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def parse_hard_break() -> dict:
|
|
231
|
+
"""Parse a hard break node.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
TipTap hard break node
|
|
235
|
+
"""
|
|
236
|
+
return create_node(constants.NodeTypes.HARD_BREAK)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def parse_image(src: str, alt: str = "", title: str | None = None) -> dict:
|
|
240
|
+
"""Parse an image node.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
src: Image source URL
|
|
244
|
+
alt: Alt text
|
|
245
|
+
title: Image title
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
TipTap image node
|
|
249
|
+
"""
|
|
250
|
+
return create_node(
|
|
251
|
+
constants.NodeTypes.IMAGE,
|
|
252
|
+
attrs={
|
|
253
|
+
constants.AttrTypes.SRC: src or "",
|
|
254
|
+
constants.AttrTypes.ALT: alt or "",
|
|
255
|
+
constants.AttrTypes.TITLE: title or "",
|
|
256
|
+
},
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def parse_table(rows: list[dict] | None = None) -> dict:
|
|
261
|
+
"""Parse a table node.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
rows: Table rows
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
TipTap table node
|
|
268
|
+
"""
|
|
269
|
+
node = create_node(constants.NodeTypes.TABLE)
|
|
270
|
+
if rows:
|
|
271
|
+
node["content"] = rows
|
|
272
|
+
return node
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def parse_table_row(cells: list[dict] | None = None) -> dict:
|
|
276
|
+
"""Parse a table row node.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
cells: Row cells
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
TipTap table row node
|
|
283
|
+
"""
|
|
284
|
+
node = create_node(constants.NodeTypes.TABLE_ROW)
|
|
285
|
+
if cells:
|
|
286
|
+
node["content"] = cells
|
|
287
|
+
return node
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def parse_table_cell(tokens: list[dict] | None = None) -> dict:
|
|
291
|
+
"""Parse a table cell node.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
tokens: Cell content tokens
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
TipTap table cell node
|
|
298
|
+
"""
|
|
299
|
+
node = create_node(constants.NodeTypes.TABLE_CELL)
|
|
300
|
+
if tokens:
|
|
301
|
+
node["content"] = tokens
|
|
302
|
+
return node
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def parse_table_header(tokens: list[dict] | None = None) -> dict:
|
|
306
|
+
"""Parse a table header node.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
tokens: Header content tokens
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
TipTap table header node
|
|
313
|
+
"""
|
|
314
|
+
node = create_node(constants.NodeTypes.TABLE_HEADER)
|
|
315
|
+
if tokens:
|
|
316
|
+
node["content"] = tokens
|
|
317
|
+
return node
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def parse_callout(
|
|
321
|
+
callout_type: str,
|
|
322
|
+
title: str | None,
|
|
323
|
+
content: list[str],
|
|
324
|
+
collapsible: bool,
|
|
325
|
+
collapsed: bool,
|
|
326
|
+
) -> dict:
|
|
327
|
+
"""Parse a callout node.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
callout_type: Callout type (info/warning/success/error)
|
|
331
|
+
title: Callout title
|
|
332
|
+
content: Content lines
|
|
333
|
+
collapsible: Whether callout is collapsible
|
|
334
|
+
collapsed: Whether callout is collapsed
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
TipTap callout node
|
|
338
|
+
"""
|
|
339
|
+
node = create_node(
|
|
340
|
+
constants.NodeTypes.CALLOUT,
|
|
341
|
+
attrs={
|
|
342
|
+
constants.AttrTypes.CALLOUT_TYPE: callout_type or "info",
|
|
343
|
+
constants.AttrTypes.CALLOUT_TITLE: title or "",
|
|
344
|
+
constants.AttrTypes.CALLOUT_COLLAPSIBLE: collapsible,
|
|
345
|
+
constants.AttrTypes.CALLOUT_COLLAPSED: collapsed,
|
|
346
|
+
},
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if content:
|
|
350
|
+
content_nodes = []
|
|
351
|
+
current_para: list[str] = []
|
|
352
|
+
|
|
353
|
+
for line in content:
|
|
354
|
+
if not line.strip():
|
|
355
|
+
if current_para:
|
|
356
|
+
content_nodes.append(
|
|
357
|
+
parse_paragraph(parse_inline_formatting(" ".join(current_para).strip()))
|
|
358
|
+
)
|
|
359
|
+
current_para = []
|
|
360
|
+
else:
|
|
361
|
+
current_para.append(line)
|
|
362
|
+
|
|
363
|
+
if current_para:
|
|
364
|
+
content_nodes.append(
|
|
365
|
+
parse_paragraph(parse_inline_formatting(" ".join(current_para).strip()))
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if content_nodes:
|
|
369
|
+
node["content"] = content_nodes
|
|
370
|
+
|
|
371
|
+
return node
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def parse_columns(
|
|
375
|
+
count: int,
|
|
376
|
+
gap: str | None,
|
|
377
|
+
columns_content: list[list[dict]],
|
|
378
|
+
) -> dict:
|
|
379
|
+
"""Parse a columns node.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
count: Number of columns
|
|
383
|
+
gap: Gap between columns
|
|
384
|
+
columns_content: Content for each column
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
TipTap columns node
|
|
388
|
+
"""
|
|
389
|
+
node = create_node(
|
|
390
|
+
constants.NodeTypes.COLUMNS,
|
|
391
|
+
attrs={
|
|
392
|
+
constants.AttrTypes.COLUMNS_COUNT: count,
|
|
393
|
+
constants.AttrTypes.COLUMNS_GAP: gap,
|
|
394
|
+
},
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
content = []
|
|
398
|
+
column_width = 100 / max(count, 1)
|
|
399
|
+
|
|
400
|
+
for i, col_content in enumerate(columns_content):
|
|
401
|
+
column_node = create_node(
|
|
402
|
+
constants.NodeTypes.COLUMN,
|
|
403
|
+
attrs={constants.AttrTypes.COLUMN_WIDTH: f"{column_width:.2f}%"},
|
|
404
|
+
)
|
|
405
|
+
if col_content:
|
|
406
|
+
column_node["content"] = col_content
|
|
407
|
+
content.append(column_node)
|
|
408
|
+
|
|
409
|
+
if i < len(columns_content) - 1:
|
|
410
|
+
content.append(create_node(constants.NodeTypes.COLUMN_RESIZER))
|
|
411
|
+
|
|
412
|
+
if content:
|
|
413
|
+
node["content"] = content
|
|
414
|
+
|
|
415
|
+
return node
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def parse_markdown(markdown: str) -> dict:
|
|
419
|
+
"""Parse Markdown text to TipTap JSON document.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
markdown: Markdown text
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
TipTap JSON document
|
|
426
|
+
"""
|
|
427
|
+
if not markdown:
|
|
428
|
+
return create_doc([])
|
|
429
|
+
|
|
430
|
+
content = _parse_markdown_content(markdown.strip())
|
|
431
|
+
return create_doc(content)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _parse_markdown_content(markdown: str) -> list[dict]:
|
|
435
|
+
"""Parse Markdown content into node list.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
markdown: Markdown text
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
List of TipTap nodes
|
|
442
|
+
"""
|
|
443
|
+
content: list[dict] = []
|
|
444
|
+
lines = markdown.split("\n")
|
|
445
|
+
i = 0
|
|
446
|
+
|
|
447
|
+
while i < len(lines):
|
|
448
|
+
line = lines[i]
|
|
449
|
+
|
|
450
|
+
if not line.strip():
|
|
451
|
+
i += 1
|
|
452
|
+
continue
|
|
453
|
+
|
|
454
|
+
# Parse headings
|
|
455
|
+
heading_match = re.match(r"^(#{1,6})\s+(.+)$", line)
|
|
456
|
+
if heading_match:
|
|
457
|
+
level = len(heading_match.group(1))
|
|
458
|
+
text = heading_match.group(2).strip()
|
|
459
|
+
content.append(parse_heading(_parse_inline_formatting(text), level))
|
|
460
|
+
i += 1
|
|
461
|
+
continue
|
|
462
|
+
|
|
463
|
+
# Parse horizontal rule
|
|
464
|
+
if re.match(r"^(\*\s*\*\s*\*|-{3,}|_{3,})$", line.strip()):
|
|
465
|
+
content.append(parse_horizontal_rule())
|
|
466
|
+
i += 1
|
|
467
|
+
continue
|
|
468
|
+
|
|
469
|
+
# Parse code block
|
|
470
|
+
code_match = re.match(r"^```(\w+)?\s*$", line)
|
|
471
|
+
if code_match:
|
|
472
|
+
language = code_match.group(1)
|
|
473
|
+
code_lines: list[str] = []
|
|
474
|
+
i += 1
|
|
475
|
+
|
|
476
|
+
while i < len(lines) and not re.match(r"^```\s*$", lines[i]):
|
|
477
|
+
code_lines.append(lines[i])
|
|
478
|
+
i += 1
|
|
479
|
+
|
|
480
|
+
code = "\n".join(code_lines)
|
|
481
|
+
content.append(parse_code_block(code, language))
|
|
482
|
+
i += 1
|
|
483
|
+
continue
|
|
484
|
+
|
|
485
|
+
# Parse blockquote
|
|
486
|
+
if line.strip().startswith(">"):
|
|
487
|
+
quote_lines: list[str] = []
|
|
488
|
+
quote_text = line.strip()[1:].strip()
|
|
489
|
+
if quote_text:
|
|
490
|
+
quote_lines.append(quote_text)
|
|
491
|
+
i += 1
|
|
492
|
+
|
|
493
|
+
while i < len(lines) and lines[i].strip().startswith(">"):
|
|
494
|
+
current_line = lines[i].strip()[1:].strip()
|
|
495
|
+
if current_line:
|
|
496
|
+
quote_lines.append(current_line)
|
|
497
|
+
i += 1
|
|
498
|
+
|
|
499
|
+
joined_text = "\n".join(quote_lines)
|
|
500
|
+
content.append(
|
|
501
|
+
parse_blockquote([parse_paragraph(_parse_inline_formatting(joined_text))])
|
|
502
|
+
)
|
|
503
|
+
continue
|
|
504
|
+
|
|
505
|
+
# Parse lists
|
|
506
|
+
list_match = re.match(r"^(\s*)([-+*]|\d+\.)\s+(.+)$", line)
|
|
507
|
+
if list_match:
|
|
508
|
+
indent = list_match.group(1)
|
|
509
|
+
list_type = list_match.group(2)
|
|
510
|
+
item_text = list_match.group(3)
|
|
511
|
+
|
|
512
|
+
# Detect task list
|
|
513
|
+
task_match = re.match(r"^(\[([ xX])\])\s+(.+)$", item_text)
|
|
514
|
+
is_task = bool(task_match)
|
|
515
|
+
|
|
516
|
+
list_items: list[dict] = []
|
|
517
|
+
current_line = i
|
|
518
|
+
|
|
519
|
+
while current_line < len(lines):
|
|
520
|
+
current = lines[current_line]
|
|
521
|
+
current_matcher = re.match(r"^(\s*)([-+*]|\d+\.)\s+(.+)$", current)
|
|
522
|
+
|
|
523
|
+
if not current_matcher:
|
|
524
|
+
break
|
|
525
|
+
|
|
526
|
+
this_indent = current_matcher.group(1)
|
|
527
|
+
this_list_type = current_matcher.group(2)
|
|
528
|
+
this_item_text = current_matcher.group(3)
|
|
529
|
+
|
|
530
|
+
# Check if same list
|
|
531
|
+
if len(this_indent) < len(indent):
|
|
532
|
+
break
|
|
533
|
+
|
|
534
|
+
if is_task:
|
|
535
|
+
current_task_match = re.match(r"^(\[([ xX])\])\s+(.+)$", this_item_text)
|
|
536
|
+
if current_task_match:
|
|
537
|
+
checked = current_task_match.group(2).lower() == "x"
|
|
538
|
+
task_text = current_task_match.group(3)
|
|
539
|
+
list_items.append(parse_task_item(task_text, checked))
|
|
540
|
+
else:
|
|
541
|
+
break
|
|
542
|
+
else:
|
|
543
|
+
current_task_match = re.match(r"^(\[([ xX])\])\s+(.+)$", this_item_text)
|
|
544
|
+
if not current_task_match:
|
|
545
|
+
list_items.append(
|
|
546
|
+
parse_list_item(
|
|
547
|
+
[parse_paragraph(_parse_inline_formatting(this_item_text))]
|
|
548
|
+
)
|
|
549
|
+
)
|
|
550
|
+
else:
|
|
551
|
+
break
|
|
552
|
+
|
|
553
|
+
current_line += 1
|
|
554
|
+
|
|
555
|
+
if is_task:
|
|
556
|
+
content.append(parse_task_list(list_items))
|
|
557
|
+
else:
|
|
558
|
+
if re.match(r"^[-+*]$", list_type):
|
|
559
|
+
content.append(parse_bullet_list(list_items))
|
|
560
|
+
else:
|
|
561
|
+
content.append(parse_ordered_list(list_items))
|
|
562
|
+
|
|
563
|
+
i = current_line
|
|
564
|
+
continue
|
|
565
|
+
|
|
566
|
+
# Parse table
|
|
567
|
+
table_match = re.match(r"^\|(.+)\|$", line)
|
|
568
|
+
if table_match:
|
|
569
|
+
table_rows: list[dict] = []
|
|
570
|
+
current_line = i
|
|
571
|
+
|
|
572
|
+
while current_line < len(lines):
|
|
573
|
+
current = lines[current_line]
|
|
574
|
+
row_match = re.match(r"^\|(.+)\|$", current)
|
|
575
|
+
|
|
576
|
+
if not row_match:
|
|
577
|
+
break
|
|
578
|
+
|
|
579
|
+
row_content = row_match.group(1)
|
|
580
|
+
|
|
581
|
+
# Skip separator row
|
|
582
|
+
if re.match(r"^[\s\-:|]+$", row_content):
|
|
583
|
+
current_line += 1
|
|
584
|
+
continue
|
|
585
|
+
|
|
586
|
+
# Parse cells
|
|
587
|
+
cells = row_content.split("|")
|
|
588
|
+
row_cells: list[dict] = []
|
|
589
|
+
is_header = len(table_rows) == 0
|
|
590
|
+
|
|
591
|
+
for cell_text in cells:
|
|
592
|
+
trimmed_cell = cell_text.strip()
|
|
593
|
+
cell_type = (
|
|
594
|
+
constants.NodeTypes.TABLE_HEADER
|
|
595
|
+
if is_header
|
|
596
|
+
else constants.NodeTypes.TABLE_CELL
|
|
597
|
+
)
|
|
598
|
+
row_cells.append(
|
|
599
|
+
create_node(cell_type, content=_parse_inline_formatting(trimmed_cell))
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
table_rows.append(parse_table_row(row_cells))
|
|
603
|
+
current_line += 1
|
|
604
|
+
|
|
605
|
+
if table_rows:
|
|
606
|
+
content.append(parse_table(table_rows))
|
|
607
|
+
|
|
608
|
+
i = current_line
|
|
609
|
+
continue
|
|
610
|
+
|
|
611
|
+
# Parse <anti:html cmp="..."> custom components
|
|
612
|
+
anti_html_match = re.match(r"^<anti:html\s+cmp=\"(\w+)\"(.*)>$", line)
|
|
613
|
+
if anti_html_match:
|
|
614
|
+
cmp_type = anti_html_match.group(1)
|
|
615
|
+
attrs = anti_html_match.group(2)
|
|
616
|
+
|
|
617
|
+
if cmp_type == "columns":
|
|
618
|
+
# Parse <anti:html type="columns" count="n" gap="m">
|
|
619
|
+
count = 2
|
|
620
|
+
gap = None
|
|
621
|
+
|
|
622
|
+
if attrs:
|
|
623
|
+
count_match = re.search(r"count=\"(\d+)\"", attrs)
|
|
624
|
+
if count_match:
|
|
625
|
+
count = int(count_match.group(1))
|
|
626
|
+
gap_match = re.search(r"gap=\"([^\"]*)\"", attrs)
|
|
627
|
+
if gap_match:
|
|
628
|
+
gap = gap_match.group(1)
|
|
629
|
+
|
|
630
|
+
# Parse each <column> content
|
|
631
|
+
column_lines: list[list[str]] = []
|
|
632
|
+
i += 1
|
|
633
|
+
|
|
634
|
+
while i < len(lines):
|
|
635
|
+
current_line_text = lines[i].strip()
|
|
636
|
+
|
|
637
|
+
if current_line_text == "</anti:html>":
|
|
638
|
+
break
|
|
639
|
+
|
|
640
|
+
column_match = re.match(r"^<column(?:\s+(.+))?>$", current_line_text)
|
|
641
|
+
if column_match:
|
|
642
|
+
col_content: list[str] = []
|
|
643
|
+
i += 1
|
|
644
|
+
|
|
645
|
+
while i < len(lines) and lines[i].strip() != "</column>":
|
|
646
|
+
col_content.append(lines[i])
|
|
647
|
+
i += 1
|
|
648
|
+
|
|
649
|
+
column_lines.append(col_content)
|
|
650
|
+
i += 1
|
|
651
|
+
continue
|
|
652
|
+
|
|
653
|
+
i += 1
|
|
654
|
+
|
|
655
|
+
# Parse each column content to nodes
|
|
656
|
+
columns_content: list[list[dict]] = []
|
|
657
|
+
for col_lines in column_lines:
|
|
658
|
+
col_markdown = "\n".join(col_lines)
|
|
659
|
+
if col_markdown.strip():
|
|
660
|
+
col_nodes = _parse_markdown_content(col_markdown)
|
|
661
|
+
columns_content.append(col_nodes)
|
|
662
|
+
else:
|
|
663
|
+
columns_content.append([])
|
|
664
|
+
|
|
665
|
+
content.append(parse_columns(count, gap, columns_content))
|
|
666
|
+
i += 1
|
|
667
|
+
continue
|
|
668
|
+
|
|
669
|
+
elif cmp_type == "callout":
|
|
670
|
+
# Callout <anti:html cmp="callout" type="xxx" title="xxx" collapsible="true" collapsed="true">
|
|
671
|
+
callout_type = "info"
|
|
672
|
+
title = None
|
|
673
|
+
collapsible = False
|
|
674
|
+
collapsed = False
|
|
675
|
+
|
|
676
|
+
if attrs:
|
|
677
|
+
type_match = re.search(r"type=\"([^\"]*)\"", attrs)
|
|
678
|
+
if type_match:
|
|
679
|
+
callout_type = type_match.group(1)
|
|
680
|
+
title_match = re.search(r"title=\"([^\"]*)\"", attrs)
|
|
681
|
+
if title_match:
|
|
682
|
+
title = title_match.group(1)
|
|
683
|
+
collapsible_match = re.search(r"collapsible=\"([^\"]*)\"", attrs)
|
|
684
|
+
if collapsible_match:
|
|
685
|
+
collapsible = collapsible_match.group(1).lower() == "true"
|
|
686
|
+
collapsed_match = re.search(r"collapsed=\"([^\"]*)\"", attrs)
|
|
687
|
+
if collapsed_match:
|
|
688
|
+
collapsed = collapsed_match.group(1).lower() == "true"
|
|
689
|
+
|
|
690
|
+
callout_content: list[str] = []
|
|
691
|
+
i += 1
|
|
692
|
+
|
|
693
|
+
while i < len(lines) and lines[i].strip() != "</anti:html>":
|
|
694
|
+
callout_content.append(lines[i])
|
|
695
|
+
i += 1
|
|
696
|
+
|
|
697
|
+
i += 1
|
|
698
|
+
content.append(
|
|
699
|
+
parse_callout(callout_type, title, callout_content, collapsible, collapsed)
|
|
700
|
+
)
|
|
701
|
+
continue
|
|
702
|
+
|
|
703
|
+
# Parse paragraph
|
|
704
|
+
para_lines: list[str] = [line]
|
|
705
|
+
i += 1
|
|
706
|
+
|
|
707
|
+
while (
|
|
708
|
+
i < len(lines)
|
|
709
|
+
and lines[i].strip()
|
|
710
|
+
and not re.match(r"^(#{1,6})\s+", lines[i])
|
|
711
|
+
and not re.match(r"^(\*\s*\*\s*\*|-{3,}|_{3,})$", lines[i].strip())
|
|
712
|
+
and not re.match(r"^```", lines[i])
|
|
713
|
+
and not re.match(r"^>\s+", lines[i])
|
|
714
|
+
and not re.match(r"^(\s*)([-+*]|\d+\.)\s+", lines[i])
|
|
715
|
+
and not re.match(r"^\|(.+)\|$", lines[i])
|
|
716
|
+
and not re.match(r"^<anti:html\s+", lines[i])
|
|
717
|
+
):
|
|
718
|
+
para_lines.append(lines[i])
|
|
719
|
+
i += 1
|
|
720
|
+
|
|
721
|
+
para_text = " ".join(para_lines)
|
|
722
|
+
content.append(parse_paragraph(_parse_inline_formatting(para_text)))
|
|
723
|
+
|
|
724
|
+
return content
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _parse_inline_formatting(text: str) -> list[dict]:
|
|
728
|
+
"""Parse inline formatting (bold, italic, etc.).
|
|
729
|
+
|
|
730
|
+
Args:
|
|
731
|
+
text: Raw text
|
|
732
|
+
|
|
733
|
+
Returns:
|
|
734
|
+
List of text nodes with marks
|
|
735
|
+
"""
|
|
736
|
+
result: list[dict] = []
|
|
737
|
+
pos = 0
|
|
738
|
+
|
|
739
|
+
# Priority order: code > link > highlight > bold > italic > strike > underline
|
|
740
|
+
patterns = [
|
|
741
|
+
(r"`([^`]+)`", "code"),
|
|
742
|
+
(r"\[([^\]]+)\]\(([^)]+)\)", "link"),
|
|
743
|
+
(r"==([^=]+)==", "highlight"),
|
|
744
|
+
(r"\*\*([^*]+)\*\*", "bold"),
|
|
745
|
+
(r"(?<!\*)\*(?!\*)([^*]+)(?<!\*)\*(?!\*)", "italic"),
|
|
746
|
+
(r"~~([^~]+)~~", "strike"),
|
|
747
|
+
(r"__([^_]+)__", "underline"),
|
|
748
|
+
]
|
|
749
|
+
|
|
750
|
+
while pos < len(text):
|
|
751
|
+
best_match = None
|
|
752
|
+
best_pattern_index = -1
|
|
753
|
+
best_start = len(text)
|
|
754
|
+
|
|
755
|
+
for j, (pattern, mark_type) in enumerate(patterns):
|
|
756
|
+
matcher = re.match(pattern, text[pos:])
|
|
757
|
+
if matcher:
|
|
758
|
+
if matcher.start() < best_start:
|
|
759
|
+
best_match = matcher
|
|
760
|
+
best_pattern_index = j
|
|
761
|
+
best_start = matcher.start()
|
|
762
|
+
|
|
763
|
+
if best_match is None:
|
|
764
|
+
result.append(create_text_node(text[pos:], None))
|
|
765
|
+
break
|
|
766
|
+
|
|
767
|
+
if best_match.start() > 0:
|
|
768
|
+
result.append(create_text_node(text[pos : pos + best_match.start()], None))
|
|
769
|
+
|
|
770
|
+
matched_text = best_match.group(1)
|
|
771
|
+
start_pos = pos + best_match.start()
|
|
772
|
+
mark_type = patterns[best_pattern_index][1]
|
|
773
|
+
|
|
774
|
+
if mark_type == "link":
|
|
775
|
+
link_text = best_match.group(1)
|
|
776
|
+
link_url = best_match.group(2)
|
|
777
|
+
link_node = create_text_node(link_text, None)
|
|
778
|
+
link_node["marks"] = [
|
|
779
|
+
{
|
|
780
|
+
"type": constants.MarkTypes.LINK,
|
|
781
|
+
"attrs": {
|
|
782
|
+
constants.AttrTypes.HREF: link_url,
|
|
783
|
+
constants.AttrTypes.TITLE: "",
|
|
784
|
+
},
|
|
785
|
+
}
|
|
786
|
+
]
|
|
787
|
+
result.append(link_node)
|
|
788
|
+
elif mark_type == "code":
|
|
789
|
+
code_node = create_text_node(matched_text, None)
|
|
790
|
+
code_node["marks"] = [{"type": constants.MarkTypes.CODE}]
|
|
791
|
+
result.append(code_node)
|
|
792
|
+
else:
|
|
793
|
+
marked_node = create_text_node(matched_text, None)
|
|
794
|
+
marked_node["marks"] = [{"type": mark_type}]
|
|
795
|
+
result.append(marked_node)
|
|
796
|
+
|
|
797
|
+
pos = start_pos + best_match.end()
|
|
798
|
+
|
|
799
|
+
if not result:
|
|
800
|
+
result.append(create_text_node(text, None))
|
|
801
|
+
|
|
802
|
+
# Merge adjacent plain text nodes
|
|
803
|
+
merged_result: list[dict] = []
|
|
804
|
+
for node in result:
|
|
805
|
+
node_text = node.get("text")
|
|
806
|
+
node_marks = node.get("marks")
|
|
807
|
+
|
|
808
|
+
if node_text and (node_marks is None or len(node_marks) == 0):
|
|
809
|
+
if merged_result:
|
|
810
|
+
last_node = merged_result[-1]
|
|
811
|
+
last_text = last_node.get("text")
|
|
812
|
+
last_marks = last_node.get("marks")
|
|
813
|
+
|
|
814
|
+
if last_text and (last_marks is None or len(last_marks) == 0):
|
|
815
|
+
last_node["text"] = last_text + node_text
|
|
816
|
+
continue
|
|
817
|
+
|
|
818
|
+
merged_result.append(node)
|
|
819
|
+
|
|
820
|
+
return merged_result
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def parse_fallback(token_type: str, token: dict) -> dict | None:
|
|
824
|
+
"""Fallback parser for handling unhandled types.
|
|
825
|
+
|
|
826
|
+
Args:
|
|
827
|
+
token_type: Token type
|
|
828
|
+
token: Token data
|
|
829
|
+
|
|
830
|
+
Returns:
|
|
831
|
+
Parsed node or None
|
|
832
|
+
"""
|
|
833
|
+
if token_type == "paragraph":
|
|
834
|
+
return parse_paragraph(token.get("tokens"))
|
|
835
|
+
elif token_type == "heading":
|
|
836
|
+
return parse_heading(token.get("tokens"), token.get("depth", 1))
|
|
837
|
+
elif token_type == "text":
|
|
838
|
+
return parse_text(token.get("text", ""))
|
|
839
|
+
return None
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def pick_tags(content: str) -> dict[str, list[dict]]:
|
|
843
|
+
"""Extract all HTML tags from content.
|
|
844
|
+
|
|
845
|
+
Args:
|
|
846
|
+
content: HTML content
|
|
847
|
+
|
|
848
|
+
Returns:
|
|
849
|
+
Dict mapping tag names to list of tag objects
|
|
850
|
+
"""
|
|
851
|
+
result: dict[str, list[dict]] = {}
|
|
852
|
+
|
|
853
|
+
if not content or not content.strip():
|
|
854
|
+
return result
|
|
855
|
+
|
|
856
|
+
tag_pattern = r"<(\w+)([^>]*)>(.*?)</\1>|<(\w+)([^>]*)/>"
|
|
857
|
+
pattern = re.compile(tag_pattern, re.DOTALL)
|
|
858
|
+
matcher = pattern.finditer(content)
|
|
859
|
+
|
|
860
|
+
for match in matcher:
|
|
861
|
+
if match.group(1) is not None:
|
|
862
|
+
tag_name = match.group(1)
|
|
863
|
+
tag_attrs = match.group(2)
|
|
864
|
+
tag_content = match.group(3)
|
|
865
|
+
else:
|
|
866
|
+
tag_name = match.group(4)
|
|
867
|
+
tag_attrs = match.group(5)
|
|
868
|
+
tag_content = None
|
|
869
|
+
|
|
870
|
+
tag_obj: dict[str, Any] = {}
|
|
871
|
+
|
|
872
|
+
attr_pattern = re.compile(r'(\w+)="([^"]*)"|(\w+)=' "'([^']*)'")
|
|
873
|
+
attr_matcher = attr_pattern.finditer(tag_attrs or "")
|
|
874
|
+
|
|
875
|
+
for attr_match in attr_matcher:
|
|
876
|
+
attr_name = attr_match.group(1) or attr_match.group(3)
|
|
877
|
+
attr_value = attr_match.group(2) or attr_match.group(4)
|
|
878
|
+
if attr_name:
|
|
879
|
+
tag_obj[attr_name] = attr_value
|
|
880
|
+
|
|
881
|
+
if tag_content and tag_content.strip():
|
|
882
|
+
tag_obj["content"] = tag_content.strip()
|
|
883
|
+
|
|
884
|
+
if tag_name:
|
|
885
|
+
if tag_name not in result:
|
|
886
|
+
result[tag_name] = []
|
|
887
|
+
result[tag_name].append(tag_obj)
|
|
888
|
+
|
|
889
|
+
return result
|