notionary 0.1.24__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 (35) 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 -8
  15. notionary/elements/prompts/synthax_prompt_builder.py +64 -17
  16. notionary/elements/qoute_element.py +72 -74
  17. notionary/elements/registry/block_element_registry.py +1 -1
  18. notionary/elements/registry/block_element_registry_builder.py +6 -9
  19. notionary/elements/table_element.py +49 -36
  20. notionary/elements/text_inline_formatter.py +23 -15
  21. notionary/elements/{todo_lists.py → todo_element.py} +34 -25
  22. notionary/elements/toggle_element.py +184 -108
  23. notionary/elements/toggleable_heading_element.py +269 -0
  24. notionary/elements/video_element.py +37 -28
  25. notionary/page/content/page_content_manager.py +5 -8
  26. notionary/page/markdown_to_notion_converter.py +269 -274
  27. notionary/page/notion_page.py +1 -1
  28. notionary/page/notion_to_markdown_converter.py +20 -95
  29. {notionary-0.1.24.dist-info → notionary-0.1.26.dist-info}/METADATA +1 -1
  30. notionary-0.1.26.dist-info/RECORD +58 -0
  31. {notionary-0.1.24.dist-info → notionary-0.1.26.dist-info}/WHEEL +1 -1
  32. notionary/elements/column_element.py +0 -307
  33. notionary-0.1.24.dist-info/RECORD +0 -58
  34. {notionary-0.1.24.dist-info → notionary-0.1.26.dist-info}/licenses/LICENSE +0 -0
  35. {notionary-0.1.24.dist-info → notionary-0.1.26.dist-info}/top_level.txt +0 -0
