notionary 0.2.7__py3-none-any.whl → 0.2.8__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.
@@ -1,113 +1,322 @@
1
1
  import re
2
- from typing import Dict, Any, Optional, List, Tuple
2
+ from typing import Dict, Any, Optional, List, Tuple, Callable
3
+
3
4
  from notionary.elements.notion_block_element import NotionBlockElement
4
- from notionary.prompting.element_prompt_content import (
5
- ElementPromptBuilder,
6
- ElementPromptContent,
7
- )
5
+ from notionary.prompting.element_prompt_content import ElementPromptContent
8
6
 
9
7
 
10
- # Fix Column Element
11
- class ColumnsElement(NotionBlockElement):
8
+ class ColumnElement(NotionBlockElement):
12
9
  """
13
- Handles conversion between Markdown column syntax and Notion column_list blocks.
10
+ Handles conversion between custom Markdown column syntax and Notion column blocks.
11
+
12
+ Markdown column syntax:
13
+ ::: columns
14
+ ::: column
15
+ Content for first column
16
+ :::
17
+ ::: column
18
+ Content for second column
19
+ :::
20
+ :::
14
21
 
15
- Note: Due to Notion's column structure, this element requires special handling.
16
- It returns a column_list block with placeholder content, as the actual columns
17
- must be added as children after the column_list is created.
22
+ This creates a column layout in Notion with the specified content in each column.
18
23
  """
19
24
 
20
- PATTERN = re.compile(
21
- r"^::: columns\n((?:::: column\n(?:.*?\n)*?:::\n?)+):::\s*$",
22
- re.MULTILINE | re.DOTALL,
23
- )
25
+ COLUMNS_START = re.compile(r"^:::\s*columns\s*$")
26
+ COLUMN_START = re.compile(r"^:::\s*column\s*$")
27
+ BLOCK_END = re.compile(r"^:::\s*$")
24
28
 
25
- COLUMN_PATTERN = re.compile(r"::: column\n(.*?):::", re.DOTALL)
29
+ _converter_callback = None
26
30
 
27
31
  @classmethod
28
- def match_markdown(cls, text: str) -> bool:
29
- """Check if text contains a columns block."""
30
- return bool(cls.PATTERN.search(text))
32
+ def set_converter_callback(
33
+ cls, callback: Callable[[str], List[Dict[str, Any]]]
34
+ ) -> None:
35
+ """
36
+ Setze die Callback-Funktion, die zum Konvertieren von Markdown zu Notion-Blöcken verwendet wird.
31
37
 
32
- @classmethod
33
- def match_notion(cls, block: Dict[str, Any]) -> bool:
34
- """Check if block is a Notion column_list block."""
35
- return block.get("type") == "column_list"
38
+ Args:
39
+ callback: Funktion, die Markdown-Text annimmt und eine Liste von Notion-Blöcken zurückgibt
40
+ """
41
+ cls._converter_callback = callback
36
42
 
37
- @classmethod
38
- def markdown_to_notion(cls, text: str) -> Optional[Dict[str, Any]]:
39
- """Convert markdown columns to Notion column_list block."""
40
- match = cls.PATTERN.search(text)
41
- if not match:
42
- return None
43
+ @staticmethod
44
+ def match_markdown(text: str) -> bool:
45
+ """Check if text starts a columns block."""
46
+ return bool(ColumnElement.COLUMNS_START.match(text.strip()))
43
47
 
44
- columns_content = match.group(1)
45
- column_matches = cls.COLUMN_PATTERN.findall(columns_content)
48
+ @staticmethod
49
+ def match_notion(block: Dict[str, Any]) -> bool:
50
+ """Check if block is a Notion column_list."""
51
+ return block.get("type") == "column_list"
52
+
53
+ @staticmethod
54
+ def markdown_to_notion(text: str) -> Optional[Dict[str, Any]]:
55
+ """
56
+ Convert markdown column syntax to Notion column blocks.
46
57
 
47
- if not column_matches:
58
+ Note: This only processes the first line (columns start).
59
+ The full column content needs to be processed separately.
60
+ """
61
+ if not ColumnElement.COLUMNS_START.match(text.strip()):
48
62
  return None
49
63
 
50
- return {"type": "column_list", "column_list": {}}
64
+ # Create an empty column_list block
65
+ # Child columns will be added by the column processor
66
+ return {"type": "column_list", "column_list": {"children": []}}
51
67
 
52
- @classmethod
53
- def notion_to_markdown(cls, block: Dict[str, Any]) -> Optional[str]:
54
- """Convert Notion column_list block to markdown columns."""
68
+ @staticmethod
69
+ def notion_to_markdown(block: Dict[str, Any]) -> Optional[str]:
70
+ """Convert Notion column_list block to markdown column syntax."""
55
71
  if block.get("type") != "column_list":
56
72
  return None
57
73
 
58
- # In a real implementation, you'd need to fetch the child column blocks
59
- # This is a placeholder showing the expected output format
60
- markdown = "::: columns\n"
74
+ column_children = block.get("column_list", {}).get("children", [])
75
+
76
+ # Start the columns block
77
+ result = ["::: columns"]
78
+
79
+ # Process each column
80
+ for column_block in column_children:
81
+ if column_block.get("type") == "column":
82
+ result.append("::: column")
83
+
84
+ for _ in column_block.get("column", {}).get("children", []):
85
+ result.append(" [Column content]") # Placeholder
86
+
87
+ result.append(":::")
61
88
 
62
- # Placeholder for column content extraction
63
- # In reality, you'd iterate through the child blocks
64
- markdown += "::: column\nColumn content here\n:::\n"
89
+ # End the columns block
90
+ result.append(":::")
65
91
 
66
- markdown += ":::"
67
- return markdown
92
+ return "\n".join(result)
93
+
94
+ @staticmethod
95
+ def is_multiline() -> bool:
96
+ """Column blocks span multiple lines."""
97
+ return True
68
98
 
