notionary 0.2.21__py3-none-any.whl → 0.2.23__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 (100) hide show
  1. notionary/blocks/_bootstrap.py +9 -1
  2. notionary/blocks/audio/audio_element.py +53 -28
  3. notionary/blocks/audio/audio_markdown_node.py +10 -4
  4. notionary/blocks/base_block_element.py +15 -3
  5. notionary/blocks/bookmark/bookmark_element.py +39 -36
  6. notionary/blocks/bookmark/bookmark_markdown_node.py +16 -17
  7. notionary/blocks/breadcrumbs/breadcrumb_element.py +2 -2
  8. notionary/blocks/bulleted_list/bulleted_list_element.py +21 -4
  9. notionary/blocks/callout/callout_element.py +20 -4
  10. notionary/blocks/child_database/__init__.py +11 -4
  11. notionary/blocks/child_database/child_database_element.py +59 -0
  12. notionary/blocks/child_database/child_database_models.py +7 -14
  13. notionary/blocks/child_page/child_page_element.py +94 -0
  14. notionary/blocks/client.py +0 -1
  15. notionary/blocks/code/code_element.py +51 -2
  16. notionary/blocks/code/code_markdown_node.py +52 -1
  17. notionary/blocks/column/column_element.py +9 -3
  18. notionary/blocks/column/column_list_element.py +18 -3
  19. notionary/blocks/divider/divider_element.py +3 -11
  20. notionary/blocks/embed/embed_element.py +27 -6
  21. notionary/blocks/equation/equation_element.py +94 -41
  22. notionary/blocks/equation/equation_element_markdown_node.py +8 -9
  23. notionary/blocks/file/file_element.py +56 -37
  24. notionary/blocks/file/file_element_markdown_node.py +9 -7
  25. notionary/blocks/guards.py +22 -0
  26. notionary/blocks/heading/heading_element.py +23 -4
  27. notionary/blocks/image_block/image_element.py +43 -38
  28. notionary/blocks/image_block/image_markdown_node.py +10 -5
  29. notionary/blocks/mixins/captions/__init__.py +4 -0
  30. notionary/blocks/mixins/captions/caption_markdown_node_mixin.py +31 -0
  31. notionary/blocks/mixins/captions/caption_mixin.py +92 -0
  32. notionary/blocks/models.py +3 -1
  33. notionary/blocks/numbered_list/numbered_list_element.py +21 -4
  34. notionary/blocks/paragraph/paragraph_element.py +21 -5
  35. notionary/blocks/pdf/pdf_element.py +47 -41
  36. notionary/blocks/pdf/pdf_markdown_node.py +9 -7
  37. notionary/blocks/quote/quote_element.py +26 -9
  38. notionary/blocks/quote/quote_markdown_node.py +2 -2
  39. notionary/blocks/registry/block_registry.py +1 -46
  40. notionary/blocks/registry/block_registry_builder.py +8 -0
  41. notionary/blocks/rich_text/rich_text_models.py +62 -29
  42. notionary/blocks/rich_text/text_inline_formatter.py +432 -101
  43. notionary/blocks/syntax_prompt_builder.py +137 -0
  44. notionary/blocks/table/table_element.py +110 -9
  45. notionary/blocks/table_of_contents/table_of_contents_element.py +19 -2
  46. notionary/blocks/todo/todo_element.py +21 -4
  47. notionary/blocks/toggle/toggle_element.py +19 -3
  48. notionary/blocks/toggle/toggle_markdown_node.py +1 -1
  49. notionary/blocks/toggleable_heading/toggleable_heading_element.py +19 -4
  50. notionary/blocks/types.py +69 -0
  51. notionary/blocks/video/video_element.py +44 -39
  52. notionary/blocks/video/video_markdown_node.py +10 -5
  53. notionary/comments/__init__.py +26 -0
  54. notionary/comments/client.py +211 -0
  55. notionary/comments/models.py +129 -0
  56. notionary/database/client.py +23 -0
  57. notionary/file_upload/models.py +2 -2
  58. notionary/markdown/markdown_builder.py +34 -27
  59. notionary/page/client.py +21 -6
  60. notionary/page/notion_page.py +77 -2
  61. notionary/page/page_content_deleting_service.py +117 -0
  62. notionary/page/page_content_writer.py +89 -113
  63. notionary/page/page_context.py +64 -0
  64. notionary/page/reader/handler/__init__.py +2 -0
  65. notionary/page/reader/handler/base_block_renderer.py +4 -4
  66. notionary/page/reader/handler/block_rendering_context.py +5 -0
  67. notionary/page/reader/handler/line_renderer.py +16 -3
  68. notionary/page/reader/handler/numbered_list_renderer.py +85 -0
  69. notionary/page/reader/page_content_retriever.py +17 -5
  70. notionary/page/writer/handler/__init__.py +2 -0
  71. notionary/page/writer/handler/code_handler.py +12 -40
  72. notionary/page/writer/handler/column_handler.py +12 -12
  73. notionary/page/writer/handler/column_list_handler.py +13 -13
  74. notionary/page/writer/handler/equation_handler.py +74 -0
  75. notionary/page/writer/handler/line_handler.py +4 -4
  76. notionary/page/writer/handler/regular_line_handler.py +31 -37
  77. notionary/page/writer/handler/table_handler.py +8 -72
  78. notionary/page/writer/handler/toggle_handler.py +14 -12
  79. notionary/page/writer/handler/toggleable_heading_handler.py +22 -16
  80. notionary/page/writer/markdown_to_notion_converter.py +28 -9
  81. notionary/page/writer/markdown_to_notion_converter_context.py +30 -0
  82. notionary/page/writer/markdown_to_notion_formatting_post_processor.py +73 -0
  83. notionary/page/writer/markdown_to_notion_post_processor.py +0 -0
  84. notionary/page/writer/markdown_to_notion_text_length_post_processor.py +0 -0
  85. notionary/page/writer/notion_text_length_processor.py +150 -0
  86. notionary/shared/__init__.py +5 -0
  87. notionary/shared/name_to_id_resolver.py +203 -0
  88. notionary/telemetry/service.py +0 -1
  89. notionary/user/notion_user_manager.py +22 -95
  90. notionary/util/concurrency_limiter.py +0 -0
  91. notionary/workspace.py +4 -4
  92. notionary-0.2.23.dist-info/METADATA +235 -0
  93. {notionary-0.2.21.dist-info → notionary-0.2.23.dist-info}/RECORD +96 -77
  94. notionary/page/markdown_whitespace_processor.py +0 -80
  95. notionary/page/notion_text_length_utils.py +0 -119
  96. notionary/user/notion_user_provider.py +0 -1
  97. notionary-0.2.21.dist-info/METADATA +0 -229
  98. /notionary/page/reader/handler/{context.py → equation_renderer.py} +0 -0
  99. {notionary-0.2.21.dist-info → notionary-0.2.23.dist-info}/LICENSE +0 -0
  100. {notionary-0.2.21.dist-info → notionary-0.2.23.dist-info}/WHEEL +0 -0
