notionary 0.2.19__py3-none-any.whl → 0.2.21__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 (205) 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 +263 -0
  5. notionary/blocks/audio/__init__.py +8 -2
  6. notionary/blocks/audio/audio_element.py +42 -104
  7. notionary/blocks/audio/audio_markdown_node.py +3 -1
  8. notionary/blocks/audio/audio_models.py +6 -55
  9. notionary/blocks/base_block_element.py +30 -0
  10. notionary/blocks/bookmark/__init__.py +9 -2
  11. notionary/blocks/bookmark/bookmark_element.py +46 -139
  12. notionary/blocks/bookmark/bookmark_markdown_node.py +3 -1
  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 +40 -55
  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 +40 -89
  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 +7 -0
  27. notionary/blocks/child_database/child_database_models.py +19 -0
  28. notionary/blocks/child_page/__init__.py +9 -0
  29. notionary/blocks/child_page/child_page_models.py +12 -0
  30. notionary/blocks/{shared/block_client.py → client.py} +55 -54
  31. notionary/blocks/code/__init__.py +6 -2
  32. notionary/blocks/code/code_element.py +53 -187
  33. notionary/blocks/code/code_markdown_node.py +13 -13
  34. notionary/blocks/code/code_models.py +94 -0
  35. notionary/blocks/column/__init__.py +25 -1
  36. notionary/blocks/column/column_element.py +40 -314
  37. notionary/blocks/column/column_list_element.py +37 -0
  38. notionary/blocks/column/column_list_markdown_node.py +50 -0
  39. notionary/blocks/column/column_markdown_node.py +59 -0
  40. notionary/blocks/column/column_models.py +26 -0
  41. notionary/blocks/divider/__init__.py +9 -2
  42. notionary/blocks/divider/divider_element.py +26 -49
  43. notionary/blocks/divider/divider_markdown_node.py +2 -1
  44. notionary/blocks/divider/divider_models.py +12 -0
  45. notionary/blocks/embed/__init__.py +9 -2
  46. notionary/blocks/embed/embed_element.py +47 -114
  47. notionary/blocks/embed/embed_markdown_node.py +3 -1
  48. notionary/blocks/embed/embed_models.py +14 -0
  49. notionary/blocks/equation/__init__.py +14 -0
  50. notionary/blocks/equation/equation_element.py +80 -0
  51. notionary/blocks/equation/equation_element_markdown_node.py +36 -0
  52. notionary/blocks/equation/equation_models.py +11 -0
  53. notionary/blocks/file/__init__.py +25 -0
  54. notionary/blocks/file/file_element.py +93 -0
  55. notionary/blocks/file/file_element_markdown_node.py +35 -0
  56. notionary/blocks/file/file_element_models.py +39 -0
  57. notionary/blocks/heading/__init__.py +16 -2
  58. notionary/blocks/heading/heading_element.py +67 -72
  59. notionary/blocks/heading/heading_markdown_node.py +2 -1
  60. notionary/blocks/heading/heading_models.py +29 -0
  61. notionary/blocks/image_block/__init__.py +13 -0
  62. notionary/blocks/image_block/image_element.py +84 -0
  63. notionary/blocks/{image → image_block}/image_markdown_node.py +3 -1
  64. notionary/blocks/image_block/image_models.py +10 -0
  65. notionary/blocks/models.py +172 -0
  66. notionary/blocks/numbered_list/__init__.py +12 -2
  67. notionary/blocks/numbered_list/numbered_list_element.py +33 -58
  68. notionary/blocks/numbered_list/numbered_list_markdown_node.py +3 -1
  69. notionary/blocks/numbered_list/numbered_list_models.py +17 -0
  70. notionary/blocks/paragraph/__init__.py +12 -2
  71. notionary/blocks/paragraph/paragraph_element.py +27 -69
  72. notionary/blocks/paragraph/paragraph_markdown_node.py +2 -1
  73. notionary/blocks/paragraph/paragraph_models.py +16 -0
  74. notionary/blocks/pdf/__init__.py +13 -0
  75. notionary/blocks/pdf/pdf_element.py +91 -0
  76. notionary/blocks/pdf/pdf_markdown_node.py +35 -0
  77. notionary/blocks/pdf/pdf_models.py +11 -0
  78. notionary/blocks/quote/__init__.py +11 -2
  79. notionary/blocks/quote/quote_element.py +31 -65
  80. notionary/blocks/quote/quote_markdown_node.py +4 -1
  81. notionary/blocks/quote/quote_models.py +18 -0
  82. notionary/blocks/registry/__init__.py +4 -0
  83. notionary/blocks/registry/block_registry.py +75 -91
  84. notionary/blocks/registry/block_registry_builder.py +107 -59
  85. notionary/blocks/rich_text/__init__.py +33 -0
  86. notionary/blocks/rich_text/rich_text_models.py +188 -0
  87. notionary/blocks/rich_text/text_inline_formatter.py +125 -0
  88. notionary/blocks/table/__init__.py +16 -2
  89. notionary/blocks/table/table_element.py +48 -241
  90. notionary/blocks/table/table_markdown_node.py +2 -1
  91. notionary/blocks/table/table_models.py +28 -0
  92. notionary/blocks/table_of_contents/__init__.py +19 -0
  93. notionary/blocks/table_of_contents/table_of_contents_element.py +51 -0
  94. notionary/blocks/table_of_contents/table_of_contents_markdown_node.py +35 -0
  95. notionary/blocks/table_of_contents/table_of_contents_models.py +18 -0
  96. notionary/blocks/todo/__init__.py +9 -2
  97. notionary/blocks/todo/todo_element.py +38 -95
  98. notionary/blocks/todo/todo_markdown_node.py +2 -1
  99. notionary/blocks/todo/todo_models.py +19 -0
  100. notionary/blocks/toggle/__init__.py +13 -3
  101. notionary/blocks/toggle/toggle_element.py +57 -264
  102. notionary/blocks/toggle/toggle_markdown_node.py +24 -14
  103. notionary/blocks/toggle/toggle_models.py +17 -0
  104. notionary/blocks/toggleable_heading/__init__.py +6 -2
  105. notionary/blocks/toggleable_heading/toggleable_heading_element.py +74 -244
  106. notionary/blocks/toggleable_heading/toggleable_heading_markdown_node.py +26 -18
  107. notionary/blocks/types.py +61 -0
  108. notionary/blocks/video/__init__.py +8 -2
  109. notionary/blocks/video/video_element.py +67 -143
  110. notionary/blocks/video/video_element_models.py +10 -0
  111. notionary/blocks/video/video_markdown_node.py +3 -1
  112. notionary/database/client.py +3 -8
  113. notionary/database/database.py +13 -14
  114. notionary/database/database_filter_builder.py +2 -2
  115. notionary/database/database_provider.py +5 -4
  116. notionary/database/models.py +337 -0
  117. notionary/database/notion_database.py +6 -7
  118. notionary/file_upload/client.py +5 -7
  119. notionary/file_upload/models.py +2 -1
  120. notionary/file_upload/notion_file_upload.py +2 -3
  121. notionary/markdown/markdown_builder.py +722 -0
  122. notionary/markdown/markdown_document_model.py +228 -0
  123. notionary/{blocks → markdown}/markdown_node.py +1 -0
  124. notionary/models/notion_database_response.py +0 -338
  125. notionary/page/client.py +9 -10
  126. notionary/page/models.py +327 -0
  127. notionary/page/notion_page.py +99 -52
  128. notionary/page/notion_text_length_utils.py +119 -0
  129. notionary/page/{content/page_content_writer.py → page_content_writer.py} +88 -38
  130. notionary/page/reader/handler/__init__.py +17 -0
  131. notionary/page/reader/handler/base_block_renderer.py +44 -0
  132. notionary/page/reader/handler/block_processing_context.py +35 -0
  133. notionary/page/reader/handler/block_rendering_context.py +43 -0
  134. notionary/page/reader/handler/column_list_renderer.py +51 -0
  135. notionary/page/reader/handler/column_renderer.py +60 -0
  136. notionary/page/reader/handler/line_renderer.py +60 -0
  137. notionary/page/reader/handler/toggle_renderer.py +69 -0
  138. notionary/page/reader/handler/toggleable_heading_renderer.py +89 -0
  139. notionary/page/reader/page_content_retriever.py +69 -0
  140. notionary/page/search_filter_builder.py +2 -1
  141. notionary/page/writer/handler/__init__.py +22 -0
  142. notionary/page/writer/handler/code_handler.py +100 -0
  143. notionary/page/writer/handler/column_handler.py +141 -0
  144. notionary/page/writer/handler/column_list_handler.py +139 -0
  145. notionary/page/writer/handler/line_handler.py +35 -0
  146. notionary/page/writer/handler/line_processing_context.py +54 -0
  147. notionary/page/writer/handler/regular_line_handler.py +92 -0
  148. notionary/page/writer/handler/table_handler.py +130 -0
  149. notionary/page/writer/handler/toggle_handler.py +153 -0
  150. notionary/page/writer/handler/toggleable_heading_handler.py +167 -0
  151. notionary/page/writer/markdown_to_notion_converter.py +76 -0
  152. notionary/telemetry/__init__.py +2 -2
  153. notionary/telemetry/service.py +4 -3
  154. notionary/user/__init__.py +2 -2
  155. notionary/user/base_notion_user.py +2 -1
  156. notionary/user/client.py +2 -3
  157. notionary/user/models.py +1 -0
  158. notionary/user/notion_bot_user.py +4 -5
  159. notionary/user/notion_user.py +3 -4
  160. notionary/user/notion_user_manager.py +3 -2
  161. notionary/user/notion_user_provider.py +1 -1
  162. notionary/util/__init__.py +3 -2
  163. notionary/util/fuzzy.py +2 -1
  164. notionary/util/logging_mixin.py +2 -2
  165. notionary/util/singleton_metaclass.py +1 -1
  166. notionary/workspace.py +3 -2
  167. {notionary-0.2.19.dist-info → notionary-0.2.21.dist-info}/METADATA +12 -8
  168. notionary-0.2.21.dist-info/RECORD +185 -0
  169. notionary/blocks/document/__init__.py +0 -7
  170. notionary/blocks/document/document_element.py +0 -102
  171. notionary/blocks/document/document_markdown_node.py +0 -31
  172. notionary/blocks/image/__init__.py +0 -7
  173. notionary/blocks/image/image_element.py +0 -151
  174. notionary/blocks/markdown_builder.py +0 -356
  175. notionary/blocks/mention/__init__.py +0 -7
  176. notionary/blocks/mention/mention_element.py +0 -229
  177. notionary/blocks/mention/mention_markdown_node.py +0 -38
  178. notionary/blocks/prompts/element_prompt_builder.py +0 -83
  179. notionary/blocks/prompts/element_prompt_content.py +0 -41
  180. notionary/blocks/shared/__init__.py +0 -0
  181. notionary/blocks/shared/models.py +0 -713
  182. notionary/blocks/shared/notion_block_element.py +0 -37
  183. notionary/blocks/shared/text_inline_formatter.py +0 -262
  184. notionary/blocks/shared/text_inline_formatter_new.py +0 -139
  185. notionary/blocks/toggleable_heading/toggleable_heading_models.py +0 -0
  186. notionary/database/models/page_result.py +0 -10
  187. notionary/elements/__init__.py +0 -0
  188. notionary/models/notion_block_response.py +0 -264
  189. notionary/models/notion_page_response.py +0 -78
  190. notionary/models/search_response.py +0 -0
  191. notionary/page/__init__.py +0 -0
  192. notionary/page/content/notion_text_length_utils.py +0 -87
  193. notionary/page/content/page_content_retriever.py +0 -60
  194. notionary/page/formatting/line_processor.py +0 -153
  195. notionary/page/formatting/markdown_to_notion_converter.py +0 -153
  196. notionary/page/markdown_syntax_prompt_generator.py +0 -114
  197. notionary/page/notion_to_markdown_converter.py +0 -179
  198. notionary/page/properites/property_value_extractor.py +0 -0
  199. notionary-0.2.19.dist-info/RECORD +0 -150
  200. /notionary/{blocks/document/document_models.py → markdown/___init__.py} +0 -0
  201. /notionary/{blocks/image/image_models.py → markdown/makdown_document_model.py} +0 -0
  202. /notionary/page/{content/markdown_whitespace_processor.py → markdown_whitespace_processor.py} +0 -0
  203. /notionary/{blocks/mention/mention_models.py → page/reader/handler/context.py} +0 -0
  204. {notionary-0.2.19.dist-info → notionary-0.2.21.dist-info}/LICENSE +0 -0
  205. {notionary-0.2.19.dist-info → notionary-0.2.21.dist-info}/WHEEL +0 -0