69
99
  @classmethod
70
- def find_matches(cls, text: str) -> List[Tuple[int, int, Dict[str, Any]]]:
100
+ def find_matches(
101
+ cls, text: str, converter_callback: Optional[Callable] = None
102
+ ) -> List[Tuple[int, int, Dict[str, Any]]]:
71
103
  """
72
- Find all column block matches in the text and return their positions.
104
+ Find all column block matches in the text and return their positions and blocks.
73
105
 
74
106
  Args:
75
- text: The text to search in
107
+ text: The input markdown text
108
+ converter_callback: Optional callback to convert nested content
76
109
 
77
110
  Returns:
78
- List of tuples with (start_pos, end_pos, block_data)
111
+ List of tuples (start_pos, end_pos, block)
79
112
  """
113
+ # Wenn ein Callback übergeben wurde, nutze diesen, sonst die gespeicherte Referenz
114
+ converter = converter_callback or cls._converter_callback
115
+ if not converter:
116
+ raise ValueError(
117
+ "No converter callback provided for ColumnElement. Call set_converter_callback first or provide converter_callback parameter."
118
+ )
119
+
80
120
  matches = []
81
- for match in cls.PATTERN.finditer(text):
82
- block_data = cls.markdown_to_notion(match.group(0))
83
- if block_data:
84
- matches.append((match.start(), match.end(), block_data))
121
+ lines = text.split("\n")
122
+ i = 0
123
+
124
+ while i < len(lines):
125
+ # Skip non-column lines
126
+ if not ColumnElement.COLUMNS_START.match(lines[i].strip()):
127
+ i += 1
128
+ continue
129
+
130
+ # Process a column block and add to matches
131
+ column_block_info = cls._process_column_block(
132
+ lines=lines, start_index=i, converter_callback=converter
133
+ )
134
+ matches.append(column_block_info)
135
+
136
+ # Skip to the end of the processed column block
137
+ i = column_block_info[3] # i is returned as the 4th element in the tuple
138
+
139
+ return [(start, end, block) for start, end, block, _ in matches]
140
+
141
+ @classmethod
142
+ def _process_column_block(
143
+ cls, lines: List[str], start_index: int, converter_callback: Callable
144
+ ) -> Tuple[int, int, Dict[str, Any], int]:
145
+ """
146
+ Process a complete column block structure from the given starting line.
147
+
148
+ Args:
149
+ lines: All lines of the text
150
+ start_index: Index of the column block start line
151
+ converter_callback: Callback function to convert markdown to notion blocks
152
+
153
+ Returns:
154
+ Tuple of (start_pos, end_pos, block, next_line_index)
155
+ """
156
+ columns_start = start_index
157
+ columns_block = cls.markdown_to_notion(lines[start_index].strip())
158
+ columns_children = []
159
+
160
+ next_index = cls._collect_columns(
161
+ lines, start_index + 1, columns_children, converter_callback
162
+ )
163
+
164
+ # Add columns to the main block
165
+ if columns_children:
166
+ columns_block["column_list"]["children"] = columns_children
167
+
168
+ # Calculate positions
169
+ start_pos = sum(len(lines[j]) + 1 for j in range(columns_start))
170
+ end_pos = sum(len(lines[j]) + 1 for j in range(next_index))
171
+
172
+ return (start_pos, end_pos, columns_block, next_index)
173
+
174
+ @classmethod
175
+ def _collect_columns(
176
+ cls,
177
+ lines: List[str],
178
+ start_index: int,
179
+ columns_children: List[Dict[str, Any]],
180
+ converter_callback: Callable,
181
+ ) -> int:
182
+ """
183
+ Collect all columns within a column block structure.
184
+
185
+ Args:
186
+ lines: All lines of the text
187
+ start_index: Index to start collecting from
188
+ columns_children: List to append collected columns to
189
+ converter_callback: Callback function to convert column content
190
+
191
+ Returns:
192
+ Next line index after all columns have been processed
193
+ """
194
+ i = start_index
195
+ in_column = False
196
+ column_content = []
197
+
198
+ while i < len(lines):
199
+ current_line = lines[i].strip()
200
+
201
+ if cls.COLUMNS_START.match(current_line):
202
+ break
203
+
204
+ if cls.COLUMN_START.match(current_line):
205
+ cls._finalize_column(
206
+ column_content, columns_children, in_column, converter_callback
207
+ )
208
+ column_content = []
209
+ in_column = True
210
+ i += 1
211
+ continue
212
+
213
+ if cls.BLOCK_END.match(current_line) and in_column:
214
+ cls._finalize_column(
215
+ column_content, columns_children, in_column, converter_callback
216
+ )
217
+ column_content = []
218
+ in_column = False
219
+ i += 1
220
+ continue
221
+
222
+ if cls.BLOCK_END.match(current_line) and not in_column:
223
+ i += 1
224
+ break
225
+
226
+ if in_column:
227
+ column_content.append(lines[i])
228
+
229
+ i += 1
230
+
231
+ cls._finalize_column(
232
+ column_content, columns_children, in_column, converter_callback
233
+ )
234
+
235
+ return i
236
+
237
+ @staticmethod
238
+ def _finalize_column(
239
+ column_content: List[str],
240
+ columns_children: List[Dict[str, Any]],
241
+ in_column: bool,
242
+ converter_callback: Callable,
243
+ ) -> None:
244
+ """
245
+ Finalize a column by processing its content and adding it to the columns_children list.
246
+
247
+ Args:
248
+ column_content: Content lines of the column
249
+ columns_children: List to append the column block to
250
+ in_column: Whether we're currently in a column (if False, does nothing)
251
+ converter_callback: Callback function to convert column content
252
+ """
253
+ if not (in_column and column_content):
254
+ return
255
+
256
+ processed_content = ColumnElement._preprocess_column_content(column_content)
257
+
258
+ column_blocks = converter_callback("\n".join(processed_content))
85
259
 