@@ -1,3 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
1
5
  from notionary.page.reader.handler import BlockHandler, BlockRenderingContext
2
6
 
3
7
 
@@ -8,9 +12,9 @@ class LineRenderer(BlockHandler):
8
12
  # Always can handle - this is the fallback handler
9
13
  return True
10
14
 
11
- def _process(self, context: BlockRenderingContext) -> None:
12
- # Convert the block itself
13
- block_markdown = context.block_registry.notion_to_markdown(context.block)
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)
14
18
 
15
19
  # If block has no direct markdown, either return empty or process children
16
20
  if not block_markdown:
@@ -58,3 +62,12 @@ class LineRenderer(BlockHandler):
58
62
  else block_markdown
59
63
  )
60
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}"
@@ -5,6 +5,7 @@ from notionary.page.reader.handler import (
5
5
  ColumnListRenderer,
6
6
  ColumnRenderer,
7
7
  LineRenderer,
8
+ NumberedListRenderer,
8
9
  ToggleableHeadingRenderer,
9
10
  ToggleRenderer,
10
11
  )
@@ -27,7 +28,7 @@ class PageContentRetriever(LoggingMixin):
27
28
  Retrieve page content and convert it to Markdown.
28
29
  Uses the chain of responsibility pattern for scalable block processing.
