notionary 0.2.23__py3-none-any.whl → 0.2.24__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 (99) hide show
  1. notionary/__init__.py +1 -1
  2. notionary/blocks/__init__.py +3 -1
  3. notionary/blocks/audio/__init__.py +0 -2
  4. notionary/blocks/audio/audio_element.py +92 -49
  5. notionary/blocks/audio/audio_markdown_node.py +4 -17
  6. notionary/blocks/bookmark/__init__.py +0 -2
  7. notionary/blocks/bookmark/bookmark_markdown_node.py +5 -21
  8. notionary/blocks/breadcrumbs/__init__.py +0 -2
  9. notionary/blocks/breadcrumbs/breadcrumb_markdown_node.py +2 -21
  10. notionary/blocks/bulleted_list/__init__.py +0 -2
  11. notionary/blocks/bulleted_list/bulleted_list_markdown_node.py +3 -17
  12. notionary/blocks/bulleted_list/bulleted_list_models.py +0 -1
  13. notionary/blocks/callout/__init__.py +0 -2
  14. notionary/blocks/callout/callout_markdown_node.py +4 -18
  15. notionary/blocks/callout/callout_models.py +3 -4
  16. notionary/blocks/code/code_markdown_node.py +5 -19
  17. notionary/blocks/column/__init__.py +0 -4
  18. notionary/blocks/column/column_list_markdown_node.py +3 -19
  19. notionary/blocks/column/column_markdown_node.py +4 -21
  20. notionary/blocks/divider/__init__.py +0 -2
  21. notionary/blocks/divider/divider_markdown_node.py +2 -16
  22. notionary/blocks/embed/__init__.py +0 -2
  23. notionary/blocks/embed/embed_markdown_node.py +4 -17
  24. notionary/blocks/equation/__init__.py +0 -1
  25. notionary/blocks/equation/equation_element_markdown_node.py +3 -15
  26. notionary/blocks/file/__init__.py +0 -2
  27. notionary/blocks/file/file_element.py +67 -46
  28. notionary/blocks/file/file_element_markdown_node.py +4 -17
  29. notionary/blocks/heading/__init__.py +0 -2
  30. notionary/blocks/heading/heading_markdown_node.py +5 -19
  31. notionary/blocks/heading/heading_models.py +3 -3
  32. notionary/blocks/image_block/__init__.py +0 -2
  33. notionary/blocks/image_block/image_element.py +66 -25
  34. notionary/blocks/image_block/image_markdown_node.py +5 -20
  35. notionary/{markdown → blocks/markdown}/markdown_builder.py +29 -233
  36. notionary/blocks/markdown/markdown_node.py +25 -0
  37. notionary/blocks/mixins/file_upload/__init__.py +3 -0
  38. notionary/blocks/mixins/file_upload/file_upload_mixin.py +320 -0
  39. notionary/blocks/numbered_list/__init__.py +0 -1
  40. notionary/blocks/numbered_list/numbered_list_markdown_node.py +3 -17
  41. notionary/blocks/numbered_list/numbered_list_models.py +3 -3
  42. notionary/blocks/paragraph/__init__.py +0 -2
  43. notionary/blocks/paragraph/paragraph_markdown_node.py +3 -13
  44. notionary/blocks/pdf/__init__.py +0 -2
  45. notionary/blocks/pdf/pdf_element.py +81 -32
  46. notionary/blocks/pdf/pdf_markdown_node.py +5 -18
  47. notionary/blocks/quote/__init__.py +0 -2
  48. notionary/blocks/quote/quote_markdown_node.py +3 -13
  49. notionary/blocks/registry/__init__.py +1 -2
  50. notionary/blocks/registry/block_registry.py +116 -61
  51. notionary/blocks/table/__init__.py +0 -2
  52. notionary/blocks/table/table_markdown_node.py +17 -16
  53. notionary/blocks/table_of_contents/__init__.py +0 -2
  54. notionary/blocks/table_of_contents/table_of_contents_element.py +27 -15
  55. notionary/blocks/table_of_contents/table_of_contents_markdown_node.py +3 -17
  56. notionary/blocks/table_of_contents/table_of_contents_models.py +2 -2
  57. notionary/blocks/todo/__init__.py +0 -2
  58. notionary/blocks/todo/todo_markdown_node.py +9 -20
  59. notionary/blocks/todo/todo_models.py +2 -3
  60. notionary/blocks/toggle/__init__.py +0 -2
  61. notionary/blocks/toggle/toggle_markdown_node.py +5 -19
  62. notionary/blocks/toggleable_heading/__init__.py +0 -2
  63. notionary/blocks/toggleable_heading/toggleable_heading_markdown_node.py +6 -23
  64. notionary/blocks/video/__init__.py +0 -2
  65. notionary/blocks/video/video_element.py +110 -34
  66. notionary/blocks/video/video_markdown_node.py +4 -15
  67. notionary/comments/client.py +1 -1
  68. notionary/file_upload/client.py +3 -2
  69. notionary/file_upload/models.py +10 -1
  70. notionary/file_upload/notion_file_upload.py +5 -5
  71. notionary/page/markdown_whitespace_processor.py +129 -0
  72. notionary/page/notion_page.py +35 -40
  73. notionary/page/page_content_deleting_service.py +1 -1
  74. notionary/page/page_content_writer.py +32 -129
  75. notionary/page/page_context.py +0 -5
  76. notionary/page/reader/handler/column_list_renderer.py +2 -2
  77. notionary/page/reader/handler/column_renderer.py +2 -2
  78. notionary/page/reader/handler/line_renderer.py +2 -2
  79. notionary/page/reader/handler/toggle_renderer.py +2 -2
  80. notionary/page/reader/handler/toggleable_heading_renderer.py +2 -2
  81. notionary/page/writer/handler/toggle_handler.py +8 -4
  82. notionary/page/writer/handler/toggleable_heading_handler.py +3 -2
  83. notionary/page/writer/markdown_to_notion_converter.py +74 -30
  84. notionary/schemas/__init__.py +3 -0
  85. notionary/schemas/base.py +73 -0
  86. notionary/shared/__init__.py +1 -3
  87. {notionary-0.2.23.dist-info → notionary-0.2.24.dist-info}/METADATA +16 -1
  88. {notionary-0.2.23.dist-info → notionary-0.2.24.dist-info}/RECORD +91 -93
  89. notionary/blocks/guards.py +0 -22
  90. notionary/blocks/registry/block_registry_builder.py +0 -264
  91. notionary/markdown/makdown_document_model.py +0 -0
  92. notionary/markdown/markdown_document_model.py +0 -228
  93. notionary/markdown/markdown_node.py +0 -30
  94. notionary/models/notion_database_response.py +0 -0
  95. notionary/page/writer/markdown_to_notion_formatting_post_processor.py +0 -73
  96. notionary/page/writer/markdown_to_notion_post_processor.py +0 -0
  97. /notionary/{markdown/___init__.py → blocks/markdown/markdown_document_model.py} +0 -0
  98. {notionary-0.2.23.dist-info → notionary-0.2.24.dist-info}/LICENSE +0 -0
  99. {notionary-0.2.23.dist-info → notionary-0.2.24.dist-info}/WHEEL +0 -0
