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.
Files changed (34) hide show
  1. notionary/elements/audio_element.py +41 -38
  2. notionary/elements/bookmark_element.py +36 -27
  3. notionary/elements/bulleted_list_element.py +28 -21
  4. notionary/elements/callout_element.py +39 -31
  5. notionary/elements/code_block_element.py +38 -26
  6. notionary/elements/divider_element.py +29 -18
  7. notionary/elements/embed_element.py +37 -28
  8. notionary/elements/heading_element.py +39 -24
  9. notionary/elements/image_element.py +33 -24
  10. notionary/elements/mention_element.py +40 -29
  11. notionary/elements/notion_block_element.py +13 -31
  12. notionary/elements/numbered_list_element.py +29 -20
  13. notionary/elements/paragraph_element.py +37 -31
  14. notionary/elements/prompts/element_prompt_content.py +91 -7
  15. notionary/elements/prompts/synthax_prompt_builder.py +63 -16
  16. notionary/elements/qoute_element.py +72 -74
  17. notionary/elements/registry/block_element_registry_builder.py +6 -9
  18. notionary/elements/table_element.py +49 -36
  19. notionary/elements/text_inline_formatter.py +23 -15
  20. notionary/elements/{todo_lists.py → todo_element.py} +34 -25
  21. notionary/elements/toggle_element.py +184 -108
  22. notionary/elements/toggleable_heading_element.py +269 -0
  23. notionary/elements/video_element.py +37 -28
  24. notionary/page/content/page_content_manager.py +3 -8
  25. notionary/page/markdown_to_notion_converter.py +269 -274
  26. notionary/page/notion_page.py +2 -4
  27. notionary/page/notion_to_markdown_converter.py +20 -95
  28. {notionary-0.1.25.dist-info → notionary-0.1.26.dist-info}/METADATA +1 -1
  29. notionary-0.1.26.dist-info/RECORD +58 -0
  30. {notionary-0.1.25.dist-info → notionary-0.1.26.dist-info}/WHEEL +1 -1
  31. notionary/elements/column_element.py +0 -307
  32. notionary-0.1.25.dist-info/RECORD +0 -58
  33. {notionary-0.1.25.dist-info → notionary-0.1.26.dist-info}/licenses/LICENSE +0 -0
  34. {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 ElementPromptContent
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
- Verbesserte ToggleElement-Klasse, die Kontext berücksichtigt.
13
+ Improved ToggleElement class using pipe syntax instead of indentation.
11
14
  """
12
15
 
13
16
  TOGGLE_PATTERN = re.compile(r"^[+]{3}\s+(.+)$")
14
- INDENT_PATTERN = re.compile(r"^(\s{2,}|\t+)(.+)$")
17
+ PIPE_CONTENT_PATTERN = re.compile(r"^\|\s?(.*)$")
15
18
 
16
19
  TRANSCRIPT_TOGGLE_PATTERN = re.compile(r"^[+]{3}\s+Transcript$")
17
20
 
18
- @staticmethod
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
- @staticmethod
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
- @staticmethod
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 content
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": [], # Will be populated with nested content
46
+ "children": [],
44
47
  },
45
48
  }
46
49
 
47
- @staticmethod
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
- Extract the nested content of a toggle element.
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
- line = lines[current_index]
68
+ current_line = lines[current_index]
66
69
 
67
- # Empty line is still part of toggle content
68
- if not line.strip():
69
- nested_content.append("")
70
- current_index += 1
71
- continue
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
- # Check if line is indented (part of toggle content)
74
- if line.startswith(" ") or line.startswith("\t"):
75
- # Extract content with indentation removed
76
- content_line = ToggleElement._remove_indentation(line)
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
- # Non-indented, non-empty line marks the end of toggle content
87
+ # Case 3: Regular line - end of nested content
82
88
  break
83
89
 
84
90
  return nested_content, current_index
85
91
 
86
- @staticmethod
87
- def _remove_indentation(line: str) -> str:
88
- """Remove indentation from a line, handling both spaces and tabs."""
89
- if line.startswith("\t"):
90
- return line[1:]
91
- else:
92
- # Find number of leading spaces
93
- leading_spaces = len(line) - len(line.lstrip(" "))
94
- # Remove at least 2 spaces, but not more than what's there
95
- return line[min(2, leading_spaces) :]
96
-
97
- @staticmethod
98
- def notion_to_markdown(block: Dict[str, Any]) -> Optional[str]:
99
- """Convert Notion toggle block to markdown toggle."""
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 the toggle line
127
+ # Create toggle line
109
128
  toggle_line = f"+++ {title}"
110
129
 
111
- # Process children if any
130
+ # Process children if available
112
131
  children = toggle_data.get("children", [])
113
- if children:
114
- child_lines = []
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
- return toggle_line + "\n" + "\n".join(child_lines)
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
- @staticmethod
126
- def is_multiline() -> bool:
127
- """Toggle blocks can span multiple lines due to their nested content."""
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
- @staticmethod
131
- def _extract_text_content(rich_text: List[Dict[str, Any]]) -> str:
132
- """Extract plain text content from Notion rich_text elements."""
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
- Verbesserte find_matches-Methode, die Kontext beim Finden von Toggles berücksichtigt.
164
+ Finds all toggle elements in markdown using pipe syntax for nested content.
150
165
 
151
166
  Args:
152
- text: Der zu durchsuchende Text
153
- process_nested_content: Optionale Callback-Funktion zur Verarbeitung verschachtelter Inhalte
154
- context_aware: Ob der Kontext (vorhergehende Zeilen) beim Finden von Toggles berücksichtigt werden soll
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
- Liste von (start_pos, end_pos, block) Tupeln
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
- i = 0
166
- while i < len(lines):
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.match_markdown(line):
171
- i += 1
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
- is_transcript_toggle = cls.TRANSCRIPT_TOGGLE_PATTERN.match(line.strip())
175
-
176
- if context_aware and is_transcript_toggle:
177
- if i > 0 and lines[i - 1].strip().startswith("- "):
178
- pass
179
- else:
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
- start_pos = 0
184
- for j in range(i):
185
- start_pos += len(lines[j]) + 1
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
- i += 1
201
+ current_line_index += 1
190
202
  continue
191
203
 
192
204
  # Extract nested content
193
- nested_content, next_index = cls.extract_nested_content(lines, i + 1)
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
- # Calculate ending position
196
- end_pos = start_pos + len(line) + sum(len(l) + 1 for l in nested_content)
221
+ return toggle_blocks
197
222
 
198
- if nested_content and process_nested_content:
199
- nested_text = "\n".join(nested_content)
200
- nested_blocks = process_nested_content(nested_text)
201
- if nested_blocks:
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
- toggle_blocks.append((start_pos, end_pos, toggle_block))
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
- i = next_index
235
+ if not (context_aware and is_transcript_toggle):
236
+ return False
207
237
 
208
- return toggle_blocks
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
- "description": "Toggle elements are collapsible sections that help organize and hide detailed information.",
217
- "when_to_use": (
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
- "syntax": "+++ Toggle Title",
222
- "examples": [
223
- "+++ Key Findings\n The research demonstrates **three main conclusions**:\n 1. First important point\n 2. Second important point",
224
- "+++ FAQ\n **Q: When should I use toggles?**\n *A: Use toggles for supplementary information.*",
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
+ )