notionary 0.2.19__py3-none-any.whl → 0.2.22__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 (220) hide show
  1. notionary/__init__.py +8 -4
  2. notionary/base_notion_client.py +3 -1
  3. notionary/blocks/__init__.py +2 -91
  4. notionary/blocks/_bootstrap.py +271 -0
  5. notionary/blocks/audio/__init__.py +8 -2
  6. notionary/blocks/audio/audio_element.py +69 -106
  7. notionary/blocks/audio/audio_markdown_node.py +13 -5
  8. notionary/blocks/audio/audio_models.py +6 -55
  9. notionary/blocks/base_block_element.py +42 -0
  10. notionary/blocks/bookmark/__init__.py +9 -2
  11. notionary/blocks/bookmark/bookmark_element.py +49 -139
  12. notionary/blocks/bookmark/bookmark_markdown_node.py +19 -18
  13. notionary/blocks/bookmark/bookmark_models.py +15 -0
  14. notionary/blocks/breadcrumbs/__init__.py +17 -0
  15. notionary/blocks/breadcrumbs/breadcrumb_element.py +39 -0
  16. notionary/blocks/breadcrumbs/breadcrumb_markdown_node.py +32 -0
  17. notionary/blocks/breadcrumbs/breadcrumb_models.py +12 -0
  18. notionary/blocks/bulleted_list/__init__.py +12 -2
  19. notionary/blocks/bulleted_list/bulleted_list_element.py +55 -53
  20. notionary/blocks/bulleted_list/bulleted_list_markdown_node.py +2 -1
  21. notionary/blocks/bulleted_list/bulleted_list_models.py +18 -0
  22. notionary/blocks/callout/__init__.py +9 -2
  23. notionary/blocks/callout/callout_element.py +53 -86
  24. notionary/blocks/callout/callout_markdown_node.py +3 -1
  25. notionary/blocks/callout/callout_models.py +33 -0
  26. notionary/blocks/child_database/__init__.py +14 -0
  27. notionary/blocks/child_database/child_database_element.py +61 -0
  28. notionary/blocks/child_database/child_database_models.py +12 -0
  29. notionary/blocks/child_page/__init__.py +9 -0
  30. notionary/blocks/child_page/child_page_element.py +94 -0
  31. notionary/blocks/child_page/child_page_models.py +12 -0
  32. notionary/blocks/{shared/block_client.py → client.py} +54 -54
  33. notionary/blocks/code/__init__.py +6 -2
  34. notionary/blocks/code/code_element.py +96 -181
  35. notionary/blocks/code/code_markdown_node.py +64 -13
  36. notionary/blocks/code/code_models.py +94 -0
  37. notionary/blocks/column/__init__.py +25 -1
  38. notionary/blocks/column/column_element.py +44 -312
  39. notionary/blocks/column/column_list_element.py +52 -0
  40. notionary/blocks/column/column_list_markdown_node.py +50 -0
  41. notionary/blocks/column/column_markdown_node.py +59 -0
  42. notionary/blocks/column/column_models.py +26 -0
  43. notionary/blocks/divider/__init__.py +9 -2
  44. notionary/blocks/divider/divider_element.py +18 -49
  45. notionary/blocks/divider/divider_markdown_node.py +2 -1
  46. notionary/blocks/divider/divider_models.py +12 -0
  47. notionary/blocks/embed/__init__.py +9 -2
  48. notionary/blocks/embed/embed_element.py +65 -111
  49. notionary/blocks/embed/embed_markdown_node.py +3 -1
  50. notionary/blocks/embed/embed_models.py +14 -0
  51. notionary/blocks/equation/__init__.py +14 -0
  52. notionary/blocks/equation/equation_element.py +133 -0
  53. notionary/blocks/equation/equation_element_markdown_node.py +35 -0
  54. notionary/blocks/equation/equation_models.py +11 -0
  55. notionary/blocks/file/__init__.py +25 -0
  56. notionary/blocks/file/file_element.py +112 -0
  57. notionary/blocks/file/file_element_markdown_node.py +37 -0
  58. notionary/blocks/file/file_element_models.py +39 -0
  59. notionary/blocks/guards.py +22 -0
  60. notionary/blocks/heading/__init__.py +16 -2
  61. notionary/blocks/heading/heading_element.py +83 -69
  62. notionary/blocks/heading/heading_markdown_node.py +2 -1
  63. notionary/blocks/heading/heading_models.py +29 -0
  64. notionary/blocks/image_block/__init__.py +13 -0
  65. notionary/blocks/image_block/image_element.py +89 -0
  66. notionary/blocks/{image → image_block}/image_markdown_node.py +13 -6
  67. notionary/blocks/image_block/image_models.py +10 -0
  68. notionary/blocks/mixins/captions/__init__.py +4 -0
  69. notionary/blocks/mixins/captions/caption_markdown_node_mixin.py +31 -0
  70. notionary/blocks/mixins/captions/caption_mixin.py +92 -0
  71. notionary/blocks/models.py +174 -0
  72. notionary/blocks/numbered_list/__init__.py +12 -2
  73. notionary/blocks/numbered_list/numbered_list_element.py +48 -56
  74. notionary/blocks/numbered_list/numbered_list_markdown_node.py +3 -1
  75. notionary/blocks/numbered_list/numbered_list_models.py +17 -0
  76. notionary/blocks/paragraph/__init__.py +12 -2
  77. notionary/blocks/paragraph/paragraph_element.py +40 -66
  78. notionary/blocks/paragraph/paragraph_markdown_node.py +2 -1
  79. notionary/blocks/paragraph/paragraph_models.py +16 -0
  80. notionary/blocks/pdf/__init__.py +13 -0
  81. notionary/blocks/pdf/pdf_element.py +97 -0
  82. notionary/blocks/pdf/pdf_markdown_node.py +37 -0
  83. notionary/blocks/pdf/pdf_models.py +11 -0
  84. notionary/blocks/quote/__init__.py +11 -2
  85. notionary/blocks/quote/quote_element.py +45 -62
  86. notionary/blocks/quote/quote_markdown_node.py +6 -3
  87. notionary/blocks/quote/quote_models.py +18 -0
  88. notionary/blocks/registry/__init__.py +4 -0
  89. notionary/blocks/registry/block_registry.py +60 -121
  90. notionary/blocks/registry/block_registry_builder.py +115 -59
  91. notionary/blocks/rich_text/__init__.py +33 -0
  92. notionary/blocks/rich_text/name_to_id_resolver.py +205 -0
  93. notionary/blocks/rich_text/rich_text_models.py +221 -0
  94. notionary/blocks/rich_text/text_inline_formatter.py +456 -0
  95. notionary/blocks/syntax_prompt_builder.py +137 -0
  96. notionary/blocks/table/__init__.py +16 -2
  97. notionary/blocks/table/table_element.py +136 -228
  98. notionary/blocks/table/table_markdown_node.py +2 -1
  99. notionary/blocks/table/table_models.py +28 -0
  100. notionary/blocks/table_of_contents/__init__.py +19 -0
  101. notionary/blocks/table_of_contents/table_of_contents_element.py +68 -0
  102. notionary/blocks/table_of_contents/table_of_contents_markdown_node.py +35 -0
  103. notionary/blocks/table_of_contents/table_of_contents_models.py +18 -0
  104. notionary/blocks/todo/__init__.py +9 -2
  105. notionary/blocks/todo/todo_element.py +52 -92
  106. notionary/blocks/todo/todo_markdown_node.py +2 -1
  107. notionary/blocks/todo/todo_models.py +19 -0
  108. notionary/blocks/toggle/__init__.py +13 -3
  109. notionary/blocks/toggle/toggle_element.py +69 -260
  110. notionary/blocks/toggle/toggle_markdown_node.py +25 -15
  111. notionary/blocks/toggle/toggle_models.py +17 -0
  112. notionary/blocks/toggleable_heading/__init__.py +6 -2
  113. notionary/blocks/toggleable_heading/toggleable_heading_element.py +86 -241
  114. notionary/blocks/toggleable_heading/toggleable_heading_markdown_node.py +26 -18
  115. notionary/blocks/types.py +130 -0
  116. notionary/blocks/video/__init__.py +8 -2
  117. notionary/blocks/video/video_element.py +70 -141
  118. notionary/blocks/video/video_element_models.py +10 -0
  119. notionary/blocks/video/video_markdown_node.py +13 -6
  120. notionary/database/client.py +26 -8
  121. notionary/database/database.py +13 -14
  122. notionary/database/database_filter_builder.py +2 -2
  123. notionary/database/database_provider.py +5 -4
  124. notionary/database/models.py +337 -0
  125. notionary/database/notion_database.py +6 -7
  126. notionary/file_upload/client.py +5 -7
  127. notionary/file_upload/models.py +3 -2
  128. notionary/file_upload/notion_file_upload.py +2 -3
  129. notionary/markdown/markdown_builder.py +729 -0
  130. notionary/markdown/markdown_document_model.py +228 -0
  131. notionary/{blocks → markdown}/markdown_node.py +1 -0
  132. notionary/models/notion_database_response.py +0 -338
  133. notionary/page/client.py +34 -15
  134. notionary/page/models.py +327 -0
  135. notionary/page/notion_page.py +136 -58
  136. notionary/page/{content/page_content_writer.py → page_content_deleting_service.py} +25 -59
  137. notionary/page/page_content_writer.py +177 -0
  138. notionary/page/page_context.py +65 -0
  139. notionary/page/reader/handler/__init__.py +19 -0
  140. notionary/page/reader/handler/base_block_renderer.py +44 -0
  141. notionary/page/reader/handler/block_processing_context.py +35 -0
  142. notionary/page/reader/handler/block_rendering_context.py +48 -0
  143. notionary/page/reader/handler/column_list_renderer.py +51 -0
  144. notionary/page/reader/handler/column_renderer.py +60 -0
  145. notionary/page/reader/handler/line_renderer.py +73 -0
  146. notionary/page/reader/handler/numbered_list_renderer.py +85 -0
  147. notionary/page/reader/handler/toggle_renderer.py +69 -0
  148. notionary/page/reader/handler/toggleable_heading_renderer.py +89 -0
  149. notionary/page/reader/page_content_retriever.py +81 -0
  150. notionary/page/search_filter_builder.py +2 -1
  151. notionary/page/writer/handler/__init__.py +24 -0
  152. notionary/page/writer/handler/code_handler.py +72 -0
  153. notionary/page/writer/handler/column_handler.py +141 -0
  154. notionary/page/writer/handler/column_list_handler.py +139 -0
  155. notionary/page/writer/handler/equation_handler.py +74 -0
  156. notionary/page/writer/handler/line_handler.py +35 -0
  157. notionary/page/writer/handler/line_processing_context.py +54 -0
  158. notionary/page/writer/handler/regular_line_handler.py +86 -0
  159. notionary/page/writer/handler/table_handler.py +66 -0
  160. notionary/page/writer/handler/toggle_handler.py +155 -0
  161. notionary/page/writer/handler/toggleable_heading_handler.py +173 -0
  162. notionary/page/writer/markdown_to_notion_converter.py +95 -0
  163. notionary/page/writer/markdown_to_notion_converter_context.py +30 -0
  164. notionary/page/writer/markdown_to_notion_formatting_post_processor.py +73 -0
  165. notionary/page/writer/notion_text_length_processor.py +150 -0
  166. notionary/telemetry/__init__.py +2 -2
  167. notionary/telemetry/service.py +3 -3
  168. notionary/user/__init__.py +2 -2
  169. notionary/user/base_notion_user.py +2 -1
  170. notionary/user/client.py +2 -3
  171. notionary/user/models.py +1 -0
  172. notionary/user/notion_bot_user.py +4 -5
  173. notionary/user/notion_user.py +3 -4
  174. notionary/user/notion_user_manager.py +23 -95
  175. notionary/util/__init__.py +3 -2
  176. notionary/util/fuzzy.py +2 -1
  177. notionary/util/logging_mixin.py +2 -2
  178. notionary/util/singleton_metaclass.py +1 -1
  179. notionary/workspace.py +6 -5
  180. notionary-0.2.22.dist-info/METADATA +237 -0
  181. notionary-0.2.22.dist-info/RECORD +200 -0
  182. notionary/blocks/document/__init__.py +0 -7
  183. notionary/blocks/document/document_element.py +0 -102
  184. notionary/blocks/document/document_markdown_node.py +0 -31
  185. notionary/blocks/image/__init__.py +0 -7
  186. notionary/blocks/image/image_element.py +0 -151
  187. notionary/blocks/markdown_builder.py +0 -356
  188. notionary/blocks/mention/__init__.py +0 -7
  189. notionary/blocks/mention/mention_element.py +0 -229
  190. notionary/blocks/mention/mention_markdown_node.py +0 -38
  191. notionary/blocks/prompts/element_prompt_builder.py +0 -83
  192. notionary/blocks/prompts/element_prompt_content.py +0 -41
  193. notionary/blocks/shared/models.py +0 -713
  194. notionary/blocks/shared/notion_block_element.py +0 -37
  195. notionary/blocks/shared/text_inline_formatter.py +0 -262
  196. notionary/blocks/shared/text_inline_formatter_new.py +0 -139
  197. notionary/database/models/page_result.py +0 -10
  198. notionary/models/notion_block_response.py +0 -264
  199. notionary/models/notion_page_response.py +0 -78
  200. notionary/models/search_response.py +0 -0
  201. notionary/page/__init__.py +0 -0
  202. notionary/page/content/markdown_whitespace_processor.py +0 -80
  203. notionary/page/content/notion_text_length_utils.py +0 -87
  204. notionary/page/content/page_content_retriever.py +0 -60
  205. notionary/page/formatting/line_processor.py +0 -153
  206. notionary/page/formatting/markdown_to_notion_converter.py +0 -153
  207. notionary/page/markdown_syntax_prompt_generator.py +0 -114
  208. notionary/page/notion_to_markdown_converter.py +0 -179
  209. notionary/page/properites/property_value_extractor.py +0 -0
  210. notionary/user/notion_user_provider.py +0 -1
  211. notionary-0.2.19.dist-info/METADATA +0 -225
  212. notionary-0.2.19.dist-info/RECORD +0 -150
  213. /notionary/{blocks/document/document_models.py → markdown/___init__.py} +0 -0
  214. /notionary/{blocks/image/image_models.py → markdown/makdown_document_model.py} +0 -0
  215. /notionary/{blocks/mention/mention_models.py → page/reader/handler/equation_renderer.py} +0 -0
  216. /notionary/{blocks/shared/__init__.py → page/writer/markdown_to_notion_post_processor.py} +0 -0
  217. /notionary/{blocks/toggleable_heading/toggleable_heading_models.py → page/writer/markdown_to_notion_text_length_post_processor.py} +0 -0
  218. /notionary/{elements/__init__.py → util/concurrency_limiter.py} +0 -0
  219. {notionary-0.2.19.dist-info → notionary-0.2.22.dist-info}/LICENSE +0 -0
  220. {notionary-0.2.19.dist-info → notionary-0.2.22.dist-info}/WHEEL +0 -0