29
30
  """
30
- return self._convert_blocks_to_markdown(blocks, indent_level=0)
31
+ return await self._convert_blocks_to_markdown(blocks, indent_level=0)
31
32
 
32
33
  def _setup_handler_chain(self) -> None:
33
34
  """Setup the chain of handlers in priority order."""
@@ -35,16 +36,19 @@ class PageContentRetriever(LoggingMixin):
35
36
  toggleable_heading_handler = ToggleableHeadingRenderer()
36
37
  column_list_handler = ColumnListRenderer()
37
38
  column_handler = ColumnRenderer()
39
+ numbered_list_handler = NumberedListRenderer()
38
40
  regular_handler = LineRenderer()
39
41
 
40
42
  # Chain handlers - most specific first
41
43
  toggle_handler.set_next(toggleable_heading_handler).set_next(
42
44
  column_list_handler
43
- ).set_next(column_handler).set_next(regular_handler)
45
+ ).set_next(column_handler).set_next(numbered_list_handler).set_next(
46
+ regular_handler
47
+ )
44
48
 
45
49
  self._handler_chain = toggle_handler
46
50
 
47
- def _convert_blocks_to_markdown(
51
+ async def _convert_blocks_to_markdown(
48
52
  self, blocks: list[Block], indent_level: int = 0
49
53
  ) -> str:
50
54
  """Convert blocks to Markdown using the handler chain."""
@@ -52,18 +56,26 @@ class PageContentRetriever(LoggingMixin):
52
56
  return ""
53
57
 
54
58
  markdown_parts = []
59
+ i = 0
55
60
 
56
- for block in blocks:
61
+ while i < len(blocks):
62
+ block = blocks[i]
57
63
  context = BlockRenderingContext(
58
64
  block=block,
59
65
  indent_level=indent_level,
60
66
  block_registry=self._block_registry,
67
+ all_blocks=blocks,
68
+ current_block_index=i,
69
+ convert_children_callback=self._convert_blocks_to_markdown,
61
70
  )
62
71
 
63
- self._handler_chain.handle(context)
72
+ await self._handler_chain.handle(context)
64
73
 
65
74
  if context.was_processed and context.markdown_result:
66
75
  markdown_parts.append(context.markdown_result)
67
76
 
77
+ # Skip additional blocks if they were consumed by batch processing
78
+ i += max(1, context.blocks_consumed)
79
+
68
80
  separator = "\n\n" if indent_level == 0 else "\n"
69
81
  return separator.join(markdown_parts)
@@ -1,6 +1,7 @@
1
1
  from .code_handler import CodeHandler
2
2
  from .column_handler import ColumnHandler
3
3
  from .column_list_handler import ColumnListHandler
4
+ from .equation_handler import EquationHandler
4
5
  from .line_handler import LineHandler
5
6
  from .line_processing_context import LineProcessingContext, ParentBlockContext
6
7
  from .regular_line_handler import RegularLineHandler
@@ -19,4 +20,5 @@ __all__ = [
19
20
  "TableHandler",
20
21
  "RegularLineHandler",
21
22
  "CodeHandler",
23
+ "EquationHandler",
22
24
  ]
@@ -1,7 +1,6 @@
1
1
  import re
2
2
 
3
3
  from notionary.blocks.code.code_element import CodeElement
4
- from notionary.blocks.rich_text.rich_text_models import RichTextObject
5
4
  from notionary.page.writer.handler.line_handler import (
6
5
  LineHandler,
7
6
  LineProcessingContext,
@@ -27,9 +26,9 @@ class CodeHandler(LineHandler):
27
26
  return False
28
27
  return self._is_code_start(context)
29
28
 
30
- def _process(self, context: LineProcessingContext) -> None:
29
+ async def _process(self, context: LineProcessingContext) -> None:
31
30
  if self._is_code_start(context):
32
- self._process_complete_code_block(context)
31
+ await self._process_complete_code_block(context)
33
32
  self._mark_processed(context)
34
33
 
35
34
  def _is_code_start(self, context: LineProcessingContext) -> bool:
@@ -40,33 +39,19 @@ class CodeHandler(LineHandler):
40
39
  """Check if we're currently inside any parent context (toggle, heading, etc.)."""
41
40
  return len(context.parent_stack) > 0
42
41
 
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
-
42
+ async def _process_complete_code_block(
43
+ self, context: LineProcessingContext
44
+ ) -> None:
45
+ """Process the entire code block in one go using CodeElement."""
55
46
  code_lines, lines_to_consume = self._collect_code_lines(context)