86
- return matches
260
+ # Create column block
261
+ column_block = {"type": "column", "column": {"children": column_blocks}}
262
+ columns_children.append(column_block)
87
263
 
88
264
  @classmethod
89
265
  def is_multiline(cls) -> bool:
266
+ """Column blocks span multiple lines."""
90
267
  return True
268
+
269
+ @staticmethod
270
+ def _preprocess_column_content(lines: List[str]) -> List[str]:
271
+ """
272
+ Preprocess column content to handle special cases like first headings.
273
+
274
+ This removes any spacer markers that might have been added before the first
275
+ heading in a column, as each column should have its own heading context.
276
+
277
+ Args:
278
+ lines: The lines of content for the column
279
+
280
+ Returns:
281
+ Processed lines ready for conversion
282
+ """
283
+ from notionary.page.markdown_to_notion_converter import MarkdownToNotionConverter
284
+
285
+ processed_lines = []
286
+ found_first_heading = False
287
+ spacer_marker = MarkdownToNotionConverter.SPACER_MARKER
288
+
289
+ i = 0
290
+ while i < len(lines):
291
+ line = lines[i]
292
+
293
+ # Check if this is a heading line
294
+ if re.match(r"^(#{1,6})\s+(.+)$", line.strip()):
295
+ # If it's the first heading, look ahead to check for spacer
296
+ if not found_first_heading and i > 0 and processed_lines and processed_lines[-1] == spacer_marker:
297
+ # Remove spacer before first heading in column
298
+ processed_lines.pop()
299
+
300
+ found_first_heading = True
301
+
302
+ processed_lines.append(line)
303
+ i += 1
304
+
305
+ return processed_lines
91
306
 
92
307
  @classmethod
93
308
  def get_llm_prompt_content(cls) -> ElementPromptContent:
94
309
  """
95
- Returns structured LLM prompt metadata for the columns element.
310
+ Returns structured LLM prompt metadata for the column layout element.
96
311
  """
97
- return (
98
- ElementPromptBuilder()
99
- .with_description(
100
- "Create multi-column layouts using Pandoc-style fenced divs. Perfect for side-by-side comparisons, "
101
- "parallel content, or creating newsletter-style layouts. Each column can contain any markdown content "
102
- "including headers, lists, images, and even nested blocks."
103
- )
104
- .with_usage_guidelines(
105
- "Use columns when you need to present information side-by-side for comparison, create visual balance "
106
- "in your layout, or organize related content in parallel. Great for pros/cons lists, before/after "
107
- "comparisons, or displaying multiple related items. Keep column content balanced in length for best "
108
- "visual results."
109
- )
110
- .with_syntax(
312
+ return {
313
+ "description": "Creates a multi-column layout that displays content side by side.",
314
+ "when_to_use": (
315
+ "Use columns sparingly, only for direct comparisons or when parallel presentation significantly improves readability. "
316
+ "Best for pros/cons lists, feature comparisons, or pairing images with descriptions. "
317
+ "Avoid overusing as it can complicate document structure."
318
+ ),
319
+ "syntax": (
111
320
  "::: columns\n"
112
321
  "::: column\n"
113
322
  "Content for first column\n"
@@ -116,89 +325,29 @@ class ColumnsElement(NotionBlockElement):
116
325
  "Content for second column\n"
117
326
  ":::\n"
118
327
  ":::"
119
- )
120
- .with_examples(
121
- [
122
- # Simple two-column example
123
- "::: columns\n"
124
- "::: column\n"
125
- "### Pros\n"
126
- "- Fast performance\n"
127
- "- Easy to use\n"
128
- "- Great documentation\n"
129
- ":::\n"
130
- "::: column\n"
131
- "### Cons\n"
132
- "- Limited customization\n"
133
- "- Requires subscription\n"
134
- "- No offline mode\n"
135
- ":::\n"
136
- ":::",
137
- # Three-column example
138
- "::: columns\n"
139
- "::: column\n"
140
- "**Python**\n"
141
- "```python\n"
142
- "print('Hello')\n"
143
- "```\n"
144
- ":::\n"
145
- "::: column\n"
146
- "**JavaScript**\n"
147
- "```javascript\n"
148
- "console.log('Hello');\n"
149
- "```\n"
150
- ":::\n"
151
- "::: column\n"
152
- "**Ruby**\n"
153
- "```ruby\n"
154
- "puts 'Hello'\n"
155
- "```\n"
156
- ":::\n"
157
- ":::",
158
- # Mixed content example
159
- "::: columns\n"
160
- "::: column\n"
161
- "![Image](url)\n"
162
- "Product photo\n"
163
- ":::\n"
164
- "::: column\n"
165
- "## Product Details\n"
166
- "- Price: $99\n"
167
- "- Weight: 2kg\n"
168
- "- Color: Blue\n"
169
- "\n"
170
- "[Order Now](link)\n"
171
- ":::\n"
172
- ":::",
173
- ]
174
- )
175
- .with_avoidance_guidelines(
176
- "Avoid nesting column blocks within column blocks - this creates confusing layouts. "
177
- "Don't use columns for content that should be read sequentially. Keep the number of columns "
178
- "reasonable (2-4 max) for readability. Ensure each ::: marker is on its own line with proper "
179
- "nesting. Don't mix column syntax with regular markdown formatting on the same line."
180
- )
181
- .build()
182
- )
183
-
184
- @classmethod
185
- def get_column_content(cls, text: str) -> List[str]:
186
- """
187
- Extract the content of individual columns from the markdown.
188
- This is a helper method that can be used by the implementation
189
- to process column content separately.
190
-
191
- Args:
192
- text: The complete columns markdown block
193
-
194
- Returns:
195
- List of column content strings
196
- """
197
- match = cls.PATTERN.search(text)
198
- if not match:
199
- return []
200
-
201
- columns_content = match.group(1)
202
- return [
203
- content.strip() for content in cls.COLUMN_PATTERN.findall(columns_content)
204
- ]
328
+ ),
329
+ "examples": [
330
+ "::: columns\n"
331
+ "::: column\n"
332
+ "## Features\n"
333
+ "- Fast response time\n"
334
+ "- Intuitive interface\n"
335
+ "- Regular updates\n"
336
+ ":::\n"
337
+ "::: column\n"
338
+ "## Benefits\n"
339
+ "- Increased productivity\n"
340
+ "- Better collaboration\n"
341
+ "- Simplified workflows\n"
342
+ ":::\n"
343
+ ":::",
344
+ "::: columns\n"
345
+ "::: column\n"
346
+ "![Image placeholder](/api/placeholder/400/320)\n"
347
+ ":::\n"
348
+ "::: column\n"
349
+ "This text appears next to the image, creating a media-with-caption style layout that's perfect for documentation or articles.\n"
350
+ ":::\n"
351
+ ":::",
352
+ ],
353
+ }
@@ -64,4 +64,4 @@ class DividerElement(NotionBlockElement):
64
64
  ["## Section 1\nContent\n\n---\n\n## Section 2\nMore content"]