@@ -7,180 +7,157 @@ from notionary.elements.registry.block_element_registry_builder import (
7
7
 
8
8
 
9
9
  class MarkdownToNotionConverter:
10
+ """Converts Markdown text to Notion API block format with support for pipe syntax for nested structures."""
11
+
10
12
  SPACER_MARKER = "<!-- spacer -->"
11
- MULTILINE_CONTENT_MARKER = "<!-- REMOVED_MULTILINE_CONTENT -->"
12
- TOGGLE_MARKER = "<!-- toggle_content -->"
13
- TOGGLE_MARKER_PREFIX = "<!-- toggle_"
14
- TOGGLE_MARKER_SUFFIX = " -->"
13
+ TOGGLE_ELEMENT_TYPES = ["ToggleElement", "ToggleableHeadingElement"]
14
+ PIPE_CONTENT_PATTERN = r"^\|\s?(.*)$"
15
15
 
16
16
  def __init__(self, block_registry: Optional[BlockElementRegistry] = None):
17
- """
18
- Initialize the MarkdownToNotionConverter.
19
-
20
- Args:
21
- block_registry: Optional registry of Notion block elements
22
- """
17
+ """Initialize the converter with an optional custom block registry."""
23
18
  self._block_registry = (
24
19
  block_registry or BlockElementRegistryBuilder().create_full_registry()
25
20
  )
26
21
 
27
- self._setup_element_callbacks()
28
-
29
- def _setup_element_callbacks(self) -> None:
30
- """Registriert den Converter als Callback für Elemente, die ihn benötigen."""
22
+ def convert(self, markdown_text: str) -> List[Dict[str, Any]]:
23
+ """Convert markdown text to Notion API block format."""
24
+ if not markdown_text:
25
+ return []
31
26
 
32
- for element in self._block_registry.get_elements():
33
- if hasattr(element, "set_converter_callback"):
34
- element.set_converter_callback(self.convert)
27
+ # Collect all blocks with their positions in the text
28
+ all_blocks_with_positions = self._collect_all_blocks_with_positions(
29
+ markdown_text
30
+ )
35
31
 
36
- def convert(self, markdown_text: str) -> List[Dict[str, Any]]:
37
- """
38
- Convert markdown text to Notion API block format.
32
+ # Sort all blocks by their position in the text
33
+ all_blocks_with_positions.sort(key=lambda x: x[0])
39
34
 
40
- Args:
41
- markdown_text: The markdown text to convert
35
+ # Extract just the blocks without position information
36
+ blocks = [block for _, _, block in all_blocks_with_positions]
42
37
 
43
- Returns:
44
- List of Notion blocks
45
- """
46
- if not markdown_text:
47
- return []
38
+ # Process spacing between blocks
39
+ return self._process_block_spacing(blocks)
48
40
 
49
- # We'll process all blocks in order, preserving their original positions
41
+ def _collect_all_blocks_with_positions(
42
+ self, markdown_text: str
43
+ ) -> List[Tuple[int, int, Dict[str, Any]]]:
44
+ """Collect all blocks with their positions in the text."""
50
45
  all_blocks = []
51
46
 
52
- # First, identify all toggle blocks
53
- toggle_blocks = self._identify_toggle_blocks(markdown_text)
54
-
55
- # If we have toggles, process them and extract positions
56
- if toggle_blocks:
57
- all_blocks.extend(toggle_blocks)
47
+ # Process toggleable elements first (both Toggle and ToggleableHeading)
48
+ toggleable_blocks = self._identify_toggleable_blocks(markdown_text)
58
49
 
59
50
  # Process other multiline elements
60
- multiline_blocks = self._identify_multiline_blocks(markdown_text, toggle_blocks)
61
- if multiline_blocks:
62
- all_blocks.extend(multiline_blocks)
63
-
64
- # Process remaining text line by line
65
- line_blocks = self._process_text_lines(
66
- markdown_text, toggle_blocks + multiline_blocks
51
+ multiline_blocks = self._identify_multiline_blocks(
52
+ markdown_text, toggleable_blocks
67
53
  )
68
- if line_blocks:
69
- all_blocks.extend(line_blocks)
70
54
 
71
- # Sort all blocks by their position in the text
72
- all_blocks.sort(key=lambda x: x[0])
55
+ # Process remaining text line by line
56
+ processed_blocks = toggleable_blocks + multiline_blocks
57
+ line_blocks = self._process_text_lines(markdown_text, processed_blocks)
73
58
 
74
- # Extract just the blocks without position information
75
- blocks = [block for _, _, block in all_blocks]
59
+ # Combine all blocks
60
+ all_blocks.extend(toggleable_blocks)
61
+ all_blocks.extend(multiline_blocks)
62
+ all_blocks.extend(line_blocks)
76
63
 
77
- # Process spacing between blocks
78
- return self._process_block_spacing(blocks)
64
+ return all_blocks
79
65
 
80
- def _identify_toggle_blocks(
66
+ def _identify_toggleable_blocks(
81
67
  self, text: str
82
68
  ) -> List[Tuple[int, int, Dict[str, Any]]]:
83
- """
84
- Identify all toggle blocks in the text without replacing them.
69
+ """Identify all toggleable blocks (Toggle and ToggleableHeading) in the text."""
70
+ toggleable_blocks = []
71
+
72
+ # Find all toggleable elements
73
+ toggleable_elements = self._get_toggleable_elements()
85
74
 
86
- Args:
87
- text: The text to process
75
+ if not toggleable_elements:
76
+ return []
77
+
78
+ # Process each toggleable element type
79
+ for element in toggleable_elements:
80
+ if hasattr(element, "find_matches"):
81
+ # Find matches with context awareness
82
+ matches = element.find_matches(text, self.convert, context_aware=True)
83
+ if matches:
84
+ toggleable_blocks.extend(matches)
88
85
 
89
- Returns:
90
- List of (start_pos, end_pos, block) tuples
91
- """
92
- # Find toggle element in registry
93
- toggle_element = None
86
+ return toggleable_blocks
87
+
88
+ def _get_toggleable_elements(self):
89
+ """Return all toggleable elements from the registry."""
90
+ toggleable_elements = []
94
91
  for element in self._block_registry.get_elements():
95
92
  if (
96
93
  element.is_multiline()
97
94
  and hasattr(element, "match_markdown")
98
- and element.__name__ == "ToggleElement"
95
+ and element.__name__ in self.TOGGLE_ELEMENT_TYPES
99
96
  ):
100
- toggle_element = element
101
- break
102
-
103
- if not toggle_element:
104
- return []
105
-
106
- # Use the find_matches method with context awareness
107
- # Pass the converter's convert method as a callback to process nested content
108
- toggle_blocks = toggle_element.find_matches(
109
- text, self.convert, context_aware=True
110
- )
111
- return toggle_blocks
97
+ toggleable_elements.append(element)
98
+ return toggleable_elements
112
99
 
113
100
  def _identify_multiline_blocks(
114
101
  self, text: str, exclude_blocks: List[Tuple[int, int, Dict[str, Any]]]
115
102
  ) -> List[Tuple[int, int, Dict[str, Any]]]:
116
- """
117
- Identify all multiline blocks (except toggle blocks) without altering the text.
118
-
119
- Args:
120
- text: The text to process
121
- exclude_blocks: Blocks to exclude (e.g., already identified toggle blocks)
122
-
123
- Returns:
124
- List of (start_pos, end_pos, block) tuples
125
- """
126
- # Get all multiline elements except ToggleElement
127
- multiline_elements = [
128
- element
129
- for element in self._block_registry.get_multiline_elements()
130
- if element.__name__ != "ToggleElement"
131
- ]
103
+ """Identify all multiline blocks (except toggleable blocks)."""
104
+ # Get all multiline elements except toggleable ones
105
+ multiline_elements = self._get_non_toggleable_multiline_elements()
132
106
 
133
107
  if not multiline_elements:
134
108
  return []
135
109
 
136
- # Create a set of ranges to exclude
137
- exclude_ranges = set()
138
- for start, end, _ in exclude_blocks:
139
- exclude_ranges.update(range(start, end + 1))
110
+ # Create set of positions to exclude
111
+ excluded_ranges = self._create_excluded_position_set(exclude_blocks)
140
112
 
141
113
  multiline_blocks = []
142
114
  for element in multiline_elements:
143
115
  if not hasattr(element, "find_matches"):
144
116
  continue
145
117
 
146
- # Find all matches for this element
147
- if hasattr(element, "set_converter_callback"):
148
- matches = element.find_matches(text, self.convert)
149
- else:
150
- matches = element.find_matches(text)
118
+ matches = element.find_matches(text)
151
119
 
152
120
  if not matches:
153
121
  continue
154
122
 
155
- # Add only blocks that don't overlap with excluded ranges
156
- for start, end, block in matches:
157
- # Check if this block overlaps with any excluded range
158
- if any(start <= i <= end for i in exclude_ranges):
123
+ # Add blocks that don't overlap with excluded positions
124
+ for start_pos, end_pos, block in matches:
125
+ if self._overlaps_with_excluded_positions(
126
+ start_pos, end_pos, excluded_ranges
127
+ ):
159
128
  continue
160
- multiline_blocks.append((start, end, block))
129
+ multiline_blocks.append((start_pos, end_pos, block))
161
130
 
162
131
  return multiline_blocks
163
132
 
133
+ def _get_non_toggleable_multiline_elements(self):
134
+ """Get multiline elements that are not toggleable elements."""
135
+ return [
136
+ element
137
+ for element in self._block_registry.get_multiline_elements()
138
+ if element.__name__ not in self.TOGGLE_ELEMENT_TYPES
139
+ ]
140
+
141
+ def _create_excluded_position_set(self, exclude_blocks):
142
+ """Create a set of positions to exclude based on block ranges."""
143
+ excluded_positions = set()
144
+ for start_pos, end_pos, _ in exclude_blocks:
145
+ excluded_positions.update(range(start_pos, end_pos + 1))
146
+ return excluded_positions
147
+
148
+ def _overlaps_with_excluded_positions(self, start_pos, end_pos, excluded_positions):
149
+ """Check if a range overlaps with any excluded positions."""
150
+ return any(pos in excluded_positions for pos in range(start_pos, end_pos + 1))
151
+
164
152
  def _process_text_lines(
165
153
  self, text: str, exclude_blocks: List[Tuple[int, int, Dict[str, Any]]]
166
154
  ) -> List[Tuple[int, int, Dict[str, Any]]]:
167
- """
168
- Process text line by line, excluding ranges already processed.
169
-
170
- Args:
171
- text: The text to process
172
- exclude_blocks: Blocks to exclude (e.g., already identified toggle and multiline blocks)
173
-
174
- Returns:
175
- List of (start_pos, end_pos, block) tuples
176
- """
155
+ """Process text line by line, excluding already processed ranges and handling pipe syntax lines."""
177
156
  if not text:
178
157
  return []
179
158
 
180
- # Create a set of excluded positions
181
- exclude_positions = set()
182
- for start, end, _ in exclude_blocks:
183
- exclude_positions.update(range(start, end + 1))
159
+ # Create set of excluded positions
160
+ excluded_positions = self._create_excluded_position_set(exclude_blocks)
184
161
 
185
162
  line_blocks = []
186
163
  lines = text.split("\n")
@@ -194,210 +171,245 @@ class MarkdownToNotionConverter:
194
171
  line_length = len(line) + 1 # +1 for newline
195
172
  line_end = current_pos + line_length - 1
196
173
 
197
- # Skip lines that are part of excluded blocks
198
- if any(current_pos <= pos <= line_end for pos in exclude_positions):
174
+ # Skip excluded lines and pipe syntax lines (they're part of toggleable content)
175
+ if self._overlaps_with_excluded_positions(
176
+ current_pos, line_end, excluded_positions
177
+ ) or self._is_pipe_syntax_line(line):
199
178
  current_pos += line_length
200
179
  continue
201
180
 
202
- # Check for spacer marker
203
- if self._is_spacer_marker(line):
204
- line_blocks.append(
205
- (
206
- current_pos,
207
- current_pos + line_length - 1,
208
- self._create_empty_paragraph(),
209
- )
210
- )
211
- current_pos += line_length
212
- continue
181
+ processed = self._process_line(
182
+ line,
183
+ current_pos,
184
+ line_end,
185
+ line_blocks,
186
+ current_paragraph,
187
+ paragraph_start,
188
+ in_todo_sequence,
189
+ )
213
190
 
214
- # Process todos first to keep them grouped
215
- todo_block = self._extract_todo_item(line)
216
- if todo_block:
217
- self._handle_todo_item(
218
- todo_block,
219
- line_length,
220
- current_pos,
221
- current_paragraph,
222
- paragraph_start,
223
- line_blocks,
224
- in_todo_sequence,
225
- )
226
- in_todo_sequence = True
227
- current_pos += line_length
228
- continue
191
+ current_pos = processed["current_pos"]
192
+ current_paragraph = processed["current_paragraph"]
193
+ paragraph_start = processed["paragraph_start"]
194
+ in_todo_sequence = processed["in_todo_sequence"]
229
195
 
230
- if in_todo_sequence:
231
- in_todo_sequence = False
196
+ # Process remaining paragraph
197
+ self._process_paragraph(
198
+ current_paragraph, paragraph_start, current_pos, line_blocks
199
+ )
232
200
 
233
- if not line.strip():
234
- self._process_paragraph_if_present(
235
- current_paragraph, paragraph_start, current_pos, line_blocks
236
- )
237
- current_paragraph = []
238
- current_pos += line_length
239
- continue
201
+ return line_blocks
240
202
 
241
- special_block = self._extract_special_block(line)
242
- if special_block:
243
- self._process_paragraph_if_present(
244
- current_paragraph, paragraph_start, current_pos, line_blocks
245
- )
246
- line_blocks.append(
247
- (current_pos, current_pos + line_length - 1, special_block)
248
- )
249
- current_paragraph = []
250
- current_pos += line_length
251
- continue
203
+ def _is_pipe_syntax_line(self, line: str) -> bool:
204
+ """Check if a line uses pipe syntax (for nested content)."""
205
+ import re
252
206
 
253
- # Handle as part of paragraph
254
- if not current_paragraph:
255
- paragraph_start = current_pos
256
- current_paragraph.append(line)
257
- current_pos += line_length
207
+ return bool(re.match(self.PIPE_CONTENT_PATTERN, line))
258
208
 
259
- # Process any remaining paragraph content
260
- self._process_paragraph_if_present(
261
- current_paragraph, paragraph_start, current_pos, line_blocks
262
- )
209
+ def _process_line(
210
+ self,
211
+ line: str,
212
+ current_pos: int,
213
+ line_end: int,
214
+ line_blocks: List[Tuple[int, int, Dict[str, Any]]],
215
+ current_paragraph: List[str],
216
+ paragraph_start: int,
217
+ in_todo_sequence: bool,
218
+ ) -> Dict[str, Any]:
219
+ """Process a single line of text."""
220
+ line_length = len(line) + 1 # +1 for newline
221
+
222
+ # Check for spacer
223
+ if self._is_spacer_line(line):
224
+ line_blocks.append((current_pos, line_end, self._create_empty_paragraph()))
225
+ return self._update_line_state(
226
+ current_pos + line_length,
227
+ current_paragraph,
228
+ paragraph_start,
229
+ in_todo_sequence,
230
+ )
263
231
 
264
- return line_blocks
232
+ # Handle todo items
233
+ todo_block = self._extract_todo_item(line)
234
+ if todo_block:
235
+ return self._process_todo_line(
236
+ todo_block,
237
+ current_pos,
238
+ line_end,
239
+ line_blocks,
240
+ current_paragraph,
241
+ paragraph_start,
242
+ in_todo_sequence,
243
+ line_length,
244
+ )
265
245
 
266
- def _is_spacer_marker(self, line: str) -> bool:
267
- """Check if a line is a spacer marker."""
268
- return line.strip() == self.SPACER_MARKER
246
+ if in_todo_sequence:
247
+ in_todo_sequence = False
269
248
 
270
- def _extract_todo_item(self, line: str) -> Optional[Dict[str, Any]]:
271
- """
272
- Try to extract a todo item from a line.
249
+ # Handle empty lines
250
+ if not line.strip():
251
+ self._process_paragraph(
252
+ current_paragraph, paragraph_start, current_pos, line_blocks
253
+ )
254
+ return self._update_line_state(
255
+ current_pos + line_length, [], paragraph_start, False
256
+ )
273
257
 
274
- Returns:
275
- Todo block if line is a todo item, None otherwise
276
- """
277
- for element in self._block_registry.get_elements():
278
- if (
279
- not element.is_multiline()
280
- and hasattr(element, "match_markdown")
281
- and element.__name__ == "TodoElement"
282
- and element.match_markdown(line)
283
- ):
284
- return element.markdown_to_notion(line)
285
- return None
258
+ # Handle special blocks
259
+ special_block = self._extract_special_block(line)
260
+ if special_block:
261
+ self._process_paragraph(
262
+ current_paragraph, paragraph_start, current_pos, line_blocks
263
+ )
264
+ line_blocks.append((current_pos, line_end, special_block))
265
+ return self._update_line_state(
266
+ current_pos + line_length, [], paragraph_start, False
267
+ )
268
+
269
+ # Handle as paragraph
270
+ if not current_paragraph:
271
+ paragraph_start = current_pos
272
+ current_paragraph.append(line)
286
273
 
287
- def _handle_todo_item(
274
+ return self._update_line_state(
275
+ current_pos + line_length,
276
+ current_paragraph,
277
+ paragraph_start,
278
+ in_todo_sequence,
279
+ )
280
+
281
+ def _is_spacer_line(self, line: str) -> bool:
282
+ """Check if a line is a spacer marker."""
283
+ return line.strip() == self.SPACER_MARKER
284
+
285
+ def _process_todo_line(
288
286
  self,
289
287
  todo_block: Dict[str, Any],
290
- line_length: int,
291
288
  current_pos: int,
289
+ line_end: int,
290
+ line_blocks: List[Tuple[int, int, Dict[str, Any]]],
292
291
  current_paragraph: List[str],
293
292
  paragraph_start: int,
294
- line_blocks: List[Tuple[int, int, Dict[str, Any]]],
295
293
  in_todo_sequence: bool,
296
- ) -> None:
297
- """Handle a todo item line."""
298
- # If we were building a paragraph, finish it before starting todos
294
+ line_length: int,
295
+ ) -> Dict[str, Any]:
296
+ """Process a line that contains a todo item."""
297
+ # Finish paragraph if needed
299
298
  if not in_todo_sequence and current_paragraph:
300
- self._process_paragraph_if_present(
299
+ self._process_paragraph(
301
300
  current_paragraph, paragraph_start, current_pos, line_blocks
302
301
  )
303
- current_paragraph.clear()
304
302
 
305
- line_blocks.append((current_pos, current_pos + line_length - 1, todo_block))
303
+ line_blocks.append((current_pos, line_end, todo_block))
304
+
305
+ return self._update_line_state(
306
+ current_pos + line_length, [], paragraph_start, True
307
+ )
308
+
309
+ def _update_line_state(
310
+ self,
311
+ current_pos: int,
312
+ current_paragraph: List[str],
313
+ paragraph_start: int,
314
+ in_todo_sequence: bool,
315
+ ) -> Dict[str, Any]:
316
+ """Update and return the state after processing a line."""
317
+ return {
318
+ "current_pos": current_pos,
319
+ "current_paragraph": current_paragraph,
320
+ "paragraph_start": paragraph_start,
321
+ "in_todo_sequence": in_todo_sequence,
322
+ }
323
+
324
+ def _extract_todo_item(self, line: str) -> Optional[Dict[str, Any]]:
325
+ """Extract a todo item from a line if possible."""
326
+ todo_elements = [
327
+ element
328
+ for element in self._block_registry.get_elements()
329
+ if not element.is_multiline() and element.__name__ == "TodoElement"
330
+ ]
331
+
332
+ for element in todo_elements:
333
+ if element.match_markdown(line):
334
+ return element.markdown_to_notion(line)
335
+ return None
306
336
 
307
337
  def _extract_special_block(self, line: str) -> Optional[Dict[str, Any]]:
308
- """
309
- Try to extract a special block (not paragraph) from a line.
338
+ """Extract a special block (not paragraph) from a line if possible."""
339
+ non_multiline_elements = [
340
+ element
341
+ for element in self._block_registry.get_elements()
342
+ if not element.is_multiline()
343
+ ]
310
344
 
311
- Returns:
312
- Block if line is a special block, None otherwise
313
- """
314
- for element in self._block_registry.get_elements():
315
- if (
316
- not element.is_multiline()
317
- and hasattr(element, "match_markdown")
318
- and element.match_markdown(line)
319
- ):
345
+ for element in non_multiline_elements:
346
+ if element.match_markdown(line):
320
347
  block = element.markdown_to_notion(line)
321
348
  if block and block.get("type") != "paragraph":
322
349
  return block
323
350
  return None
324
351
 
325
- def _process_paragraph_if_present(
352
+ def _process_paragraph(
326
353
  self,
327
354
  paragraph_lines: List[str],
328
355
  start_pos: int,
329
356
  end_pos: int,
330
357
  blocks: List[Tuple[int, int, Dict[str, Any]]],
331
358
  ) -> None:
332
- """
333
- Process a paragraph and add it to the blocks list if valid.
334
-
335
- Args:
336
- paragraph_lines: Lines that make up the paragraph
337
- start_pos: Starting position of the paragraph
338
- end_pos: Ending position of the paragraph
339
- blocks: List to add the processed paragraph block to
340
- """
359
+ """Process a paragraph and add it to blocks if valid."""
341
360
  if not paragraph_lines:
342
361
  return
343
362
 
344
363
  paragraph_text = "\n".join(paragraph_lines)
345
364
  block = self._block_registry.markdown_to_notion(paragraph_text)
346
365
 
347
- if not block:
348
- return
349
-
350
- blocks.append((start_pos, end_pos, block))
366
+ if block:
367
+ blocks.append((start_pos, end_pos, block))
351
368
 
352
369
  def _process_block_spacing(
353
370
  self, blocks: List[Dict[str, Any]]
354
371
  ) -> List[Dict[str, Any]]:
355
- """
356
- Process blocks and add spacing only where no explicit spacer is present.
357
-
358
- Args:
359
- blocks: List of Notion blocks
360
-
361
- Returns:
362
- List of Notion blocks with processed spacing
363
- """
372
+ """Add spacing between blocks where needed."""
364
373
  if not blocks:
365
374
  return blocks
366
375
 
367
376
  final_blocks = []
368
- i = 0
369
377
 
370
- while i < len(blocks):
371
- current_block = blocks[i]
378
+ for block_index, current_block in enumerate(blocks):
372
379
  final_blocks.append(current_block)
373
380
 
374
- # Check if this is a multiline element that needs spacing
381
+ # Only add spacing after multiline blocks
375
382
  if not self._is_multiline_block_type(current_block.get("type")):
376
- i += 1
377
383
  continue
378
384
 
379
- # Check if the next block is already a spacer
380
- if i + 1 < len(blocks) and self._is_empty_paragraph(blocks[i + 1]):
381
- # Next block is already a spacer, don't add another
382
- pass
383
- else:
384
- # No explicit spacer found, add one automatically
385
+ # Check if we need to add a spacer
386
+ if self._needs_spacer_after_block(blocks, block_index):
385
387
  final_blocks.append(self._create_empty_paragraph())
386
388
 
387
- i += 1
388
-
389
389
  return final_blocks
390
390
 
391
- def _is_multiline_block_type(self, block_type: str) -> bool:
392
- """
393
- Check if a block type corresponds to a multiline element.
391
+ def _needs_spacer_after_block(
392
+ self, blocks: List[Dict[str, Any]], block_index: int
393
+ ) -> bool:
394
+ """Determine if we need to add a spacer after the current block."""
395
+ # Check if this is the last block (no need for spacer)
396
+ if block_index + 1 >= len(blocks):
397
+ return False
398
+
399
+ # Check if next block is already a spacer
400
+ next_block = blocks[block_index + 1]
401
+ if self._is_empty_paragraph(next_block):
402
+ return False
394
403
 
395
- Args:
396
- block_type: The type of block to check
404
+ # No spacer needed
405
+ return True
397
406
 
398
- Returns:
399
- True if the block type is a multiline element, False otherwise
400
- """
407
+ def _create_empty_paragraph(self):
408
+ """Create an empty paragraph block."""
409
+ return {"type": "paragraph", "paragraph": {"rich_text": []}}
410
+
411
+ def _is_multiline_block_type(self, block_type: str) -> bool:
412
+ """Check if a block type corresponds to a multiline element."""
401
413
  if not block_type:
402
414
  return False
403
415
 
@@ -416,26 +428,9 @@ class MarkdownToNotionConverter:
416
428
  return False
417
429
 
418
430
  def _is_empty_paragraph(self, block: Dict[str, Any]) -> bool:
419
- """
420
- Check if a block is an empty paragraph.
421
-
422
- Args:
423
- block: The block to check
424
-
425
- Returns:
426
- True if it's an empty paragraph, False otherwise
427
- """
431
+ """Check if a block is an empty paragraph."""
428
432
  if block.get("type") != "paragraph":
429
433
  return False
430
434
 
431
435
  rich_text = block.get("paragraph", {}).get("rich_text", [])
432
436
  return not rich_text or len(rich_text) == 0
433
-
434
- def _create_empty_paragraph(self) -> Dict[str, Any]:
435
- """
436
- Create an empty paragraph block.
437
-
438
- Returns:
439
- Empty paragraph block dictionary
440
- """
441
- return {"type": "paragraph", "paragraph": {"rich_text": []}}