56
47
 
57
- self._set_block_content(block, code_lines)
48
+ block = CodeElement.create_from_markdown_block(
49
+ opening_line=context.line, code_lines=code_lines
50
+ )
58
51
 
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
52
+ if block:
53
+ context.lines_consumed = lines_to_consume
54
+ context.result_blocks.append(block)
70
55
 
71
56
  def _collect_code_lines(
72
57
  self, context: LineProcessingContext
@@ -85,16 +70,3 @@ class CodeHandler(LineHandler):
85
70
  """Mark context as processed and continue."""
86
71
  context.was_processed = True
87
72
  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))
@@ -26,14 +26,14 @@ class ColumnHandler(LineHandler):
26
26
  def _can_handle(self, context: LineProcessingContext) -> bool:
27
27
  return self._is_column_start(context) or self._is_column_end(context)
28
28
 
29
- def _process(self, context: LineProcessingContext) -> None:
29
+ async def _process(self, context: LineProcessingContext) -> None:
30
30
  if self._is_column_start(context):
31
- self._start_column(context)
31
+ await self._start_column(context)
32
32
  self._mark_processed(context)
33
33
  return
34
34
 
35
35
  if self._is_column_end(context):
36
- self._finalize_column(context)
36
+ await self._finalize_column(context)
37
37
  self._mark_processed(context)
38
38
 
39
39
  def _is_column_start(self, context: LineProcessingContext) -> bool:
@@ -52,15 +52,15 @@ class ColumnHandler(LineHandler):
52
52
  current_parent = context.parent_stack[-1]
53
53
  return issubclass(current_parent.element_type, ColumnElement)
54
54
 
55
- def _start_column(self, context: LineProcessingContext) -> None:
55
+ async def _start_column(self, context: LineProcessingContext) -> None:
56
56
  """Start a new column."""
57
57
  # Create Column block directly - much more efficient!
58
58
  column_element = ColumnElement()
59
- result = column_element.markdown_to_notion(context.line)
59
+ result = await column_element.markdown_to_notion(context.line)
60
60
  if not result:
61
61
  return
62
62
 
63
- block = result if not isinstance(result, list) else result[0]
63
+ block = result
64
64
 
65
65
  # Push to parent stack
66
66
  parent_context = ParentBlockContext(
@@ -70,10 +70,10 @@ class ColumnHandler(LineHandler):
70
70
  )
71
71
  context.parent_stack.append(parent_context)
72
72
 
73
- def _finalize_column(self, context: LineProcessingContext) -> None:
73
+ async def _finalize_column(self, context: LineProcessingContext) -> None:
74
74
  """Finalize a single column and add it to the column list or result."""
75
75
  column_context = context.parent_stack.pop()
76
- self._assign_column_children_if_any(column_context, context)
76
+ await self._assign_column_children_if_any(column_context, context)
77
77
 
78
78
  if context.parent_stack:
79
79
  parent = context.parent_stack[-1]
@@ -87,7 +87,7 @@ class ColumnHandler(LineHandler):
87
87
  # Fallback: no parent or parent is not ColumnList
88
88
  context.result_blocks.append(column_context.block)
89
89
 