65
65
  )
66
66
  .build()
67
- )
67
+ )
@@ -85,7 +85,10 @@ class HeadingElement(NotionBlockElement):
85
85
  .with_usage_guidelines(
86
86
  "Use to group content into sections and define a visual hierarchy."
87
87
  )
88
+ .with_avoidance_guidelines(
89
+ "Only H1-H3 syntax is supported. H4 and deeper heading levels are not available."
90
+ )
88
91
  .with_syntax("## Your Heading Text")
89
92
  .with_standard_markdown()
90
93
  .build()
91
- )
94
+ )
@@ -126,7 +126,7 @@ class BlockRegistry:
126
126
 
127
127
  formatter_names = [e.__name__ for e in element_classes]
128
128
  if "TextInlineFormatter" not in formatter_names:
129
- element_classes = element_classes + [TextInlineFormatter]
129
+ element_classes = element_classes + [TextInlineFormatter]
130
130
 
131
131
  return MarkdownSyntaxPromptGenerator.generate_system_prompt(element_classes)
132
132
 
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
  from typing import List, Type
3
3
  from collections import OrderedDict
4
4
 
5
+ from notionary.elements.column_element import ColumnElement
5
6
  from notionary.elements.notion_block_element import NotionBlockElement
6
7
 
7
8
  from notionary.elements.audio_element import AudioElement
@@ -27,7 +28,6 @@ from notionary.elements.toggleable_heading_element import ToggleableHeadingEleme
27
28
  from notionary.elements.video_element import VideoElement
28
29
  from notionary.elements.toggle_element import ToggleElement
29
30
  from notionary.elements.bookmark_element import BookmarkElement
30
- from notionary.elements.column_element import ColumnsElement
31
31
 
32
32
 
33
33
  class BlockRegistryBuilder:
@@ -64,6 +64,7 @@ class BlockRegistryBuilder:
64
64
  .with_videos()
65
65
  .with_embeds()
66
66
  .with_audio()
67
+ .with_columns()
67
68
  .with_mention()
68
69
  .with_paragraphs()
69
70
  .with_toggleable_heading_element()
@@ -267,7 +268,7 @@ class BlockRegistryBuilder:
267
268
  """
268
269
  Add support for column elements.
269
270
  """
270
- return self.add_element(ColumnsElement)
271
+ return self.add_element(ColumnElement)
271
272
 
272
273
  def build(self) -> BlockRegistry:
273
274
  """
@@ -1,5 +1,4 @@
1
1
  from typing import Any, Dict
2
- from textwrap import dedent
3
2
 
4
3
  from notionary.elements.divider_element import DividerElement
5
4
  from notionary.elements.registry.block_registry import BlockRegistry
@@ -39,16 +38,6 @@ class PageContentWriter(LoggingMixin):
39
38
  """
40
39
  Append markdown text to a Notion page, automatically handling content length limits.