@@ -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,69 @@
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
+ ToggleableHeadingRenderer,
9
+ ToggleRenderer,
10
+ )
11
+ from notionary.util import LoggingMixin
12
+
13
+
14
+ class PageContentRetriever(LoggingMixin):
15
+ """Retrieves Notion page content and converts it to Markdown using chain of responsibility."""
16
+
17
+ def __init__(
18
+ self,
19
+ block_registry: BlockRegistry,
20
+ ):
21
+ self._block_registry = block_registry
22
+
23
+ self._setup_handler_chain()
24
+
25
+ async def convert_to_markdown(self, blocks: list[Block]) -> str:
26
+ """
27
+ Retrieve page content and convert it to Markdown.
28
+ Uses the chain of responsibility pattern for scalable block processing.
29
+ """
30
+ return self._convert_blocks_to_markdown(blocks, indent_level=0)
31
+
32
+ def _setup_handler_chain(self) -> None:
33
+ """Setup the chain of handlers in priority order."""
34
+ toggle_handler = ToggleRenderer()
35
+ toggleable_heading_handler = ToggleableHeadingRenderer()
36
+ column_list_handler = ColumnListRenderer()
37
+ column_handler = ColumnRenderer()
38
+ regular_handler = LineRenderer()
39
+
40
+ # Chain handlers - most specific first
41
+ toggle_handler.set_next(toggleable_heading_handler).set_next(
42
+ column_list_handler
43
+ ).set_next(column_handler).set_next(regular_handler)
44
+
45
+ self._handler_chain = toggle_handler
46
+
47
+ def _convert_blocks_to_markdown(
48
+ self, blocks: list[Block], indent_level: int = 0
49
+ ) -> str:
50
+ """Convert blocks to Markdown using the handler chain."""
51
+ if not blocks:
52
+ return ""
53
+
54
+ markdown_parts = []
55
+
56
+ for block in blocks:
57
+ context = BlockRenderingContext(
58
+ block=block,
59
+ indent_level=indent_level,
60
+ block_registry=self._block_registry,
61
+ )
62
+
63
+ self._handler_chain.handle(context)
64
+
65
+ if context.was_processed and context.markdown_result:
66
+ markdown_parts.append(context.markdown_result)
67
+
68
+ separator = "\n\n" if indent_level == 0 else "\n"
69
+ 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,22 @@
1
+ from .code_handler import CodeHandler
2
+ from .column_handler import ColumnHandler
3
+ from .column_list_handler import ColumnListHandler
4
+ from .line_handler import LineHandler
5
+ from .line_processing_context import LineProcessingContext, ParentBlockContext
6
+ from .regular_line_handler import RegularLineHandler
7
+ from .table_handler import TableHandler
8
+ from .toggle_handler import ToggleHandler
9
+ from .toggleable_heading_handler import ToggleableHeadingHandler
10
+
11
+ __all__ = [
12
+ "LineProcessingContext",
13
+ "ParentBlockContext",
14
+ "LineHandler",
15
+ "ColumnListHandler",
16
+ "ColumnHandler",
17
+ "ToggleableHeadingHandler",
18
+ "ToggleHandler",
19
+ "TableHandler",
20
+ "RegularLineHandler",
21
+ "CodeHandler",
22
+ ]
@@ -0,0 +1,100 @@
1
+ import re
2
+
3
+ from notionary.blocks.code.code_element import CodeElement
4
+ from notionary.blocks.rich_text.rich_text_models import RichTextObject
5
+ from notionary.page.writer.handler.line_handler import (
6
+ LineHandler,
7
+ LineProcessingContext,
8
+ )
9
+
10
+
11
+ class CodeHandler(LineHandler):
12
+ """Handles code block specific logic with batching.
13
+
14
+ Markdown syntax:
15
+ ```language "optional caption"
16
+ code lines...
17
+ ```
18
+ """
19
+
20
+ def __init__(self):
21
+ super().__init__()
22
+ self._code_start_pattern = re.compile(r"^```(\w*)\s*(?:\"([^\"]*)\")?\s*$")
23
+ self._code_end_pattern = re.compile(r"^```\s*$")
24
+
25
+ def _can_handle(self, context: LineProcessingContext) -> bool:
26
+ if self._is_inside_parent_context(context):
27
+ return False
28
+ return self._is_code_start(context)
29
+
30
+ def _process(self, context: LineProcessingContext) -> None:
31
+ if self._is_code_start(context):
32
+ self._process_complete_code_block(context)
33
+ self._mark_processed(context)
34
+
35
+ def _is_code_start(self, context: LineProcessingContext) -> bool:
36
+ """Check if this line starts a code block."""
37
+ return self._code_start_pattern.match(context.line.strip()) is not None
38
+
39
+ def _is_inside_parent_context(self, context: LineProcessingContext) -> bool:
40
+ """Check if we're currently inside any parent context (toggle, heading, etc.)."""
41
+ return len(context.parent_stack) > 0
42
+
43
+ def _process_complete_code_block(self, context: LineProcessingContext) -> None:
44
+ """Process the entire code block in one go."""
45
+ # Extract language and caption from opening fence
46
+ language, caption = self._extract_fence_info(context.line)
47
+
48
+ # Create base code block
49
+ result = CodeElement.markdown_to_notion(f"```{language}")
50
+ if not result:
51
+ return
52
+
53
+ block = result[0] if isinstance(result, list) else result
54
+
55
+ code_lines, lines_to_consume = self._collect_code_lines(context)
56
+
57
+ self._set_block_content(block, code_lines)
58
+
59
+ self._set_block_caption(block, caption)
60
+
61
+ context.lines_consumed = lines_to_consume
62
+ context.result_blocks.append(block)
63
+
64
+ def _extract_fence_info(self, line: str) -> tuple[str, str]:
65
+ """Extract the language and optional caption from a code fence."""
66
+ match = self._code_start_pattern.match(line.strip())
67
+ lang = match.group(1) if match and match.group(1) else ""
68
+ cap = match.group(2) if match and match.group(2) else ""
69
+ return lang, cap
70
+
71
+ def _collect_code_lines(
72
+ self, context: LineProcessingContext
73
+ ) -> tuple[list[str], int]:
74
+ """Collect lines until closing fence and return (lines, count_to_consume)."""
75
+ lines = []
76
+ for idx, ln in enumerate(context.get_remaining_lines()):
77
+ if self._code_end_pattern.match(ln.strip()):
78
+ return lines, idx + 1
79
+ lines.append(ln)
80
+ # No closing fence: consume all remaining
81
+ rem = context.get_remaining_lines()
82
+ return rem, len(rem)
83
+
84
+ def _mark_processed(self, context: LineProcessingContext) -> None:
85
+ """Mark context as processed and continue."""
86
+ context.was_processed = True
87
+ context.should_continue = True
88
+
89
+ def _set_block_content(self, block, code_lines: list[str]) -> None:
90
+ """Set the code rich_text content on the block."""
91
+ if not code_lines:
92
+ return
93
+ content = "\n".join(code_lines)
94
+ block.code.rich_text = [RichTextObject.for_code_block(content)]
95
+
96
+ def _set_block_caption(self, block, caption: str) -> None:
97
+ """Append caption to the code block if provided."""
98
+ if not caption:
99
+ return
100
+ block.code.caption.append(RichTextObject.for_code_block(caption))
@@ -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
+ def _process(self, context: LineProcessingContext) -> None:
30
+ if self._is_column_start(context):
31
+ self._start_column(context)
32
+ self._mark_processed(context)
33
+ return
34
+
35
+ if self._is_column_end(context):
36
+ 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
+ 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 = column_element.markdown_to_notion(context.line)
60
+ if not result:
61
+ return
62
+
63
+ block = result if not isinstance(result, list) else result[0]
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
+ 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
+ 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
+ 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 = 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
+ 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 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
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from notionary.blocks.column.column_list_element import ColumnListElement
6
+ from notionary.page.writer.handler.line_handler import (
7
+ LineHandler,
8
+ LineProcessingContext,
9
+ )
10
+
11
+ from notionary.page.writer.handler.line_processing_context import ParentBlockContext
12
+
13
+ class ColumnListHandler(LineHandler):
14
+ """Handles column list elements - both start and end.
15
+ Syntax:
16
+ ::: columns # Start column list
17
+ ::: column # Individual column
18
+ Content here
19
+ ::: # End column
20
+ ::: column # Another column
21
+ More content
22
+ ::: # End column
23
+ ::: # End column list
24
+ """
25
+
26
+ def __init__(self):
27
+ super().__init__()
28
+ self._start_pattern = re.compile(r"^:::\s*columns?\s*$", re.IGNORECASE)
29
+ self._end_pattern = re.compile(r"^:::\s*$")
30
+
31
+ def _can_handle(self, context: LineProcessingContext) -> bool:
32
+ return self._is_column_list_start(context) or self._is_column_list_end(context)
33
+
34
+ def _process(self, context: LineProcessingContext) -> None:
35
+ if self._is_column_list_start(context):
36
+ self._start_column_list(context)
37
+ context.was_processed = True
38
+ context.should_continue = True
39
+ return
40
+
41
+ if self._is_column_list_end(context):
42
+ self._finalize_column_list(context)
43
+ context.was_processed = True
44
+ context.should_continue = True
45
+
46
+ def _is_column_list_start(self, context: LineProcessingContext) -> bool:
47
+ """Check if line starts a column list (::: columns)."""
48
+ return self._start_pattern.match(context.line.strip()) is not None
49
+
50
+ def _is_column_list_end(self, context: LineProcessingContext) -> bool:
51
+ """Check if we need to end a column list (:::)."""
52
+ if not self._end_pattern.match(context.line.strip()):
53
+ return False
54
+
55
+ if not context.parent_stack:
56
+ return False
57
+
58
+ # Check if top of stack is a ColumnList
59
+ current_parent = context.parent_stack[-1]
60
+ return issubclass(current_parent.element_type, ColumnListElement)
61
+
62
+ def _start_column_list(self, context: LineProcessingContext) -> None:
63
+ """Start a new column list."""
64
+ # Create ColumnList block using the element from registry
65
+ column_list_element = None
66
+ for element in context.block_registry.get_elements():
67
+ if issubclass(element, ColumnListElement):
68
+ column_list_element = element
69
+ break
70
+
71
+ if not column_list_element:
72
+ return
73
+
74
+ # Create the block
75
+ result = column_list_element.markdown_to_notion(context.line)
76
+ if not result:
77
+ return
78
+
79
+ block = result if not isinstance(result, list) else result[0]
80
+
81
+ # Push to parent stack
82
+ parent_context = ParentBlockContext(
83
+ block=block,
84
+ element_type=column_list_element,
85
+ child_lines=[],
86
+ )
87
+ context.parent_stack.append(parent_context)
88
+
89
+ def _finalize_column_list(self, context: LineProcessingContext) -> None:
90
+ """Finalize a column list and add it to result_blocks."""
91
+ column_list_context = context.parent_stack.pop()
92
+ self._assign_column_list_children_if_any(column_list_context, context)
93
+
94
+ # Check if we have a parent context to add this column_list to
95
+ if context.parent_stack:
96
+ # Add this column_list as a child block to the parent (like Toggle)
97
+ parent_context = context.parent_stack[-1]
98
+ parent_context.add_child_block(column_list_context.block)
99
+
100
+ else:
101
+ # No parent, add to top level
102
+ context.result_blocks.append(column_list_context.block)
103
+
104
+ def _assign_column_list_children_if_any(
105
+ self, column_list_context: ParentBlockContext, context: LineProcessingContext
106
+ ) -> None:
107
+ """Collect and assign any column children blocks inside this column list."""
108
+ all_children = []
109
+
110
+ # Process text lines
111
+ if column_list_context.child_lines:
112
+ children_text = "\n".join(column_list_context.child_lines)
113
+ children_blocks = self._convert_children_text(
114
+ children_text, context.block_registry
115
+ )
116
+ all_children.extend(children_blocks)
117
+
118
+ if column_list_context.child_blocks:
119
+ all_children.extend(column_list_context.child_blocks)
120
+
121
+ # Filter only column blocks
122
+ column_children = [
123
+ block
124
+ for block in all_children
125
+ if hasattr(block, "column") and getattr(block, "type", None) == "column"
126
+ ]
127
+ column_list_context.block.column_list.children = column_children
128
+
129
+ def _convert_children_text(self, text: str, block_registry) -> list:
130
+ """Convert children text to blocks."""
131
+ from notionary.page.writer.markdown_to_notion_converter import (
132
+ MarkdownToNotionConverter,
133
+ )
134
+
135
+ if not text.strip():
136
+ return []
137
+
138
+ child_converter = MarkdownToNotionConverter(block_registry)
139
+ return child_converter._process_lines(text)
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Optional
5
+
6
+ from notionary.page.writer.handler.line_processing_context import LineProcessingContext
7
+
8
+
9
+ class LineHandler(ABC):
10
+ """Abstract base class for line handlers."""
11
+
12
+ def __init__(self):
13
+ self._next_handler: Optional[LineHandler] = None
14
+
15
+ def set_next(self, handler: LineHandler) -> LineHandler:
16
+ """Set the next handler in the chain."""
17
+ self._next_handler = handler
18
+ return handler
19
+
20
+ def handle(self, context: LineProcessingContext) -> None:
21
+ """Handle the line or pass to next handler."""
22
+ if self._can_handle(context):
23
+ self._process(context)
24
+ elif self._next_handler:
25
+ self._next_handler.handle(context)
26
+
27
+ @abstractmethod
28
+ def _can_handle(self, context: LineProcessingContext) -> bool:
29
+ """Check if this handler can process the current line."""
30
+ pass
31
+
32
+ @abstractmethod
33
+ def _process(self, context: LineProcessingContext) -> None:
34
+ """Process the line and update context."""
35
+ pass
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Optional
5
+
6
+ from notionary.blocks.base_block_element import BaseBlockElement
7
+ from notionary.blocks.models import BlockCreateRequest
8
+ from notionary.blocks.registry.block_registry import BlockRegistry
9
+
10
+
11
+ @dataclass
12
+ class ParentBlockContext:
13
+ """Context for a block that expects children."""
14
+
15
+ block: BlockCreateRequest
16
+ element_type: BaseBlockElement
17
+ child_lines: list[str]
18
+ child_blocks: list[BlockCreateRequest] = field(default_factory=list)
19
+
20
+ def add_child_line(self, content: str):
21
+ """Adds a child line."""
22
+ self.child_lines.append(content)
23
+
24
+ def add_child_block(self, block: BlockCreateRequest):
25
+ """Adds a processed child block."""
26
+ self.child_blocks.append(block)
27
+
28
+ def has_children(self) -> bool:
29
+ """Checks if children have been collected."""
30
+ return len(self.child_lines) > 0 or len(self.child_blocks) > 0
31
+
32
+
33
+ @dataclass
34
+ class LineProcessingContext:
35
+ """Context that gets passed through the handler chain."""
36
+
37
+ line: str
38
+ result_blocks: list[BlockCreateRequest]
39
+ parent_stack: list[ParentBlockContext]
40
+ block_registry: BlockRegistry
41
+
42
+ all_lines: Optional[list[str]] = None
43
+ current_line_index: Optional[int] = None
44
+ lines_consumed: int = 0
45
+
46
+ # Result indicators
47
+ was_processed: bool = False
48
+ should_continue: bool = False
49
+
50
+ def get_remaining_lines(self) -> list[str]:
51
+ """Get all remaining lines from current position."""
52
+ if self.all_lines is None or self.current_line_index is None:
53
+ return []
54
+ return self.all_lines[self.current_line_index + 1 :]