90
- def _assign_column_children_if_any(
90
+ async def _assign_column_children_if_any(
91
91
  self, column_context: ParentBlockContext, context: LineProcessingContext
92
92
  ) -> None:
93
93
  """Collect and assign any children blocks inside this column."""
@@ -96,7 +96,7 @@ class ColumnHandler(LineHandler):
96
96
  # Process text lines
97
97
  if column_context.child_lines:
98
98
  children_text = "\n".join(column_context.child_lines)
99
- text_blocks = self._convert_children_text(
99
+ text_blocks = await self._convert_children_text(
100
100
  children_text, context.block_registry
101
101
  )
102
102
  all_children.extend(text_blocks)
@@ -123,7 +123,7 @@ class ColumnHandler(LineHandler):
123
123
  parent.block.column_list.children.append(column_context.block)
124
124
  return True
125
125
 
126
- def _convert_children_text(self, text: str, block_registry) -> list:
126
+ async def _convert_children_text(self, text: str, block_registry) -> list:
127
127
  """Convert children text to blocks."""
128
128
  from notionary.page.writer.markdown_to_notion_converter import (
129
129
  MarkdownToNotionConverter,
@@ -133,7 +133,7 @@ class ColumnHandler(LineHandler):
133
133
  return []
134
134
 
135
135
  child_converter = MarkdownToNotionConverter(block_registry)
136
- return child_converter._process_lines(text)
136
+ return await child_converter.process_lines(text)
137
137
 
138
138
  def _mark_processed(self, context: LineProcessingContext) -> None:
139
139
  """Mark context as processed and signal to continue."""
@@ -7,9 +7,9 @@ from notionary.page.writer.handler.line_handler import (
7
7
  LineHandler,
8
8
  LineProcessingContext,
9
9
  )
10
-
11
10
  from notionary.page.writer.handler.line_processing_context import ParentBlockContext
12
11
 
12
+
13
13
  class ColumnListHandler(LineHandler):
14
14
  """Handles column list elements - both start and end.
15
15
  Syntax:
@@ -31,15 +31,15 @@ class ColumnListHandler(LineHandler):
31
31
  def _can_handle(self, context: LineProcessingContext) -> bool:
32
32
  return self._is_column_list_start(context) or self._is_column_list_end(context)
33
33
 
34
- def _process(self, context: LineProcessingContext) -> None:
34
+ async def _process(self, context: LineProcessingContext) -> None:
35
35
  if self._is_column_list_start(context):
36
- self._start_column_list(context)
36
+ await self._start_column_list(context)
37
37
  context.was_processed = True
38
38
  context.should_continue = True
39
39
  return
40
40
 
41
41
  if self._is_column_list_end(context):
42
- self._finalize_column_list(context)
42
+ await self._finalize_column_list(context)
43
43
  context.was_processed = True
44
44
  context.should_continue = True
45
45
 
@@ -59,7 +59,7 @@ class ColumnListHandler(LineHandler):
59
59
  current_parent = context.parent_stack[-1]
60
60
  return issubclass(current_parent.element_type, ColumnListElement)
61
61
 
62
- def _start_column_list(self, context: LineProcessingContext) -> None:
62
+ async def _start_column_list(self, context: LineProcessingContext) -> None:
63
63
  """Start a new column list."""
64
64
  # Create ColumnList block using the element from registry
65
65
  column_list_element = None
@@ -72,11 +72,11 @@ class ColumnListHandler(LineHandler):
72
72
  return
73
73
 
74
74
  # Create the block
75
- result = column_list_element.markdown_to_notion(context.line)
75
+ result = await column_list_element.markdown_to_notion(context.line)
76
76
  if not result:
77
77
  return
78
78
 
79
- block = result if not isinstance(result, list) else result[0]
79
+ block = result
80
80
 
81
81
  # Push to parent stack
82
82
  parent_context = ParentBlockContext(
@@ -86,10 +86,10 @@ class ColumnListHandler(LineHandler):
86
86
  )
87
87
  context.parent_stack.append(parent_context)
88
88
 
89
- def _finalize_column_list(self, context: LineProcessingContext) -> None:
89
+ async def _finalize_column_list(self, context: LineProcessingContext) -> None:
90
90
  """Finalize a column list and add it to result_blocks."""
91
91
  column_list_context = context.parent_stack.pop()
92
- self._assign_column_list_children_if_any(column_list_context, context)
92
+ await self._assign_column_list_children_if_any(column_list_context, context)
93
93
 
94
94
  # Check if we have a parent context to add this column_list to
95
95
  if context.parent_stack:
@@ -101,7 +101,7 @@ class ColumnListHandler(LineHandler):
101
101
  # No parent, add to top level
102
102
  context.result_blocks.append(column_list_context.block)
103
103
 
104
- def _assign_column_list_children_if_any(
104
+ async def _assign_column_list_children_if_any(
105
105
  self, column_list_context: ParentBlockContext, context: LineProcessingContext
106
106
  ) -> None:
107
107
  """Collect and assign any column children blocks inside this column list."""
@@ -110,7 +110,7 @@ class ColumnListHandler(LineHandler):
110
110
  # Process text lines
111
111
  if column_list_context.child_lines:
112
112
  children_text = "\n".join(column_list_context.child_lines)
113
- children_blocks = self._convert_children_text(
113
+ children_blocks = await self._convert_children_text(
114
114
  children_text, context.block_registry
115
115
  )
116
116
  all_children.extend(children_blocks)
@@ -126,7 +126,7 @@ class ColumnListHandler(LineHandler):
126
126
  ]
127
127
  column_list_context.block.column_list.children = column_children
128
128
 
129
- def _convert_children_text(self, text: str, block_registry) -> list:
129
+ async def _convert_children_text(self, text: str, block_registry) -> list:
130
130
  """Convert children text to blocks."""
131
131
  from notionary.page.writer.markdown_to_notion_converter import (
132
132
  MarkdownToNotionConverter,
@@ -136,4 +136,4 @@ class ColumnListHandler(LineHandler):
136
136
  return []
137
137
 
138
138
  child_converter = MarkdownToNotionConverter(block_registry)
139
- return child_converter._process_lines(text)
139
+ return await child_converter.process_lines(text)
@@ -0,0 +1,74 @@
1
+ import re
2
+
3
+ from notionary.blocks.equation.equation_element import EquationElement
4
+ from notionary.page.writer.handler.line_handler import (
5
+ LineHandler,
6
+ LineProcessingContext,
7
+ )
8
+
9
+
10
+ class EquationHandler(LineHandler):
11
+ """Handles equation block specific logic with batching.
12
+
13
+ Markdown syntax:
14
+ $$
15
+ \sum_{i=1}^n i = \frac{n(n+1)}{2} \\
16
+ \sum_{i=1}^n i^2 = \frac{n(n+1)(2n+1)}{6} \\
17
+ \sum_{i=1}^n i^3 = \left(\frac{n(n+1)}{2}\right)^2
18
+ $$
19
+ """
20
+
21
+ def __init__(self):
22
+ super().__init__()
23
+ self._equation_start_pattern = re.compile(r"^\$\$\s*$")
24
+ self._equation_end_pattern = re.compile(r"^\$\$\s*$")
25
+
26
+ def _can_handle(self, context: LineProcessingContext) -> bool:
27
+ if self._is_inside_parent_context(context):
28
+ return False
29
+ return self._is_equation_start(context)
30
+
31
+ async def _process(self, context: LineProcessingContext) -> None:
32
+ if self._is_equation_start(context):
33
+ await self._process_complete_equation_block(context)
34
+ self._mark_processed(context)
35
+
36
+ def _is_equation_start(self, context: LineProcessingContext) -> bool:
37
+ """Check if this line starts an equation block."""
38
+ return self._equation_start_pattern.match(context.line.strip()) is not None
39
+
40
+ def _is_inside_parent_context(self, context: LineProcessingContext) -> bool:
41
+ """Check if we're currently inside any parent context (toggle, heading, etc.)."""
42
+ return len(context.parent_stack) > 0
43
+
44
+ async def _process_complete_equation_block(
45
+ self, context: LineProcessingContext
46
+ ) -> None:
47
+ """Process the entire equation block in one go using EquationElement."""
48
+ equation_lines, lines_to_consume = self._collect_equation_lines(context)
49
+
50
+ block = EquationElement.create_from_markdown_block(
51
+ opening_line=context.line, equation_lines=equation_lines
52
+ )
53
+
54
+ if block:
55
+ context.lines_consumed = lines_to_consume
56
+ context.result_blocks.append(block)
57
+
58
+ def _collect_equation_lines(
59
+ self, context: LineProcessingContext
60
+ ) -> tuple[list[str], int]:
61
+ """Collect lines until closing $$ fence and return (lines, count_to_consume)."""
62
+ lines = []
63
+ for idx, ln in enumerate(context.get_remaining_lines()):
64
+ if self._equation_end_pattern.match(ln.strip()):
65
+ return lines, idx + 1
66
+ lines.append(ln)
67
+ # No closing fence: consume all remaining
68
+ rem = context.get_remaining_lines()
69
+ return rem, len(rem)
70
+
71
+ def _mark_processed(self, context: LineProcessingContext) -> None:
72
+ """Mark context as processed and continue."""
73
+ context.was_processed = True
74
+ context.should_continue = True
@@ -17,12 +17,12 @@ class LineHandler(ABC):
17
17
  self._next_handler = handler
18
18
  return handler
19
19
 
20
- def handle(self, context: LineProcessingContext) -> None:
20
+ async def handle(self, context: LineProcessingContext) -> None:
21
21
  """Handle the line or pass to next handler."""
22
22
  if self._can_handle(context):
23
- self._process(context)
23
+ await self._process(context)
24
24
  elif self._next_handler:
25
- self._next_handler.handle(context)
25
+ await self._next_handler.handle(context)
26
26
 
27
27
  @abstractmethod
28
28
  def _can_handle(self, context: LineProcessingContext) -> bool:
@@ -30,6 +30,6 @@ class LineHandler(ABC):
30
30
  pass
31
31
 
32
32
  @abstractmethod
33
- def _process(self, context: LineProcessingContext) -> None:
33
+ async def _process(self, context: LineProcessingContext) -> None:
34
34
  """Process the line and update context."""
35
35
  pass
@@ -1,6 +1,5 @@
1
1
  from notionary.blocks.column.column_element import ColumnElement
2
2
  from notionary.blocks.column.column_list_element import ColumnListElement
3
- from notionary.blocks.models import BlockCreateRequest, BlockCreateResult
4
3
  from notionary.page.writer.handler import LineHandler, LineProcessingContext
5
4
 
6
5
 
@@ -10,16 +9,16 @@ class RegularLineHandler(LineHandler):
10
9
  def _can_handle(self, context: LineProcessingContext) -> bool:
11
10
  return context.line.strip()
12
11
 
13
- def _process(self, context: LineProcessingContext) -> None:
12
+ async def _process(self, context: LineProcessingContext) -> None:
14
13
  if self._is_in_column_context(context):
15
14
  self._add_to_column_context(context)
16
15
  context.was_processed = True
17
16
  context.should_continue = True
18
17
  return
19
18
 
20
- block_created = self._process_single_line_content(context)
19
+ block_created = await self._process_single_line_content(context)
21
20
  if not block_created:
22
- self._process_as_paragraph(context)
21
+ await self._process_as_paragraph(context)
23
22
 
24
23
  context.was_processed = True
25
24
 
@@ -37,56 +36,51 @@ class RegularLineHandler(LineHandler):
37
36
  """Add line as child to the current Column context."""
38
37
  context.parent_stack[-1].add_child_line(context.line)
39
38
 
40
- def _process_single_line_content(self, context: LineProcessingContext) -> bool:
39
+ async def _process_single_line_content(
40
+ self, context: LineProcessingContext
41
+ ) -> bool:
41
42
  """Process a regular line for simple elements (lists, etc.)."""
43
+ specialized_elements = self._get_specialized_elements()
44
+
42
45
  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
46
 
60
47
  if issubclass(element, specialized_elements):
61
48
  continue
62
49
 
63
- result = element.markdown_to_notion(context.line)
50
+ result = await element.markdown_to_notion(context.line)
64
51
  if not result:
65
52
  continue
66
53
 
67
- blocks = self._normalize_to_list(result)
68
- for block in blocks:
69
- context.result_blocks.append(block)
54
+ context.result_blocks.append(result)
70
55
 
71
56
  return True
72
57
 
73
58
  return False
74
59
 
75
- def _process_as_paragraph(self, context: LineProcessingContext) -> None:
60
+ async def _process_as_paragraph(self, context: LineProcessingContext) -> None:
76
61
  """Process a line as a paragraph."""
77
62
  from notionary.blocks.paragraph.paragraph_element import ParagraphElement
78
63
 
79
64
  paragraph_element = ParagraphElement()
80
- result = paragraph_element.markdown_to_notion(context.line)
65
+ result = await paragraph_element.markdown_to_notion(context.line)
81
66
 
82
67
  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]
68
+ context.result_blocks.append(result)
69
+
70
+ def _get_specialized_elements(self):
71
+ """Get tuple of elements that have specialized handlers."""
72
+ from notionary.blocks.code import CodeElement
73
+ from notionary.blocks.paragraph import ParagraphElement
74
+ from notionary.blocks.table import TableElement
75
+ from notionary.blocks.toggle import ToggleElement
76
+ from notionary.blocks.toggleable_heading import ToggleableHeadingElement
77
+
78
+ return (
79
+ ColumnListElement,
80
+ ColumnElement,
81
+ ToggleElement,
82
+ ToggleableHeadingElement,
83
+ TableElement,
84
+ CodeElement,
85
+ ParagraphElement,
86
+ )