41
40
  """
42
- # Check for leading whitespace in the first three lines and log a warning if found
43
- first_three_lines = markdown_text.split('\n')[:3]
44
- if any(line.startswith(' ') or line.startswith('\t') for line in first_three_lines):
45
- self.logger.warning(
46
- "Leading whitespace detected in input markdown. Consider using textwrap.dedent or similar logic: "
47
- "this code is indented the wrong way, which could lead to formatting issues."
48
- )
49
-
50
- markdown_text = "\n".join(line.lstrip() for line in markdown_text.split("\n"))
51
-
52
41
  if append_divider and not self.block_registry.contains(DividerElement):
53
42
  self.logger.warning(
54
43
  "DividerElement not registered. Appending divider skipped."
@@ -57,7 +46,9 @@ class PageContentWriter(LoggingMixin):
57
46
 
58
47
  # Append divider in markdown format as it will be converted to a Notion divider block
59
48
  if append_divider:
60
- markdown_text = markdown_text + "\n\n---\n\n"
49
+ markdown_text = markdown_text + "\n---"
50
+
51
+ markdown_text = self._process_markdown_whitespace(markdown_text)
61
52
 
62
53
  try:
63
54
  blocks = self._markdown_to_notion_converter.convert(markdown_text)
@@ -111,3 +102,102 @@ class PageContentWriter(LoggingMixin):
111
102
  except Exception as e:
112
103
  self.logger.error("Failed to delete block: %s", str(e))
113
104
  return False
105
+
106
+ def _process_markdown_whitespace(self, markdown_text: str) -> str:
107
+ """
108
+ Process markdown text to preserve code structure while removing unnecessary indentation.
109
+ Strips all leading whitespace from regular lines, but preserves relative indentation
110
+ within code blocks.
111
+
112
+ Args:
113
+ markdown_text: Original markdown text with potential leading whitespace
114
+
115
+ Returns:
116
+ Processed markdown text with corrected whitespace
117
+ """
118
+ lines = markdown_text.split("\n")
119
+ if not lines:
120
+ return ""
121
+
122
+ processed_lines = []
123
+ in_code_block = False
124
+ current_code_block = []
125
+
126
+ for line in lines:
127
+ # Handle code block markers
128
+ if self._is_code_block_marker(line):
129
+ if not in_code_block:
130
+ # Starting a new code block
131
+ in_code_block = True
132
+ processed_lines.append(self._process_code_block_start(line))
133
+ current_code_block = []
134
+ continue
135
+
136
+ # Ending a code block
137
+ processed_lines.extend(
138
+ self._process_code_block_content(current_code_block)
139
+ )
140
+ processed_lines.append("```")
141
+ in_code_block = False
142
+ continue
143
+
144
+ # Handle code block content
145
+ if in_code_block:
146
+ current_code_block.append(line)
147
+ continue
148
+
149
+ # Handle regular text
150
+ processed_lines.append(line.lstrip())
151
+
152
+ # Handle unclosed code block
153
+ if in_code_block and current_code_block:
154
+ processed_lines.extend(self._process_code_block_content(current_code_block))
155
+ processed_lines.append("```")
156
+
157
+ return "\n".join(processed_lines)
158
+
159
+ def _is_code_block_marker(self, line: str) -> bool:
160
+ """Check if a line is a code block marker."""
161
+ return line.lstrip().startswith("```")
162
+
163
+ def _process_code_block_start(self, line: str) -> str:
164
+ """Extract and normalize the code block opening marker."""
165
+ language = line.lstrip().replace("```", "", 1).strip()
166
+ return "```" + language
167
+
168
+ def _process_code_block_content(self, code_lines: list) -> list:
169
+ """
170
+ Normalize code block indentation by removing the minimum common indentation.
171
+
172
+ Args:
173
+ code_lines: List of code block content lines
174
+
175
+ Returns:
176
+ List of processed code lines with normalized indentation
177
+ """
178
+ if not code_lines:
179
+ return []
180
+
181
+ # Find non-empty lines to determine minimum indentation
182
+ non_empty_code_lines = [line for line in code_lines if line.strip()]
183
+ if not non_empty_code_lines:
184
+ return [""] * len(code_lines) # All empty lines stay empty
185
+
186
+ # Calculate minimum indentation
187
+ min_indent = min(
188
+ len(line) - len(line.lstrip()) for line in non_empty_code_lines
189
+ )
190
+ if min_indent == 0:
191
+ return code_lines # No common indentation to remove
192
+
193
+ # Process each line
194
+ processed_code_lines = []
195
+ for line in code_lines:
196
+ if not line.strip():
197
+ processed_code_lines.append("") # Keep empty lines empty
198
+ continue
199
+
200
+ # Remove exactly the minimum indentation
201
+ processed_code_lines.append(line[min_indent:])
202
+
203
+ return processed_code_lines
@@ -1,6 +1,7 @@
1
1
  from typing import Dict, Any, List, Optional, Tuple
2
2
  import re
3
3
 
4
+ from notionary.elements.column_element import ColumnElement
4
5
  from notionary.elements.registry.block_registry import BlockRegistry
5
6
  from notionary.elements.registry.block_registry_builder import (
6
7
  BlockRegistryBuilder,
@@ -22,6 +23,9 @@ class MarkdownToNotionConverter:
22
23
  block_registry or BlockRegistryBuilder().create_full_registry()
23
24
  )
24
25
 
26
+ if self._block_registry.contains(ColumnElement):
27
+ ColumnElement.set_converter_callback(self.convert)
28
+
25
29
  def convert(self, markdown_text: str) -> List[Dict[str, Any]]:
26
30
  """Convert markdown text to Notion API block format."""
27
31
  if not markdown_text:
@@ -29,6 +33,7 @@ class MarkdownToNotionConverter:
29
33
 
30
34
  # Preprocess markdown to add spacers before headings and dividers
31
35
  processed_markdown = self._add_spacers_before_elements(markdown_text)
36
+ print("Processed Markdown:", processed_markdown)
32
37
 
33
38
  # Collect all blocks with their positions in the text
34
39
  all_blocks_with_positions = self._collect_all_blocks_with_positions(
@@ -45,37 +50,133 @@ class MarkdownToNotionConverter:
45
50
  return self._process_block_spacing(blocks)
46
51
 
47
52
  def _add_spacers_before_elements(self, markdown_text: str) -> str:
48
- """Add spacer markers before every heading (except the first one) and before every divider."""
49
- lines = markdown_text.split('\n')
53
+ """Add spacer markers before every heading (except the first one) and before every divider,
54
+ but ignore content inside code blocks and consecutive headings."""
55
+ lines = markdown_text.split("\n")
50
56
  processed_lines = []
51
57
  found_first_heading = False
52
-
58
+ in_code_block = False
59
+ last_line_was_spacer = False
60
+ last_non_empty_was_heading = False
61
+
53
62
  i = 0
54
63
  while i < len(lines):
55
64
  line = lines[i]
56
-
57
- # Check if line is a heading
58
- if re.match(self.HEADING_PATTERN, line):
59
- if found_first_heading:
60
- # Only add a single spacer line before headings (no extra line breaks)
61
- processed_lines.append(self.SPACER_MARKER)
62
- else:
63
- found_first_heading = True
64
-
65
+
66
+ # Check for code block boundaries and handle accordingly
67
+ if self._is_code_block_marker(line):
68
+ in_code_block = not in_code_block
69
+ processed_lines.append(line)
70
+ if line.strip(): # If not empty
71
+ last_non_empty_was_heading = False
72
+ last_line_was_spacer = False
73
+ i += 1
74
+ continue
75
+
76
+ # Skip processing markdown inside code blocks
77
+ if in_code_block:
65
78
  processed_lines.append(line)
66
-
67
- # Check if line is a divider
68
- elif re.match(self.DIVIDER_PATTERN, line):
79
+ if line.strip(): # If not empty
80
+ last_non_empty_was_heading = False
81
+ last_line_was_spacer = False
82
+ i += 1
83
+ continue
84
+
85
+ # Process line with context about consecutive headings
86
+ result = self._process_line_for_spacers(
87
+ line,
88
+ processed_lines,
89
+ found_first_heading,
90
+ last_line_was_spacer,
91
+ last_non_empty_was_heading,
92
+ )
93
+
94
+ last_line_was_spacer = result["added_spacer"]
95
+
96
+ # Update tracking of consecutive headings and first heading
97
+ if line.strip(): # Not empty line
98
+ is_heading = re.match(self.HEADING_PATTERN, line) is not None
99
+ if is_heading:
100
+ if not found_first_heading:
101
+ found_first_heading = True
102
+ last_non_empty_was_heading = True
103
+ elif line.strip() != self.SPACER_MARKER: # Not a spacer or heading
104
+ last_non_empty_was_heading = False
105
+
106
+ i += 1
107
+
108
+ return "\n".join(processed_lines)
109
+
110
+ def _is_code_block_marker(self, line: str) -> bool:
111
+ """Check if a line is a code block marker (start or end)."""
112
+ return line.strip().startswith("```")
113
+
114
+ def _process_line_for_spacers(
115
+ self,
116
+ line: str,
117
+ processed_lines: List[str],
118
+ found_first_heading: bool,
119
+ last_line_was_spacer: bool,
120
+ last_non_empty_was_heading: bool,
121
+ ) -> Dict[str, bool]:
122
+ """
123
+ Process a single line to add spacers before headings and dividers if needed.
124
+
125
+ Args:
126
+ line: The line to process
127
+ processed_lines: List of already processed lines to append to
128
+ found_first_heading: Whether the first heading has been found
129
+ last_line_was_spacer: Whether the last added line was a spacer
130
+ last_non_empty_was_heading: Whether the last non-empty line was a heading
131
+
132
+ Returns:
133
+ Dictionary with processing results
134
+ """
135
+ added_spacer = False
136
+ line_stripped = line.strip()
137
+ is_empty = not line_stripped
138
+
139
+ # Skip empty lines
140
+ if is_empty:
141
+ processed_lines.append(line)
142
+ return {"added_spacer": False}
143
+
144
+ # Check if line is a heading
145
+ if re.match(self.HEADING_PATTERN, line):
146
+ if (
147
+ found_first_heading
148
+ and not last_line_was_spacer
149
+ and not last_non_empty_was_heading
150
+ ):
151
+ # Add spacer only if:
152
+ # 1. Not the first heading
153
+ # 2. Last non-empty line was not a heading
154
+ # 3. Last line was not already a spacer
155
+ processed_lines.append(self.SPACER_MARKER)
156
+ added_spacer = True
157
+
158
+ processed_lines.append(line)
159
+
160
+ # Check if line is a divider
161
+ elif re.match(self.DIVIDER_PATTERN, line):
162
+ if not last_line_was_spacer:
69
163
  # Only add a single spacer line before dividers (no extra line breaks)
70
164
  processed_lines.append(self.SPACER_MARKER)
165
+ added_spacer = True
166
+
167
+ processed_lines.append(line)
168
+
169
+ # Check if this line itself is a spacer
170
+ elif line_stripped == self.SPACER_MARKER:
171
+ # Never add consecutive spacers
172
+ if not last_line_was_spacer:
71
173
  processed_lines.append(line)
72
-
73
- else:
74
- processed_lines.append(line)
75
-
76
- i += 1
77
-
78
- return '\n'.join(processed_lines)
174
+ added_spacer = True
175
+
176
+ else:
177
+ processed_lines.append(line)
178
+
179
+ return {"added_spacer": added_spacer}
79
180
 
80
181
  def _collect_all_blocks_with_positions(
81
182
  self, markdown_text: str
@@ -464,4 +565,4 @@ class MarkdownToNotionConverter:
464
565
  return False
465
566
 
466
567
  rich_text = block.get("paragraph", {}).get("rich_text", [])
467
- return not rich_text or len(rich_text) == 0
568
+ return not rich_text or len(rich_text) == 0
@@ -11,8 +11,7 @@ class MarkdownSyntaxPromptGenerator:
11
11
  and formats them optimally for LLMs.
12
12
  """
