notionary 0.1.25__py3-none-any.whl → 0.1.26__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.
- notionary/elements/audio_element.py +41 -38
- notionary/elements/bookmark_element.py +36 -27
- notionary/elements/bulleted_list_element.py +28 -21
- notionary/elements/callout_element.py +39 -31
- notionary/elements/code_block_element.py +38 -26
- notionary/elements/divider_element.py +29 -18
- notionary/elements/embed_element.py +37 -28
- notionary/elements/heading_element.py +39 -24
- notionary/elements/image_element.py +33 -24
- notionary/elements/mention_element.py +40 -29
- notionary/elements/notion_block_element.py +13 -31
- notionary/elements/numbered_list_element.py +29 -20
- notionary/elements/paragraph_element.py +37 -31
- notionary/elements/prompts/element_prompt_content.py +91 -7
- notionary/elements/prompts/synthax_prompt_builder.py +63 -16
- notionary/elements/qoute_element.py +72 -74
- notionary/elements/registry/block_element_registry_builder.py +6 -9
- notionary/elements/table_element.py +49 -36
- notionary/elements/text_inline_formatter.py +23 -15
- notionary/elements/{todo_lists.py → todo_element.py} +34 -25
- notionary/elements/toggle_element.py +184 -108
- notionary/elements/toggleable_heading_element.py +269 -0
- notionary/elements/video_element.py +37 -28
- notionary/page/content/page_content_manager.py +3 -8
- notionary/page/markdown_to_notion_converter.py +269 -274
- notionary/page/notion_page.py +2 -4
- notionary/page/notion_to_markdown_converter.py +20 -95
- {notionary-0.1.25.dist-info → notionary-0.1.26.dist-info}/METADATA +1 -1
- notionary-0.1.26.dist-info/RECORD +58 -0
- {notionary-0.1.25.dist-info → notionary-0.1.26.dist-info}/WHEEL +1 -1
- notionary/elements/column_element.py +0 -307
- notionary-0.1.25.dist-info/RECORD +0 -58
- {notionary-0.1.25.dist-info → notionary-0.1.26.dist-info}/licenses/LICENSE +0 -0
- {notionary-0.1.25.dist-info → notionary-0.1.26.dist-info}/top_level.txt +0 -0
@@ -2,37 +2,40 @@ import re
|
|
2
2
|
from typing import Dict, Any, Optional, List, Tuple, Callable
|
3
3
|
|
4
4
|
from notionary.elements.notion_block_element import NotionBlockElement
|
5
|
-
from notionary.elements.prompts.element_prompt_content import
|
5
|
+
from notionary.elements.prompts.element_prompt_content import (
|
6
|
+
ElementPromptBuilder,
|
7
|
+
ElementPromptContent,
|
8
|
+
)
|
6
9
|
|
7
10
|
|
8
11
|
class ToggleElement(NotionBlockElement):
|
9
12
|
"""
|
10
|
-
|
13
|
+
Improved ToggleElement class using pipe syntax instead of indentation.
|
11
14
|
"""
|
12
15
|
|
13
16
|
TOGGLE_PATTERN = re.compile(r"^[+]{3}\s+(.+)$")
|
14
|
-
|
17
|
+
PIPE_CONTENT_PATTERN = re.compile(r"^\|\s?(.*)$")
|
15
18
|
|
16
19
|
TRANSCRIPT_TOGGLE_PATTERN = re.compile(r"^[+]{3}\s+Transcript$")
|
17
20
|
|
18
|
-
@
|
19
|
-
def match_markdown(text: str) -> bool:
|
20
|
-
"""Check if text is a markdown toggle."""
|
21
|
+
@classmethod
|
22
|
+
def match_markdown(cls, text: str) -> bool:
|
23
|
+
"""Check if the text is a markdown toggle."""
|
21
24
|
return bool(ToggleElement.TOGGLE_PATTERN.match(text.strip()))
|
22
25
|
|
23
|
-
@
|
24
|
-
def match_notion(block: Dict[str, Any]) -> bool:
|
25
|
-
"""Check if block is a Notion toggle."""
|
26
|
+
@classmethod
|
27
|
+
def match_notion(cls, block: Dict[str, Any]) -> bool:
|
28
|
+
"""Check if the block is a Notion toggle block."""
|
26
29
|
return block.get("type") == "toggle"
|
27
30
|
|
28
|
-
@
|
29
|
-
def markdown_to_notion(text: str) -> Optional[Dict[str, Any]]:
|
30
|
-
"""Convert markdown toggle to Notion toggle block."""
|
31
|
+
@classmethod
|
32
|
+
def markdown_to_notion(cls, text: str) -> Optional[Dict[str, Any]]:
|
33
|
+
"""Convert markdown toggle line to Notion toggle block."""
|
31
34
|
toggle_match = ToggleElement.TOGGLE_PATTERN.match(text.strip())
|
32
35
|
if not toggle_match:
|
33
36
|
return None
|
34
37
|
|
35
|
-
# Extract
|
38
|
+
# Extract toggle title
|
36
39
|
title = toggle_match.group(1)
|
37
40
|
|
38
41
|
return {
|
@@ -40,20 +43,20 @@ class ToggleElement(NotionBlockElement):
|
|
40
43
|
"toggle": {
|
41
44
|
"rich_text": [{"type": "text", "text": {"content": title}}],
|
42
45
|
"color": "default",
|
43
|
-
"children": [],
|
46
|
+
"children": [],
|
44
47
|
},
|
45
48
|
}
|
46
49
|
|
47
|
-
@
|
50
|
+
@classmethod
|
48
51
|
def extract_nested_content(
|
49
|
-
lines: List[str], start_index: int
|
52
|
+
cls, lines: List[str], start_index: int
|
50
53
|
) -> Tuple[List[str], int]:
|
51
54
|
"""
|
52
|
-
|
55
|
+
Extracts the nested content lines of a toggle block using pipe syntax.
|
53
56
|
|
54
57
|
Args:
|
55
|
-
lines: All lines of text
|
56
|
-
start_index: Starting index to look for nested content
|
58
|
+
lines: All lines of text.
|
59
|
+
start_index: Starting index to look for nested content.
|
57
60
|
|
58
61
|
Returns:
|
59
62
|
Tuple of (nested_content_lines, next_line_index)
|
@@ -62,41 +65,57 @@ class ToggleElement(NotionBlockElement):
|
|
62
65
|
current_index = start_index
|
63
66
|
|
64
67
|
while current_index < len(lines):
|
65
|
-
|
68
|
+
current_line = lines[current_index]
|
66
69
|
|
67
|
-
# Empty line
|
68
|
-
if not
|
69
|
-
|
70
|
-
|
71
|
-
|
70
|
+
# Case 1: Empty line - could be part of the content if next line is a pipe line
|
71
|
+
if not current_line.strip():
|
72
|
+
if ToggleElement.is_next_line_pipe_content(lines, current_index):
|
73
|
+
nested_content.append("")
|
74
|
+
current_index += 1
|
75
|
+
continue
|
76
|
+
else:
|
77
|
+
# Empty line not followed by pipe ends the block
|
78
|
+
break
|
72
79
|
|
73
|
-
#
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
nested_content.append(content_line)
|
80
|
+
# Case 2: Pipe-prefixed line - part of the nested content
|
81
|
+
pipe_content = ToggleElement.extract_pipe_content(current_line)
|
82
|
+
if pipe_content is not None:
|
83
|
+
nested_content.append(pipe_content)
|
78
84
|
current_index += 1
|
79
85
|
continue
|
80
86
|
|
81
|
-
#
|
87
|
+
# Case 3: Regular line - end of nested content
|
82
88
|
break
|
83
89
|
|
84
90
|
return nested_content, current_index
|
85
91
|
|
86
|
-
@
|
87
|
-
def
|
88
|
-
"""
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
92
|
+
@classmethod
|
93
|
+
def is_next_line_pipe_content(cls, lines: List[str], current_index: int) -> bool:
|
94
|
+
"""Checks if the next line starts with a pipe prefix."""
|
95
|
+
next_index = current_index + 1
|
96
|
+
return (
|
97
|
+
next_index < len(lines)
|
98
|
+
and ToggleElement.PIPE_CONTENT_PATTERN.match(lines[next_index]) is not None
|
99
|
+
)
|
100
|
+
|
101
|
+
@classmethod
|
102
|
+
def extract_pipe_content(cls, line: str) -> Optional[str]:
|
103
|
+
"""
|
104
|
+
Extracts content from a line with pipe prefix.
|
105
|
+
|
106
|
+
Returns:
|
107
|
+
The content without the pipe, or None if not a pipe-prefixed line.
|
108
|
+
"""
|
109
|
+
pipe_match = ToggleElement.PIPE_CONTENT_PATTERN.match(line)
|
110
|
+
if pipe_match:
|
111
|
+
return pipe_match.group(1)
|
112
|
+
return None
|
113
|
+
|
114
|
+
@classmethod
|
115
|
+
def notion_to_markdown(cls, block: Dict[str, Any]) -> Optional[str]:
|
116
|
+
"""
|
117
|
+
Converts a Notion toggle block into markdown using pipe-prefixed lines.
|
118
|
+
"""
|
100
119
|
if block.get("type") != "toggle":
|
101
120
|
return None
|
102
121
|
|
@@ -105,31 +124,27 @@ class ToggleElement(NotionBlockElement):
|
|
105
124
|
# Extract title from rich_text
|
106
125
|
title = ToggleElement._extract_text_content(toggle_data.get("rich_text", []))
|
107
126
|
|
108
|
-
# Create
|
127
|
+
# Create toggle line
|
109
128
|
toggle_line = f"+++ {title}"
|
110
129
|
|
111
|
-
# Process children if
|
130
|
+
# Process children if available
|
112
131
|
children = toggle_data.get("children", [])
|
113
|
-
if children:
|
114
|
-
|
115
|
-
for child_block in children:
|
116
|
-
# This would need to be handled by a full converter that can dispatch
|
117
|
-
# to the appropriate element type for each child block
|
118
|
-
child_markdown = " [Nested content]" # Placeholder
|
119
|
-
child_lines.append(f" {child_markdown}")
|
132
|
+
if not children:
|
133
|
+
return toggle_line
|
120
134
|
|
121
|
-
|
135
|
+
# Add a placeholder line for each child using pipe syntax
|
136
|
+
child_lines = ["| [Nested content]" for _ in children]
|
122
137
|
|
123
|
-
return toggle_line
|
138
|
+
return toggle_line + "\n" + "\n".join(child_lines)
|
124
139
|
|
125
|
-
@
|
126
|
-
def is_multiline() -> bool:
|
127
|
-
"""Toggle blocks can span multiple lines due to
|
140
|
+
@classmethod
|
141
|
+
def is_multiline(cls) -> bool:
|
142
|
+
"""Toggle blocks can span multiple lines due to nested content."""
|
128
143
|
return True
|
129
144
|
|
130
|
-
@
|
131
|
-
def _extract_text_content(rich_text: List[Dict[str, Any]]) -> str:
|
132
|
-
"""
|
145
|
+
@classmethod
|
146
|
+
def _extract_text_content(cls, rich_text: List[Dict[str, Any]]) -> str:
|
147
|
+
"""Extracts plain text content from Notion rich_text blocks."""
|
133
148
|
result = ""
|
134
149
|
for text_obj in rich_text:
|
135
150
|
if text_obj.get("type") == "text":
|
@@ -146,81 +161,142 @@ class ToggleElement(NotionBlockElement):
|
|
146
161
|
context_aware: bool = True,
|
147
162
|
) -> List[Tuple[int, int, Dict[str, Any]]]:
|
148
163
|
"""
|
149
|
-
|
164
|
+
Finds all toggle elements in markdown using pipe syntax for nested content.
|
150
165
|
|
151
166
|
Args:
|
152
|
-
text:
|
153
|
-
process_nested_content:
|
154
|
-
context_aware:
|
167
|
+
text: The markdown input.
|
168
|
+
process_nested_content: Optional function to parse nested content into blocks.
|
169
|
+
context_aware: Whether to skip contextually irrelevant transcript toggles.
|
155
170
|
|
156
171
|
Returns:
|
157
|
-
|
172
|
+
List of (start_pos, end_pos, block) tuples.
|
158
173
|
"""
|
159
174
|
if not text:
|
160
175
|
return []
|
161
176
|
|
162
177
|
toggle_blocks = []
|
163
178
|
lines = text.split("\n")
|
179
|
+
current_line_index = 0
|
164
180
|
|
165
|
-
|
166
|
-
|
167
|
-
line = lines[i]
|
181
|
+
while current_line_index < len(lines):
|
182
|
+
current_line = lines[current_line_index]
|
168
183
|
|
169
|
-
# Check if line is a toggle
|
170
|
-
if not cls.
|
171
|
-
|
184
|
+
# Check if the current line is a toggle
|
185
|
+
if not cls._is_toggle_line(current_line):
|
186
|
+
current_line_index += 1
|
172
187
|
continue
|
173
188
|
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
i += 1
|
181
|
-
continue
|
189
|
+
# Skip transcript toggles if required by context
|
190
|
+
if cls._should_skip_transcript_toggle(
|
191
|
+
current_line, lines, current_line_index, context_aware
|
192
|
+
):
|
193
|
+
current_line_index += 1
|
194
|
+
continue
|
182
195
|
|
183
|
-
|
184
|
-
|
185
|
-
|
196
|
+
# Create toggle block and determine character positions
|
197
|
+
start_position = cls._calculate_start_position(lines, current_line_index)
|
198
|
+
toggle_block = cls.markdown_to_notion(current_line)
|
186
199
|
|
187
|
-
toggle_block = cls.markdown_to_notion(line)
|
188
200
|
if not toggle_block:
|
189
|
-
|
201
|
+
current_line_index += 1
|
190
202
|
continue
|
191
203
|
|
192
204
|
# Extract nested content
|
193
|
-
nested_content,
|
205
|
+
nested_content, next_line_index = cls.extract_nested_content(
|
206
|
+
lines, current_line_index + 1
|
207
|
+
)
|
208
|
+
end_position = cls._calculate_end_position(
|
209
|
+
start_position, current_line, nested_content
|
210
|
+
)
|
211
|
+
|
212
|
+
# Process nested content if needed
|
213
|
+
cls._process_nested_content_if_needed(
|
214
|
+
nested_content, process_nested_content, toggle_block
|
215
|
+
)
|
216
|
+
|
217
|
+
# Save result
|
218
|
+
toggle_blocks.append((start_position, end_position, toggle_block))
|
219
|
+
current_line_index = next_line_index
|
194
220
|
|
195
|
-
|
196
|
-
end_pos = start_pos + len(line) + sum(len(l) + 1 for l in nested_content)
|
221
|
+
return toggle_blocks
|
197
222
|
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
toggle_block["toggle"]["children"] = nested_blocks
|
223
|
+
@classmethod
|
224
|
+
def _is_toggle_line(cls, line: str) -> bool:
|
225
|
+
"""Checks whether the given line is a markdown toggle."""
|
226
|
+
return bool(ToggleElement.TOGGLE_PATTERN.match(line.strip()))
|
203
227
|
|
204
|
-
|
228
|
+
@classmethod
|
229
|
+
def _should_skip_transcript_toggle(
|
230
|
+
cls, line: str, lines: List[str], current_index: int, context_aware: bool
|
231
|
+
) -> bool:
|
232
|
+
"""Determines if a transcript toggle should be skipped based on the surrounding context."""
|
233
|
+
is_transcript_toggle = cls.TRANSCRIPT_TOGGLE_PATTERN.match(line.strip())
|
205
234
|
|
206
|
-
|
235
|
+
if not (context_aware and is_transcript_toggle):
|
236
|
+
return False
|
207
237
|
|
208
|
-
|
238
|
+
# Only keep transcript toggles that follow a list item
|
239
|
+
has_list_item_before = current_index > 0 and lines[
|
240
|
+
current_index - 1
|
241
|
+
].strip().startswith("- ")
|
242
|
+
return not has_list_item_before
|
243
|
+
|
244
|
+
@classmethod
|
245
|
+
def _calculate_start_position(cls, lines: List[str], current_index: int) -> int:
|
246
|
+
"""Calculates the character start position of a line within the full text."""
|
247
|
+
start_pos = 0
|
248
|
+
for index in range(current_index):
|
249
|
+
start_pos += len(lines[index]) + 1 # +1 for line break
|
250
|
+
return start_pos
|
251
|
+
|
252
|
+
@classmethod
|
253
|
+
def _calculate_end_position(
|
254
|
+
cls, start_pos: int, current_line: str, nested_content: List[str]
|
255
|
+
) -> int:
|
256
|
+
"""Calculates the end position of a toggle block including nested lines."""
|
257
|
+
line_length = len(current_line)
|
258
|
+
nested_content_length = sum(
|
259
|
+
len(line) + 1 for line in nested_content
|
260
|
+
) # +1 for each line break
|
261
|
+
return start_pos + line_length + nested_content_length
|
262
|
+
|
263
|
+
@classmethod
|
264
|
+
def _process_nested_content_if_needed(
|
265
|
+
cls,
|
266
|
+
nested_content: List[str],
|
267
|
+
process_function: Optional[Callable],
|
268
|
+
toggle_block: Dict[str, Any],
|
269
|
+
) -> None:
|
270
|
+
"""Processes nested content using the provided function if applicable."""
|
271
|
+
if not (nested_content and process_function):
|
272
|
+
return
|
273
|
+
|
274
|
+
nested_text = "\n".join(nested_content)
|
275
|
+
nested_blocks = process_function(nested_text)
|
276
|
+
|
277
|
+
if nested_blocks:
|
278
|
+
toggle_block["toggle"]["children"] = nested_blocks
|
209
279
|
|
210
280
|
@classmethod
|
211
281
|
def get_llm_prompt_content(cls) -> ElementPromptContent:
|
212
282
|
"""
|
213
|
-
Returns structured LLM prompt metadata for the toggle element.
|
283
|
+
Returns structured LLM prompt metadata for the toggle element with pipe syntax examples.
|
214
284
|
"""
|
215
|
-
return
|
216
|
-
|
217
|
-
|
285
|
+
return (
|
286
|
+
ElementPromptBuilder()
|
287
|
+
.with_description(
|
288
|
+
"Toggle elements are collapsible sections that help organize and hide detailed information."
|
289
|
+
)
|
290
|
+
.with_usage_guidelines(
|
218
291
|
"Use toggles for supplementary information that's not essential for the first reading, "
|
219
292
|
"such as details, examples, or technical information."
|
220
|
-
)
|
221
|
-
"
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
293
|
+
)
|
294
|
+
.with_syntax("+++ Toggle Title\n| Toggle content with pipe prefix")
|
295
|
+
.with_examples(
|
296
|
+
[
|
297
|
+
"+++ Key Findings\n| The research demonstrates **three main conclusions**:\n| 1. First important point\n| 2. Second important point",
|
298
|
+
"+++ FAQ\n| **Q: When should I use toggles?**\n| *A: Use toggles for supplementary information.*",
|
299
|
+
]
|
300
|
+
)
|
301
|
+
.build()
|
302
|
+
)
|
@@ -0,0 +1,269 @@
|
|
1
|
+
import re
|
2
|
+
from typing import Dict, Any, Optional, List, Tuple, Callable
|
3
|
+
|
4
|
+
from notionary.elements.notion_block_element import NotionBlockElement
|
5
|
+
from notionary.elements.prompts.element_prompt_content import (
|
6
|
+
ElementPromptBuilder,
|
7
|
+
ElementPromptContent,
|
8
|
+
)
|
9
|
+
from notionary.elements.text_inline_formatter import TextInlineFormatter
|
10
|
+
|
11
|
+
|
12
|
+
class ToggleableHeadingElement(NotionBlockElement):
|
13
|
+
"""Handles conversion between Markdown collapsible headings and Notion toggleable heading blocks with pipe syntax."""
|
14
|
+
|
15
|
+
PATTERN = re.compile(r"^\+(?P<level>#{1,3})\s+(?P<content>.+)$")
|
16
|
+
PIPE_CONTENT_PATTERN = re.compile(r"^\|\s?(.*)$")
|
17
|
+
|
18
|
+
@staticmethod
|
19
|
+
def match_markdown(text: str) -> bool:
|
20
|
+
"""Check if text is a markdown collapsible heading."""
|
21
|
+
return bool(ToggleableHeadingElement.PATTERN.match(text))
|
22
|
+
|
23
|
+
@staticmethod
|
24
|
+
def match_notion(block: Dict[str, Any]) -> bool:
|
25
|
+
"""Check if block is a Notion toggleable heading."""
|
26
|
+
block_type: str = block.get("type", "")
|
27
|
+
if not block_type.startswith("heading_") or block_type[-1] not in "123":
|
28
|
+
return False
|
29
|
+
|
30
|
+
# Check if it has the is_toggleable property set to true
|
31
|
+
heading_data = block.get(block_type, {})
|
32
|
+
return heading_data.get("is_toggleable", False) is True
|
33
|
+
|
34
|
+
@staticmethod
|
35
|
+
def markdown_to_notion(text: str) -> Optional[Dict[str, Any]]:
|
36
|
+
"""Convert markdown collapsible heading to Notion toggleable heading block."""
|
37
|
+
header_match = ToggleableHeadingElement.PATTERN.match(text)
|
38
|
+
if not header_match:
|
39
|
+
return None
|
40
|
+
|
41
|
+
level = len(header_match.group(1))
|
42
|
+
content = header_match.group(2)
|
43
|
+
|
44
|
+
return {
|
45
|
+
"type": f"heading_{level}",
|
46
|
+
f"heading_{level}": {
|
47
|
+
"rich_text": TextInlineFormatter.parse_inline_formatting(content),
|
48
|
+
"is_toggleable": True,
|
49
|
+
"color": "default",
|
50
|
+
"children": [], # Will be populated with nested content if needed
|
51
|
+
},
|
52
|
+
}
|
53
|
+
|
54
|
+
@staticmethod
|
55
|
+
def notion_to_markdown(block: Dict[str, Any]) -> Optional[str]:
|
56
|
+
"""Convert Notion toggleable heading block to markdown collapsible heading with pipe syntax."""
|
57
|
+
block_type = block.get("type", "")
|
58
|
+
|
59
|
+
if not block_type.startswith("heading_"):
|
60
|
+
return None
|
61
|
+
|
62
|
+
try:
|
63
|
+
level = int(block_type[-1])
|
64
|
+
if not 1 <= level <= 3:
|
65
|
+
return None
|
66
|
+
except ValueError:
|
67
|
+
return None
|
68
|
+
|
69
|
+
heading_data = block.get(block_type, {})
|
70
|
+
|
71
|
+
# Check if it's toggleable
|
72
|
+
if not heading_data.get("is_toggleable", False):
|
73
|
+
return None
|
74
|
+
|
75
|
+
rich_text = heading_data.get("rich_text", [])
|
76
|
+
text = TextInlineFormatter.extract_text_with_formatting(rich_text)
|
77
|
+
prefix = "#" * level
|
78
|
+
return f"+{prefix} {text or ''}"
|
79
|
+
|
80
|
+
@staticmethod
|
81
|
+
def is_multiline() -> bool:
|
82
|
+
"""Collapsible headings can have children, so they're multiline elements."""
|
83
|
+
return True
|
84
|
+
|
85
|
+
@classmethod
|
86
|
+
def find_matches(
|
87
|
+
cls,
|
88
|
+
text: str,
|
89
|
+
process_nested_content: Callable = None,
|
90
|
+
context_aware: bool = True,
|
91
|
+
) -> List[Tuple[int, int, Dict[str, Any]]]:
|
92
|
+
"""
|
93
|
+
Find all collapsible heading matches in the text with pipe syntax for nested content.
|
94
|
+
Improved version with reduced cognitive complexity.
|
95
|
+
|
96
|
+
Args:
|
97
|
+
text: The text to process
|
98
|
+
process_nested_content: Optional callback function to process nested content
|
99
|
+
context_aware: Whether to consider context when finding collapsible headings
|
100
|
+
|
101
|
+
Returns:
|
102
|
+
List of (start_pos, end_pos, block) tuples
|
103
|
+
"""
|
104
|
+
if not text:
|
105
|
+
return []
|
106
|
+
|
107
|
+
collapsible_blocks = []
|
108
|
+
lines = text.split("\n")
|
109
|
+
line_index = 0
|
110
|
+
|
111
|
+
while line_index < len(lines):
|
112
|
+
current_line = lines[line_index]
|
113
|
+
|
114
|
+
# Skip non-collapsible heading lines
|
115
|
+
if not cls._is_collapsible_heading(current_line):
|
116
|
+
line_index += 1
|
117
|
+
continue
|
118
|
+
|
119
|
+
# Process collapsible heading
|
120
|
+
start_position = cls._calculate_line_position(lines, line_index)
|
121
|
+
heading_block = cls.markdown_to_notion(current_line)
|
122
|
+
|
123
|
+
if not heading_block:
|
124
|
+
line_index += 1
|
125
|
+
continue
|
126
|
+
|
127
|
+
# Extract and process nested content
|
128
|
+
nested_content, next_line_index = cls._extract_nested_content(
|
129
|
+
lines, line_index + 1
|
130
|
+
)
|
131
|
+
end_position = cls._calculate_block_end_position(
|
132
|
+
start_position, current_line, nested_content
|
133
|
+
)
|
134
|
+
|
135
|
+
cls._process_nested_content(
|
136
|
+
heading_block, nested_content, process_nested_content
|
137
|
+
)
|
138
|
+
|
139
|
+
# Add block to results
|
140
|
+
collapsible_blocks.append((start_position, end_position, heading_block))
|
141
|
+
line_index = next_line_index
|
142
|
+
|
143
|
+
return collapsible_blocks
|
144
|
+
|
145
|
+
@classmethod
|
146
|
+
def _is_collapsible_heading(cls, line: str) -> bool:
|
147
|
+
"""Check if a line represents a collapsible heading."""
|
148
|
+
return bool(cls.PATTERN.match(line))
|
149
|
+
|
150
|
+
@staticmethod
|
151
|
+
def _calculate_line_position(lines: List[str], current_index: int) -> int:
|
152
|
+
"""Calculate the character position of a line in the text."""
|
153
|
+
position = 0
|
154
|
+
for i in range(current_index):
|
155
|
+
position += len(lines[i]) + 1 # +1 for newline
|
156
|
+
return position
|
157
|
+
|
158
|
+
@classmethod
|
159
|
+
def _extract_nested_content(
|
160
|
+
cls, lines: List[str], start_index: int
|
161
|
+
) -> Tuple[List[str], int]:
|
162
|
+
"""
|
163
|
+
Extract nested content with pipe syntax from lines following a collapsible heading.
|
164
|
+
|
165
|
+
Args:
|
166
|
+
lines: All text lines
|
167
|
+
start_index: Index to start looking for nested content
|
168
|
+
|
169
|
+
Returns:
|
170
|
+
Tuple of (nested_content, next_line_index)
|
171
|
+
"""
|
172
|
+
nested_content = []
|
173
|
+
current_index = start_index
|
174
|
+
|
175
|
+
while current_index < len(lines):
|
176
|
+
current_line = lines[current_index]
|
177
|
+
|
178
|
+
# Case 1: Empty line - check if it's followed by pipe content
|
179
|
+
if not current_line.strip():
|
180
|
+
if cls._is_next_line_pipe_content(lines, current_index):
|
181
|
+
nested_content.append("")
|
182
|
+
current_index += 1
|
183
|
+
continue
|
184
|
+
|
185
|
+
# Case 2: Pipe content line - part of nested content
|
186
|
+
pipe_content = cls._extract_pipe_content(current_line)
|
187
|
+
if pipe_content is not None:
|
188
|
+
nested_content.append(pipe_content)
|
189
|
+
current_index += 1
|
190
|
+
continue
|
191
|
+
|
192
|
+
# Case 3: Another collapsible heading - ends current heading's content
|
193
|
+
if cls.PATTERN.match(current_line):
|
194
|
+
break
|
195
|
+
|
196
|
+
# Case 4: Any other line - ends nested content
|
197
|
+
break
|
198
|
+
|
199
|
+
return nested_content, current_index
|
200
|
+
|
201
|
+
@classmethod
|
202
|
+
def _is_next_line_pipe_content(cls, lines: List[str], current_index: int) -> bool:
|
203
|
+
"""Check if the next line uses pipe syntax for nested content."""
|
204
|
+
next_index = current_index + 1
|
205
|
+
if next_index >= len(lines):
|
206
|
+
return False
|
207
|
+
return bool(cls.PIPE_CONTENT_PATTERN.match(lines[next_index]))
|
208
|
+
|
209
|
+
@classmethod
|
210
|
+
def _extract_pipe_content(cls, line: str) -> Optional[str]:
|
211
|
+
"""Extract content from a line with pipe prefix."""
|
212
|
+
pipe_match = cls.PIPE_CONTENT_PATTERN.match(line)
|
213
|
+
if not pipe_match:
|
214
|
+
return None
|
215
|
+
return pipe_match.group(1)
|
216
|
+
|
217
|
+
@staticmethod
|
218
|
+
def _calculate_block_end_position(
|
219
|
+
start_position: int, heading_line: str, nested_content: List[str]
|
220
|
+
) -> int:
|
221
|
+
"""Calculate the end position of a collapsible heading block including nested content."""
|
222
|
+
block_length = len(heading_line)
|
223
|
+
if nested_content:
|
224
|
+
# Add length of each nested content line plus newline
|
225
|
+
nested_length = sum(len(line) + 1 for line in nested_content)
|
226
|
+
block_length += nested_length
|
227
|
+
return start_position + block_length
|
228
|
+
|
229
|
+
@classmethod
|
230
|
+
def _process_nested_content(
|
231
|
+
cls,
|
232
|
+
heading_block: Dict[str, Any],
|
233
|
+
nested_content: List[str],
|
234
|
+
processor: Optional[Callable],
|
235
|
+
) -> None:
|
236
|
+
"""Process nested content with the provided callback function if available."""
|
237
|
+
if not (nested_content and processor):
|
238
|
+
return
|
239
|
+
|
240
|
+
nested_text = "\n".join(nested_content)
|
241
|
+
nested_blocks = processor(nested_text)
|
242
|
+
|
243
|
+
if nested_blocks:
|
244
|
+
block_type = heading_block["type"]
|
245
|
+
heading_block[block_type]["children"] = nested_blocks
|
246
|
+
|
247
|
+
@classmethod
|
248
|
+
def get_llm_prompt_content(cls) -> ElementPromptContent:
|
249
|
+
"""
|
250
|
+
Returns structured LLM prompt metadata for the collapsible heading element with pipe syntax.
|
251
|
+
"""
|
252
|
+
return (
|
253
|
+
ElementPromptBuilder()
|
254
|
+
.with_description(
|
255
|
+
"Collapsible headings combine heading structure with toggleable visibility."
|
256
|
+
)
|
257
|
+
.with_usage_guidelines(
|
258
|
+
"Use when you want to create a structured section that can be expanded or collapsed."
|
259
|
+
)
|
260
|
+
.with_syntax("+# Collapsible Heading\n| Content with pipe prefix")
|
261
|
+
.with_examples(
|
262
|
+
[
|
263
|
+
"+# Main Collapsible Section\n| Content under the section",
|
264
|
+
"+## Subsection\n| This content is hidden until expanded",
|
265
|
+
"+### Detailed Information\n| Technical details go here",
|
266
|
+
]
|
267
|
+
)
|
268
|
+
.build()
|
269
|
+
)
|