@@ -1,10 +1,9 @@
1
- from typing import Optional, Dict, Any
1
+ from typing import Any, Optional
2
+
2
3
  from notionary.base_notion_client import BaseNotionClient
3
- from notionary.util import singleton
4
- from notionary.blocks.shared.models import Block, BlockChildrenResponse
4
+ from notionary.blocks.models import Block, BlockChildrenResponse, BlockCreateRequest
5
5
 
6
6
 
7
- @singleton
8
7
  class NotionBlockClient(BaseNotionClient):
9
8
  """
10
9
  Client for Notion Block API operations.
@@ -25,6 +24,36 @@ class NotionBlockClient(BaseNotionClient):
25
24
  return None
26
25
  return None
27
26
 
27
+ async def get_blocks_by_page_id_recursively(
28
+ self, page_id: str, parent_id: Optional[str] = None
29
+ ) -> list[Block]:
30
+ response = (
31
+ await self.get_block_children(block_id=page_id)
32
+ if parent_id is None
33
+ else await self.get_block_children(block_id=parent_id)
34
+ )
35
+
36
+ if not response or not response.results:
37
+ return []
38
+
39
+ blocks = response.results
40
+
41
+ for block in blocks:
42
+ if not block.has_children:
43
+ continue
44
+
45
+ block_id = block.id
46
+ if not block_id:
47
+ continue
48
+
49
+ children = await self.get_blocks_by_page_id_recursively(
50
+ page_id=page_id, parent_id=block_id
51
+ )
52
+ if children:
53
+ block.children = children
54
+
55
+ return blocks
56
+
28
57
  async def get_block_children(
29
58
  self, block_id: str, start_cursor: Optional[str] = None, page_size: int = 100
30
59
  ) -> Optional[BlockChildrenResponse]:
@@ -38,13 +67,15 @@ class NotionBlockClient(BaseNotionClient):
38
67
  params["start_cursor"] = start_cursor
39
68
 
40
69
  response = await self.get(f"blocks/{block_id}/children", params=params)
41
- if response:
42
- try:
43
- return BlockChildrenResponse.model_validate(response)
44
- except Exception as e:
45
- self.logger.error("Failed to parse block children response: %s", str(e))
46
- return None
47
- return None
70
+
71
+ if not response:
72
+ return None
73
+
74
+ try:
75
+ return BlockChildrenResponse.model_validate(response)
76
+ except Exception as e:
77
+ self.logger.error("Failed to parse block children response: %s", str(e))
78
+ return None
48
79
 
49
80
  async def get_all_block_children(self, block_id: str) -> list[Block]:
50
81
  """