@@ -5,7 +5,7 @@ from io import BytesIO
5
5
  from pathlib import Path
6
6
  from typing import Optional
7
7
 
8
- from notionary.file_upload.models import FileUploadResponse
8
+ from notionary.file_upload.models import FileUploadResponse, UploadMode
9
9
  from notionary.util import LoggingMixin
10
10
 
11
11
 
@@ -196,7 +196,7 @@ class NotionFileUpload(LoggingMixin):
196
196
  filename=filename,
197
197
  content_type=content_type,
198
198
  content_length=file_size,
199
- mode="single_part",
199
+ mode=UploadMode.SINGLE_PART,
200
200
  )
201
201
 
202
202
  if not file_upload:
@@ -228,7 +228,7 @@ class NotionFileUpload(LoggingMixin):
228
228
  filename=filename,
229
229
  content_type=content_type,
230
230
  content_length=file_size,
231
- mode="multi_part",
231
+ mode=UploadMode.MULTI_PART,
232
232
  )
233
233
 
234
234
  if not file_upload:
@@ -269,7 +269,7 @@ class NotionFileUpload(LoggingMixin):
269
269
  filename=filename,
270
270
  content_type=content_type,
271
271
  content_length=file_size,
272
- mode="single_part",
272
+ mode=UploadMode.SINGLE_PART,
273
273
  )
274
274
 
275
275
  if not file_upload:
@@ -299,7 +299,7 @@ class NotionFileUpload(LoggingMixin):
299
299
  filename=filename,
300
300
  content_type=content_type,
301
301
  content_length=file_size,
302
- mode="multi_part",
302
+ mode=UploadMode.MULTI_PART,
303
303
  )
304
304
 
305
305
  if not file_upload:
