notionary 0.2.21__py3-none-any.whl → 0.2.23__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 (100) hide show
  1. notionary/blocks/_bootstrap.py +9 -1
  2. notionary/blocks/audio/audio_element.py +53 -28
  3. notionary/blocks/audio/audio_markdown_node.py +10 -4
  4. notionary/blocks/base_block_element.py +15 -3
  5. notionary/blocks/bookmark/bookmark_element.py +39 -36
  6. notionary/blocks/bookmark/bookmark_markdown_node.py +16 -17
  7. notionary/blocks/breadcrumbs/breadcrumb_element.py +2 -2
  8. notionary/blocks/bulleted_list/bulleted_list_element.py +21 -4
  9. notionary/blocks/callout/callout_element.py +20 -4
  10. notionary/blocks/child_database/__init__.py +11 -4
  11. notionary/blocks/child_database/child_database_element.py +59 -0
  12. notionary/blocks/child_database/child_database_models.py +7 -14
  13. notionary/blocks/child_page/child_page_element.py +94 -0
  14. notionary/blocks/client.py +0 -1
  15. notionary/blocks/code/code_element.py +51 -2
  16. notionary/blocks/code/code_markdown_node.py +52 -1
  17. notionary/blocks/column/column_element.py +9 -3
  18. notionary/blocks/column/column_list_element.py +18 -3
  19. notionary/blocks/divider/divider_element.py +3 -11
  20. notionary/blocks/embed/embed_element.py +27 -6
  21. notionary/blocks/equation/equation_element.py +94 -41
  22. notionary/blocks/equation/equation_element_markdown_node.py +8 -9
  23. notionary/blocks/file/file_element.py +56 -37
  24. notionary/blocks/file/file_element_markdown_node.py +9 -7
  25. notionary/blocks/guards.py +22 -0
  26. notionary/blocks/heading/heading_element.py +23 -4
  27. notionary/blocks/image_block/image_element.py +43 -38
  28. notionary/blocks/image_block/image_markdown_node.py +10 -5
  29. notionary/blocks/mixins/captions/__init__.py +4 -0
  30. notionary/blocks/mixins/captions/caption_markdown_node_mixin.py +31 -0
  31. notionary/blocks/mixins/captions/caption_mixin.py +92 -0
  32. notionary/blocks/models.py +3 -1
  33. notionary/blocks/numbered_list/numbered_list_element.py +21 -4
  34. notionary/blocks/paragraph/paragraph_element.py +21 -5
  35. notionary/blocks/pdf/pdf_element.py +47 -41
  36. notionary/blocks/pdf/pdf_markdown_node.py +9 -7
  37. notionary/blocks/quote/quote_element.py +26 -9
  38. notionary/blocks/quote/quote_markdown_node.py +2 -2
  39. notionary/blocks/registry/block_registry.py +1 -46
  40. notionary/blocks/registry/block_registry_builder.py +8 -0
  41. notionary/blocks/rich_text/rich_text_models.py +62 -29
  42. notionary/blocks/rich_text/text_inline_formatter.py +432 -101
  43. notionary/blocks/syntax_prompt_builder.py +137 -0
  44. notionary/blocks/table/table_element.py +110 -9
  45. notionary/blocks/table_of_contents/table_of_contents_element.py +19 -2
  46. notionary/blocks/todo/todo_element.py +21 -4
  47. notionary/blocks/toggle/toggle_element.py +19 -3
  48. notionary/blocks/toggle/toggle_markdown_node.py +1 -1
  49. notionary/blocks/toggleable_heading/toggleable_heading_element.py +19 -4
  50. notionary/blocks/types.py +69 -0
  51. notionary/blocks/video/video_element.py +44 -39
  52. notionary/blocks/video/video_markdown_node.py +10 -5
  53. notionary/comments/__init__.py +26 -0
  54. notionary/comments/client.py +211 -0
  55. notionary/comments/models.py +129 -0
  56. notionary/database/client.py +23 -0
  57. notionary/file_upload/models.py +2 -2
  58. notionary/markdown/markdown_builder.py +34 -27
  59. notionary/page/client.py +21 -6
  60. notionary/page/notion_page.py +77 -2
  61. notionary/page/page_content_deleting_service.py +117 -0
  62. notionary/page/page_content_writer.py +89 -113
  63. notionary/page/page_context.py +64 -0
  64. notionary/page/reader/handler/__init__.py +2 -0
  65. notionary/page/reader/handler/base_block_renderer.py +4 -4
  66. notionary/page/reader/handler/block_rendering_context.py +5 -0
  67. notionary/page/reader/handler/line_renderer.py +16 -3
  68. notionary/page/reader/handler/numbered_list_renderer.py +85 -0
  69. notionary/page/reader/page_content_retriever.py +17 -5
  70. notionary/page/writer/handler/__init__.py +2 -0
  71. notionary/page/writer/handler/code_handler.py +12 -40
  72. notionary/page/writer/handler/column_handler.py +12 -12
  73. notionary/page/writer/handler/column_list_handler.py +13 -13
  74. notionary/page/writer/handler/equation_handler.py +74 -0
  75. notionary/page/writer/handler/line_handler.py +4 -4
  76. notionary/page/writer/handler/regular_line_handler.py +31 -37
  77. notionary/page/writer/handler/table_handler.py +8 -72
  78. notionary/page/writer/handler/toggle_handler.py +14 -12
  79. notionary/page/writer/handler/toggleable_heading_handler.py +22 -16
  80. notionary/page/writer/markdown_to_notion_converter.py +28 -9
  81. notionary/page/writer/markdown_to_notion_converter_context.py +30 -0
  82. notionary/page/writer/markdown_to_notion_formatting_post_processor.py +73 -0
  83. notionary/page/writer/markdown_to_notion_post_processor.py +0 -0
  84. notionary/page/writer/markdown_to_notion_text_length_post_processor.py +0 -0
  85. notionary/page/writer/notion_text_length_processor.py +150 -0
  86. notionary/shared/__init__.py +5 -0
  87. notionary/shared/name_to_id_resolver.py +203 -0
  88. notionary/telemetry/service.py +0 -1
  89. notionary/user/notion_user_manager.py +22 -95
  90. notionary/util/concurrency_limiter.py +0 -0
  91. notionary/workspace.py +4 -4
  92. notionary-0.2.23.dist-info/METADATA +235 -0
  93. {notionary-0.2.21.dist-info → notionary-0.2.23.dist-info}/RECORD +96 -77
  94. notionary/page/markdown_whitespace_processor.py +0 -80
  95. notionary/page/notion_text_length_utils.py +0 -119
  96. notionary/user/notion_user_provider.py +0 -1
  97. notionary-0.2.21.dist-info/METADATA +0 -229
  98. /notionary/page/reader/handler/{context.py → equation_renderer.py} +0 -0
  99. {notionary-0.2.21.dist-info → notionary-0.2.23.dist-info}/LICENSE +0 -0
  100. {notionary-0.2.21.dist-info → notionary-0.2.23.dist-info}/WHEEL +0 -0
notionary/page/client.py CHANGED
@@ -20,18 +20,33 @@ class NotionPageClient(BaseNotionClient):
20
20
 
21
21
  async def create_page(
22
22
  self,
23
+ *,
23
24
  parent_database_id: Optional[str] = None,
24
- properties: Optional[dict[str, Any]] = None,
25
+ parent_page_id: Optional[str] = None,
26
+ title: str,
25
27
  ) -> NotionPageResponse:
26
28
  """
27
- Creates a new page in a Notion database or as a child page.
29
+ Creates a new page either in a database or as a child of another page.
30
+ Exactly one of parent_database_id or parent_page_id must be provided.
31
+ Only 'title' is supported here (no icon/cover/children).
28
32
  """
29
- page_data = {
30
- "parent": {"database_id": parent_database_id} if parent_database_id else {},
31
- "properties": properties or {},
33
+ # Exakt einen Parent zulassen
34
+ if (parent_database_id is None) == (parent_page_id is None):
35
+ raise ValueError("Specify exactly one parent: database OR page")
36
+
37
+ # Parent bauen
38
+ parent = (
39
+ {"database_id": parent_database_id}
40
+ if parent_database_id
41
+ else {"page_id": parent_page_id}
42
+ )
43
+
44
+ properties: dict[str, Any] = {
45
+ "title": {"title": [{"type": "text", "text": {"content": title}}]}
32
46
  }
33
47
 
34
- response = await self.post("pages", page_data)
48
+ payload = {"parent": parent, "properties": properties}
49
+ response = await self.post("pages", payload)
35
50
  return NotionPageResponse.model_validate(response)
36
51
 
37
52
  async def patch_page(
@@ -5,12 +5,16 @@ import random
5
5
  from typing import TYPE_CHECKING, Any, Callable, Optional, Union
6
6
 
7
7
  from notionary.blocks.client import NotionBlockClient
8
+ from notionary.comments import CommentClient, Comment
9
+ from notionary.blocks.syntax_prompt_builder import SyntaxPromptBuilder
8
10
  from notionary.blocks.models import DatabaseParent
9
11
  from notionary.blocks.registry.block_registry import BlockRegistry
10
12
  from notionary.blocks.registry.block_registry_builder import BlockRegistryBuilder
13
+ from notionary.database.client import NotionDatabaseClient
11
14
  from notionary.markdown.markdown_builder import MarkdownBuilder
12
15
  from notionary.page.client import NotionPageClient
13
16
  from notionary.page.models import NotionPageResponse
17
+ from notionary.page.page_content_deleting_service import PageContentDeletingService
14
18
  from notionary.page.page_content_writer import PageContentWriter
15
19
  from notionary.page.property_formatter import NotionPropertyFormatter
16
20
  from notionary.page.reader.page_content_retriever import PageContentRetriever
@@ -54,6 +58,7 @@ class NotionPage(LoggingMixin):
54
58
 
55
59
  self._client = NotionPageClient(token=token)
56
60
  self._block_client = NotionBlockClient(token=token)
61
+ self._comment_client = CommentClient(token=token)
57
62
  self._page_data = None
58
63
 
59
64
  self.block_element_registry = BlockRegistry.create_registry()
@@ -63,6 +68,11 @@ class NotionPage(LoggingMixin):
63
68
  block_registry=self.block_element_registry,
64
69
  )
65
70
 
71
+ self._page_content_deleting_service = PageContentDeletingService(
72
+ page_id=self._page_id,
73
+ block_registry=self.block_element_registry,
74
+ )
75
+
66
76
  self._page_content_retriever = PageContentRetriever(
67
77
  block_registry=self.block_element_registry,
68
78
  )
@@ -202,6 +212,45 @@ class NotionPage(LoggingMixin):
202
212
  """
203
213
  return self.block_element_registry.builder
204
214
 
215
+ def get_prompt_information(self) -> str:
216
+ markdown_syntax_builder = SyntaxPromptBuilder()
217
+ return markdown_syntax_builder.build_concise_reference()
218
+
219
+ async def get_comments(self) -> list[Comment]:
220
+ return await self._comment_client.list_all_comments_for_page(page_id=self._page_id)
221
+
222
+ async def post_comment(
223
+ self,
224
+ content: str,
225
+ *,
226
+ discussion_id: Optional[str] = None,
227
+ rich_text: Optional[list[dict[str, Any]]] = None,
228
+ ) -> Optional[Comment]:
229
+ """
230
+ Post a comment on this page.
231
+
232
+ Args:
233
+ content: The plain text content of the comment
234
+ discussion_id: Optional discussion ID to reply to an existing discussion
235
+ rich_text: Optional rich text formatting for the comment content
236
+
237
+ Returns:
238
+ Comment: The created comment object, or None if creation failed
239
+ """
240
+ try:
241
+ # Use the comment client to create the comment
242
+ comment = await self._comment_client.create_comment(
243
+ page_id=self._page_id,
244
+ content=content,
245
+ discussion_id=discussion_id,
246
+ rich_text=rich_text,
247
+ )
248
+ self.logger.info(f"Successfully posted comment on page '{self._title}'")
249
+ return comment
250
+ except Exception as e:
251
+ self.logger.error(f"Failed to post comment on page '{self._title}': {str(e)}")
252
+ return None
253
+
205
254
  async def set_title(self, title: str) -> str:
206
255
  """
207
256
  Set the title of the page.
@@ -264,7 +313,7 @@ class NotionPage(LoggingMixin):
264
313
  Returns:
265
314
  bool: True if successful, False otherwise
266
315
  """
267
- clear_result = await self._page_content_writer.clear_page_content()
316
+ clear_result = await self._page_content_deleting_service.clear_page_content()
268
317
  if not clear_result:
269
318
  self.logger.error("Failed to clear page content before replacement")
270
319
 
@@ -279,7 +328,7 @@ class NotionPage(LoggingMixin):
279
328
  """
280
329
  Clear all content from the page.
281
330
  """
282
- return await self._page_content_writer.clear_page_content()
331
+ return await self._page_content_deleting_service.clear_page_content()
283
332
 
284
333
  async def get_text_content(self) -> str:
285
334
  """
@@ -310,6 +359,32 @@ class NotionPage(LoggingMixin):
310
359
  self.logger.error(f"Error updating page emoji: {str(e)}")
311
360
  return None
312
361
 
362
+ async def create_child_database(self, title: str) -> NotionDatabase:
363
+ from notionary import NotionDatabase
364
+
365
+ database_client = NotionDatabaseClient(token=self._client.token)
366
+
367
+ create_database_response = await database_client.create_database(
368
+ title=title,
369
+ parent_page_id=self._page_id,
370
+ )
371
+
372
+ return await NotionDatabase.from_database_id(
373
+ id=create_database_response.id, token=self._client.token
374
+ )
375
+
376
+ async def create_child_page(self, title: str) -> NotionPage:
377
+ from notionary import NotionPage
378
+
379
+ child_page_response = await self._client.create_page(
380
+ parent_page_id=self._page_id,
381
+ title=title,
382
+ )
383
+
384
+ return await NotionPage.from_page_id(
385
+ page_id=child_page_response.id, token=self._client.token
386
+ )
387
+
313
388
  async def set_external_icon(self, url: str) -> Optional[str]:
314
389
  """
315
390
  Sets the page icon to an external image.
@@ -0,0 +1,117 @@
1
+ from typing import Optional
2
+
3
+ from notionary.blocks.client import NotionBlockClient
4
+ from notionary.blocks.models import Block
5
+ from notionary.blocks.registry.block_registry import BlockRegistry
6
+ from notionary.page.reader.page_content_retriever import PageContentRetriever
7
+ from notionary.util import LoggingMixin
8
+
9
+
10
+ class PageContentDeletingService(LoggingMixin):
11
+ """Service responsible for deleting page content and blocks."""
12
+
13
+ def __init__(self, page_id: str, block_registry: BlockRegistry):
14
+ self.page_id = page_id
15
+ self.block_registry = block_registry
16
+ self._block_client = NotionBlockClient()
17
+ self._content_retriever = PageContentRetriever(block_registry=block_registry)
18
+
19
+ async def clear_page_content(self) -> Optional[str]:
20
+ """Clear all content of the page and return deleted content as markdown."""
21
+ try:
22
+ children_response = await self._block_client.get_block_children(
23
+ block_id=self.page_id
24
+ )
25
+
26
+ if not children_response or not children_response.results:
27
+ return None
28
+
29
+ # Use PageContentRetriever for sophisticated markdown conversion
30
+ deleted_content = self._content_retriever._convert_blocks_to_markdown(
31
+ children_response.results, indent_level=0
32
+ )
33
+
34
+ # Delete blocks
35
+ success = True
36
+ for block in children_response.results:
37
+ block_success = await self._delete_block_with_children(block)
38
+ if not block_success:
39
+ success = False
40
+
41
+ if not success:
42
+ self.logger.warning("Some blocks could not be deleted")
43
+
44
+ return deleted_content if deleted_content else None
45
+
46
+ except Exception:
47
+ self.logger.error("Error clearing page content", exc_info=True)
48
+ return None
49
+
50
+ async def _delete_block_with_children(self, block: Block) -> bool:
51
+ """Delete a block and all its children recursively."""
52
+ if not block.id:
53
+ self.logger.error("Block has no valid ID")
54
+ return False
55
+
56
+ self.logger.debug("Deleting block: %s (type: %s)", block.id, block.type)
57
+
58
+ try:
59
+ if block.has_children and not await self._delete_block_children(block):
60
+ return False
61
+
62
+ return await self._delete_single_block(block)
63
+
64
+ except Exception as e:
65
+ self.logger.error("Failed to delete block %s: %s", block.id, str(e))
66
+ return False
67
+
68
+ async def _delete_block_children(self, block: Block) -> bool:
69
+ """Delete all children of a block."""
70
+ self.logger.debug("Block %s has children, deleting children first", block.id)
71
+
72
+ try:
73
+ children_blocks = await self._block_client.get_all_block_children(block.id)
74
+
75
+ if not children_blocks:
76
+ self.logger.debug("No children found for block: %s", block.id)
77
+ return True
78
+
79
+ self.logger.debug(
80
+ "Found %d children to delete for block: %s",
81
+ len(children_blocks),
82
+ block.id,
83
+ )
84
+
85
+ # Delete all children recursively
86
+ for child_block in children_blocks:
87
+ if not await self._delete_block_with_children(child_block):
88
+ self.logger.error(
89
+ "Failed to delete child block: %s", child_block.id
90
+ )
91
+ return False
92
+
93
+ self.logger.debug(
94
+ "Successfully deleted all children of block: %s", block.id
95
+ )
96
+ return True
97
+
98
+ except Exception as e:
99
+ self.logger.error(
100
+ "Failed to delete children of block %s: %s", block.id, str(e)
101
+ )
102
+ return False
103
+
104
+ async def _delete_single_block(self, block: Block) -> bool:
105
+ """Delete a single block."""
106
+ deleted_block: Optional[Block] = await self._block_client.delete_block(block.id)
107
+
108
+ if deleted_block is None:
109
+ self.logger.error("Failed to delete block: %s", block.id)
110
+ return False
111
+
112
+ if deleted_block.archived or deleted_block.in_trash:
113
+ self.logger.debug("Successfully deleted/archived block: %s", block.id)
114
+ return True
115
+ else:
116
+ self.logger.warning("Block %s was not properly archived/deleted", block.id)
117
+ return False
@@ -2,12 +2,9 @@ from typing import Callable, Optional, Union
2
2
 
3
3
  from notionary.blocks.client import NotionBlockClient
4
4
  from notionary.blocks.divider import DividerElement
5
- from notionary.blocks.models import Block
6
5
  from notionary.blocks.registry.block_registry import BlockRegistry
7
6
  from notionary.blocks.table_of_contents import TableOfContentsElement
8
7
  from notionary.markdown.markdown_builder import MarkdownBuilder
9
- from notionary.page.markdown_whitespace_processor import MarkdownWhitespaceProcessor
10
- from notionary.page.reader.page_content_retriever import PageContentRetriever
11
8
  from notionary.page.writer.markdown_to_notion_converter import MarkdownToNotionConverter
12
9
  from notionary.util import LoggingMixin
13
10
 
@@ -22,8 +19,6 @@ class PageContentWriter(LoggingMixin):
22
19
  block_registry=block_registry
23
20
  )
24
21
 
25
- self._content_retriever = PageContentRetriever(block_registry=block_registry)
26
-
27
22
  async def append_markdown(
28
23
  self,
29
24
  content: Union[str, Callable[[MarkdownBuilder], MarkdownBuilder]],
@@ -33,14 +28,6 @@ class PageContentWriter(LoggingMixin):
33
28
  ) -> Optional[str]:
34
29
  """
35
30
  Append markdown content to a Notion page using either text or builder callback.
36
-
37
- Args:
38
- content: Either raw markdown text OR a callback function that receives a MarkdownBuilder
39
- append_divider: Whether to append a divider
40
- prepend_table_of_contents: Whether to prepend table of contents
41
-
42
- Returns:
43
- str: The processed markdown content that was appended (None if failed)
44
31
  """
45
32
 
46
33
  if isinstance(content, str):
@@ -66,7 +53,9 @@ class PageContentWriter(LoggingMixin):
66
53
  processed_markdown = self._process_markdown_whitespace(final_markdown)
67
54
 
68
55
  try:
69
- blocks = self._markdown_to_notion_converter.convert(processed_markdown)
56
+ blocks = await self._markdown_to_notion_converter.convert(
57
+ processed_markdown
58
+ )
70
59
 
71
60
  result = await self._block_client.append_block_children(
72
61
  block_id=self.page_id, children=blocks
@@ -83,114 +72,101 @@ class PageContentWriter(LoggingMixin):
83
72
  self.logger.error("Error appending markdown: %s", str(e), exc_info=True)
84
73
  return None
85
74
 
86
- async def clear_page_content(self) -> Optional[str]:
87
- """Clear all content of the page and return deleted content as markdown."""
88
- try:
89
- children_response = await self._block_client.get_block_children(
90
- block_id=self.page_id
91
- )
92
-
93
- if not children_response or not children_response.results:
94
- return None
95
-
96
- # Use PageContentRetriever for sophisticated markdown conversion
97
- deleted_content = self._content_retriever._convert_blocks_to_markdown(
98
- children_response.results, indent_level=0
99
- )
100
-
101
- # Delete blocks
102
- success = True
103
- for block in children_response.results:
104
- block_success = await self._delete_block_with_children(block)
105
- if not block_success:
106
- success = False
107
-
108
- if not success:
109
- self.logger.warning("Some blocks could not be deleted")
110
-
111
- return deleted_content if deleted_content else None
112
-
113
- except Exception:
114
- self.logger.error("Error clearing page content", exc_info=True)
115
- return None
116
-
117
- async def _delete_block_with_children(self, block: Block) -> bool:
118
- """Delete a block and all its children recursively."""
119
- if not block.id:
120
- self.logger.error("Block has no valid ID")
121
- return False
122
-
123
- self.logger.debug("Deleting block: %s (type: %s)", block.id, block.type)
124
-
125
- try:
126
- if block.has_children and not await self._delete_block_children(block):
127
- return False
128
-
129
- return await self._delete_single_block(block)
130
-
131
- except Exception as e:
132
- self.logger.error("Failed to delete block %s: %s", block.id, str(e))
133
- return False
134
-
135
- async def _delete_block_children(self, block: Block) -> bool:
136
- """Delete all children of a block."""
137
- self.logger.debug("Block %s has children, deleting children first", block.id)
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 ""
138
80
 
139
- try:
140
- children_blocks = await self._block_client.get_all_block_children(block.id)
81
+ return self._process_whitespace_lines(lines)
141
82
 
142
- if not children_blocks:
143
- self.logger.debug("No children found for block: %s", block.id)
144
- return True
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 = []
145
88
 
146
- self.logger.debug(
147
- "Found %d children to delete for block: %s",
148
- len(children_blocks),
149
- block.id,
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
+ )
150
94
  )
151
95
 
152
- # Delete all children recursively
153
- for child_block in children_blocks:
154
- if not await self._delete_block_with_children(child_block):
155
- self.logger.error(
156
- "Failed to delete child block: %s", child_block.id
157
- )
158
- return False
159
-
160
- self.logger.debug(
161
- "Successfully deleted all children of block: %s", block.id
162
- )
163
- return True
96
+ return "\n".join(processed_lines)
164
97
 
165
- except Exception as e:
166
- self.logger.error(
167
- "Failed to delete children of block %s: %s", block.id, str(e)
98
+ def _process_single_line(
99
+ 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
168
109
  )
169
- return False
170
-
171
- async def _delete_single_block(self, block: Block) -> bool:
172
- """Delete a single block."""
173
- deleted_block: Optional[Block] = await self._block_client.delete_block(block.id)
174
-
175
- if deleted_block is None:
176
- self.logger.error("Failed to delete block: %s", block.id)
177
- return False
178
-
179
- if deleted_block.archived or deleted_block.in_trash:
180
- self.logger.debug("Successfully deleted/archived block: %s", block.id)
181
- return True
110
+ if in_code_block:
111
+ current_code_block.append(line)
112
+ return processed_lines, in_code_block, current_code_block
182
113
  else:
183
- self.logger.warning("Block %s was not properly archived/deleted", block.id)
184
- return False
114
+ processed_lines.append(line.lstrip())
115
+ return processed_lines, in_code_block, current_code_block
185
116
 
186
- def _process_markdown_whitespace(self, markdown_text: str) -> str:
187
- """Process markdown text to normalize whitespace while preserving code blocks."""
188
- lines = markdown_text.split("\n")
189
- if not lines:
190
- return ""
191
-
192
- processor = MarkdownWhitespaceProcessor()
193
- return processor.process_lines(lines)
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)
127
+ 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]
194
170
 
195
171
  def _ensure_table_of_contents_exists_in_registry(self) -> None:
196
172
  """Ensure TableOfContents is registered in the block registry."""
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Optional
4
+ from dataclasses import dataclass
5
+ from contextvars import ContextVar
6
+
7
+ if TYPE_CHECKING:
8
+ from notionary.database.client import NotionDatabaseClient
9
+ from notionary.file_upload import NotionFileUploadClient
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class PageContextProvider:
14
+ """Context object that provides dependencies for block conversion operations."""
15
+
16
+ page_id: str
17
+ database_client: NotionDatabaseClient
18
+ file_upload_client: NotionFileUploadClient
19
+
20
+
21
+ # Context variable
22
+ _page_context: ContextVar[Optional[PageContextProvider]] = ContextVar(
23
+ "page_context", default=None
24
+ )
25
+
26
+
27
+ def get_page_context() -> PageContextProvider:
28
+ """Get current page context or raise if not available."""
29
+ context = _page_context.get()
30
+ if context is None:
31
+ raise RuntimeError(
32
+ "No page context available. Use 'async with page_context(...)'"
33
+ )
34
+ return context
35
+
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
+ class page_context:
43
+ """Async-only context manager for page operations."""
44
+
45
+ def __init__(self, provider: PageContextProvider):
46
+ self.provider = provider
47
+ self._token = None
48
+
49
+ def _set_context(self) -> PageContextProvider:
50
+ """Helper to set context and return provider."""
51
+ self._token = _page_context.set(self.provider)
52
+ return self.provider
53
+
54
+ def _reset_context(self) -> None:
55
+ """Helper to reset context."""
56
+ if self._token is not None:
57
+ _page_context.reset(self._token)
58
+
59
+ async def __aenter__(self) -> PageContextProvider:
60
+ return self._set_context()
61
+
62
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
63
+ self._reset_context()
64
+ return False
@@ -3,6 +3,7 @@ from .block_rendering_context import BlockRenderingContext
3
3
  from .column_list_renderer import ColumnListRenderer
4
4
  from .column_renderer import ColumnRenderer
5
5
  from .line_renderer import LineRenderer
6
+ from .numbered_list_renderer import NumberedListRenderer
6
7
  from .toggle_renderer import ToggleRenderer
7
8
  from .toggleable_heading_renderer import ToggleableHeadingRenderer
8
9
 
@@ -12,6 +13,7 @@ __all__ = [
12
13
  "ColumnListRenderer",
13
14
  "ColumnRenderer",
14
15
  "LineRenderer",
16
+ "NumberedListRenderer",
15
17
  "ToggleRenderer",
16
18
  "ToggleableHeadingRenderer",
17
19
  ]
@@ -17,12 +17,12 @@ class BlockHandler(ABC):
17
17
  self._next_handler = handler
18
18
  return handler
19
19
 
20
- def handle(self, context: BlockRenderingContext) -> None:
20
+ async def handle(self, context: BlockRenderingContext) -> None:
21
21
  """Handle the block or pass to next handler."""
22
22
  if self._can_handle(context):
23
- self._process(context)
23
+ await self._process(context)
24
24
  elif self._next_handler:
25
- self._next_handler.handle(context)
25
+ await self._next_handler.handle(context)
26
26
 
27
27
  @abstractmethod
28
28
  def _can_handle(self, context: BlockRenderingContext) -> bool:
@@ -30,7 +30,7 @@ class BlockHandler(ABC):
30
30
  pass
31
31
 
32
32
  @abstractmethod
33
- def _process(self, context: BlockRenderingContext) -> None:
33
+ async def _process(self, context: BlockRenderingContext) -> None:
34
34
  """Process the block and update context."""
35
35
  pass
36
36
 
@@ -16,6 +16,11 @@ class BlockRenderingContext:
16
16
  block_registry: BlockRegistry
17
17
  convert_children_callback: Optional[Callable[[list[Block], int], str]] = None
18
18
 
19
+ # For batch processing
20
+ all_blocks: Optional[list[Block]] = None
21
+ current_block_index: Optional[int] = None
22
+ blocks_consumed: int = 0
23
+
19
24
  # Result
20
25
  markdown_result: Optional[str] = None
21
26
  children_result: Optional[str] = None