@@ -74,7 +105,10 @@ class NotionBlockClient(BaseNotionClient):
74
105
  return all_blocks
75
106
 
76
107
  async def append_block_children(
77
- self, block_id: str, children: list[Dict[str, Any]], after: Optional[str] = None
108
+ self,
109
+ block_id: str,
110
+ children: list[BlockCreateRequest],
111
+ after: Optional[str] = None,
78
112
  ) -> Optional[BlockChildrenResponse]:
79
113
  """
80
114
  Appends new child blocks to a parent block.
@@ -86,15 +120,18 @@ class NotionBlockClient(BaseNotionClient):
86
120
 
87
121
  self.logger.debug("Appending %d children to block: %s", len(children), block_id)
88
122
 
123
+ # Convert Pydantic models to dictionaries for API
124
+ children_dicts = [block.model_dump(exclude_none=True) for block in children]
125
+
89
126
  # If 100 or fewer blocks, use single request
90
- if len(children) <= 100:
91
- return await self._append_single_batch(block_id, children, after)
127
+ if len(children_dicts) <= 100:
128
+ return await self._append_single_batch(block_id, children_dicts, after)
92
129
 
93
130
  # For more than 100 blocks, use batch processing
94
- return await self._append_multiple_batches(block_id, children, after)
131
+ return await self._append_multiple_batches(block_id, children_dicts, after)
95
132
 
96
133
  async def _append_single_batch(
97
- self, block_id: str, children: list[Dict[str, Any]], after: Optional[str] = None
134
+ self, block_id: str, children: list[dict[str, Any]], after: Optional[str] = None
98
135
  ) -> Optional[BlockChildrenResponse]:
99
136
  """