@@ -0,0 +1,129 @@
1
+ """
2
+ Markdown whitespace processing utilities.
3
+
4
+ Handles normalization of markdown text while preserving code blocks and their indentation.
5
+ """
6
+
7
+ from typing import Tuple
8
+
9
+
10
+ class MarkdownWhitespaceProcessor:
11
+ """
12
+ Processes markdown text to normalize whitespace while preserving code block formatting.
13
+
14
+ This processor handles:
15
+ - Removing leading whitespace from regular lines
16
+ - Preserving code block structure and indentation
17
+ - Normalizing code block markers
18
+ """
19
+
20
+ @staticmethod
21
+ def process_markdown_whitespace(markdown_text: str) -> str:
22
+ """Process markdown text to normalize whitespace while preserving code blocks."""
23
+ lines = markdown_text.split("\n")
24
+ if not lines:
25
+ return ""
26
+
27
+ return MarkdownWhitespaceProcessor._process_whitespace_lines(lines)
28
+
29
+ @staticmethod
30
+ def _process_whitespace_lines(lines: list[str]) -> str:
31
+ """Process all lines and return the processed markdown."""
32
+ processed_lines = []
33
+ in_code_block = False
34
+ current_code_block = []
35
+
36
+ for line in lines:
37
+ processed_lines, in_code_block, current_code_block = (
38
+ MarkdownWhitespaceProcessor._process_single_line(
39
+ line, processed_lines, in_code_block, current_code_block
40
+ )
41
+ )
42
+
43
+ return "\n".join(processed_lines)
44
+
45
+ @staticmethod
46
+ def _process_single_line(
47
+ line: str,
48
+ processed_lines: list[str],
49
+ in_code_block: bool,
50
+ current_code_block: list[str],
51
+ ) -> Tuple[list[str], bool, list[str]]:
52
+ """Process a single line and return updated state."""
53
+ if MarkdownWhitespaceProcessor._is_code_block_marker(line):
54
+ return MarkdownWhitespaceProcessor._handle_code_block_marker(
55
+ line, processed_lines, in_code_block, current_code_block
56
+ )
57
+ if in_code_block:
58
+ current_code_block.append(line)
59
+ return processed_lines, in_code_block, current_code_block
60
+ else:
61
+ processed_lines.append(line.lstrip())
62
+ return processed_lines, in_code_block, current_code_block
63
+
64
+ @staticmethod
65
+ def _handle_code_block_marker(
66
+ line: str,
67
+ processed_lines: list[str],
68
+ in_code_block: bool,
69
+ current_code_block: list[str],
70
+ ) -> Tuple[list[str], bool, list[str]]:
71
+ """Handle code block start/end markers."""
72
+ if not in_code_block:
73
+ return MarkdownWhitespaceProcessor._start_code_block(line, processed_lines)
74
+ else:
75
+ return MarkdownWhitespaceProcessor._end_code_block(
76
+ processed_lines, current_code_block
77
+ )
78
+
79
+ @staticmethod
80
+ def _start_code_block(
81
+ line: str, processed_lines: list[str]
82
+ ) -> Tuple[list[str], bool, list[str]]:
83
+ """Start a new code block."""
84
+ processed_lines.append(
85
+ MarkdownWhitespaceProcessor._normalize_code_block_start(line)
86
+ )
87
+ return processed_lines, True, []
88
+
89
+ @staticmethod
90
+ def _end_code_block(
91
+ processed_lines: list[str], current_code_block: list[str]
92
+ ) -> Tuple[list[str], bool, list[str]]:
93
+ """End the current code block."""
94
+ processed_lines.extend(
95
+ MarkdownWhitespaceProcessor._normalize_code_block_content(
96
+ current_code_block
97
+ )
98
+ )
99
+ processed_lines.append("```")
100
+ return processed_lines, False, []
101
+
102
+ @staticmethod
103
+ def _is_code_block_marker(line: str) -> bool:
104
+ """Check if line is a code block marker."""
105
+ return line.lstrip().startswith("```")
106
+
107
+ @staticmethod
108
+ def _normalize_code_block_start(line: str) -> str:
109
+ """Normalize code block opening marker."""
110
+ language = line.lstrip().replace("```", "", 1).strip()
111
+ return "```" + language
112
+
113
+ @staticmethod
114
+ def _normalize_code_block_content(code_lines: list[str]) -> list[str]:
115
+ """Normalize code block indentation."""
116
+ if not code_lines:
117
+ return []
118
+
119
+ # Find minimum indentation from non-empty lines
120
+ non_empty_lines = [line for line in code_lines if line.strip()]
121
+ if not non_empty_lines:
122
+ return [""] * len(code_lines)
123
+
124
+ min_indent = min(len(line) - len(line.lstrip()) for line in non_empty_lines)
125
+ if min_indent == 0:
126
+ return code_lines
127
+
128
+ # Remove common indentation
129
+ return ["" if not line.strip() else line[min_indent:] for line in code_lines]
@@ -9,13 +9,16 @@ from notionary.comments import CommentClient, Comment
9
9
  from notionary.blocks.syntax_prompt_builder import SyntaxPromptBuilder
