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
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from notionary.blocks.models import BlockCreateRequest
6
+ from notionary.blocks.toggleable_heading.toggleable_heading_element import (
7
+ ToggleableHeadingElement,
8
+ )
9
+ from notionary.blocks.types import BlockType
10
+ from notionary.page.writer.handler import (
11
+ LineHandler,
12
+ LineProcessingContext,
13
+ ParentBlockContext,
14
+ )
15
+
16
+
17
+ class ToggleableHeadingHandler(LineHandler):
18
+ """Handles toggleable heading blocks with +++# syntax."""
19
+
20
+ def __init__(self):
21
+ super().__init__()
22
+ self._start_pattern = re.compile(
23
+ r"^[+]{3}(?P<level>#{1,3})\s+(.+)$", re.IGNORECASE
24
+ )
25
+ # +++
26
+ self._end_pattern = re.compile(r"^[+]{3}\s*$")
27
+
28
+ def _can_handle(self, context: LineProcessingContext) -> bool:
29
+ return (
30
+ self._is_toggleable_heading_start(context)
31
+ or self._is_toggleable_heading_end(context)
32
+ or self._is_toggleable_heading_content(context)
33
+ )
34
+
35
+ async def _process(self, context: LineProcessingContext) -> None:
36
+ """Process toggleable heading start, end, or content with unified handling."""
37
+
38
+ async def _handle(action):
39
+ await action(context)
40
+ context.was_processed = True
41
+ context.should_continue = True
42
+ return True
43
+
44
+ if self._is_toggleable_heading_start(context):
45
+ return await _handle(self._start_toggleable_heading)
46
+ if self._is_toggleable_heading_end(context):
47
+ return await _handle(self._finalize_toggleable_heading)
48
+ if self._is_toggleable_heading_content(context):
49
+ return await _handle(self._add_toggleable_heading_content)
50
+
51
+ def _is_toggleable_heading_start(self, context: LineProcessingContext) -> bool:
52
+ """Check if line starts a toggleable heading (+++# "Title")."""
53
+ return self._start_pattern.match(context.line.strip()) is not None
54
+
55
+ def _is_toggleable_heading_end(self, context: LineProcessingContext) -> bool:
56
+ """Check if we need to end a toggleable heading (+++)."""
57
+ if not self._end_pattern.match(context.line.strip()):
58
+ return False
59
+
60
+ if not context.parent_stack:
61
+ return False
62
+
63
+ # Check if top of stack is a ToggleableHeading
64
+ current_parent = context.parent_stack[-1]
65
+ return issubclass(current_parent.element_type, ToggleableHeadingElement)
66
+
67
+ async def _start_toggleable_heading(self, context: LineProcessingContext) -> None:
68
+ """Start a new toggleable heading block."""
69
+ toggleable_heading_element = ToggleableHeadingElement()
70
+
71
+ # Create the block
72
+ result = await toggleable_heading_element.markdown_to_notion(context.line)
73
+ if not result:
74
+ return
75
+
76
+ block = result
77
+
78
+ # Push to parent stack
79
+ parent_context = ParentBlockContext(
80
+ block=block,
81
+ element_type=ToggleableHeadingElement,
82
+ child_lines=[],
83
+ )
84
+ context.parent_stack.append(parent_context)
85
+
86
+ def _is_toggleable_heading_content(self, context: LineProcessingContext) -> bool:
87
+ """Check if we're inside a toggleable heading context and should handle content."""
88
+ if not context.parent_stack:
89
+ return False
90
+
91
+ current_parent = context.parent_stack[-1]
92
+ if not issubclass(current_parent.element_type, ToggleableHeadingElement):
93
+ return False
94
+
95
+ # Handle all content inside toggleable heading (not start/end patterns)
96
+ line = context.line.strip()
97
+ return not (self._start_pattern.match(line) or self._end_pattern.match(line))
98
+
99
+ async def _add_toggleable_heading_content(
100
+ self, context: LineProcessingContext
101
+ ) -> None:
102
+ """Add content to the current toggleable heading context."""
103
+ context.parent_stack[-1].add_child_line(context.line)
104
+
105
+ async def _finalize_toggleable_heading(
106
+ self, context: LineProcessingContext
107
+ ) -> None:
108
+ """Finalize a toggleable heading block and add it to result_blocks."""
109
+ heading_context = context.parent_stack.pop()
110
+
111
+ if heading_context.has_children():
112
+ all_children = await self._get_all_children(
113
+ heading_context, context.block_registry
114
+ )
115
+ self._assign_heading_children(heading_context.block, all_children)
116
+
117
+ # Check if we have a parent context to add this heading to
118
+ if context.parent_stack:
119
+ # Add this heading as a child block to the parent
120
+ parent_context = context.parent_stack[-1]
121
+ if hasattr(parent_context, "add_child_block"):
122
+ parent_context.add_child_block(heading_context.block)
123
+ else:
124
+ # Fallback: add to result_blocks for backward compatibility
125
+ context.result_blocks.append(heading_context.block)
126
+ else:
127
+ # No parent, add to top level
128
+ context.result_blocks.append(heading_context.block)
129
+
130
+ async def _get_all_children(
131
+ self, parent_context: ParentBlockContext, block_registry
132
+ ) -> list:
133
+ """Helper method to combine text-based and direct block children."""
134
+ children_blocks = []
135
+
136
+ # Process text lines
137
+ if parent_context.child_lines:
138
+ children_text = "\n".join(parent_context.child_lines)
139
+ text_blocks = await self._convert_children_text(
140
+ children_text, block_registry
141
+ )
142
+ children_blocks.extend(text_blocks)
143
+
144
+ # Add direct blocks
145
+ if hasattr(parent_context, "child_blocks") and parent_context.child_blocks:
146
+ children_blocks.extend(parent_context.child_blocks)
147
+
148
+ return children_blocks
149
+
150
+ def _assign_heading_children(
151
+ self, parent_block: BlockCreateRequest, children: list[BlockCreateRequest]
152
+ ) -> None:
153
+ """Assign children to toggleable heading blocks."""
154
+ block_type = parent_block.type
155
+
156
+ if block_type == BlockType.HEADING_1:
157
+ parent_block.heading_1.children = children
158
+ elif block_type == BlockType.HEADING_2:
159
+ parent_block.heading_2.children = children
160
+ elif block_type == BlockType.HEADING_3:
161
+ parent_block.heading_3.children = children
162
+
163
+ async def _convert_children_text(self, text: str, block_registry) -> list:
164
+ """Convert children text to blocks."""
165
+ from notionary.page.writer.markdown_to_notion_converter import (
166
+ MarkdownToNotionConverter,
167
+ )
168
+
169
+ if not text.strip():
170
+ return []
171
+
172
+ child_converter = MarkdownToNotionConverter(block_registry)
173
+ return await child_converter.process_lines(text)
@@ -0,0 +1,95 @@
1
+ from notionary.blocks.models import BlockCreateRequest
2
+ from notionary.blocks.registry.block_registry import BlockRegistry
3
+ from notionary.page.writer.handler import (
4
+ CodeHandler,
5
+ ColumnHandler,
6
+ ColumnListHandler,
7
+ EquationHandler,
8
+ LineProcessingContext,
9
+ ParentBlockContext,
10
+ RegularLineHandler,
11
+ TableHandler,
12
+ ToggleableHeadingHandler,
13
+ ToggleHandler,
14
+ )
15
+ from notionary.page.writer.markdown_to_notion_formatting_post_processor import (
16
+ MarkdownToNotionFormattingPostProcessor,
17
+ )
18
+ from notionary.page.writer.notion_text_length_processor import (
19
+ NotionTextLengthProcessor,
20
+ )
21
+
22
+
23
+ class MarkdownToNotionConverter:
24
+ """Converts Markdown text to Notion API block format with unified stack-based processing."""
25
+
26
+ def __init__(self, block_registry: BlockRegistry) -> None:
27
+ self._block_registry = block_registry
28
+ self._formatting_post_processor = MarkdownToNotionFormattingPostProcessor()
29
+ self._text_length_post_processor = NotionTextLengthProcessor()
30
+
31
+ self._setup_handler_chain()
32
+
33
+ def _setup_handler_chain(self) -> None:
34
+ code_handler = CodeHandler()
35
+ equation_handler = EquationHandler()
36
+ table_handler = TableHandler()
37
+ column_list_handler = ColumnListHandler()
38
+ column_handler = ColumnHandler()
39
+ toggle_handler = ToggleHandler()
40
+ toggleable_heading_handler = ToggleableHeadingHandler()
41
+ regular_handler = RegularLineHandler()
42
+
43
+ # register more specific elements first
44
+ code_handler.set_next(equation_handler).set_next(table_handler).set_next(
45
+ column_list_handler
46
+ ).set_next(column_handler).set_next(toggleable_heading_handler).set_next(
47
+ toggle_handler
48
+ ).set_next(
49
+ regular_handler
50
+ )
51
+
52
+ self._handler_chain = code_handler
53
+
54
+ async def convert(self, markdown_text: str) -> list[BlockCreateRequest]:
55
+ if not markdown_text.strip():
56
+ return []
57
+
58
+ all_blocks = await self.process_lines(markdown_text)
59
+
60
+ # Apply formatting post-processing (empty paragraphs)
61
+ all_blocks = self._formatting_post_processor.process(all_blocks)
62
+
63
+ # Apply text length post-processing (truncation)
64
+ all_blocks = self._text_length_post_processor.process(all_blocks)
65
+
66
+ return all_blocks
67
+
68
+ async def process_lines(self, text: str) -> list[BlockCreateRequest]:
69
+ lines = text.split("\n")
70
+ result_blocks: list[BlockCreateRequest] = []
71
+ parent_stack: list[ParentBlockContext] = []
72
+
73
+ i = 0
74
+ while i < len(lines):
75
+ line = lines[i]
76
+
77
+ context = LineProcessingContext(
78
+ line=line,
79
+ result_blocks=result_blocks,
80
+ parent_stack=parent_stack,
81
+ block_registry=self._block_registry,
82
+ all_lines=lines,
83
+ current_line_index=i,
84
+ lines_consumed=0,
85
+ )
86
+
87
+ await self._handler_chain.handle(context)
88
+
89
+ # Skip consumed lines
90
+ i += 1 + context.lines_consumed
91
+
92
+ if context.should_continue:
93
+ continue
94
+
95
+ return result_blocks
@@ -0,0 +1,30 @@
1
+ # notionary/blocks/context/conversion_context.py
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional, TYPE_CHECKING
5
+ from dataclasses import dataclass
6
+
7
+ if TYPE_CHECKING:
8
+ from notionary.database.client import NotionDatabaseClient
9
+
10
+
11
+ @dataclass
12
+ class ConverterContext:
13
+ """
14
+ Context object that provides dependencies for block conversion operations.
15
+ """
16
+
17
+ page_id: Optional[str] = None
18
+ database_client: Optional["NotionDatabaseClient"] = None
19
+
20
+ def require_database_client(self) -> NotionDatabaseClient:
21
+ """Get database client or raise if not available."""
22
+ if self.database_client is None:
23
+ raise ValueError("Database client required but not provided in context")
24
+ return self.database_client
25
+
26
+ def require_page_id(self) -> str:
27
+ """Get parent page ID or raise if not available."""
28
+ if self.page_id is None:
29
+ raise ValueError("Parent page ID required but not provided in context")
30
+ return self.page_id
@@ -0,0 +1,73 @@
1
+ """
2
+ Post-processor for handling block formatting in Markdown to Notion conversion.
3
+
4
+ Handles block formatting tasks like adding empty paragraphs before media blocks
5
+ and other formatting-related post-processing.
6
+ """
7
+
8
+ from typing import cast
9
+
10
+ from notionary.blocks.models import BlockCreateRequest
11
+ from notionary.blocks.types import BlockType
12
+ from notionary.blocks.paragraph.paragraph_models import (
13
+ CreateParagraphBlock,
14
+ ParagraphBlock,
15
+ )
16
+
17
+
18
+ class MarkdownToNotionFormattingPostProcessor:
19
+ """Handles block formatting post-processing for Notion blocks."""
20
+
21
+ BLOCKS_NEEDING_EMPTY_PARAGRAPH: set[BlockType] = {
22
+ BlockType.DIVIDER,
23
+ BlockType.FILE,
24
+ BlockType.IMAGE,
25
+ BlockType.PDF,
26
+ BlockType.VIDEO,
27
+ }
28
+
29
+ def process(self, blocks: list[BlockCreateRequest]) -> list[BlockCreateRequest]:
30
+ """Process blocks with all formatting steps."""
31
+ if not blocks:
32
+ return blocks
33
+
34
+ return self._add_empty_paragraphs_for_media_blocks(blocks)
35
+
36
+ def _add_empty_paragraphs_for_media_blocks(
37
+ self, blocks: list[BlockCreateRequest]
38
+ ) -> list[BlockCreateRequest]:
39
+ """Add empty paragraphs before configured block types."""
40
+ if not blocks:
41
+ return blocks
42
+
43
+ result: list[BlockCreateRequest] = []
44
+
45
+ for i, block in enumerate(blocks):
46
+ block_type = block.type
47
+
48
+ if (
49
+ block_type in self.BLOCKS_NEEDING_EMPTY_PARAGRAPH
50
+ and i > 0
51
+ and not self._is_empty_paragraph(result[-1] if result else None)
52
+ ):
53
+
54
+ # Create empty paragraph block inline
55
+ empty_paragraph = CreateParagraphBlock(
56
+ paragraph=ParagraphBlock(rich_text=[])
57
+ )
58
+ result.append(empty_paragraph)
59
+
60
+ result.append(block)
61
+
62
+ return result
63
+
64
+ def _is_empty_paragraph(self, block: BlockCreateRequest | None) -> bool:
65
+ if not block or block.type != BlockType.PARAGRAPH:
66
+ return False
67
+ if not isinstance(block, CreateParagraphBlock):
68
+ return False
69
+
70
+ para_block = cast(CreateParagraphBlock, block)
71
+ paragraph: ParagraphBlock | None = para_block.paragraph
72
+ if not paragraph:
73
+ return False
@@ -0,0 +1,150 @@
1
+ """
2
+ Post-processor for handling Notion API text length limitations.
3
+
4
+ Handles text length validation and truncation for blocks that exceed
5
+ Notion's rich_text character limit of 2000 characters per element.
6
+ """
7
+
8
+ from typing import TypeGuard, Union
9
+
10
+ from notionary.blocks.models import BlockCreateRequest
11
+ from notionary.blocks.rich_text.rich_text_models import RichTextObject
12
+ from notionary.blocks.types import HasRichText, HasChildren
13
+ from notionary.util import LoggingMixin
14
+
15
+
16
+ class NotionTextLengthProcessor(LoggingMixin):
17
+ """
18
+ Processes Notion blocks to ensure text content doesn't exceed API limits.
19
+
20
+ The Notion API has a limit of 2000 characters per rich_text element.
21
+ This processor truncates content that exceeds the specified limit.
22
+ """
23
+
24
+ DEFAULT_MAX_LENGTH = 1900 # Leave some buffer under the 2000 limit
25
+
26
+ def __init__(self, max_text_length: int = DEFAULT_MAX_LENGTH) -> None:
27
+ """
28
+ Initialize the processor.
29
+
30
+ Args:
31
+ max_text_length: Maximum allowed text length (default: 1900)
32
+ """
33
+ if max_text_length <= 0:
34
+ raise ValueError("max_text_length must be positive")
35
+ if max_text_length > 2000:
36
+ self.logger.warning(
37
+ "max_text_length (%d) exceeds Notion's limit of 2000 characters",
38
+ max_text_length,
39
+ )
40
+
41
+ self.max_text_length = max_text_length
42
+
43
+ def process(self, blocks: list[BlockCreateRequest]) -> list[BlockCreateRequest]:
44
+ """
45
+ Process blocks to fix text length limits.
46
+ """
47
+ if not blocks:
48
+ return blocks
49
+
50
+ flattened_blocks = self._flatten_block_list(blocks)
51
+ return [self._process_single_block(block) for block in flattened_blocks]
52
+
53
+ def _process_single_block(self, block: BlockCreateRequest) -> BlockCreateRequest:
54
+ """
55
+ Process a single block to fix text length issues.
56
+ """
57
+ block_copy = block.model_copy(deep=True)
58
+
59
+ block_content = self._extract_block_content(block_copy)
60
+
61
+ if block_content is not None:
62
+ self._fix_content_text_lengths(block_content)
63
+
64
+ return block_copy
65
+
66
+ def _extract_block_content(self, block: BlockCreateRequest) -> object | None:
67
+ """
68
+ Extract the content object from a block using type-safe attribute access.
69
+ """
70
+ # Get the block's content using the block type as attribute name
71
+ # We assume block.type always exists as per the BlockCreateRequest structure
72
+ content = getattr(block, block.type, None)
73
+
74
+ # Verify it's a valid content object (has rich_text or children)
75
+ if content and (
76
+ self._is_rich_text_container(content)
77
+ or self._is_children_container(content)
78
+ ):
79
+ return content
80
+
81
+ return None
82
+
83
+ def _fix_content_text_lengths(self, content: object) -> None:
84
+ """
85
+ Fix text lengths in a content object and its children recursively.
86
+ """
87
+ # Process rich_text if present
88
+ if self._is_rich_text_container(content):
89
+ self._truncate_rich_text_content(content.rich_text)
90
+
91
+ # Process children recursively if present
92
+ if self._is_children_container(content):
93
+ for child in content.children:
94
+ child_content = self._extract_block_content(child)
95
+ if child_content:
96
+ self._fix_content_text_lengths(child_content)
97
+
98
+ def _truncate_rich_text_content(self, rich_text_list: list[RichTextObject]) -> None:
99
+ """
100
+ Truncate text content in rich text objects that exceed the limit.
101
+ """
102
+ for rich_text_obj in rich_text_list:
103
+ if not self._is_text_rich_text_object(rich_text_obj):
104
+ continue
105
+
106
+ content = rich_text_obj.text.content
107
+ if len(content) > self.max_text_length:
108
+ self.logger.warning(
109
+ "Truncating text content from %d to %d characters",
110
+ len(content),
111
+ self.max_text_length,
112
+ )
113
+ # Truncate the content
114
+ rich_text_obj.text.content = content[: self.max_text_length]
115
+
116
+ def _flatten_block_list(
117
+ self, blocks: list[Union[BlockCreateRequest, list]]
118
+ ) -> list[BlockCreateRequest]:
119
+ """
120
+ Flatten a potentially nested list of blocks.
121
+ """
122
+ flattened: list[BlockCreateRequest] = []
123
+
124
+ for item in blocks:
125
+ if isinstance(item, list):
126
+ # Recursively flatten nested lists
127
+ flattened.extend(self._flatten_block_list(item))
128
+ else:
129
+ # Add individual block
130
+ flattened.append(item)
131
+
132
+ return flattened
133
+
134
+ def _is_rich_text_container(self, obj: object) -> TypeGuard[HasRichText]:
135
+ """Type guard to check if an object has rich_text attribute."""
136
+ return hasattr(obj, "rich_text") and isinstance(getattr(obj, "rich_text"), list)
137
+
138
+ def _is_children_container(self, obj: object) -> TypeGuard[HasChildren]:
139
+ """Type guard to check if an object has children attribute."""
140
+ return hasattr(obj, "children") and isinstance(getattr(obj, "children"), list)
141
+
142
+ def _is_text_rich_text_object(
143
+ self, rich_text_obj: RichTextObject
144
+ ) -> TypeGuard[RichTextObject]:
145
+ """Type guard to check if a RichTextObject is of type 'text' with content."""
146
+ return (
147
+ rich_text_obj.type == "text"
148
+ and rich_text_obj.text is not None
149
+ and rich_text_obj.text.content is not None
150
+ )
@@ -2,10 +2,10 @@ from .service import ProductTelemetry
2
2
  from .views import (
3
3
  BaseTelemetryEvent,
4
4
  DatabaseFactoryUsedEvent,
5
- QueryOperationEvent,
6
- NotionMarkdownSyntaxPromptEvent,
7
5
  MarkdownToNotionConversionEvent,
6
+ NotionMarkdownSyntaxPromptEvent,
8
7
  NotionToMarkdownConversionEvent,
8
+ QueryOperationEvent,
9
9
  )