100
137
  Appends a single batch of blocks (≤100).
@@ -113,7 +150,7 @@ class NotionBlockClient(BaseNotionClient):
113
150
  return None
114
151
 
115
152
  async def _append_multiple_batches(
116
- self, block_id: str, children: list[Dict[str, Any]], after: Optional[str] = None
153
+ self, block_id: str, children: list[dict[str, Any]], after: Optional[str] = None
117
154
  ) -> Optional[BlockChildrenResponse]:
118
155
  """
119
156
  Appends multiple batches of blocks, handling pagination.
@@ -206,27 +243,6 @@ class NotionBlockClient(BaseNotionClient):
206
243
  request_id=responses[-1].request_id, # Use last request ID
207
244
  )
208
245
 
209
- async def update_block(
210
- self, block_id: str, block_data: Dict[str, Any], archived: Optional[bool] = None
211
- ) -> Optional[Block]:
212
- """
213
- Updates an existing block.
214
- """
215
- self.logger.debug("Updating block: %s", block_id)
216
-
217
- data = block_data.copy()
218
- if archived is not None:
219
- data["archived"] = archived
220
-
221
- response = await self.patch(f"blocks/{block_id}", data)
222
- if response:
223
- try:
224
- return Block.model_validate(response)
225
- except Exception as e:
226
- self.logger.error("Failed to parse update response: %s", str(e))
227
- return None
228
- return None
229
-
230
246
  async def delete_block(self, block_id: str) -> Optional[Block]:
231
247
  """