10
10
  from notionary.blocks.models import DatabaseParent
11
11
  from notionary.blocks.registry.block_registry import BlockRegistry
12
- from notionary.blocks.registry.block_registry_builder import BlockRegistryBuilder
13
12
  from notionary.database.client import NotionDatabaseClient
14
- from notionary.markdown.markdown_builder import MarkdownBuilder
13
+ from notionary.file_upload.client import NotionFileUploadClient
14
+ from notionary.blocks.markdown.markdown_builder import MarkdownBuilder
15
+ from notionary.schemas import NotionContentSchema
16
+ from notionary.page import page_context
15
17
  from notionary.page.client import NotionPageClient
16
18
  from notionary.page.models import NotionPageResponse
17
19
  from notionary.page.page_content_deleting_service import PageContentDeletingService
18
20
  from notionary.page.page_content_writer import PageContentWriter
21
+ from notionary.page.page_context import PageContextProvider, page_context
19
22
  from notionary.page.property_formatter import NotionPropertyFormatter
20
23
  from notionary.page.reader.page_content_retriever import PageContentRetriever
21
24
  from notionary.page.utils import extract_property_value
@@ -77,6 +80,8 @@ class NotionPage(LoggingMixin):
77
80
  block_registry=self.block_element_registry,
78
81
  )
79
82
 
83
+ self.page_context_provider = self._setup_page_context_provider()
84
+
80
85
  @classmethod
