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,73 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from notionary.page.reader.handler import BlockHandler, BlockRenderingContext
6
+
7
+
8
+ class LineRenderer(BlockHandler):
9
+ """Handles all regular blocks that don't need special parent/children processing."""
10
+
11
+ def _can_handle(self, context: BlockRenderingContext) -> bool:
12
+ # Always can handle - this is the fallback handler
13
+ return True
14
+
15
+ async def _process(self, context: BlockRenderingContext) -> None:
16
+ # Convert the block itself using direct element iteration
17
+ block_markdown = await self._convert_block_to_markdown(context)
18
+
19
+ # If block has no direct markdown, either return empty or process children
20
+ if not block_markdown:
21
+ if not context.has_children():
22
+ context.markdown_result = ""
23
+ context.was_processed = True
24
+ return
25
+
26
+ # Import here to avoid circular dependency and process children
27
+ from notionary.page.reader.page_content_retriever import (
28
+ PageContentRetriever,
29
+ )
30
+
31
+ retriever = PageContentRetriever(context.block_registry)
32
+ children_markdown = retriever._convert_blocks_to_markdown(
33
+ context.get_children_blocks(), indent_level=context.indent_level + 1
34
+ )
35
+ context.markdown_result = children_markdown
36
+ context.was_processed = True
37
+ return
38
+
39
+ # Apply indentation if needed
40
+ if context.indent_level > 0:
41
+ block_markdown = self._indent_text(
42
+ block_markdown, spaces=context.indent_level * 4
43
+ )
44
+
45
+ # If there are no children, return the block markdown directly
46
+ if not context.has_children():
47
+ context.markdown_result = block_markdown
48
+ context.was_processed = True
49
+ return
50
+
51
+ # Otherwise process children and combine
52
+ from notionary.page.reader.page_content_retriever import PageContentRetriever
53
+
54
+ retriever = PageContentRetriever(context.block_registry)
55
+ children_markdown = retriever._convert_blocks_to_markdown(
56
+ context.get_children_blocks(), indent_level=context.indent_level + 1
57
+ )
58
+
59
+ context.markdown_result = (
60
+ f"{block_markdown}\n{children_markdown}"
61
+ if children_markdown
62
+ else block_markdown
63
+ )
64
+ context.was_processed = True
65
+
66
+ async def _convert_block_to_markdown(
67
+ self, context: BlockRenderingContext
68
+ ) -> Optional[str]:
69
+ """Convert a Notion block to markdown using registered elements."""
70
+ for element in context.block_registry.get_elements():
71
+ if element.match_notion(context.block):
72
+ return await element.notion_to_markdown(context.block)
73
+ return None
@@ -0,0 +1,85 @@
1
+ from notionary.blocks.models import Block, BlockType
2
+ from notionary.blocks.registry.block_registry import BlockRegistry
3
+ from notionary.page.reader.handler.base_block_renderer import BlockHandler
4
+ from notionary.page.reader.handler.block_rendering_context import BlockRenderingContext
5
+
6
+
7
+ class NumberedListRenderer(BlockHandler):
8
+ """Handles numbered list items with sequential numbering."""
9
+
10
+ def _can_handle(self, context: BlockRenderingContext) -> bool:
11
+ """Check if this is a numbered list item."""
12
+ return (
13
+ context.block.type == BlockType.NUMBERED_LIST_ITEM
14
+ and context.block.numbered_list_item is not None
15
+ )
16
+
17
+ async def _process(self, context: BlockRenderingContext) -> None:
18
+ """Process numbered list item with sequential numbering."""
19
+ if context.all_blocks is None or context.current_block_index is None:
20
+ await self._process_single_item(context, 1)
21
+ return
22
+
23
+ items, blocks_to_skip = self._collect_numbered_list_items(context)
24
+
25
+ markdown_parts = []
26
+ for i, item_context in enumerate(items, 1):
27
+ item_markdown = await self._process_single_item(item_context, i)
28
+ if item_markdown:
29
+ markdown_parts.append(item_markdown)
30
+
31
+ # Set result and mark how many blocks to skip
32
+ if markdown_parts:
33
+ context.markdown_result = "\n".join(markdown_parts)
34
+ context.was_processed = True
35
+ context.blocks_consumed = blocks_to_skip
36
+
37
+ def _collect_numbered_list_items(
38
+ self, context: BlockRenderingContext
39
+ ) -> tuple[list[BlockRenderingContext], int]:
40
+ """Collect all consecutive numbered list items starting from current position."""
41
+ items = []
42
+ current_index = context.current_block_index
43
+ all_blocks = context.all_blocks
44
+
45
+ # Start with current block
46
+ items.append(context)
47
+ blocks_processed = 1
48
+
49
+ # Look ahead for more numbered list items
50
+ for i in range(current_index + 1, len(all_blocks)):
51
+ block = all_blocks[i]
52
+
53
+ # Check if it's a numbered list item
54
+ if (
55
+ block.type == BlockType.NUMBERED_LIST_ITEM
56
+ and block.numbered_list_item is not None
57
+ ):
58
+
59
+ # Create context for this item
60
+ item_context = BlockRenderingContext(
61
+ block=block,
62
+ indent_level=context.indent_level,
63
+ block_registry=context.block_registry,
64
+ convert_children_callback=context.convert_children_callback,
65
+ )
66
+ items.append(item_context)
67
+ blocks_processed += 1
68
+ else:
69
+ # Not a numbered list item - stop collecting
70
+ break
71
+
72
+ return items, blocks_processed
73
+
74
+ async def _process_single_item(
75
+ self, context: BlockRenderingContext, number: int
76
+ ) -> str:
77
+ """Process a single numbered list item with the given number."""
78
+ from notionary.blocks.rich_text.text_inline_formatter import TextInlineFormatter
79
+
80
+ rich_text = context.block.numbered_list_item.rich_text
81
+ content = await TextInlineFormatter.extract_text_with_formatting(rich_text)
82
+
83
+ # Apply indentation
84
+ indent = " " * context.indent_level
85
+ return f"{indent}{number}. {content}"
@@ -0,0 +1,69 @@
1
+ from notionary.blocks.toggle.toggle_element import ToggleElement
2
+ from notionary.page.reader.handler import BlockHandler, BlockRenderingContext
3
+
4
+
5
+ class ToggleRenderer(BlockHandler):
6
+ """Handles toggle blocks with their children content."""
7
+
8
+ def _can_handle(self, context: BlockRenderingContext) -> bool:
9
+ return ToggleElement.match_notion(context.block)
10
+
11
+ def _process(self, context: BlockRenderingContext) -> None:
12
+ # Get the toggle title from the block
13
+ toggle_title = self._extract_toggle_title(context.block)
14
+
15
+ if not toggle_title:
16
+ return
17
+
18
+ # Create toggle start line
19
+ toggle_start = f"+++ {toggle_title}"
20
+
21
+ # Apply indentation if needed
22
+ if context.indent_level > 0:
23
+ toggle_start = self._indent_text(
24
+ toggle_start, spaces=context.indent_level * 4
25
+ )
26
+
27
+ # Process children if they exist
28
+ children_markdown = ""
29
+ if context.has_children():
30
+ # Import here to avoid circular dependency
31
+ from notionary.page.reader.page_content_retriever import (
32
+ PageContentRetriever,
33
+ )
34
+
35
+ # Create a temporary retriever to process children
36
+ retriever = PageContentRetriever(context.block_registry)
37
+ children_markdown = retriever._convert_blocks_to_markdown(
38
+ context.get_children_blocks(),
39
+ indent_level=0, # No indentation for content inside toggles
40
+ )
41
+
42
+ # Create toggle end line
43
+ toggle_end = "+++"
44
+ if context.indent_level > 0:
45
+ toggle_end = self._indent_text(toggle_end, spaces=context.indent_level * 4)
46
+
47
+ # Combine toggle with children content
48
+ if children_markdown:
49
+ context.markdown_result = (
50
+ f"{toggle_start}\n{children_markdown}\n{toggle_end}"
51
+ )
52
+ else:
53
+ context.markdown_result = f"{toggle_start}\n{toggle_end}"
54
+
55
+ context.was_processed = True
56
+
57
+ def _extract_toggle_title(self, block) -> str:
58
+ """Extract toggle title from the block."""
59
+ if not block.toggle or not block.toggle.rich_text:
60
+ return ""
61
+
62
+ title = ""
63
+ for text_obj in block.toggle.rich_text:
64
+ if hasattr(text_obj, "plain_text"):
65
+ title += text_obj.plain_text or ""
66
+ elif hasattr(text_obj, "text") and hasattr(text_obj.text, "content"):
67
+ title += text_obj.text.content or ""
68
+
69
+ return title.strip()
@@ -0,0 +1,89 @@
1
+ from notionary.blocks.toggleable_heading.toggleable_heading_element import (
2
+ ToggleableHeadingElement,
3
+ )
4
+ from notionary.blocks.types import BlockType
5
+ from notionary.page.reader.handler import BlockHandler, BlockRenderingContext
6
+
7
+
8
+ class ToggleableHeadingRenderer(BlockHandler):
9
+ """Handles toggleable heading blocks with their children content."""
10
+
11
+ def _can_handle(self, context: BlockRenderingContext) -> bool:
12
+ return ToggleableHeadingElement.match_notion(context.block)
13
+
14
+ def _process(self, context: BlockRenderingContext) -> None:
15
+ # Get the heading level and title
16
+ level, title = self._extract_heading_info(context.block)
17
+
18
+ if not title or level == 0:
19
+ return
20
+
21
+ # Create toggleable heading start line
22
+ prefix = "+++" + ("#" * level)
23
+ heading_start = f"{prefix} {title}"
24
+
25
+ # Apply indentation if needed
26
+ if context.indent_level > 0:
27
+ heading_start = self._indent_text(
28
+ heading_start, spaces=context.indent_level * 4
29
+ )
30
+
31
+ # Process children if they exist
32
+ children_markdown = ""
33
+ if context.has_children():
34
+ # Import here to avoid circular dependency
35
+ from notionary.page.reader.page_content_retriever import (
36
+ PageContentRetriever,
37
+ )
38
+
39
+ # Create a temporary retriever to process children
40
+ retriever = PageContentRetriever(context.block_registry)
41
+ children_markdown = retriever._convert_blocks_to_markdown(
42
+ context.get_children_blocks(),
43
+ indent_level=0, # No indentation for content inside toggleable headings
44
+ )
45
+
46
+ # Create toggleable heading end line
47
+ heading_end = "+++"
48
+ if context.indent_level > 0:
49
+ heading_end = self._indent_text(
50
+ heading_end, spaces=context.indent_level * 4
51
+ )
52
+
53
+ # Combine heading with children content
54
+ if children_markdown:
55
+ context.markdown_result = (
56
+ f"{heading_start}\n{children_markdown}\n{heading_end}"
57
+ )
58
+ else:
59
+ context.markdown_result = f"{heading_start}\n{heading_end}"
60
+
61
+ context.was_processed = True
62
+
63
+ def _extract_heading_info(self, block) -> tuple[int, str]:
64
+ """Extract heading level and title from the block."""
65
+ # Determine heading level from block type
66
+ if block.type == BlockType.HEADING_1:
67
+ level = 1
68
+ heading_content = block.heading_1
69
+ elif block.type == BlockType.HEADING_2:
70
+ level = 2
71
+ heading_content = block.heading_2
72
+ elif block.type == BlockType.HEADING_3:
73
+ level = 3
74
+ heading_content = block.heading_3
75
+ else:
76
+ return 0, ""
77
+
78
+ if not heading_content or not heading_content.rich_text:
79
+ return level, ""
80
+
81
+ # Extract title from rich_text
82
+ title = ""
83
+ for text_obj in heading_content.rich_text:
84
+ if hasattr(text_obj, "plain_text"):
85
+ title += text_obj.plain_text or ""
86
+ elif hasattr(text_obj, "text") and hasattr(text_obj.text, "content"):
87
+ title += text_obj.text.content or ""
88
+
89
+ return level, title.strip()
@@ -0,0 +1,81 @@
1
+ from notionary.blocks.models import Block
2
+ from notionary.blocks.registry.block_registry import BlockRegistry
3
+ from notionary.page.reader.handler import (
4
+ BlockRenderingContext,
5
+ ColumnListRenderer,
6
+ ColumnRenderer,
7
+ LineRenderer,
8
+ NumberedListRenderer,
9
+ ToggleableHeadingRenderer,
10
+ ToggleRenderer,
11
+ )
12
+ from notionary.util import LoggingMixin
13
+
14
+
15
+ class PageContentRetriever(LoggingMixin):
16
+ """Retrieves Notion page content and converts it to Markdown using chain of responsibility."""
17
+
18
+ def __init__(
19
+ self,
20
+ block_registry: BlockRegistry,
21
+ ):
22
+ self._block_registry = block_registry
23
+
24
+ self._setup_handler_chain()
25
+
26
+ async def convert_to_markdown(self, blocks: list[Block]) -> str:
27
+ """
28
+ Retrieve page content and convert it to Markdown.
29
+ Uses the chain of responsibility pattern for scalable block processing.
30
+ """
31
+ return await self._convert_blocks_to_markdown(blocks, indent_level=0)
32
+
33
+ def _setup_handler_chain(self) -> None:
34
+ """Setup the chain of handlers in priority order."""
35
+ toggle_handler = ToggleRenderer()
36
+ toggleable_heading_handler = ToggleableHeadingRenderer()
37
+ column_list_handler = ColumnListRenderer()
38
+ column_handler = ColumnRenderer()
39
+ numbered_list_handler = NumberedListRenderer()
40
+ regular_handler = LineRenderer()
41
+
42
+ # Chain handlers - most specific first
43
+ toggle_handler.set_next(toggleable_heading_handler).set_next(
44
+ column_list_handler
45
+ ).set_next(column_handler).set_next(numbered_list_handler).set_next(
46
+ regular_handler
47
+ )
48
+
49
+ self._handler_chain = toggle_handler
50
+
51
+ async def _convert_blocks_to_markdown(
52
+ self, blocks: list[Block], indent_level: int = 0
53
+ ) -> str:
54
+ """Convert blocks to Markdown using the handler chain."""
55
+ if not blocks:
56
+ return ""
57
+
58
+ markdown_parts = []
59
+ i = 0
60
+
61
+ while i < len(blocks):
62
+ block = blocks[i]
63
+ context = BlockRenderingContext(
64
+ block=block,
65
+ indent_level=indent_level,
66
+ block_registry=self._block_registry,
67
+ all_blocks=blocks,
68
+ current_block_index=i,
69
+ convert_children_callback=self._convert_blocks_to_markdown,
70
+ )
71
+
72
+ await self._handler_chain.handle(context)
73
+
74
+ if context.was_processed and context.markdown_result:
75
+ markdown_parts.append(context.markdown_result)
76
+
77
+ # Skip additional blocks if they were consumed by batch processing
78
+ i += max(1, context.blocks_consumed)
79
+
80
+ separator = "\n\n" if indent_level == 0 else "\n"
81
+ return separator.join(markdown_parts)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
- from typing import Any, Dict, Optional, Literal
2
+
3
3
  from dataclasses import dataclass
4
+ from typing import Any, Dict, Literal, Optional
4
5
 
5
6
 
6
7
  @dataclass
@@ -0,0 +1,24 @@
1
+ from .code_handler import CodeHandler
2
+ from .column_handler import ColumnHandler
3
+ from .column_list_handler import ColumnListHandler
4
+ from .equation_handler import EquationHandler
5
+ from .line_handler import LineHandler
6
+ from .line_processing_context import LineProcessingContext, ParentBlockContext
7
+ from .regular_line_handler import RegularLineHandler
8
+ from .table_handler import TableHandler
9
+ from .toggle_handler import ToggleHandler
10
+ from .toggleable_heading_handler import ToggleableHeadingHandler
11
+
12
+ __all__ = [
13
+ "LineProcessingContext",
14
+ "ParentBlockContext",
15
+ "LineHandler",
16
+ "ColumnListHandler",
17
+ "ColumnHandler",
18
+ "ToggleableHeadingHandler",
19
+ "ToggleHandler",
20
+ "TableHandler",
21
+ "RegularLineHandler",
22
+ "CodeHandler",
23
+ "EquationHandler",
24
+ ]
@@ -0,0 +1,72 @@
1
+ import re
2
+
3
+ from notionary.blocks.code.code_element import CodeElement
4
+ from notionary.page.writer.handler.line_handler import (
5
+ LineHandler,
6
+ LineProcessingContext,
7
+ )
8
+
9
+
10
+ class CodeHandler(LineHandler):
11
+ """Handles code block specific logic with batching.
12
+
13
+ Markdown syntax:
14
+ ```language "optional caption"
15
+ code lines...
16
+ ```
17
+ """
18
+
19
+ def __init__(self):
20
+ super().__init__()
21
+ self._code_start_pattern = re.compile(r"^```(\w*)\s*(?:\"([^\"]*)\")?\s*$")
22
+ self._code_end_pattern = re.compile(r"^```\s*$")
23
+
24
+ def _can_handle(self, context: LineProcessingContext) -> bool:
25
+ if self._is_inside_parent_context(context):
26
+ return False
27
+ return self._is_code_start(context)
28
+
29
+ async def _process(self, context: LineProcessingContext) -> None:
30
+ if self._is_code_start(context):
31
+ await self._process_complete_code_block(context)
32
+ self._mark_processed(context)
33
+
34
+ def _is_code_start(self, context: LineProcessingContext) -> bool:
35
+ """Check if this line starts a code block."""
36
+ return self._code_start_pattern.match(context.line.strip()) is not None
37
+
38
+ def _is_inside_parent_context(self, context: LineProcessingContext) -> bool:
39
+ """Check if we're currently inside any parent context (toggle, heading, etc.)."""
40
+ return len(context.parent_stack) > 0
41
+
42
+ async def _process_complete_code_block(
43
+ self, context: LineProcessingContext
44
+ ) -> None:
45
+ """Process the entire code block in one go using CodeElement."""
46
+ code_lines, lines_to_consume = self._collect_code_lines(context)
47
+
48
+ block = CodeElement.create_from_markdown_block(
49
+ opening_line=context.line, code_lines=code_lines
50
+ )
51
+
52
+ if block:
53
+ context.lines_consumed = lines_to_consume
54
+ context.result_blocks.append(block)
55
+
56
+ def _collect_code_lines(
57
+ self, context: LineProcessingContext
58
+ ) -> tuple[list[str], int]:
59
+ """Collect lines until closing fence and return (lines, count_to_consume)."""
60
+ lines = []
61
+ for idx, ln in enumerate(context.get_remaining_lines()):
62
+ if self._code_end_pattern.match(ln.strip()):
63
+ return lines, idx + 1
64
+ lines.append(ln)
65
+ # No closing fence: consume all remaining
66
+ rem = context.get_remaining_lines()
67
+ return rem, len(rem)
68
+
69
+ def _mark_processed(self, context: LineProcessingContext) -> None:
70
+ """Mark context as processed and continue."""
71
+ context.was_processed = True
72
+ context.should_continue = True
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from notionary.blocks.column.column_element import ColumnElement
6
+ from notionary.page.writer.handler.line_handler import (
7
+ LineHandler,
8
+ LineProcessingContext,
9
+ )
10
+ from notionary.page.writer.handler.line_processing_context import ParentBlockContext
11
+
12
+
13
+ class ColumnHandler(LineHandler):
14
+ """Handles single column elements - both start and end.
15
+ Syntax:
16
+ ::: column # Start individual column (can have optional parameters)
17
+ Content here
18
+ ::: # End column
19
+ """
20
+
21
+ def __init__(self):
22
+ super().__init__()
23
+ self._start_pattern = re.compile(r"^:::\s*column(\s+.*?)?\s*$", re.IGNORECASE)
24
+ self._end_pattern = re.compile(r"^:::\s*$")
25
+
26
+ def _can_handle(self, context: LineProcessingContext) -> bool:
27
+ return self._is_column_start(context) or self._is_column_end(context)
28
+
29
+ async def _process(self, context: LineProcessingContext) -> None:
30
+ if self._is_column_start(context):
31
+ await self._start_column(context)
32
+ self._mark_processed(context)
33
+ return
34
+
35
+ if self._is_column_end(context):
36
+ await self._finalize_column(context)
37
+ self._mark_processed(context)
38
+
39
+ def _is_column_start(self, context: LineProcessingContext) -> bool:
40
+ """Check if line starts a column (::: column)."""
41
+ return self._start_pattern.match(context.line.strip()) is not None
42
+
43
+ def _is_column_end(self, context: LineProcessingContext) -> bool:
44
+ """Check if we need to end a single column (:::)."""
45
+ if not self._end_pattern.match(context.line.strip()):
46
+ return False
47
+
48
+ if not context.parent_stack:
49
+ return False
50
+
51
+ # Check if top of stack is a Column (not ColumnList)
52
+ current_parent = context.parent_stack[-1]
53
+ return issubclass(current_parent.element_type, ColumnElement)
54
+
55
+ async def _start_column(self, context: LineProcessingContext) -> None:
56
+ """Start a new column."""
57
+ # Create Column block directly - much more efficient!
58
+ column_element = ColumnElement()
59
+ result = await column_element.markdown_to_notion(context.line)
60
+ if not result:
61
+ return
62
+
63
+ block = result
64
+
65
+ # Push to parent stack
66
+ parent_context = ParentBlockContext(
67
+ block=block,
68
+ element_type=ColumnElement,
69
+ child_lines=[],
70
+ )
71
+ context.parent_stack.append(parent_context)
72
+
73
+ async def _finalize_column(self, context: LineProcessingContext) -> None:
74
+ """Finalize a single column and add it to the column list or result."""
75
+ column_context = context.parent_stack.pop()
76
+ await self._assign_column_children_if_any(column_context, context)
77
+
78
+ if context.parent_stack:
79
+ parent = context.parent_stack[-1]
80
+ from notionary.blocks.column.column_list_element import ColumnListElement
81
+
82
+ if issubclass(parent.element_type, ColumnListElement):
83
+ # Add to parent using the new system
84
+ parent.add_child_block(column_context.block)
85
+ return
86
+
87
+ # Fallback: no parent or parent is not ColumnList
88
+ context.result_blocks.append(column_context.block)
89
+
90
+ async def _assign_column_children_if_any(
91
+ self, column_context: ParentBlockContext, context: LineProcessingContext
92
+ ) -> None:
93
+ """Collect and assign any children blocks inside this column."""
94
+ all_children = []
95
+
96
+ # Process text lines
97
+ if column_context.child_lines:
98
+ children_text = "\n".join(column_context.child_lines)
99
+ text_blocks = await self._convert_children_text(
100
+ children_text, context.block_registry
101
+ )
102
+ all_children.extend(text_blocks)
103
+
104
+ # Add direct child blocks (like processed toggles)
105
+ if column_context.child_blocks:
106
+ all_children.extend(column_context.child_blocks)
107
+
108
+ column_context.block.column.children = all_children
109
+
110
+ def _try_add_to_parent_column_list(
111
+ self, column_context: ParentBlockContext, context: LineProcessingContext
112
+ ) -> bool:
113
+ """If the previous stack element is a ColumnList, append column and return True."""
114
+ if not context.parent_stack:
115
+ return False
116
+
117
+ parent = context.parent_stack[-1]
118
+ from notionary.blocks.column.column_list_element import ColumnListElement
119
+
120
+ if not issubclass(parent.element_type, ColumnListElement):
121
+ return False
122
+
123
+ parent.block.column_list.children.append(column_context.block)
124
+ return True
125
+
126
+ async def _convert_children_text(self, text: str, block_registry) -> list:
127
+ """Convert children text to blocks."""
128
+ from notionary.page.writer.markdown_to_notion_converter import (
129
+ MarkdownToNotionConverter,
130
+ )
131
+
132
+ if not text.strip():
133
+ return []
134
+
135
+ child_converter = MarkdownToNotionConverter(block_registry)
136
+ return await child_converter.process_lines(text)
137
+
138
+ def _mark_processed(self, context: LineProcessingContext) -> None:
139
+ """Mark context as processed and signal to continue."""
140
+ context.was_processed = True
141
+ context.should_continue = True