232
248
  Deletes (archives) a block.
@@ -238,19 +254,3 @@ class NotionBlockClient(BaseNotionClient):
238
254
  # After deletion, retrieve the block to return the updated state
239
255
  return await self.get_block(block_id)
240
256
  return None
241
-
242
- async def archive_block(self, block_id: str) -> Optional[Block]:
243
- """
244
- Archives a block by setting archived=True.
245
- """
246
- self.logger.debug("Archiving block: %s", block_id)
247
-
248
- return await self.update_block(block_id=block_id, block_data={}, archived=True)
249
-
250
- async def unarchive_block(self, block_id: str) -> Optional[Block]:
251
- """
252
- Unarchives a block by setting archived=False.
253
- """
254
- self.logger.debug("Unarchiving block: %s", block_id)
255
-
256
- return await self.update_block(block_id=block_id, block_data={}, archived=False)
@@ -1,7 +1,11 @@
1
- from .code_element import CodeElement
2
- from .code_markdown_node import CodeMarkdownNode
1
+ from notionary.blocks.code.code_element import CodeElement
2
+ from notionary.blocks.code.code_markdown_node import CodeMarkdownNode
3
+ from notionary.blocks.code.code_models import CodeBlock, CodeLanguage, CreateCodeBlock
3
4
 
4
5
  __all__ = [
5
6
  "CodeElement",
7
+ "CodeBlock",
8
+ "CodeLanguage",
9
+ "CreateCodeBlock",
6
10
  "CodeMarkdownNode",
7
11
  ]
@@ -1,117 +1,108 @@
1
+ from __future__ import annotations
2
+
1
3
  import re
4
+ from typing import Optional
2
5
 
3
- from typing import Optional, Any
4
- from notionary.blocks import NotionBlockElement
5
- from notionary.blocks import (
6
- ElementPromptContent,
7
- ElementPromptBuilder,
8
- NotionBlockResult,
9
- )
10
- from notionary.blocks.shared.models import RichTextObject
6
+ from notionary.blocks.base_block_element import BaseBlockElement
7
+ from notionary.blocks.code.code_models import CodeBlock, CodeLanguage, CreateCodeBlock
8
+ from notionary.blocks.syntax_prompt_builder import BlockElementMarkdownInformation
9
+ from notionary.blocks.models import Block, BlockCreateResult, BlockType
10
+ from notionary.blocks.rich_text.rich_text_models import RichTextObject
11
11
 
12
12
 
13
- class CodeElement(NotionBlockElement):
13
+ class CodeElement(BaseBlockElement):
14
14
  """
15
15
  Handles conversion between Markdown code blocks and Notion code blocks.
16
+ Now integrated into the LineProcessor stack system.
16
17
 
17
18
  Markdown code block syntax:
18
19
  ```language
19
- code content
20
+ [code content as child lines]
20
21
  ```
21
- Caption: optional caption text
22
-
23
- Where:
24
- - language is optional and specifies the programming language
25
- - code content is the code to be displayed
26
- - Caption line is optional and must appear immediately after the closing ```
27
22
  """
28
23
 
29
- PATTERN = re.compile(
30
- r"```(\w*)\n([\s\S]+?)```(?:\n(?:Caption|caption):\s*(.+))?", re.MULTILINE
31
- )
24
+ DEFAULT_LANGUAGE = "plain text"
25
+ CODE_START_PATTERN = re.compile(r"^```(\w*)\s*$")
26
+ CODE_START_WITH_CAPTION_PATTERN = re.compile(r"^```(\w*)\s*(?:\"([^\"]*)\")?\s*$")
32
27
 
33
28
  @classmethod
34
- def match_markdown(cls, text: str) -> bool:
35
- """Check if text contains a markdown code block."""
36
- return bool(cls.PATTERN.search(text))
29
+ def match_notion(cls, block: Block) -> bool:
30
+ """Check if block is a Notion code block."""
31
+ return block.type == BlockType.CODE and block.code
37
32
 