81
86
  async def from_page_id(
82
87
  cls, page_id: str, token: Optional[str] = None
@@ -205,20 +210,15 @@ class NotionPage(LoggingMixin):
205
210
  def is_in_trash(self) -> bool:
206
211
  return self._is_in_trash
207
212
 
208
- @property
209
- def block_registry_builder(self) -> BlockRegistryBuilder:
210
- """
211
- Returns the block registry builder for this page.
212
- """
213
- return self.block_element_registry.builder
214
-
215
213
  def get_prompt_information(self) -> str:
216
214
  markdown_syntax_builder = SyntaxPromptBuilder()
217
215
  return markdown_syntax_builder.build_concise_reference()
218
216
 
219
217
  async def get_comments(self) -> list[Comment]:
220
- return await self._comment_client.list_all_comments_for_page(page_id=self._page_id)
221
-
218
+ return await self._comment_client.list_all_comments_for_page(
219
+ page_id=self._page_id
220
+ )
221
+
222
222
  async def post_comment(
223
223
  self,
224
224
  content: str,
@@ -248,7 +248,9 @@ class NotionPage(LoggingMixin):
248
248
  self.logger.info(f"Successfully posted comment on page '{self._title}'")
249
249
  return comment
250
250
  except Exception as e:
251
- self.logger.error(f"Failed to post comment on page '{self._title}': {str(e)}")
251
+ self.logger.error(
252
+ f"Failed to post comment on page '{self._title}': {str(e)}"
253
+ )
252
254
  return None
253
255
 
254
256
  async def set_title(self, title: str) -> str:
@@ -272,43 +274,31 @@ class NotionPage(LoggingMixin):
272
274
 
273
275
  async def append_markdown(
274
276
  self,
275
- content: Union[str, Callable[[MarkdownBuilder], MarkdownBuilder]],
276
- *,
277
- prepend_table_of_contents: bool = False,
278
- append_divider: bool = False,
277
+ content: Union[
278
+ str, Callable[[MarkdownBuilder], MarkdownBuilder], NotionContentSchema
279
+ ],
279
280
  ) -> bool:
280
281
  """
281
- Append markdown content to the page.
282
-
283
- Args:
284
- content: Either raw markdown text OR a callback function that receives a MarkdownBuilder
285
- prepend_table_of_contents: Whether to prepend table of contents
286
- append_divider: Whether to append a divider
287
-
288
- Returns:
289
- bool: True if successful, False otherwise
282
+ Append markdown content to the page using text, builder callback, MarkdownDocumentModel, or NotionContentSchema.
290
283
  """
291
- result = await self._page_content_writer.append_markdown(
292
- content=content,
293
- append_divider=append_divider,
294
- prepend_table_of_contents=prepend_table_of_contents,
295
- )
296
- return result is not None
284
+ async with page_context(self.page_context_provider):
285
+ result = await self._page_content_writer.append_markdown(
286
+ content=content,
287
+ )
288
+ return result is not None
297
289
 
298
290
  async def replace_content(
299
291
  self,
300
- content: Union[str, Callable[[MarkdownBuilder], MarkdownBuilder]],
301
- *,
302
- prepend_table_of_contents: bool = False,
303
- append_divider: bool = False,
292
+ content: Union[
293
+ str, Callable[[MarkdownBuilder], MarkdownBuilder], NotionContentSchema
294
+ ],
304
295
  ) -> bool:
305
296
  """
306
297
  Replace the entire page content with new markdown content.
307
298
 
308
299
  Args:
309
- content: Either raw markdown text OR a callback function that receives a MarkdownBuilder
310
- prepend_table_of_contents: Whether to prepend table of contents
311
- append_divider: Whether to append a divider
300
+ content: Either raw markdown text, a callback function that receives a MarkdownBuilder,
301
+ a MarkdownDocumentModel, or a NotionContentSchema
312
302
 
313
303
  Returns:
314
304
  bool: True if successful, False otherwise
@@ -319,8 +309,6 @@ class NotionPage(LoggingMixin):
319
309
 
320
310
  result = await self._page_content_writer.append_markdown(
321
311
  content=content,
322
- prepend_table_of_contents=prepend_table_of_contents,
323
- append_divider=append_divider,
324
312
  )
325
313
  return result is not None
326
314
 
@@ -681,3 +669,10 @@ class NotionPage(LoggingMixin):
681
669
  parent = page_response.parent
682
670
  if isinstance(parent, DatabaseParent):
683
671
  return parent.database_id
672
+
673
+ def _setup_page_context_provider(self) -> PageContextProvider:
674
+ return PageContextProvider(
675
+ page_id=self._page_id,
676
+ database_client=NotionDatabaseClient(token=self._client.token),
677
+ file_upload_client=NotionFileUploadClient(),
678
+ )
@@ -27,7 +27,7 @@ class PageContentDeletingService(LoggingMixin):
27
27
  return None
28
28
 
29
29
  # Use PageContentRetriever for sophisticated markdown conversion
30
- deleted_content = self._content_retriever._convert_blocks_to_markdown(
30
+ deleted_content = await self._content_retriever._convert_blocks_to_markdown(
31
31
  children_response.results, indent_level=0
32
32
  )
33
33
 
@@ -1,10 +1,10 @@
1
1
  from typing import Callable, Optional, Union
2
2
 
3
3
  from notionary.blocks.client import NotionBlockClient
4
- from notionary.blocks.divider import DividerElement
5
4
  from notionary.blocks.registry.block_registry import BlockRegistry
6
- from notionary.blocks.table_of_contents import TableOfContentsElement
7
- from notionary.markdown.markdown_builder import MarkdownBuilder
5
+ from notionary.blocks.markdown.markdown_builder import MarkdownBuilder
6
+ from notionary.schemas.base import NotionContentSchema
7
+ from notionary.page.markdown_whitespace_processor import MarkdownWhitespaceProcessor
8
8
  from notionary.page.writer.markdown_to_notion_converter import MarkdownToNotionConverter
9
9
  from notionary.util import LoggingMixin
10
10
 
@@ -21,36 +21,18 @@ class PageContentWriter(LoggingMixin):
21
21
 
22
22
  async def append_markdown(
23
23
  self,
24
- content: Union[str, Callable[[MarkdownBuilder], MarkdownBuilder]],
25
- *,
26
- append_divider: bool = True,
27
- prepend_table_of_contents: bool = False,
24
+ content: Union[
25
+ str, Callable[[MarkdownBuilder], MarkdownBuilder], NotionContentSchema
26
+ ],
28
27
  ) -> Optional[str]:
29
28
  """
30
- Append markdown content to a Notion page using either text or builder callback.
29
+ Append markdown content to a Notion page using text, builder callback, MarkdownDocumentModel, or NotionContentSchema.
31
30
  """
31
+ markdown = self._extract_markdown_from_param(content)
32
32
 
33
- if isinstance(content, str):
34
- final_markdown = content
35
- elif callable(content):
36
- builder = MarkdownBuilder()
37
- content(builder)
38
- final_markdown = builder.build()
39
- else:
40
- raise ValueError(
41
- "content must be either a string or a callable that takes a MarkdownBuilder"
42
- )
43
-
44
- # Add optional components
45
- if prepend_table_of_contents:
46
- self._ensure_table_of_contents_exists_in_registry()
47
- final_markdown = "[toc]\n\n" + final_markdown
48
-
49
- if append_divider:
50
- self._ensure_divider_exists_in_registry()
51
- final_markdown = final_markdown + "\n\n---\n"
52
-
53
- processed_markdown = self._process_markdown_whitespace(final_markdown)
33
+ processed_markdown = MarkdownWhitespaceProcessor.process_markdown_whitespace(
34
+ markdown
35
+ )
54
36
 
55
37
  try:
56
38
  blocks = await self._markdown_to_notion_converter.convert(
@@ -72,106 +54,27 @@ class PageContentWriter(LoggingMixin):
72
54
  self.logger.error("Error appending markdown: %s", str(e), exc_info=True)
73
55
  return None
74
56
 
75
- def _process_markdown_whitespace(self, markdown_text: str) -> str:
76
- """Process markdown text to normalize whitespace while preserving code blocks."""
77
- lines = markdown_text.split("\n")
78
- if not lines:
79
- return ""
80
-
81
- return self._process_whitespace_lines(lines)
82
-
83
- def _process_whitespace_lines(self, lines: list[str]) -> str:
84
- """Process all lines and return the processed markdown."""
85
- processed_lines = []
86
- in_code_block = False
87
- current_code_block = []
88
-
89
- for line in lines:
90
- processed_lines, in_code_block, current_code_block = (
91
- self._process_single_line(
92
- line, processed_lines, in_code_block, current_code_block
93
- )
94
- )
95
-
96
- return "\n".join(processed_lines)
97
-
98
- def _process_single_line(
57
+ def _extract_markdown_from_param(
99
58
  self,
100
- line: str,
101
- processed_lines: list[str],
102
- in_code_block: bool,
103
- current_code_block: list[str],
104
- ) -> tuple[list[str], bool, list[str]]:
105
- """Process a single line and return updated state."""
106
- if self._is_code_block_marker(line):
107
- return self._handle_code_block_marker(
108
- line, processed_lines, in_code_block, current_code_block
109
- )
110
- if in_code_block:
111
- current_code_block.append(line)
112
- return processed_lines, in_code_block, current_code_block
113
- else:
114
- processed_lines.append(line.lstrip())
115
- return processed_lines, in_code_block, current_code_block
59
+ content: Union[
60
+ str, Callable[[MarkdownBuilder], MarkdownBuilder], NotionContentSchema
61
+ ],
62
+ ) -> str:
63
+ """
64
+ Prepare markdown content from string, builder callback, MarkdownDocumentModel, or NotionContentSchema.
65
+ """
66
+ if isinstance(content, str):
67
+ return content
68
+ elif isinstance(content, NotionContentSchema):
69
+ # Use new injection-based API
70
+ builder = MarkdownBuilder()
71
+ return content.to_notion_content(builder)
116
72
 
117
- def _handle_code_block_marker(
118
- self,
119
- line: str,
120
- processed_lines: list[str],
121
- in_code_block: bool,
122
- current_code_block: list[str],
123
- ) -> tuple[list[str], bool, list[str]]:
124
- """Handle code block start/end markers."""
125
- if not in_code_block:
126
- return self._start_code_block(line, processed_lines)
73
+ elif callable(content):
74
+ builder = MarkdownBuilder()
75
+ content(builder)
76
+ return builder.build()
127
77
  else:
128
- return self._end_code_block(processed_lines, current_code_block)
129
-
130
- def _start_code_block(
131
- self, line: str, processed_lines: list[str]
132
- ) -> tuple[list[str], bool, list[str]]:
133
- """Start a new code block."""
134
- processed_lines.append(self._normalize_code_block_start(line))
135
- return processed_lines, True, []
136
-
137
- def _end_code_block(
138
- self, processed_lines: list[str], current_code_block: list[str]
139
- ) -> tuple[list[str], bool, list[str]]:
140
- """End the current code block."""
141
- processed_lines.extend(self._normalize_code_block_content(current_code_block))
142
- processed_lines.append("```")
143
- return processed_lines, False, []
144
-
145
- def _is_code_block_marker(self, line: str) -> bool:
146
- """Check if line is a code block marker."""
147
- return line.lstrip().startswith("```")
148
-
149
- def _normalize_code_block_start(self, line: str) -> str:
150
- """Normalize code block opening marker."""
151
- language = line.lstrip().replace("```", "", 1).strip()
152
- return "```" + language
153
-
154
- def _normalize_code_block_content(self, code_lines: list[str]) -> list[str]:
155
- """Normalize code block indentation."""
156
- if not code_lines:
157
- return []
158
-
159
- # Find minimum indentation from non-empty lines
160
- non_empty_lines = [line for line in code_lines if line.strip()]
161
- if not non_empty_lines:
162
- return [""] * len(code_lines)
163
-
164
- min_indent = min(len(line) - len(line.lstrip()) for line in non_empty_lines)
165
- if min_indent == 0:
166
- return code_lines
167
-
168
- # Remove common indentation
169
- return ["" if not line.strip() else line[min_indent:] for line in code_lines]
170
-
171
- def _ensure_table_of_contents_exists_in_registry(self) -> None:
172
- """Ensure TableOfContents is registered in the block registry."""
173
- self.block_registry.register(TableOfContentsElement)
174
-
175
- def _ensure_divider_exists_in_registry(self) -> None:
176
- """Ensure DividerBlock is registered in the block registry."""
177
- self.block_registry.register(DividerElement)
78
+ raise ValueError(
79
+ "content must be either a string, a NotionContentSchema, a MarkdownDocumentModel, or a callable that takes a MarkdownBuilder"
80
+ )
@@ -34,11 +34,6 @@ def get_page_context() -> PageContextProvider:
34
34
  return context
35
35
 
36
36
 
37
- def get_page_context_optional() -> Optional[PageContextProvider]:
38
- """Get current page context or None if not available."""
39
- return _page_context.get()
40
-
41
-
42
37
  class page_context:
43
38
  """Async-only context manager for page operations."""
44
39
 
@@ -8,7 +8,7 @@ class ColumnListRenderer(BlockHandler):
8
8
  def _can_handle(self, context: BlockRenderingContext) -> bool:
9
9
  return ColumnListElement.match_notion(context.block)
10
10
 
11
- def _process(self, context: BlockRenderingContext) -> None:
11
+ async def _process(self, context: BlockRenderingContext) -> None:
12
12
  # Create column list start line
13
13
  column_list_start = "::: columns"
14
14
 
@@ -28,7 +28,7 @@ class ColumnListRenderer(BlockHandler):
28
28
 
29
29
  # Create a temporary retriever to process children
30
30
  retriever = PageContentRetriever(context.block_registry)
31
- children_markdown = retriever._convert_blocks_to_markdown(
31
+ children_markdown = await retriever._convert_blocks_to_markdown(
32
32
  context.get_children_blocks(),
33
33
  indent_level=0, # No indentation for content inside column lists
34
34
  )
@@ -8,7 +8,7 @@ class ColumnRenderer(BlockHandler):
8
8
  def _can_handle(self, context: BlockRenderingContext) -> bool:
9
9
  return ColumnElement.match_notion(context.block)
10
10
 
11
- def _process(self, context: BlockRenderingContext) -> None:
11
+ async def _process(self, context: BlockRenderingContext) -> None:
12
12
  # Get the column start line with potential width ratio
13
13
  column_start = self._extract_column_start(context.block)
14
14
 
@@ -28,7 +28,7 @@ class ColumnRenderer(BlockHandler):
28
28
 
29
29
  # Create a temporary retriever to process children
30
30
  retriever = PageContentRetriever(context.block_registry)
31
- children_markdown = retriever._convert_blocks_to_markdown(
31
+ children_markdown = await retriever._convert_blocks_to_markdown(
32
32
  context.get_children_blocks(),
33
33
  indent_level=0, # No indentation for content inside columns
34
34
  )
@@ -29,7 +29,7 @@ class LineRenderer(BlockHandler):
29
29
  )
30
30
 
31
31
  retriever = PageContentRetriever(context.block_registry)
32
- children_markdown = retriever._convert_blocks_to_markdown(
32
+ children_markdown = await retriever._convert_blocks_to_markdown(
33
33
  context.get_children_blocks(), indent_level=context.indent_level + 1
34
34
  )
35
35
  context.markdown_result = children_markdown
@@ -52,7 +52,7 @@ class LineRenderer(BlockHandler):
52
52
  from notionary.page.reader.page_content_retriever import PageContentRetriever
53
53
 
54
54
  retriever = PageContentRetriever(context.block_registry)
55
- children_markdown = retriever._convert_blocks_to_markdown(
55
+ children_markdown = await retriever._convert_blocks_to_markdown(
56
56
  context.get_children_blocks(), indent_level=context.indent_level + 1
57
57
  )
58
58
 
@@ -8,7 +8,7 @@ class ToggleRenderer(BlockHandler):
8
8
  def _can_handle(self, context: BlockRenderingContext) -> bool:
9
9
  return ToggleElement.match_notion(context.block)
10
10
 
11
- def _process(self, context: BlockRenderingContext) -> None:
11
+ async def _process(self, context: BlockRenderingContext) -> None:
12
12
  # Get the toggle title from the block
13
13
  toggle_title = self._extract_toggle_title(context.block)
14
14
 
@@ -34,7 +34,7 @@ class ToggleRenderer(BlockHandler):
34
34
 
35
35
  # Create a temporary retriever to process children
36
36
  retriever = PageContentRetriever(context.block_registry)
37
- children_markdown = retriever._convert_blocks_to_markdown(
37
+ children_markdown = await retriever._convert_blocks_to_markdown(
38
38
  context.get_children_blocks(),
39
39
  indent_level=0, # No indentation for content inside toggles
40
40
  )
@@ -11,7 +11,7 @@ class ToggleableHeadingRenderer(BlockHandler):
11
11
  def _can_handle(self, context: BlockRenderingContext) -> bool:
12
12
  return ToggleableHeadingElement.match_notion(context.block)
13
13
 
14
- def _process(self, context: BlockRenderingContext) -> None:
14
+ async def _process(self, context: BlockRenderingContext) -> None:
15
15
  # Get the heading level and title
16
16
  level, title = self._extract_heading_info(context.block)
17
17
 
@@ -38,7 +38,7 @@ class ToggleableHeadingRenderer(BlockHandler):
38
38
 
39
39
  # Create a temporary retriever to process children
40
40
  retriever = PageContentRetriever(context.block_registry)
41
- children_markdown = retriever._convert_blocks_to_markdown(
41
+ children_markdown = await retriever._convert_blocks_to_markdown(
42
42
  context.get_children_blocks(),
43
43
  indent_level=0, # No indentation for content inside toggleable headings
44
44
  )
@@ -15,7 +15,8 @@ class ToggleHandler(LineHandler):
15
15
 
16
16
  def __init__(self):
17
17
  super().__init__()
18
- self._start_pattern = re.compile(r"^[+]{3}\s+(.+)$", re.IGNORECASE)
18
+ # Updated: Support both "+++title" and "+++ title"
19
+ self._start_pattern = re.compile(r"^[+]{3}\s*(.+)$", re.IGNORECASE)
19
20
  self._end_pattern = re.compile(r"^[+]{3}\s*$")
20
21
 
21
22
  def _can_handle(self, context: LineProcessingContext) -> bool:
@@ -43,15 +44,18 @@ class ToggleHandler(LineHandler):
43
44
  context.should_continue = True
44
45
 
45
46
  def _is_toggle_start(self, context: LineProcessingContext) -> bool:
46
- """Check if line starts a toggle (+++ Title)."""
47
+ """Check if line starts a toggle (+++ Title or +++Title)."""
47
48
  line = context.line.strip()
48
49
 
49
- # Must match our pattern
50
+ # Must match our pattern (now allows optional space)
50
51
  if not self._start_pattern.match(line):
51
52
  return False
52
53
 
53
54
  # But NOT match toggleable heading pattern (has # after +++)
54
- toggleable_heading_pattern = re.compile(r"^[+]{3}#{1,3}\s+.+$", re.IGNORECASE)
55
+ # Updated: Support both "+++#title" and "+++ # title"
56
+ toggleable_heading_pattern = re.compile(
57
+ r"^[+]{3}\s*#{1,3}\s+.+$", re.IGNORECASE
58
+ )
55
59
  if toggleable_heading_pattern.match(line):
56
60
  return False
57
61
 
@@ -19,8 +19,9 @@ class ToggleableHeadingHandler(LineHandler):
19
19
 
20
20
  def __init__(self):
21
21
  super().__init__()
22
+ # Updated: Support both "+++# title" and "+++#title"
22
23
  self._start_pattern = re.compile(
23
- r"^[+]{3}(?P<level>#{1,3})\s+(.+)$", re.IGNORECASE
24
+ r"^[+]{3}\s*(?P<level>#{1,3})\s*(.+)$", re.IGNORECASE
24
25
  )
25
26
  # +++
26
27
  self._end_pattern = re.compile(r"^[+]{3}\s*$")
@@ -49,7 +50,7 @@ class ToggleableHeadingHandler(LineHandler):
49
50
  return await _handle(self._add_toggleable_heading_content)
50
51
 
51
52
  def _is_toggleable_heading_start(self, context: LineProcessingContext) -> bool:
52
- """Check if line starts a toggleable heading (+++# "Title")."""
53
+ """Check if line starts a toggleable heading (+++# "Title" or +++#"Title")."""
53
54
  return self._start_pattern.match(context.line.strip()) is not None
54
55
 
55
56
  def _is_toggleable_heading_end(self, context: LineProcessingContext) -> bool: