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,92 @@
1
+ from notionary.blocks.column.column_element import ColumnElement
2
+ from notionary.blocks.column.column_list_element import ColumnListElement
3
+ from notionary.blocks.models import BlockCreateRequest, BlockCreateResult
4
+ from notionary.page.writer.handler import LineHandler, LineProcessingContext
5
+
6
+
7
+ class RegularLineHandler(LineHandler):
8
+ """Handles regular lines - respects parent contexts like columns."""
9
+
10
+ def _can_handle(self, context: LineProcessingContext) -> bool:
11
+ return context.line.strip()
12
+
13
+ def _process(self, context: LineProcessingContext) -> None:
14
+ if self._is_in_column_context(context):
15
+ self._add_to_column_context(context)
16
+ context.was_processed = True
17
+ context.should_continue = True
18
+ return
19
+
20
+ block_created = self._process_single_line_content(context)
21
+ if not block_created:
22
+ self._process_as_paragraph(context)
23
+
24
+ context.was_processed = True
25
+
26
+ def _is_in_column_context(self, context: LineProcessingContext) -> bool:
27
+ """Check if we're inside a Column/ColumnList context."""
28
+ if not context.parent_stack:
29
+ return False
30
+
31
+ current_parent = context.parent_stack[-1]
32
+ return issubclass(
33
+ current_parent.element_type, (ColumnListElement, ColumnElement)
34
+ )
35
+
36
+ def _add_to_column_context(self, context: LineProcessingContext) -> None:
37
+ """Add line as child to the current Column context."""
38
+ context.parent_stack[-1].add_child_line(context.line)
39
+
40
+ def _process_single_line_content(self, context: LineProcessingContext) -> bool:
41
+ """Process a regular line for simple elements (lists, etc.)."""
42
+ for element in context.block_registry.get_elements():
43
+ # Skip all elements that have specialized handlers
44
+ from notionary.blocks.code import CodeElement
45
+ from notionary.blocks.paragraph import ParagraphElement
46
+ from notionary.blocks.table import TableElement
47
+ from notionary.blocks.toggle import ToggleElement
48
+ from notionary.blocks.toggleable_heading import ToggleableHeadingElement
49
+
50
+ specialized_elements = (
51
+ ColumnListElement,
52
+ ColumnElement,
53
+ ToggleElement,
54
+ ToggleableHeadingElement,
55
+ TableElement,
56
+ CodeElement,
57
+ ParagraphElement, # Skip paragraph to ensure equations are processed first
58
+ )
59
+
60
+ if issubclass(element, specialized_elements):
61
+ continue
62
+
63
+ result = element.markdown_to_notion(context.line)
64
+ if not result:
65
+ continue
66
+
67
+ blocks = self._normalize_to_list(result)
68
+ for block in blocks:
69
+ context.result_blocks.append(block)
70
+
71
+ return True
72
+
73
+ return False
74
+
75
+ def _process_as_paragraph(self, context: LineProcessingContext) -> None:
76
+ """Process a line as a paragraph."""
77
+ from notionary.blocks.paragraph.paragraph_element import ParagraphElement
78
+
79
+ paragraph_element = ParagraphElement()
80
+ result = paragraph_element.markdown_to_notion(context.line)
81
+
82
+ if result:
83
+ blocks = self._normalize_to_list(result)
84
+ for block in blocks:
85
+ context.result_blocks.append(block)
86
+
87
+ @staticmethod
88
+ def _normalize_to_list(result: BlockCreateResult) -> list[BlockCreateRequest]:
89
+ """Normalize the result to a list."""
90
+ if result is None:
91
+ return []
92
+ return result if isinstance(result, list) else [result]
@@ -0,0 +1,130 @@
1
+ import re
2
+
3
+ from notionary.blocks.rich_text.rich_text_models import RichTextObject
4
+ from notionary.blocks.rich_text.text_inline_formatter import TextInlineFormatter
5
+ from notionary.blocks.table.table_element import TableElement
6
+ from notionary.blocks.table.table_models import CreateTableRowBlock, TableRowBlock
7
+ from notionary.page.writer.handler import LineHandler, LineProcessingContext
8
+
9
+
10
+ class TableHandler(LineHandler):
11
+ """Handles table specific logic with batching."""
12
+
13
+ def __init__(self):
14
+ super().__init__()
15
+ self._table_row_pattern = re.compile(r"^\s*\|(.+)\|\s*$")
16
+ self._separator_pattern = re.compile(r"^\s*\|([\s\-:|]+)\|\s*$")
17
+
18
+ def _can_handle(self, context: LineProcessingContext) -> bool:
19
+ if self._is_inside_parent_context(context):
20
+ return False
21
+ return self._is_table_start(context)
22
+
23
+ def _process(self, context: LineProcessingContext) -> None:
24
+ if not self._is_table_start(context):
25
+ return
26
+
27
+ self._process_complete_table(context)
28
+ context.was_processed = True
29
+ context.should_continue = True
30
+
31
+ def _is_inside_parent_context(self, context: LineProcessingContext) -> bool:
32
+ """Check if we're currently inside any parent context (toggle, heading, etc.)."""
33
+ return len(context.parent_stack) > 0
34
+
35
+ def _is_table_start(self, context: LineProcessingContext) -> bool:
36
+ """Check if this line starts a table."""
37
+ return self._table_row_pattern.match(context.line.strip()) is not None
38
+
39
+ def _process_complete_table(self, context: LineProcessingContext) -> None:
40
+ """Process the entire table in one go."""
41
+ # Create table element
42
+ table_element = TableElement()
43
+ result = table_element.markdown_to_notion(context.line)
44
+ if not result:
45
+ return
46
+
47
+ block = result if not isinstance(result, list) else result[0]
48
+
49
+ # Collect all table lines (including the current one)
50
+ table_lines = [context.line]
51
+ remaining_lines = context.get_remaining_lines()
52
+ lines_to_consume = 0
53
+
54
+ # Find all consecutive table rows
55
+ for i, line in enumerate(remaining_lines):
56
+ line_stripped = line.strip()
57
+ if not line_stripped:
58
+ # Empty line - continue to allow for spacing in tables
59
+ table_lines.append(line)
60
+ continue
61
+
62
+ if self._table_row_pattern.match(
63
+ line_stripped
64
+ ) or self._separator_pattern.match(line_stripped):
65
+ table_lines.append(line)
66
+ else:
67
+ # Not a table line - stop here
68
+ lines_to_consume = i
69
+ break
70
+ else:
71
+ # Consumed all remaining lines
72
+ lines_to_consume = len(remaining_lines)
73
+
74
+ # Process the table content
75
+ table_rows, separator_found = self._process_table_lines(table_lines)
76
+
77
+ table = block.table
78
+ table.children = table_rows
79
+ table.has_column_header = bool(separator_found)
80
+
81
+ # Tell the main loop to skip the consumed lines
82
+ context.lines_consumed = lines_to_consume
83
+ context.result_blocks.append(block)
84
+
85
+ def _process_table_lines(
86
+ self, table_lines: list[str]
87
+ ) -> tuple[list[CreateTableRowBlock], bool]:
88
+ """Process all table lines and return rows and separator status."""
89
+ table_rows = []
90
+ separator_found = False
91
+
92
+ for line in table_lines:
93
+ line = line.strip()
94
+ if not line:
95
+ continue
96
+
97
+ if self._is_separator_line(line):
98
+ separator_found = True
99
+ continue
100
+
101
+ if self._table_row_pattern.match(line):
102
+ table_row = self._create_table_row_from_line(line)
103
+ table_rows.append(table_row)
104
+
105
+ return table_rows, separator_found
106
+
107
+ def _is_separator_line(self, line: str) -> bool:
108
+ return self._separator_pattern.match(line) is not None
109
+
110
+ def _create_table_row_from_line(self, line: str) -> CreateTableRowBlock:
111
+ cells = self._parse_table_row(line)
112
+ rich_text_cells = [self._convert_cell_to_rich_text(cell) for cell in cells]
113
+ table_row = TableRowBlock(cells=rich_text_cells)
114
+ return CreateTableRowBlock(table_row=table_row)
115
+
116
+ def _convert_cell_to_rich_text(self, cell: str) -> list[RichTextObject]:
117
+ rich_text = TextInlineFormatter.parse_inline_formatting(cell)
118
+ if not rich_text:
119
+ rich_text = [RichTextObject.from_plain_text(cell)]
120
+ return rich_text
121
+
122
+ def _parse_table_row(self, row_text: str) -> list[str]:
123
+ row_content = row_text.strip()
124
+
125
+ if row_content.startswith("|"):
126
+ row_content = row_content[1:]
127
+ if row_content.endswith("|"):
128
+ row_content = row_content[:-1]
129
+
130
+ return [cell.strip() for cell in row_content.split("|")]
@@ -0,0 +1,153 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from notionary.blocks.toggle.toggle_element import ToggleElement
6
+ from notionary.page.writer.handler import (
7
+ LineHandler,
8
+ LineProcessingContext,
9
+ ParentBlockContext,
10
+ )
11
+
12
+
13
+ class ToggleHandler(LineHandler):
14
+ """Handles regular toggle blocks with ultra-simplified +++ syntax."""
15
+
16
+ def __init__(self):
17
+ super().__init__()
18
+ self._start_pattern = re.compile(r"^[+]{3}\s+(.+)$", re.IGNORECASE)
19
+ self._end_pattern = re.compile(r"^[+]{3}\s*$")
20
+
21
+ def _can_handle(self, context: LineProcessingContext) -> bool:
22
+ return (
23
+ self._is_toggle_start(context)
24
+ or self._is_toggle_end(context)
25
+ or self._is_toggle_content(context)
26
+ )
27
+
28
+ def _process(self, context: LineProcessingContext) -> None:
29
+ # Explicit, readable branches (small duplication is acceptable)
30
+ if self._is_toggle_start(context):
31
+ self._start_toggle(context)
32
+ context.was_processed = True
33
+ context.should_continue = True
34
+
35
+ if self._is_toggle_end(context):
36
+ self._finalize_toggle(context)
37
+ context.was_processed = True
38
+ context.should_continue = True
39
+
40
+ if self._is_toggle_content(context):
41
+ self._add_toggle_content(context)
42
+ context.was_processed = True
43
+ context.should_continue = True
44
+
45
+ def _is_toggle_start(self, context: LineProcessingContext) -> bool:
46
+ """Check if line starts a toggle (+++ Title)."""
47
+ line = context.line.strip()
48
+
49
+ # Must match our pattern
50
+ if not self._start_pattern.match(line):
51
+ return False
52
+
53
+ # But NOT match toggleable heading pattern (has # after +++)
54
+ toggleable_heading_pattern = re.compile(r"^[+]{3}#{1,3}\s+.+$", re.IGNORECASE)
55
+ if toggleable_heading_pattern.match(line):
56
+ return False
57
+
58
+ return True
59
+
60
+ def _is_toggle_end(self, context: LineProcessingContext) -> bool:
61
+ """Check if we need to end a toggle (+++)."""
62
+ if not self._end_pattern.match(context.line.strip()):
63
+ return False
64
+
65
+ if not context.parent_stack:
66
+ return False
67
+
68
+ # Check if top of stack is a Toggle
69
+ current_parent = context.parent_stack[-1]
70
+ return issubclass(current_parent.element_type, ToggleElement)
71
+
72
+ def _start_toggle(self, context: LineProcessingContext) -> None:
73
+ """Start a new toggle block."""
74
+ toggle_element = ToggleElement()
75
+
76
+ # Create the block
77
+ result = toggle_element.markdown_to_notion(context.line)
78
+ if not result:
79
+ return
80
+
81
+ block = result if not isinstance(result, list) else result[0]
82
+
83
+ # Push to parent stack
84
+ parent_context = ParentBlockContext(
85
+ block=block,
86
+ element_type=ToggleElement,
87
+ child_lines=[],
88
+ )
89
+ context.parent_stack.append(parent_context)
90
+
91
+ def _finalize_toggle(self, context: LineProcessingContext) -> None:
92
+ """Finalize a toggle block and add it to result_blocks."""
93
+ toggle_context = context.parent_stack.pop()
94
+
95
+ if toggle_context.has_children():
96
+ all_children = self._get_all_children(
97
+ toggle_context, context.block_registry
98
+ )
99
+ toggle_context.block.toggle.children = all_children
100
+
101
+ # Check if we have a parent context to add this toggle to
102
+ if context.parent_stack:
103
+ # Add this toggle as a child block to the parent
104
+ parent_context = context.parent_stack[-1]
105
+ parent_context.add_child_block(toggle_context.block)
106
+ else:
107
+ # No parent, add to top level
108
+ context.result_blocks.append(toggle_context.block)
109
+
110
+ def _is_toggle_content(self, context: LineProcessingContext) -> bool:
111
+ """Check if we're inside a toggle context and should handle content."""
112
+ if not context.parent_stack:
113
+ return False
114
+
115
+ current_parent = context.parent_stack[-1]
116
+ if not issubclass(current_parent.element_type, ToggleElement):
117
+ return False
118
+
119
+ # Handle all content inside toggle (not start/end patterns)
120
+ line = context.line.strip()
121
+ return not (self._start_pattern.match(line) or self._end_pattern.match(line))
122
+
123
+ def _add_toggle_content(self, context: LineProcessingContext) -> None:
124
+ """Add content to the current toggle context."""
125
+ context.parent_stack[-1].add_child_line(context.line)
126
+
127
+ def _convert_children_text(self, text: str, block_registry) -> list:
128
+ """Convert children text to blocks."""
129
+ from notionary.page.writer.markdown_to_notion_converter import (
130
+ MarkdownToNotionConverter,
131
+ )
132
+
133
+ if not text.strip():
134
+ return []
135
+
136
+ child_converter = MarkdownToNotionConverter(block_registry)
137
+ return child_converter._process_lines(text)
138
+
139
+ def _get_all_children(self, parent_context, block_registry) -> list:
140
+ """Helper method to combine text-based and direct block children."""
141
+ children_blocks = []
142
+
143
+ # Process text lines
144
+ if parent_context.child_lines:
145
+ children_text = "\n".join(parent_context.child_lines)
146
+ text_blocks = self._convert_children_text(children_text, block_registry)
147
+ children_blocks.extend(text_blocks)
148
+
149
+ # Add direct blocks (like processed columns)
150
+ if hasattr(parent_context, "child_blocks") and parent_context.child_blocks:
151
+ children_blocks.extend(parent_context.child_blocks)
152
+
153
+ return children_blocks
@@ -0,0 +1,167 @@
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
+ def _process(self, context: LineProcessingContext) -> None:
36
+ """Process toggleable heading start, end, or content with unified handling."""
37
+
38
+ def _handle(action):
39
+ 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 _handle(self._start_toggleable_heading)
46
+ if self._is_toggleable_heading_end(context):
47
+ return _handle(self._finalize_toggleable_heading)
48
+ if self._is_toggleable_heading_content(context):
49
+ return _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
+ 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 = toggleable_heading_element.markdown_to_notion(context.line)
73
+ if not result:
74
+ return
75
+
76
+ block = result if not isinstance(result, list) else result[0]
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
+ def _add_toggleable_heading_content(self, context: LineProcessingContext) -> None:
100
+ """Add content to the current toggleable heading context."""
101
+ context.parent_stack[-1].add_child_line(context.line)
102
+
103
+ def _finalize_toggleable_heading(self, context: LineProcessingContext) -> None:
104
+ """Finalize a toggleable heading block and add it to result_blocks."""
105
+ heading_context = context.parent_stack.pop()
106
+
107
+ if heading_context.has_children():
108
+ all_children = self._get_all_children(
109
+ heading_context, context.block_registry
110
+ )
111
+ self._assign_heading_children(heading_context.block, all_children)
112
+
113
+ # Check if we have a parent context to add this heading to
114
+ if context.parent_stack:
115
+ # Add this heading as a child block to the parent
116
+ parent_context = context.parent_stack[-1]
117
+ if hasattr(parent_context, "add_child_block"):
118
+ parent_context.add_child_block(heading_context.block)
119
+ else:
120
+ # Fallback: add to result_blocks for backward compatibility
121
+ context.result_blocks.append(heading_context.block)
122
+ else:
123
+ # No parent, add to top level
124
+ context.result_blocks.append(heading_context.block)
125
+
126
+ def _get_all_children(
127
+ self, parent_context: ParentBlockContext, block_registry
128
+ ) -> list:
129
+ """Helper method to combine text-based and direct block children."""
130
+ children_blocks = []
131
+
132
+ # Process text lines
133
+ if parent_context.child_lines:
134
+ children_text = "\n".join(parent_context.child_lines)
135
+ text_blocks = self._convert_children_text(children_text, block_registry)
136
+ children_blocks.extend(text_blocks)
137
+
138
+ # Add direct blocks
139
+ if hasattr(parent_context, "child_blocks") and parent_context.child_blocks:
140
+ children_blocks.extend(parent_context.child_blocks)
141
+
142
+ return children_blocks
143
+
144
+ def _assign_heading_children(
145
+ self, parent_block: BlockCreateRequest, children: list[BlockCreateRequest]
146
+ ) -> None:
147
+ """Assign children to toggleable heading blocks."""
148
+ block_type = parent_block.type
149
+
150
+ if block_type == BlockType.HEADING_1:
151
+ parent_block.heading_1.children = children
152
+ elif block_type == BlockType.HEADING_2:
153
+ parent_block.heading_2.children = children
154
+ elif block_type == BlockType.HEADING_3:
155
+ parent_block.heading_3.children = children
156
+
157
+ def _convert_children_text(self, text: str, block_registry) -> list:
158
+ """Convert children text to blocks."""
159
+ from notionary.page.writer.markdown_to_notion_converter import (
160
+ MarkdownToNotionConverter,
161
+ )
162
+
163
+ if not text.strip():
164
+ return []
165
+
166
+ child_converter = MarkdownToNotionConverter(block_registry)
167
+ return child_converter._process_lines(text)
@@ -0,0 +1,76 @@
1
+ from notionary.blocks.models import BlockCreateRequest
2
+ from notionary.blocks.registry.block_registry import BlockRegistry
3
+ from notionary.page.notion_text_length_utils import fix_blocks_content_length
4
+ from notionary.page.writer.handler import (
5
+ CodeHandler,
6
+ ColumnHandler,
7
+ ColumnListHandler,
8
+ LineProcessingContext,
9
+ ParentBlockContext,
10
+ RegularLineHandler,
11
+ TableHandler,
12
+ ToggleableHeadingHandler,
13
+ ToggleHandler,
14
+ )
15
+
16
+
17
+ class MarkdownToNotionConverter:
18
+ """Converts Markdown text to Notion API block format with unified stack-based processing."""
19
+
20
+ def __init__(self, block_registry: BlockRegistry) -> None:
21
+ self._block_registry = block_registry
22
+ self._setup_handler_chain()
23
+
24
+ def _setup_handler_chain(self) -> None:
25
+ code_handler = CodeHandler()
26
+ table_handler = TableHandler()
27
+ column_list_handler = ColumnListHandler()
28
+ column_handler = ColumnHandler()
29
+ toggle_handler = ToggleHandler()
30
+ toggleable_heading_handler = ToggleableHeadingHandler()
31
+ regular_handler = RegularLineHandler()
32
+
33
+ # register more specific elements first
34
+ code_handler.set_next(table_handler).set_next(column_list_handler).set_next(
35
+ column_handler
36
+ ).set_next(toggleable_heading_handler).set_next(toggle_handler).set_next(
37
+ regular_handler
38
+ )
39
+
40
+ self._handler_chain = code_handler
41
+
42
+ def convert(self, markdown_text: str) -> list[BlockCreateRequest]:
43
+ if not markdown_text.strip():
44
+ return []
45
+
46
+ all_blocks = self._process_lines(markdown_text)
47
+ return fix_blocks_content_length(all_blocks)
48
+
49
+ def _process_lines(self, text: str) -> list[BlockCreateRequest]:
50
+ lines = text.split("\n")
51
+ result_blocks: list[BlockCreateRequest] = []
52
+ parent_stack: list[ParentBlockContext] = []
53
+
54
+ i = 0
55
+ while i < len(lines):
56
+ line = lines[i]
57
+
58
+ context = LineProcessingContext(
59
+ line=line,
60
+ result_blocks=result_blocks,
61
+ parent_stack=parent_stack,
62
+ block_registry=self._block_registry,
63
+ all_lines=lines,
64
+ current_line_index=i,
65
+ lines_consumed=0,
66
+ )
67
+
68
+ self._handler_chain.handle(context)
69
+
70
+ # Skip consumed lines
71
+ i += 1 + context.lines_consumed
72
+
73
+ if context.should_continue:
74
+ continue
75
+
76
+ return result_blocks
@@ -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,13 @@
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
+ from typing import Any, Dict, Optional
5
+
6
6
  from dotenv import load_dotenv
7
+ from posthog import Posthog
7
8
 
8
9
  from notionary.telemetry.views import BaseTelemetryEvent
9
- from notionary.util import SingletonMetaClass, LoggingMixin
10
+ from notionary.util import LoggingMixin, SingletonMetaClass
10
11
 
11
12
  load_dotenv()
12
13
 
@@ -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.