38
33
  @classmethod
39
- def match_notion(cls, block: dict[str, any]) -> bool:
40
- """Check if block is a Notion code block."""
41
- return block.get("type") == "code"
34
+ async def markdown_to_notion(cls, text: str) -> BlockCreateResult:
35
+ """Convert opening ```language to Notion code block."""
36
+ if not (match := cls.CODE_START_PATTERN.match(text.strip())):
37
+ return None
38
+
39
+ language = (match.group(1) or cls.DEFAULT_LANGUAGE).lower()
40
+ language = cls._normalize_language(language)
41
+
42
+ # Create empty CodeBlock - content will be added by stack processor
43
+ code_block = CodeBlock(rich_text=[], language=language, caption=[])
44
+ return CreateCodeBlock(code=code_block)
42
45
 
43
46
  @classmethod
44
- def markdown_to_notion(cls, text: str) -> NotionBlockResult:
45
- """Convert markdown code block to Notion code block."""
46
- match = cls.PATTERN.search(text)
47
+ def create_from_markdown_block(
48
+ cls, opening_line: str, code_lines: list[str]
49
+ ) -> BlockCreateResult:
50
+ """
51
+ Create a complete code block from markdown components.
52
+ """
53
+ match = cls.CODE_START_WITH_CAPTION_PATTERN.match(opening_line.strip())
47
54
  if not match:
48
55
  return None
49
56
 
50
- language = match.group(1) or "plain text"
51
- content = match.group(2)
52
- caption = match.group(3)
57
+ language = (match.group(1) or cls.DEFAULT_LANGUAGE).lower()
58
+ language = cls._normalize_language(language)
53
59
 
54
- if content.endswith("\n"):
55
- content = content[:-1]
60
+ caption = match.group(2) if match.group(2) else None
56
61
 
57
- # Create code block with rich text
58
- content_rich_text = RichTextObject.from_plain_text(content)
62
+ # Create rich text content from code lines
63
+ rich_text = []
64
+ if code_lines:
65
+ content = "\n".join(code_lines)
66
+ rich_text = [RichTextObject.for_code_block(content)]
59
67
 
60
- block = {
61
- "type": "code",
62
- "code": {
63
- "rich_text": [content_rich_text.model_dump()],
64
- "language": language,
65
- },
66
- }
68
+ caption_list = []
69
+ if caption:
70
+ caption_list = [RichTextObject.for_caption(caption)]
67
71
 
68
- # Add caption if provided
69
- if caption and caption.strip():
70
- caption_rich_text = RichTextObject.from_plain_text(caption.strip())
71
- block["code"]["caption"] = [caption_rich_text.model_dump()]
72
-
73
- # Leerer Paragraph nach dem Code-Block
74
- empty_paragraph = {"type": "paragraph", "paragraph": {"rich_text": []}}
72
+ code_block = CodeBlock(
73
+ rich_text=rich_text, language=language, caption=caption_list
74
+ )
75
75
 
76
- return [block, empty_paragraph]
76
+ return CreateCodeBlock(code=code_block)
77
77
 
78
78
  @classmethod
79
- def notion_to_markdown(cls, block: dict[str, Any]) -> Optional[str]:
79
+ async def notion_to_markdown(cls, block: Block) -> Optional[str]:
80
80
  """Convert Notion code block to Markdown."""
81
- if block.get("type") != "code":
81
+ if block.type != BlockType.CODE:
82
82
  return None
83
83
 
84
- code_data = block.get("code", {})
85
- language = code_data.get("language", "")
86
- rich_text = code_data.get("rich_text", [])
87
- caption = code_data.get("caption", [])
88
-
89
- def extract_content(rich_text_list):
90
- """Extract code content from rich_text array."""
91
- return "".join(
92
- text.get("text", {}).get("content", "")
93
- if text.get("type") == "text"
94
- else text.get("plain_text", "")
95
- for text in rich_text_list
96
- )
97
-
98
- def extract_caption(caption_list):
99
- """Extract caption text from caption array."""
100
- return "".join(
101
- c.get("text", {}).get("content", "")
102
- for c in caption_list
103
- if c.get("type") == "text"
104
- )
105
-
106
- code_content = extract_content(rich_text)
107
- caption_text = extract_caption(caption)
84
+ if not block.code:
85
+ return None
86
+
87
+ language_enum = block.code.language
88
+ rich_text = block.code.rich_text or []
89
+ caption = block.code.caption or []
90
+
91
+ code_content = cls.extract_content(rich_text)
92
+ caption_text = cls.extract_caption(caption)
93
+
94
+ # Convert enum to string value
95
+ language = language_enum.value if language_enum else ""
108
96
 
109
97
  # Handle language - convert "plain text" back to empty string for markdown
110
- if language == "plain text":
98
+ if language == cls.DEFAULT_LANGUAGE:
111
99
  language = ""
112
100
 
113
101
  # Build markdown code block