13
13
 
14
- SYSTEM_PROMPT_TEMPLATE = (
15
- """
14
+ SYSTEM_PROMPT_TEMPLATE = """
16
15
  You create content for Notion pages using Markdown syntax with special Notion extensions.
17
16
 
18
17
  # Understanding Notion Blocks
@@ -56,7 +55,6 @@ class MarkdownSyntaxPromptGenerator:
56
55
  - Format as: ## 🚀 Heading Text (with space after emoji)
57
56
  - Only omit emojis if the user explicitly instructs you not to use them
58
57
  """
59
- )
60
58
 
61
59
  @staticmethod
62
60
  def generate_element_doc(element_class: Type[NotionBlockElement]) -> str:
@@ -114,4 +112,4 @@ class MarkdownSyntaxPromptGenerator:
114
112
  Generates a complete system prompt for LLMs.
115
113
  """
116
114
  element_docs = cls.generate_element_docs(element_classes)
117
- return cls.SYSTEM_PROMPT_TEMPLATE.format(element_docs=element_docs)
115
+ return cls.SYSTEM_PROMPT_TEMPLATE.format(element_docs=element_docs)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: notionary
3
- Version: 0.2.7
3
+ Version: 0.2.8
4
4
  Summary: A toolkit to convert between Markdown and Notion blocks
5
5
  Home-page: https://github.com/mathisarends/notionary
6
6
  Author: Mathis Arends
@@ -153,6 +153,31 @@ Notionary extends standard Markdown with special syntax to support Notion-specif
153
153
  | 3. Add content with replace_content() or append_markdown()
154
154
  ```
155
155
 
156
+ ### Multi-Column Layout
157
+
158
+ ```markdown
159
+ ::: columns
160
+ ::: column
161
+
162
+ ## Left Column
163
+
164
+ - Item 1
165
+ - Item 2
166
+ - Item 3
167
+ :::
168
+ ::: column
169
+
170
+ ## Right Column
171
+
172
+ This text appears in the second column. Multi-column layouts are perfect for:
173
+
174
+ - Comparing features
175
+ - Creating side-by-side content
176
+ - Improving readability of wide content
177
+ :::
178
+ :::
179
+ ```
180
+
156
181
  ### Code Blocks
157
182
 
158
183
  ```python
@@ -196,6 +221,7 @@ custom_registry = (
196
221
  .with_headings()
197
222
  .with_callouts()
198
223
  .with_toggles()
224
+ .with_columns() # Include multi-column support
199
225
  .with_code()
200
226
  .with_todos()
201
227
  .with_paragraphs()
@@ -205,7 +231,7 @@ custom_registry = (
205
231
  # Apply this registry to a page
206
232
  page = NotionPage.from_url("https://www.notion.so/your-page-url")
207
233
  page.block_registry = custom_registry
208
- ark
234
+
209
235
  # Replace content using only supported elements
210
236
  await page.replace_content("# Custom heading with selected elements only")
211
237
  ```
@@ -9,10 +9,10 @@ notionary/elements/bookmark_element.py,sha256=msCtZvuPkIj1kiShNwE8i1GDYwamFb5mwR
9
9
  notionary/elements/bulleted_list_element.py,sha256=obsb3JqUNET3uS5OZM3yzDqxSzJzUuEob-Fzx0UIg9Y,2664
10
10
  notionary/elements/callout_element.py,sha256=ZsRvRtVy9kxdTwgrB5JGjZ4qcCiwcC0WimWJ_cW0aLY,4492
11
11
  notionary/elements/code_block_element.py,sha256=YHOiV2eQIe7gbsOnrsQnztomTZ-eP3HRHsonzrj3Tt8,7529
12
- notionary/elements/column_element.py,sha256=lMVRKXndOuN6lsBMlkgZ-11uuoEFLtUFedwrPAswL1E,7555
13
- notionary/elements/divider_element.py,sha256=Kt2oJJQD3zKyWSQ3JOG0nUgYqJRodMO4UVy7EXixxes,2330
12
+ notionary/elements/column_element.py,sha256=pUgu0e2vptvti2Mms_jcivSX0VmeBY9B1asCrVnXrxc,12476
13
+ notionary/elements/divider_element.py,sha256=h6lzk4HITuBwHzxQU967QCWL4F8j2GfEOloWnZ8xs8E,2332
14
14
  notionary/elements/embed_element.py,sha256=Zcc18Kl8SGoG98P2aYE0TkBviRvSz-sYOdjMEs-tvgk,4579
15
- notionary/elements/heading_element.py,sha256=kqgjyfaawEODir2tzDyf7-7wm38DbqoZnsH5k94GsA0,3013
15
+ notionary/elements/heading_element.py,sha256=4X8LLhUb2oyfErQ5p-r0bq3wSR6TGSTSQjoVAhVnTO4,3166
16
16
  notionary/elements/image_element.py,sha256=cwdovaWK8e4uZJU97l_fJ2etAxAgM2rG2EE34t4eag8,4758
17
17
  notionary/elements/mention_element.py,sha256=L4t6eAY3RcbOqIiwVT_CAqwatDtP4tBs9FaqRhaCbpQ,8227
18
18
  notionary/elements/notion_block_element.py,sha256=BVrZH09vyojuacs3KGReVx3W0Ee6di_5o9E8N5sex28,1258
@@ -25,20 +25,20 @@ notionary/elements/todo_element.py,sha256=ND3oOzSnd0l1AUGTcG2NiHW50ZbI4-atjtNorL
25
25
  notionary/elements/toggle_element.py,sha256=h9vYkkAIUHzn-0mu31qC6UPdlk_0EFIsU5A4T_A2ZI8,11082
26
26
  notionary/elements/toggleable_heading_element.py,sha256=XdaPsd8anufwAACL8J-Egd_RcqPqZ1gFlzeol1GOyyc,9960
27
27
  notionary/elements/video_element.py,sha256=y0OmOYXdQBc2rSYAHRmA4l4rzNqPnyhuXbEipcgzQgY,5727
28
- notionary/elements/registry/block_registry.py,sha256=T2yKRyzsdC9OSWdsiG-AI2T60SmtaR-7QaM6lOz0qrw,5028
29
- notionary/elements/registry/block_registry_builder.py,sha256=KU1Qh3qaB1lMrSBj1iyi8Hkx1h0HfxtajmkXZhMb68k,9545
28
+ notionary/elements/registry/block_registry.py,sha256=g0id_Q6guzTyNY6HfnB9AjOBvCR4CvtpnUeFAY8kgY0,5027
29
+ notionary/elements/registry/block_registry_builder.py,sha256=5zRKnw2102rAeHpANs6Csu4DVufOazf1peEovChWcgs,9572
30
30
  notionary/exceptions/database_exceptions.py,sha256=I-Tx6bYRLpi5pjGPtbT-Mqxvz3BFgYTiuZxknJeLxtI,2638
31
31
  notionary/exceptions/page_creation_exception.py,sha256=4v7IuZD6GsQLrqhDLriGjuG3ML638gAO53zDCrLePuU,281
32
32
  notionary/models/notion_block_response.py,sha256=gzL4C6K9QPcaMS6NbAZaRceSEnMbNwYBVVzxysza5VU,6002
33
33
  notionary/models/notion_database_response.py,sha256=FMAasQP20S12J_KMdMlNpcHHwxFKX2YtbE4Q9xn-ruQ,1213
34
34
  notionary/models/notion_page_response.py,sha256=r4fwMwwDocj92JdbSmyrzIqBKsnEaz4aDUiPabrg9BM,1762
35
- notionary/page/markdown_to_notion_converter.py,sha256=QYlxotQoBK5Ruj7UvfYfMdbsylQCMw7OX6Ng5CyMeVo,17097
35
+ notionary/page/markdown_to_notion_converter.py,sha256=-IC2yJimrmJ7e5EorV-48V6V12n1VBmsOHKejZxAKCo,20909
36
36
  notionary/page/notion_page.py,sha256=NDxAJaNk4tlKUrenhKBdnuvjlVgnxC0Z6fprf2LyNeE,18046
37
37
  notionary/page/notion_page_factory.py,sha256=2A3M5Ub_kV2-q7PPRqDgfwBjhkGCwtL5i3Kr2RfvvVo,7213
38
38
  notionary/page/notion_to_markdown_converter.py,sha256=vUQss0J7LUFLULGvW27PjaTFuWi8OsRQAUBowSYorkM,6408
39
39
  notionary/page/content/notion_page_content_chunker.py,sha256=xRks74Dqec-De6-AVTxMPnXs-MSJBzSm1HfJfaHiKr8,3330
40
40
  notionary/page/content/page_content_retriever.py,sha256=f8IU1CIfSTTT07m72-vgpUr_VOCsisqqFHQ1JeOhb3g,2222
41
- notionary/page/content/page_content_writer.py,sha256=ZLqDBiYdkdCjgZgucoBGiUH8qM46zltIbJqu1aBqXzw,4425
41
+ notionary/page/content/page_content_writer.py,sha256=LOn70vFLOzPoCP2vqH922eNEh96B3cNiBuI3eDy8yLA,7439
42
42
  notionary/page/metadata/metadata_editor.py,sha256=HI7m8Zn_Lz6x36rBnW1EnbicVS-4Q8NmCJYKN-OlY-c,5130
43
43
  notionary/page/metadata/notion_icon_manager.py,sha256=6a9GS5sT0trfuAb0hlF2Cw_Wc1oM59a1QA4kO9asvMA,2576
44
44
  notionary/page/metadata/notion_page_cover_manager.py,sha256=gHQSA8EtO4gbkMt_C3nKc0DF44SY_4ycd57cJSihdqk,2215
@@ -50,12 +50,12 @@ notionary/page/relations/notion_page_relation_manager.py,sha256=tfkvLHClaYel_uEa
50
50
  notionary/page/relations/notion_page_title_resolver.py,sha256=dIjiEeHjjNT-DrIhz1nynkfHkMpUuJJFOEjb25Wy7f4,3575
51
51
  notionary/page/relations/page_database_relation.py,sha256=8lEp8fQjPwjWhA8nZu3k8mW6EEc54ki1Uwf4iUV1DOU,2245
52
52
  notionary/prompting/element_prompt_content.py,sha256=tHref-SKA81Ua_IQD2Km7y7BvFtHl74haSIjHNYE3FE,4403
53
- notionary/prompting/markdown_syntax_prompt_generator.py,sha256=WHTpftR7LdY-yA54TlicVwt6R-mhZ2OpUFmNKaCSG0I,5096
53
+ notionary/prompting/markdown_syntax_prompt_generator.py,sha256=_1qIYlqSfI6q6Fut10t6gGwTQuS8c3QBcC_5DBme9Mo,5084
54
54
  notionary/util/logging_mixin.py,sha256=b6wHj0IoVSWXbHh0yynfJlwvIR33G2qmaGNzrqyb7Gs,1825
55
55
  notionary/util/page_id_utils.py,sha256=EYNMxgf-7ghzL5K8lKZBZfW7g5CsdY0Xuj4IYmU8RPk,1381
56
56
  notionary/util/warn_direct_constructor_usage.py,sha256=vyJR73F95XVSRWIbyij-82IGOpAne9SBPM25eDpZfSU,1715
57
- notionary-0.2.7.dist-info/licenses/LICENSE,sha256=zOm3cRT1qD49eg7vgw95MI79rpUAZa1kRBFwL2FkAr8,1120
58
- notionary-0.2.7.dist-info/METADATA,sha256=KZbjwvx9HJQ2BCg5RnRgZrhOGa43r8QMOWF2yKwY1uA,7116
59
- notionary-0.2.7.dist-info/WHEEL,sha256=DnLRTWE75wApRYVsjgc6wsVswC54sMSJhAEd4xhDpBk,91
60
- notionary-0.2.7.dist-info/top_level.txt,sha256=fhONa6BMHQXqthx5PanWGbPL0b8rdFqhrJKVLf_adSs,10
61
- notionary-0.2.7.dist-info/RECORD,,
57
+ notionary-0.2.8.dist-info/licenses/LICENSE,sha256=zOm3cRT1qD49eg7vgw95MI79rpUAZa1kRBFwL2FkAr8,1120
58
+ notionary-0.2.8.dist-info/METADATA,sha256=RtbtcBYg8U9oqX64Tl2VD79bf199uwLZEqVD9QQ2jio,7521
59
+ notionary-0.2.8.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
60
+ notionary-0.2.8.dist-info/top_level.txt,sha256=fhONa6BMHQXqthx5PanWGbPL0b8rdFqhrJKVLf_adSs,10
61
+ notionary-0.2.8.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.4.0)
2
+ Generator: setuptools (80.8.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5