10
10
 
11
11
  __all__ = [
@@ -1,12 +1,12 @@
1
1
  import os
2
2
  import uuid
3
3
  from pathlib import Path
4
- from typing import Dict, Any, Optional
5
- from posthog import Posthog
4
+
6
5
  from dotenv import load_dotenv
6
+ from posthog import Posthog
7
7
 
8
8
  from notionary.telemetry.views import BaseTelemetryEvent
9
- from notionary.util import SingletonMetaClass, LoggingMixin
9
+ from notionary.util import LoggingMixin, SingletonMetaClass
10
10
 
11
11
  load_dotenv()
12
12
 
@@ -1,7 +1,7 @@
1
- from .notion_user import NotionUser
2
- from .notion_user_manager import NotionUserManager
3
1
  from .client import NotionUserClient
4
2
  from .notion_bot_user import NotionBotUser
3
+ from .notion_user import NotionUser
4
+ from .notion_user_manager import NotionUserManager
5
5
 
6
6
  __all__ = [
7
7
  "NotionUser",
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
+
2
3
  from abc import ABC
3
4
  from typing import Optional
4
- from notionary.user.client import NotionUserClient
5
5
 
6
+ from notionary.user.client import NotionUserClient
6
7
  from notionary.util import LoggingMixin
7
8
 
8
9
 
notionary/user/client.py CHANGED
@@ -1,14 +1,13 @@
1
- from typing import Optional, List
1
+ from typing import List, Optional
2
+
2
3
  from notionary.base_notion_client import BaseNotionClient
3
4
  from notionary.user.models import (
4
5
  NotionBotUserResponse,
5
6
  NotionUserResponse,
6
7
  NotionUsersListResponse,
7
8
  )
8
- from notionary.util import singleton
9
9
 
10
10
 
11
- @singleton
12
11
  class NotionUserClient(BaseNotionClient):
13
12
  """
14
13
  Client for Notion user-specific operations.
notionary/user/models.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from dataclasses import dataclass
2
2
  from typing import Literal, Optional
3
+
3
4
  from pydantic import BaseModel
4
5
 
5
6
 
@@ -1,11 +1,10 @@
1
1
  from __future__ import annotations
2
- from typing import Optional, List
2
+
3
+ from typing import List, Optional
4
+
3
5
  from notionary.user.base_notion_user import BaseNotionUser
4
6
  from notionary.user.client import NotionUserClient
5
- from notionary.user.models import (
6
- NotionBotUserResponse,
7
- WorkspaceLimits,
8
- )
7
+ from notionary.user.models import NotionBotUserResponse, WorkspaceLimits
9
8
  from notionary.util import factory_only
10
9
  from notionary.util.fuzzy import find_best_match
11
10
 
@@ -1,11 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import Optional, List
3
+ from typing import List, Optional
4
+
4
5
  from notionary.user.base_notion_user import BaseNotionUser
5
6
  from notionary.user.client import NotionUserClient
6
- from notionary.user.models import (
7
- NotionUserResponse,
8
- )
7
+ from notionary.user.models import NotionUserResponse
9
8
  from notionary.util import factory_only
10
9
  from notionary.util.fuzzy import find_best_matches
11
10