114
- result = f"```{language}\n{code_content}\n```" if language else f"```\n{code_content}\n```"
102
+ if language:
103
+ result = f"```{language}\n{code_content}\n```"
104
+ else:
105
+ result = f"```\n{code_content}\n```"
115
106
 
116
107
  # Add caption if present
117
108
  if caption_text:
@@ -120,115 +111,39 @@ class CodeElement(NotionBlockElement):
120
111
  return result
121
112
 
122
113
  @classmethod
123
- def find_matches(cls, text: str) -> list[tuple[int, int, dict[str, any]]]:
114
+ def _normalize_language(cls, language: str) -> CodeLanguage:
124
115
  """
125
- Find all code block matches in the text and return their positions.
126
-
127
- Args:
128
- text: The text to search in
129
-
130
- Returns:
131
- List of tuples with (start_pos, end_pos, block)
116
+ Normalize the language string to a valid CodeLanguage enum or default.
132
117
  """
133
- matches = []
134
- for match in CodeElement.PATTERN.finditer(text):
135
- language = match.group(1) or "plain text"
136
- content = match.group(2)
137
- caption = match.group(3)
138
-
139
- # Remove trailing newline if present
140
- if content.endswith("\n"):
141
- content = content[:-1]
142
-
143
- block = {
144
- "type": "code",
145
- "code": {
146
- "rich_text": [
147
- {
148
- "type": "text",
149
- "text": {"content": content},
150
- "plain_text": content,
151
- }
152
- ],
153
- "language": language,
154
- },
155
- }
156
-
157
- # Add caption if provided
158
- if caption and caption.strip():
159
- block["code"]["caption"] = [
160
- {
161
- "type": "text",
162
- "text": {"content": caption.strip()},
163
- "plain_text": caption.strip(),
164
- }
165
- ]
166
-
167
- matches.append((match.start(), match.end(), block))
168
-
169
- return matches
118
+ # Try to find matching enum by value
119
+ for lang_enum in CodeLanguage:
120
+ if lang_enum.value.lower() == language.lower():
121
+ return lang_enum
170
122
 
171
- @classmethod
172
- def is_multiline(cls) -> bool:
173
- return True
174
-
175
- @classmethod
176
- def get_llm_prompt_content(cls) -> ElementPromptContent:
177
- """
178
- Returns structured LLM prompt metadata for the code block element.
179
- """
180
- return (
181
- ElementPromptBuilder()
182
- .with_description(
183
- "Use fenced code blocks to format content as code. Supports language annotations like "
184
- "'python', 'json', or 'mermaid'. Useful for displaying code, configurations, command-line "
185
- "examples, or diagram syntax. Also suitable for explaining or visualizing systems with diagram languages. "
186
- "Code blocks can include optional captions for better documentation."
187
- )
188
- .with_usage_guidelines(
189
- "Use code blocks when you want to present technical content like code snippets, terminal commands, "
190
- "JSON structures, or system diagrams. Especially helpful when structure and formatting are essential. "
191
- "Add captions to provide context, explanations, or titles for your code blocks."
192
- )
193
- .with_syntax(
194
- "```language\ncode content\n```\nCaption: optional caption text\n\n"
195
- "OR\n\n"
196
- "```language\ncode content\n```"
197
- )
198
- .with_examples(
199
- [
200
- "```python\nprint('Hello, world!')\n```\nCaption: Basic Python greeting example",
201
- '```json\n{"name": "Alice", "age": 30}\n```\nCaption: User data structure',
202
- "```mermaid\nflowchart TD\n A --> B\n```\nCaption: Simple flow diagram",
203
- '```bash\ngit commit -m "Initial commit"\n```',
204
- ]
205
- )
206
- .with_avoidance_guidelines(
207
- "NEVER EVER wrap markdown content with ```markdown. Markdown should be written directly without code block formatting. "
208
- "NEVER use ```markdown under any circumstances. "
209
- "For Mermaid diagrams, use ONLY the default styling without colors, backgrounds, or custom styling attributes. "
210
- "Keep Mermaid diagrams simple and minimal without any styling or color modifications. "
211
- "Captions must appear immediately after the closing ``` on a new line starting with 'Caption:' - "
212
- "no empty lines between the code block and the caption."
213
- )
214
- .build()
215
- )
123
+ # Return default if not found
124
+ return CodeLanguage.PLAIN_TEXT
216
125
 
217
126
  @staticmethod
218
- def extract_content(rich_text_list: list[dict[str, Any]]) -> str:
127
+ def extract_content(rich_text_list: list[RichTextObject]) -> str:
219
128
  """Extract code content from rich_text array."""
220
- return "".join(
221
- text.get("text", {}).get("content", "")
222
- if text.get("type") == "text"
223
- else text.get("plain_text", "")
224
- for text in rich_text_list
225
- )
129
+ return "".join(rt.plain_text for rt in rich_text_list if rt.plain_text)
226
130
 
227
131
  @staticmethod
228
- def extract_caption(caption_list: list[dict[str, Any]]) -> str:
132
+ def extract_caption(caption_list: list[RichTextObject]) -> str:
229
133
  """Extract caption text from caption array."""
