notionary 0.2.28__py3-none-any.whl → 0.3.1__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 (149) hide show
  1. notionary/__init__.py +9 -2
  2. notionary/blocks/__init__.py +5 -0
  3. notionary/blocks/client.py +6 -4
  4. notionary/blocks/enums.py +28 -1
  5. notionary/blocks/rich_text/markdown_rich_text_converter.py +14 -0
  6. notionary/blocks/rich_text/models.py +14 -0
  7. notionary/blocks/rich_text/name_id_resolver/__init__.py +2 -0
  8. notionary/blocks/rich_text/name_id_resolver/data_source.py +32 -0
  9. notionary/blocks/rich_text/rich_text_markdown_converter.py +12 -0
  10. notionary/blocks/rich_text/rich_text_patterns.py +3 -0
  11. notionary/blocks/schemas.py +42 -10
  12. notionary/comments/__init__.py +5 -0
  13. notionary/comments/client.py +7 -10
  14. notionary/comments/factory.py +4 -6
  15. notionary/data_source/http/data_source_instance_client.py +14 -4
  16. notionary/data_source/properties/{models.py → schemas.py} +4 -8
  17. notionary/data_source/query/__init__.py +9 -0
  18. notionary/data_source/query/builder.py +38 -10
  19. notionary/data_source/query/schema.py +13 -10
  20. notionary/data_source/query/validator.py +11 -11
  21. notionary/data_source/schema/registry.py +104 -0
  22. notionary/data_source/schema/service.py +136 -0
  23. notionary/data_source/schemas.py +1 -1
  24. notionary/data_source/service.py +29 -103
  25. notionary/database/service.py +17 -60
  26. notionary/exceptions/__init__.py +5 -1
  27. notionary/exceptions/block_parsing.py +21 -0
  28. notionary/exceptions/search.py +24 -0
  29. notionary/http/client.py +9 -10
  30. notionary/http/models.py +5 -4
  31. notionary/page/content/factory.py +10 -3
  32. notionary/page/content/markdown/builder.py +76 -154
  33. notionary/page/content/markdown/nodes/__init__.py +0 -2
  34. notionary/page/content/markdown/nodes/audio.py +1 -1
  35. notionary/page/content/markdown/nodes/base.py +1 -1
  36. notionary/page/content/markdown/nodes/bookmark.py +1 -1
  37. notionary/page/content/markdown/nodes/breadcrumb.py +1 -1
  38. notionary/page/content/markdown/nodes/bulleted_list.py +31 -8
  39. notionary/page/content/markdown/nodes/callout.py +12 -10
  40. notionary/page/content/markdown/nodes/code.py +3 -5
  41. notionary/page/content/markdown/nodes/columns.py +39 -21
  42. notionary/page/content/markdown/nodes/container.py +64 -0
  43. notionary/page/content/markdown/nodes/divider.py +1 -1
  44. notionary/page/content/markdown/nodes/embed.py +1 -1
  45. notionary/page/content/markdown/nodes/equation.py +1 -1
  46. notionary/page/content/markdown/nodes/file.py +1 -1
  47. notionary/page/content/markdown/nodes/heading.py +26 -6
  48. notionary/page/content/markdown/nodes/image.py +1 -1
  49. notionary/page/content/markdown/nodes/mixins/__init__.py +5 -0
  50. notionary/page/content/markdown/nodes/mixins/caption.py +1 -1
  51. notionary/page/content/markdown/nodes/numbered_list.py +28 -5
  52. notionary/page/content/markdown/nodes/paragraph.py +1 -1
  53. notionary/page/content/markdown/nodes/pdf.py +1 -1
  54. notionary/page/content/markdown/nodes/quote.py +17 -5
  55. notionary/page/content/markdown/nodes/space.py +1 -1
  56. notionary/page/content/markdown/nodes/table.py +1 -1
  57. notionary/page/content/markdown/nodes/table_of_contents.py +1 -1
  58. notionary/page/content/markdown/nodes/todo.py +23 -7
  59. notionary/page/content/markdown/nodes/toggle.py +13 -14
  60. notionary/page/content/markdown/nodes/video.py +1 -1
  61. notionary/page/content/parser/context.py +98 -21
  62. notionary/page/content/parser/factory.py +1 -10
  63. notionary/page/content/parser/parsers/__init__.py +0 -2
  64. notionary/page/content/parser/parsers/audio.py +1 -1
  65. notionary/page/content/parser/parsers/base.py +1 -1
  66. notionary/page/content/parser/parsers/bookmark.py +1 -1
  67. notionary/page/content/parser/parsers/breadcrumb.py +1 -1
  68. notionary/page/content/parser/parsers/bulleted_list.py +52 -8
  69. notionary/page/content/parser/parsers/callout.py +55 -84
  70. notionary/page/content/parser/parsers/caption.py +1 -1
  71. notionary/page/content/parser/parsers/code.py +5 -5
  72. notionary/page/content/parser/parsers/column.py +23 -64
  73. notionary/page/content/parser/parsers/column_list.py +45 -45
  74. notionary/page/content/parser/parsers/divider.py +1 -1
  75. notionary/page/content/parser/parsers/embed.py +1 -1
  76. notionary/page/content/parser/parsers/equation.py +1 -1
  77. notionary/page/content/parser/parsers/file.py +1 -1
  78. notionary/page/content/parser/parsers/heading.py +65 -8
  79. notionary/page/content/parser/parsers/image.py +1 -1
  80. notionary/page/content/parser/parsers/numbered_list.py +52 -8
  81. notionary/page/content/parser/parsers/paragraph.py +3 -2
  82. notionary/page/content/parser/parsers/pdf.py +1 -1
  83. notionary/page/content/parser/parsers/quote.py +75 -15
  84. notionary/page/content/parser/parsers/space.py +14 -8
  85. notionary/page/content/parser/parsers/table.py +1 -1
  86. notionary/page/content/parser/parsers/table_of_contents.py +1 -1
  87. notionary/page/content/parser/parsers/todo.py +57 -19
  88. notionary/page/content/parser/parsers/toggle.py +17 -74
  89. notionary/page/content/parser/parsers/video.py +1 -1
  90. notionary/page/content/parser/post_processing/handlers/rich_text_length.py +6 -4
  91. notionary/page/content/parser/post_processing/handlers/rich_text_length_truncation.py +43 -22
  92. notionary/page/content/parser/pre_processsing/handlers/__init__.py +4 -0
  93. notionary/page/content/parser/pre_processsing/handlers/column_syntax.py +108 -54
  94. notionary/page/content/parser/pre_processsing/handlers/indentation.py +86 -0
  95. notionary/page/content/parser/pre_processsing/handlers/video_syntax.py +66 -0
  96. notionary/page/content/parser/pre_processsing/handlers/whitespace.py +14 -7
  97. notionary/page/content/parser/service.py +9 -0
  98. notionary/page/content/renderer/context.py +5 -2
  99. notionary/page/content/renderer/factory.py +2 -11
  100. notionary/page/content/renderer/post_processing/handlers/__init__.py +2 -2
  101. notionary/page/content/renderer/post_processing/handlers/numbered_list.py +156 -0
  102. notionary/page/content/renderer/renderers/__init__.py +0 -2
  103. notionary/page/content/renderer/renderers/base.py +1 -1
  104. notionary/page/content/renderer/renderers/bulleted_list.py +1 -1
  105. notionary/page/content/renderer/renderers/callout.py +6 -21
  106. notionary/page/content/renderer/renderers/captioned_block.py +1 -1
  107. notionary/page/content/renderer/renderers/column.py +28 -19
  108. notionary/page/content/renderer/renderers/column_list.py +24 -11
  109. notionary/page/content/renderer/renderers/heading.py +53 -27
  110. notionary/page/content/renderer/renderers/numbered_list.py +6 -5
  111. notionary/page/content/renderer/renderers/quote.py +1 -1
  112. notionary/page/content/renderer/renderers/todo.py +1 -1
  113. notionary/page/content/renderer/renderers/toggle.py +6 -7
  114. notionary/page/content/service.py +4 -1
  115. notionary/page/content/syntax/__init__.py +4 -0
  116. notionary/page/content/syntax/grammar.py +10 -0
  117. notionary/page/content/syntax/models.py +0 -2
  118. notionary/page/content/syntax/{service.py → registry.py} +31 -91
  119. notionary/page/properties/client.py +3 -3
  120. notionary/page/properties/models.py +3 -2
  121. notionary/page/properties/service.py +18 -3
  122. notionary/page/service.py +22 -80
  123. notionary/shared/entity/service.py +94 -36
  124. notionary/shared/models/cover.py +1 -1
  125. notionary/shared/typings.py +3 -0
  126. notionary/user/base.py +60 -11
  127. notionary/user/factory.py +0 -0
  128. notionary/utils/decorators.py +122 -0
  129. notionary/utils/fuzzy.py +18 -6
  130. notionary/utils/mixins/logging.py +38 -27
  131. notionary/utils/pagination.py +70 -16
  132. notionary/workspace/__init__.py +2 -1
  133. notionary/workspace/client.py +4 -2
  134. notionary/workspace/query/__init__.py +3 -0
  135. notionary/workspace/query/builder.py +25 -1
  136. notionary/workspace/query/models.py +12 -3
  137. notionary/workspace/query/service.py +57 -32
  138. notionary/workspace/service.py +31 -21
  139. {notionary-0.2.28.dist-info → notionary-0.3.1.dist-info}/METADATA +35 -105
  140. notionary-0.3.1.dist-info/RECORD +211 -0
  141. notionary/page/content/markdown/nodes/toggleable_heading.py +0 -35
  142. notionary/page/content/parser/parsers/toggleable_heading.py +0 -150
  143. notionary/page/content/renderer/post_processing/handlers/numbered_list_placeholdere.py +0 -62
  144. notionary/page/content/renderer/renderers/toggleable_heading.py +0 -78
  145. notionary/utils/async_retry.py +0 -39
  146. notionary/utils/singleton.py +0 -13
  147. notionary-0.2.28.dist-info/RECORD +0 -200
  148. {notionary-0.2.28.dist-info → notionary-0.3.1.dist-info}/WHEEL +0 -0
  149. {notionary-0.2.28.dist-info → notionary-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -2,7 +2,7 @@ from typing import override
2
2
 
3
3
  from notionary.page.content.markdown.nodes.base import MarkdownNode
4
4
  from notionary.page.content.markdown.nodes.mixins.caption import CaptionMarkdownNodeMixin
5
- from notionary.page.content.syntax.service import SyntaxRegistry
5
+ from notionary.page.content.syntax import SyntaxRegistry
6
6
 
7
7
 
8
8
  class FileMarkdownNode(MarkdownNode, CaptionMarkdownNodeMixin):
@@ -1,16 +1,36 @@
1
1
  from typing import override
2
2
 
3
3
  from notionary.page.content.markdown.nodes.base import MarkdownNode
4
- from notionary.page.content.syntax.service import SyntaxRegistry
4
+ from notionary.page.content.markdown.nodes.container import ContainerNode
5
+ from notionary.page.content.syntax import SyntaxRegistry
5
6
 
6
7
 
7
- class HeadingMarkdownNode(MarkdownNode):
8
- def __init__(self, text: str, level: int = 1, syntax_registry: SyntaxRegistry | None = None) -> None:
8
+ class HeadingMarkdownNode(ContainerNode):
9
+ MIN_LEVEL = 1
10
+ MAX_LEVEL = 3
11
+
12
+ def __init__(
13
+ self,
14
+ text: str,
15
+ level: int = 1,
16
+ children: list[MarkdownNode] | None = None,
17
+ syntax_registry: SyntaxRegistry | None = None,
18
+ ) -> None:
9
19
  super().__init__(syntax_registry=syntax_registry)
10
20
  self.text = text
11
- self.level = max(1, min(3, level))
21
+ self.level = self._validate_level(level)
22
+ self.children = children or []
12
23
 
13
24
  @override
14
25
  def to_markdown(self) -> str:
15
- heading_syntax = self._syntax_registry.get_heading_syntax()
16
- return f"{heading_syntax.start_delimiter * self.level} {self.text}"
26
+ heading_prefix = self._get_heading_prefix()
27
+ result = f"{heading_prefix} {self.text}"
28
+ result += self.render_children()
29
+ return result
30
+
31
+ def _validate_level(self, level: int) -> int:
32
+ return max(self.MIN_LEVEL, min(self.MAX_LEVEL, level))
33
+
34
+ def _get_heading_prefix(self) -> str:
35
+ delimiter = self._syntax_registry.get_heading_syntax().start_delimiter
36
+ return delimiter * self.level
@@ -2,7 +2,7 @@ from typing import override
2
2
 
3
3
  from notionary.page.content.markdown.nodes.base import MarkdownNode
4
4
  from notionary.page.content.markdown.nodes.mixins.caption import CaptionMarkdownNodeMixin
5
- from notionary.page.content.syntax.service import SyntaxRegistry
5
+ from notionary.page.content.syntax import SyntaxRegistry
6
6
 
7
7
 
8
8
  class ImageMarkdownNode(MarkdownNode, CaptionMarkdownNodeMixin):
@@ -0,0 +1,5 @@
1
+ from .caption import CaptionMarkdownNodeMixin
2
+
3
+ __all__ = [
4
+ "CaptionMarkdownNodeMixin",
5
+ ]
@@ -1,4 +1,4 @@
1
- from notionary.page.content.syntax.service import SyntaxRegistry
1
+ from notionary.page.content.syntax import SyntaxRegistry
2
2
 
3
3
 
4
4
  class CaptionMarkdownNodeMixin:
@@ -1,15 +1,38 @@
1
1
  from typing import override
2
2
 
3
3
  from notionary.page.content.markdown.nodes.base import MarkdownNode
4
- from notionary.page.content.syntax.service import SyntaxRegistry
4
+ from notionary.page.content.markdown.nodes.container import ContainerNode
5
+ from notionary.page.content.syntax import SyntaxRegistry
5
6
 
6
7
 
7
- class NumberedListMarkdownNode(MarkdownNode):
8
- def __init__(self, texts: list[str], syntax_registry: SyntaxRegistry | None = None):
8
+ class NumberedListMarkdownNode(ContainerNode):
9
+ def __init__(
10
+ self,
11
+ texts: list[str],
12
+ children: list[MarkdownNode | None] | None = None,
13
+ syntax_registry: SyntaxRegistry | None = None,
14
+ ):
9
15
  super().__init__(syntax_registry=syntax_registry)
10
16
  self.texts = texts
17
+ self.children = children or []
11
18
 
12
19
  @override
13
20
  def to_markdown(self) -> str:
14
- numbered_list_syntax = self._syntax_registry.get_numbered_list_syntax()
15
- return "\n".join(f"{i + 1}{numbered_list_syntax.end_delimiter} {text}" for i, text in enumerate(self.texts))
21
+ list_items = [self._render_list_item(index, text) for index, text in enumerate(self.texts)]
22
+ return "\n".join(list_items)
23
+
24
+ def _render_list_item(self, index: int, text: str) -> str:
25
+ item_number = index + 1
26
+ item_line = f"{item_number}. {text}"
27
+
28
+ child = self._get_child_for_item(index)
29
+ if child:
30
+ child_content = self.render_child(child)
31
+ return f"{item_line}\n{child_content}"
32
+
33
+ return item_line
34
+
35
+ def _get_child_for_item(self, index: int) -> MarkdownNode | None:
36
+ if not self.children or index >= len(self.children):
37
+ return None
38
+ return self.children[index]
@@ -1,7 +1,7 @@
1
1
  from typing import override
2
2
 
3
3
  from notionary.page.content.markdown.nodes.base import MarkdownNode
4
- from notionary.page.content.syntax.service import SyntaxRegistry
4
+ from notionary.page.content.syntax import SyntaxRegistry
5
5
 
6
6
 
7
7
  class ParagraphMarkdownNode(MarkdownNode):
@@ -2,7 +2,7 @@ from typing import override
2
2
 
3
3
  from notionary.page.content.markdown.nodes.base import MarkdownNode
4
4
  from notionary.page.content.markdown.nodes.mixins.caption import CaptionMarkdownNodeMixin
5
- from notionary.page.content.syntax.service import SyntaxRegistry
5
+ from notionary.page.content.syntax import SyntaxRegistry
6
6
 
7
7
 
8
8
  class PdfMarkdownNode(MarkdownNode, CaptionMarkdownNodeMixin):
@@ -1,15 +1,27 @@
1
1
  from typing import override
2
2
 
3
3
  from notionary.page.content.markdown.nodes.base import MarkdownNode
4
- from notionary.page.content.syntax.service import SyntaxRegistry
4
+ from notionary.page.content.markdown.nodes.container import ContainerNode
5
+ from notionary.page.content.syntax import SyntaxRegistry
5
6
 
6
7
 
7
- class QuoteMarkdownNode(MarkdownNode):
8
- def __init__(self, text: str, syntax_registry: SyntaxRegistry | None = None) -> None:
8
+ class QuoteMarkdownNode(ContainerNode):
9
+ def __init__(
10
+ self,
11
+ text: str,
12
+ children: list[MarkdownNode] | None = None,
13
+ syntax_registry: SyntaxRegistry | None = None,
14
+ ) -> None:
9
15
  super().__init__(syntax_registry=syntax_registry)
10
16
  self.text = text
17
+ self.children = children or []
11
18
 
12
19
  @override
13
20
  def to_markdown(self) -> str:
14
- quote_syntax = self._syntax_registry.get_quote_syntax()
15
- return f"{quote_syntax.start_delimiter} {self.text}"
21
+ quote_delimiter = self._get_quote_delimiter()
22
+ result = f"{quote_delimiter}{self.text}"
23
+ result += self.render_children()
24
+ return result
25
+
26
+ def _get_quote_delimiter(self) -> str:
27
+ return self._syntax_registry.get_quote_syntax().start_delimiter
@@ -1,7 +1,7 @@
1
1
  from typing import override
2
2
 
3
3
  from notionary.page.content.markdown.nodes.base import MarkdownNode
4
- from notionary.page.content.syntax.service import SyntaxRegistry
4
+ from notionary.page.content.syntax import SyntaxRegistry
5
5
 
6
6
 
7
7
  class SpaceMarkdownNode(MarkdownNode):
@@ -1,7 +1,7 @@
1
1
  from typing import override
2
2
 
3
3
  from notionary.page.content.markdown.nodes.base import MarkdownNode
4
- from notionary.page.content.syntax.service import SyntaxRegistry
4
+ from notionary.page.content.syntax import SyntaxRegistry
5
5
 
6
6
 
7
7
  class TableMarkdownNode(MarkdownNode):
@@ -1,7 +1,7 @@
1
1
  from typing import override
2
2
 
3
3
  from notionary.page.content.markdown.nodes.base import MarkdownNode
4
- from notionary.page.content.syntax.service import SyntaxRegistry
4
+ from notionary.page.content.syntax import SyntaxRegistry
5
5
 
6
6
 
7
7
  class TableOfContentsMarkdownNode(MarkdownNode):
@@ -1,22 +1,38 @@
1
1
  from typing import override
2
2
 
3
3
  from notionary.page.content.markdown.nodes.base import MarkdownNode
4
- from notionary.page.content.syntax.service import SyntaxRegistry
4
+ from notionary.page.content.markdown.nodes.container import ContainerNode
5
+ from notionary.page.content.syntax import SyntaxRegistry
5
6
 
6
7
 
7
- class TodoMarkdownNode(MarkdownNode):
8
+ class TodoMarkdownNode(ContainerNode):
9
+ VALID_MARKER = "-"
10
+
8
11
  def __init__(
9
- self, text: str, checked: bool = False, marker: str = "-", syntax_registry: SyntaxRegistry | None = None
12
+ self,
13
+ text: str,
14
+ checked: bool = False,
15
+ marker: str = "-",
16
+ children: list[MarkdownNode] | None = None,
17
+ syntax_registry: SyntaxRegistry | None = None,
10
18
  ):
11
19
  super().__init__(syntax_registry=syntax_registry)
12
20
  self.text = text
13
21
  self.checked = checked
14
22
  self.marker = marker
23
+ self.children = children or []
15
24
 
16
25
  @override
17
26
  def to_markdown(self) -> str:
18
- # Validate marker to ensure it's valid
19
- valid_marker = self.marker if self.marker in {"-", "*", "+"} else "-"
27
+ validated_marker = self._get_validated_marker()
28
+ checkbox_state = self._get_checkbox_state()
29
+ result = f"{validated_marker}{checkbox_state} {self.text}"
30
+ result += self.render_children()
31
+ return result
32
+
33
+ def _get_validated_marker(self) -> str:
34
+ return self.marker if self.marker == self.VALID_MARKER else self.VALID_MARKER
35
+
36
+ def _get_checkbox_state(self) -> str:
20
37
  todo_syntax = self._syntax_registry.get_todo_syntax()
21
- checkbox_state = todo_syntax.end_delimiter if self.checked else todo_syntax.start_delimiter
22
- return f"{valid_marker} {checkbox_state} {self.text}"
38
+ return todo_syntax.end_delimiter if self.checked else todo_syntax.start_delimiter
@@ -1,12 +1,16 @@
1
1
  from typing import override
2
2
 
3
3
  from notionary.page.content.markdown.nodes.base import MarkdownNode
4
- from notionary.page.content.syntax.service import SyntaxRegistry
4
+ from notionary.page.content.markdown.nodes.container import ContainerNode
5
+ from notionary.page.content.syntax import SyntaxRegistry
5
6
 
6
7
 
7
- class ToggleMarkdownNode(MarkdownNode):
8
+ class ToggleMarkdownNode(ContainerNode):
8
9
  def __init__(
9
- self, title: str, children: list[MarkdownNode] | None = None, syntax_registry: SyntaxRegistry | None = None
10
+ self,
11
+ title: str,
12
+ children: list[MarkdownNode] | None = None,
13
+ syntax_registry: SyntaxRegistry | None = None,
10
14
  ):
11
15
  super().__init__(syntax_registry=syntax_registry)
12
16
  self.title = title
@@ -14,15 +18,10 @@ class ToggleMarkdownNode(MarkdownNode):
14
18
 
15
19
  @override
16
20
  def to_markdown(self) -> str:
17
- toggle_syntax = self._syntax_registry.get_toggle_syntax()
18
- result = f"{toggle_syntax.start_delimiter} {self.title}"
21
+ toggle_delimiter = self._get_toggle_delimiter()
22
+ result = f"{toggle_delimiter} {self.title}"
23
+ result += self.render_children()
24
+ return result
19
25
 
20
- if not self.children:
21
- result += f"\n{toggle_syntax.end_delimiter}"
22
- return result
23
-
24
- # Convert children to markdown
25
- content_parts = [child.to_markdown() for child in self.children]
26
- content_text = "\n\n".join(content_parts)
27
-
28
- return result + "\n" + content_text + f"\n{toggle_syntax.end_delimiter}"
26
+ def _get_toggle_delimiter(self) -> str:
27
+ return self._syntax_registry.get_toggle_syntax().start_delimiter
@@ -2,7 +2,7 @@ from typing import override
2
2
 
3
3
  from notionary.page.content.markdown.nodes.base import MarkdownNode
4
4
  from notionary.page.content.markdown.nodes.mixins.caption import CaptionMarkdownNodeMixin
5
- from notionary.page.content.syntax.service import SyntaxRegistry
5
+ from notionary.page.content.syntax import SyntaxRegistry
6
6
 
7
7
 
8
8
  class VideoMarkdownNode(MarkdownNode, CaptionMarkdownNodeMixin):
@@ -1,16 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Awaitable, Callable
4
- from dataclasses import dataclass, field
5
4
 
6
5
  from notionary.blocks.schemas import BlockCreatePayload
6
+ from notionary.page.content.syntax.grammar import MarkdownGrammar
7
7
 
8
8
 
9
- @dataclass
10
9
  class ParentBlockContext:
11
- block: BlockCreatePayload
12
- child_lines: list[str]
13
- child_blocks: list[BlockCreatePayload] = field(default_factory=list)
10
+ def __init__(
11
+ self,
12
+ block: BlockCreatePayload,
13
+ child_lines: list[str],
14
+ child_blocks: list[BlockCreatePayload] | None = None,
15
+ ) -> None:
16
+ self.block = block
17
+ self.child_lines = child_lines
18
+ self.child_blocks = child_blocks if child_blocks is not None else []
14
19
 
15
20
  def add_child_line(self, content: str) -> None:
16
21
  self.child_lines.append(content)
@@ -19,31 +24,103 @@ class ParentBlockContext:
19
24
  self.child_blocks.append(block)
20
25
 
21
26
 
22
- ParseChildrenCallback = Callable[[list[str]], Awaitable[list[BlockCreatePayload]]]
27
+ _ParseChildrenCallback = Callable[[str], Awaitable[list[BlockCreatePayload]]]
23
28
 
24
29
 
25
- @dataclass
26
30
  class BlockParsingContext:
27
- line: str
28
- result_blocks: list[BlockCreatePayload]
29
- parent_stack: list[ParentBlockContext]
30
-
31
- parse_children_callback: ParseChildrenCallback | None = None
32
-
33
- all_lines: list[str] | None = None
34
- current_line_index: int | None = None
35
- lines_consumed: int = 0
36
-
37
- async def parse_nested_content(self, nested_lines: list[str]) -> list[BlockCreatePayload]:
38
- if not self.parse_children_callback or not nested_lines:
31
+ def __init__(
32
+ self,
33
+ line: str,
34
+ result_blocks: list[BlockCreatePayload],
35
+ parent_stack: list[ParentBlockContext],
36
+ parse_children_callback: _ParseChildrenCallback | None = None,
37
+ all_lines: list[str] | None = None,
38
+ current_line_index: int | None = None,
39
+ lines_consumed: int = 0,
40
+ is_previous_line_empty: bool = False,
41
+ markdown_grammar: MarkdownGrammar | None = None,
42
+ ) -> None:
43
+ self.line = line
44
+ self.result_blocks = result_blocks
45
+ self.parent_stack = parent_stack
46
+ self.parse_children_callback = parse_children_callback
47
+ self.all_lines = all_lines
48
+ self.current_line_index = current_line_index
49
+ self.lines_consumed = lines_consumed
50
+ self.is_previous_line_empty = is_previous_line_empty
51
+ markdown_grammar = markdown_grammar or MarkdownGrammar()
52
+ self._spaces_per_nesting_level = markdown_grammar.spaces_per_nesting_level
53
+
54
+ async def parse_nested_markdown(self, text: str) -> list[BlockCreatePayload]:
55
+ if not self._can_parse_children(text):
39
56
  return []
40
57
 
41
- return await self.parse_children_callback(nested_lines)
58
+ return await self.parse_children_callback(text)
59
+
60
+ def _can_parse_children(self, text: str) -> bool:
61
+ return self.parse_children_callback is not None and bool(text)
42
62
 
43
63
  def get_remaining_lines(self) -> list[str]:
44
- if self.all_lines is None or self.current_line_index is None:
64
+ if not self._has_remaining_lines():
45
65
  return []
46
66
  return self.all_lines[self.current_line_index + 1 :]
47
67
 
68
+ def _has_remaining_lines(self) -> bool:
69
+ return self.all_lines is not None and self.current_line_index is not None
70
+
48
71
  def is_inside_parent_context(self) -> bool:
49
72
  return len(self.parent_stack) > 0
73
+
74
+ def get_line_indentation_level(self, line: str | None = None) -> int:
75
+ target_line = self._get_target_line(line)
76
+ leading_spaces = self._count_leading_spaces(target_line)
77
+ return self._calculate_indentation_level(leading_spaces)
78
+
79
+ def _get_target_line(self, line: str | None) -> str:
80
+ return line if line is not None else self.line
81
+
82
+ def _count_leading_spaces(self, line: str) -> int:
83
+ return len(line) - len(line.lstrip())
84
+
85
+ def _calculate_indentation_level(self, leading_spaces: int) -> int:
86
+ return leading_spaces // self._spaces_per_nesting_level
87
+
88
+ def collect_indented_child_lines(self, parent_indent_level: int) -> list[str]:
89
+ child_lines = []
90
+ expected_child_indent = parent_indent_level + 1
91
+
92
+ for line in self.get_remaining_lines():
93
+ if self._should_include_line_as_child(line, expected_child_indent):
94
+ child_lines.append(line)
95
+ else:
96
+ break
97
+
98
+ return child_lines
99
+
100
+ def _should_include_line_as_child(self, line: str, expected_indent: int) -> bool:
101
+ if self._is_empty_line(line):
102
+ return True
103
+
104
+ line_indent = self.get_line_indentation_level(line)
105
+ return line_indent >= expected_indent
106
+
107
+ def _is_empty_line(self, line: str) -> bool:
108
+ return not line.strip()
109
+
110
+ def strip_indentation_level(self, lines: list[str], levels: int = 1) -> list[str]:
111
+ return [self._strip_line_indentation(line, levels) for line in lines]
112
+
113
+ def _strip_line_indentation(self, line: str, levels: int) -> str:
114
+ if self._is_empty_line(line):
115
+ return line
116
+
117
+ spaces_to_remove = self._calculate_spaces_to_remove(levels)
118
+ return self._remove_leading_spaces(line, spaces_to_remove)
119
+
120
+ def _calculate_spaces_to_remove(self, levels: int) -> int:
121
+ return self._spaces_per_nesting_level * levels
122
+
123
+ def _remove_leading_spaces(self, line: str, spaces: int) -> str:
124
+ if len(line) < spaces:
125
+ return line
126
+ return line[spaces:]
@@ -26,11 +26,10 @@ from notionary.page.content.parser.parsers import (
26
26
  TableOfContentsParser,
27
27
  TableParser,
28
28
  TodoParser,
29
- ToggleableHeadingParser,
30
29
  ToggleParser,
31
30
  VideoParser,
32
31
  )
33
- from notionary.page.content.syntax.service import SyntaxRegistry
32
+ from notionary.page.content.syntax import SyntaxRegistry
34
33
 
35
34
 
36
35
  class ConverterChainFactory:
@@ -49,7 +48,6 @@ class ConverterChainFactory:
49
48
  table_parser = self._create_table_parser()
50
49
  column_parser = self._create_column_parser()
51
50
  column_list_parser = self._create_column_list_parser()
52
- toggleable_heading_parser = self._create_toggleable_heading_parser()
53
51
  toggle_parser = self._create_toggle_parser()
54
52
 
55
53
  # Single-line blocks
@@ -82,7 +80,6 @@ class ConverterChainFactory:
82
80
  .set_next(table_parser)
83
81
  .set_next(column_parser)
84
82
  .set_next(column_list_parser)
85
- .set_next(toggleable_heading_parser)
86
83
  .set_next(toggle_parser)
87
84
  .set_next(divider_parser)
88
85
  .set_next(breadcrumb_parser)
@@ -128,12 +125,6 @@ class ConverterChainFactory:
128
125
  def _create_column_list_parser(self) -> ColumnListParser:
129
126
  return ColumnListParser(syntax_registry=self._syntax_registry)
130
127
 
131
- def _create_toggleable_heading_parser(self) -> ToggleableHeadingParser:
132
- return ToggleableHeadingParser(
133
- syntax_registry=self._syntax_registry,
134
- rich_text_converter=self._rich_text_converter,
135
- )
136
-
137
128
  def _create_toggle_parser(self) -> ToggleParser:
138
129
  return ToggleParser(
139
130
  syntax_registry=self._syntax_registry,
@@ -24,7 +24,6 @@ from .table import TableParser
24
24
  from .table_of_contents import TableOfContentsParser
25
25
  from .todo import TodoParser
26
26
  from .toggle import ToggleParser
27
- from .toggleable_heading import ToggleableHeadingParser
28
27
  from .video import VideoParser
29
28
 
30
29
  __all__ = [
@@ -55,6 +54,5 @@ __all__ = [
55
54
  "TableParser",
56
55
  "TodoParser",
57
56
  "ToggleParser",
58
- "ToggleableHeadingParser",
59
57
  "VideoParser",
60
58
  ]
@@ -7,7 +7,7 @@ from notionary.blocks.schemas import (
7
7
  FileType,
8
8
  )
9
9
  from notionary.page.content.parser.parsers.base import BlockParsingContext, LineParser
10
- from notionary.page.content.syntax.service import SyntaxRegistry
10
+ from notionary.page.content.syntax import SyntaxRegistry
11
11
 
12
12
 
13
13
  class AudioParser(LineParser):
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  from abc import ABC, abstractmethod
4
4
 
5
5
  from notionary.page.content.parser.context import BlockParsingContext
6
- from notionary.page.content.syntax.service import SyntaxRegistry
6
+ from notionary.page.content.syntax import SyntaxRegistry
7
7
 
8
8
 
9
9
  class LineParser(ABC):
@@ -4,7 +4,7 @@ from typing import override
4
4
 
5
5
  from notionary.blocks.schemas import BookmarkData, CreateBookmarkBlock
6
6
  from notionary.page.content.parser.parsers.base import BlockParsingContext, LineParser
7
- from notionary.page.content.syntax.service import SyntaxRegistry
7
+ from notionary.page.content.syntax import SyntaxRegistry
8
8
 
9
9
 
10
10
  class BookmarkParser(LineParser):
@@ -5,7 +5,7 @@ from notionary.page.content.parser.parsers.base import (
5
5
  BlockParsingContext,
6
6
  LineParser,
7
7
  )
8
- from notionary.page.content.syntax.service import SyntaxRegistry
8
+ from notionary.page.content.syntax import SyntaxRegistry
9
9
 
10
10
 
11
11
  class BreadcrumbParser(LineParser):
@@ -3,12 +3,12 @@ from typing import override
3
3
  from notionary.blocks.rich_text.markdown_rich_text_converter import (
4
4
  MarkdownRichTextConverter,
5
5
  )
6
- from notionary.blocks.schemas import BulletedListItemData, CreateBulletedListItemBlock
6
+ from notionary.blocks.schemas import CreateBulletedListItemBlock, CreateBulletedListItemData
7
7
  from notionary.page.content.parser.parsers.base import (
8
8
  BlockParsingContext,
9
9
  LineParser,
10
10
  )
11
- from notionary.page.content.syntax.service import SyntaxRegistry
11
+ from notionary.page.content.syntax import SyntaxRegistry
12
12
 
13
13
 
14
14
  class BulletedListParser(LineParser):
@@ -21,21 +21,65 @@ class BulletedListParser(LineParser):
21
21
  def _can_handle(self, context: BlockParsingContext) -> bool:
22
22
  if context.is_inside_parent_context():
23
23
  return False
24
- return self._syntax.regex_pattern.match(context.line) is not None
24
+ return self._is_bulleted_list_line(context.line)
25
+
26
+ def _is_bulleted_list_line(self, line: str) -> bool:
27
+ return self._syntax.regex_pattern.match(line) is not None
25
28
 
26
29
  @override
27
30
  async def _process(self, context: BlockParsingContext) -> None:
28
31
  block = await self._create_bulleted_list_block(context.line)
29
- if block:
30
- context.result_blocks.append(block)
32
+ if not block:
33
+ return
34
+
35
+ await self._process_nested_children(block, context)
36
+ context.result_blocks.append(block)
37
+
38
+ async def _process_nested_children(self, block: CreateBulletedListItemBlock, context: BlockParsingContext) -> None:
39
+ child_lines = self._collect_child_lines(context)
40
+ if not child_lines:
41
+ return
42
+
43
+ child_blocks = await self._parse_child_blocks(child_lines, context)
44
+ if child_blocks:
45
+ block.bulleted_list_item.children = child_blocks
46
+
47
+ context.lines_consumed = len(child_lines)
48
+
49
+ def _collect_child_lines(self, context: BlockParsingContext) -> list[str]:
50
+ parent_indent_level = context.get_line_indentation_level()
51
+ return context.collect_indented_child_lines(parent_indent_level)
52
+
53
+ async def _parse_child_blocks(
54
+ self, child_lines: list[str], context: BlockParsingContext
55
+ ) -> list[CreateBulletedListItemBlock]:
56
+ stripped_lines = self._remove_parent_indentation(child_lines, context)
57
+ children_text = self._convert_lines_to_text(stripped_lines)
58
+ return await context.parse_nested_markdown(children_text)
59
+
60
+ def _remove_parent_indentation(self, lines: list[str], context: BlockParsingContext) -> list[str]:
61
+ return context.strip_indentation_level(lines, levels=1)
62
+
63
+ def _convert_lines_to_text(self, lines: list[str]) -> str:
64
+ return "\n".join(lines)
31
65
 
32
66
  async def _create_bulleted_list_block(self, text: str) -> CreateBulletedListItemBlock | None:
67
+ content = self._extract_list_content(text)
68
+ if content is None:
69
+ return None
70
+
71
+ rich_text = await self._convert_to_rich_text(content)
72
+ return self._build_block(rich_text)
73
+
74
+ def _extract_list_content(self, text: str) -> str | None:
33
75
  match = self._syntax.regex_pattern.match(text)
34
76
  if not match:
35
77
  return None
78
+ return match.group(2)
36
79
 
37
- content = match.group(2)
38
- rich_text = await self._rich_text_converter.to_rich_text(content)
80
+ async def _convert_to_rich_text(self, content: str):
81
+ return await self._rich_text_converter.to_rich_text(content)
39
82
 
40
- bulleted_list_content = BulletedListItemData(rich_text=rich_text)
83
+ def _build_block(self, rich_text) -> CreateBulletedListItemBlock:
84
+ bulleted_list_content = CreateBulletedListItemData(rich_text=rich_text)
41
85
  return CreateBulletedListItemBlock(bulleted_list_item=bulleted_list_content)