230
- return "".join(
231
- c.get("text", {}).get("content", "")
232
- for c in caption_list
233
- if c.get("type") == "text"
234
- )
134
+ return "".join(rt.plain_text for rt in caption_list if rt.plain_text)
135
+
136
+ @classmethod
137
+ def get_system_prompt_information(cls) -> Optional[BlockElementMarkdownInformation]:
138
+ """Get system prompt information for code blocks."""
139
+ return BlockElementMarkdownInformation(
140
+ block_type=cls.__name__,
141
+ description="Code blocks display syntax-highlighted code with optional language specification and captions",
142
+ syntax_examples=[
143
+ "```\nprint('Hello World')\n```",
144
+ "```python\nprint('Hello World')\n```",
145
+ "```python \"Example code\"\nprint('Hello World')\n```",
146
+ "```javascript\nconsole.log('Hello');\n```",
147
+ ],
148
+ usage_guidelines="Use for displaying code snippets. Language specification enables syntax highlighting. Caption in quotes on first line provides description. Supports many programming languages.",
149
+ )
@@ -1,24 +1,20 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from typing import Optional
4
- from pydantic import BaseModel
5
- from notionary.blocks.markdown_node import MarkdownNode
6
4
 
7
-
8
- class CodeMarkdownBlockParams(BaseModel):
9
- code: str
10
- language: Optional[str] = None
11
- caption: Optional[str] = None
5
+ from notionary.blocks.code.code_models import CodeBlock
6
+ from notionary.markdown.markdown_node import MarkdownNode
12
7
 
13
8
 
14
9
  class CodeMarkdownNode(MarkdownNode):
15
10
  """
16
11
  Programmatic interface for creating Notion-style Markdown code blocks.
12
+ Automatically handles indentation normalization for multiline strings.
13
+
17
14
  Example:
18
- ```python
15
+ ```python "Basic usage"
19
16
  print("Hello, world!")
20
17
  ```
21
- Caption: Basic usage
22
18
  """
23
19
 
24
20
  def __init__(
@@ -32,12 +28,67 @@ class CodeMarkdownNode(MarkdownNode):
32
28
  self.caption = caption
33
29
 
34
30
  @classmethod
35
- def from_params(cls, params: CodeMarkdownBlockParams) -> CodeMarkdownNode:
36
- return cls(code=params.code, language=params.language, caption=params.caption)
31
+ def from_params(cls, params: CodeBlock) -> CodeMarkdownNode:
32
+ return cls(
33
+ code=params.rich_text, language=params.language, caption=params.caption
34
+ )
37
35
 
38
36
  def to_markdown(self) -> str:
39
37
  lang = self.language or ""
40
- content = f"```{lang}\n{self.code}\n```"
38
+
39
+ # Build the opening fence with optional caption
40
+ opening_fence = f"```{lang}"
41
41
  if self.caption:
42
- content += f"\nCaption: {self.caption}"
42
+ opening_fence += f' "{self.caption}"'
43
+
44
+ # Smart indentation normalization
45
+ normalized_code = self._normalize_indentation(self.code)
46
+
47
+ content = f"{opening_fence}\n{normalized_code}\n```"
43
48
  return content
49
+
50
+ def _normalize_indentation(self, code: str) -> str:
51
+ """Normalize indentation by removing common leading whitespace."""
52
+ lines = code.strip().split("\n")
53
+
54
+ if self._is_empty_or_single_line(lines):
55
+ return self._handle_simple_cases(lines)
56
+
57
+ min_indentation = self._find_minimum_indentation_excluding_first_line(lines)
58
+ return self._remove_common_indentation(lines, min_indentation)
59
+
60
+ def _is_empty_or_single_line(self, lines: list[str]) -> bool:
61
+ return not lines or len(lines) == 1
62
+
63
+ def _handle_simple_cases(self, lines: list[str]) -> str:
64
+ if not lines:
65
+ return ""
66
+ return lines[0].strip()
67
+
68
+ def _find_minimum_indentation_excluding_first_line(self, lines: list[str]) -> int:
69
+ non_empty_lines_after_first = [line for line in lines[1:] if line.strip()]
70
+
71
+ if not non_empty_lines_after_first:
72
+ return 0
73
+
74
+ return min(
75
+ len(line) - len(line.lstrip()) for line in non_empty_lines_after_first
76
+ )
77
+
78
+ def _remove_common_indentation(self, lines: list[str], min_indentation: int) -> str:
79
+ normalized_lines = [lines[0].strip()]
80
+
81
+ for line in lines[1:]:
82
+ normalized_line = self._normalize_single_line(line, min_indentation)
83
+ normalized_lines.append(normalized_line)
84
+
85
+ return "\n".join(normalized_lines)
86
+
87
+ def _normalize_single_line(self, line: str, min_indentation: int) -> str:
88
+ if not line.strip():
89
+ return ""
90
+
91
+ if len(line) > min_indentation:
92
+ return line[min_indentation:]
93
+
94
+ return line.strip()