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
@@ -1,13 +1,13 @@
1
+ import re
1
2
  from typing import override
2
3
 
3
4
  from notionary.blocks.rich_text.markdown_rich_text_converter import MarkdownRichTextConverter
4
5
  from notionary.blocks.schemas import CreateCalloutBlock, CreateCalloutData
5
- from notionary.page.content.parser.context import ParentBlockContext
6
6
  from notionary.page.content.parser.parsers.base import (
7
7
  BlockParsingContext,
8
8
  LineParser,
9
9
  )
10
- from notionary.page.content.syntax.service import SyntaxRegistry
10
+ from notionary.page.content.syntax import SyntaxRegistry
11
11
  from notionary.shared.models.icon import EmojiIcon
12
12
 
13
13
 
@@ -17,113 +17,84 @@ class CalloutParser(LineParser):
17
17
  def __init__(self, syntax_registry: SyntaxRegistry, rich_text_converter: MarkdownRichTextConverter) -> None:
18
18
  super().__init__(syntax_registry)
19
19
  self._syntax = syntax_registry.get_callout_syntax()
20
- self._start_pattern = self._syntax.regex_pattern
21
- self._end_pattern = self._syntax.end_regex_pattern
20
+ self._pattern = self._syntax.regex_pattern
22
21
  self._rich_text_converter = rich_text_converter
23
22
 
24
23
  @override
25
24
  def _can_handle(self, context: BlockParsingContext) -> bool:
26
- return self._is_callout_start(context) or self._is_callout_end(context) or self._is_callout_content(context)
25
+ return self._pattern.search(context.line) is not None
27
26
 
28
27
  @override
29
28
  async def _process(self, context: BlockParsingContext) -> None:
30
- if self._is_callout_start(context):
31
- await self._start_callout(context)
29
+ block = await self._create_callout_block(context.line)
30
+ if not block:
31
+ return
32
32
 
33
- if self._is_callout_end(context):
34
- await self._finalize_callout(context)
33
+ await self._process_nested_children(block, context)
35
34
 
36
- if self._is_callout_content(context):
37
- self._add_callout_content(context)
35
+ if self._is_nested_in_parent_context(context):
36
+ context.parent_stack[-1].add_child_block(block)
37
+ else:
38
+ context.result_blocks.append(block)
38
39
 
39
- def _is_callout_start(self, context: BlockParsingContext) -> bool:
40
- return self._start_pattern.match(context.line) is not None
40
+ async def _process_nested_children(self, block: CreateCalloutBlock, context: BlockParsingContext) -> None:
41
+ child_lines = self._collect_child_lines(context)
42
+ if not child_lines:
43
+ return
41
44
 
42
- def _is_callout_end(self, context: BlockParsingContext) -> bool:
43
- if not self._end_pattern.match(context.line):
44
- return False
45
+ child_blocks = await self._parse_child_blocks(child_lines, context)
46
+ if child_blocks:
47
+ block.callout.children = child_blocks
45
48
 
46
- if not context.parent_stack:
47
- return False
49
+ context.lines_consumed = len(child_lines)
48
50
 
49
- current_parent = context.parent_stack[-1]
50
- return isinstance(current_parent.block, CreateCalloutBlock)
51
+ def _collect_child_lines(self, context: BlockParsingContext) -> list[str]:
52
+ parent_indent_level = context.get_line_indentation_level()
53
+ return context.collect_indented_child_lines(parent_indent_level)
51
54
 
52
- async def _start_callout(self, context: BlockParsingContext) -> None:
53
- block = await self._create_callout_block(context.line)
54
- if not block:
55
- return
55
+ async def _parse_child_blocks(
56
+ self, child_lines: list[str], context: BlockParsingContext
57
+ ) -> list[CreateCalloutBlock]:
58
+ stripped_lines = self._remove_parent_indentation(child_lines, context)
59
+ children_text = self._convert_lines_to_text(stripped_lines)
60
+ return await context.parse_nested_markdown(children_text)
56
61
 
57
- parent_context = ParentBlockContext(
58
- block=block,
59
- child_lines=[],
60
- )
61
- context.parent_stack.append(parent_context)
62
+ def _remove_parent_indentation(self, lines: list[str], context: BlockParsingContext) -> list[str]:
63
+ return context.strip_indentation_level(lines, levels=1)
64
+
65
+ def _convert_lines_to_text(self, lines: list[str]) -> str:
66
+ return "\n".join(lines)
62
67
 
63
68
  async def _create_callout_block(self, line: str) -> CreateCalloutBlock | None:
64
- match = self._start_pattern.match(line)
69
+ match = self._pattern.search(line)
65
70
  if not match:
66
71
  return None
67
72
 
68
- emoji_part = match.group(1)
69
- emoji = emoji_part.strip() if emoji_part else self.DEFAULT_EMOJI
73
+ content, emoji = self._extract_content_and_emoji(match)
74
+ rich_text = await self._convert_to_rich_text(content)
75
+ return self._build_block(rich_text, emoji)
76
+
77
+ def _extract_content_and_emoji(self, match: re.Match[str]) -> tuple[str, str]:
78
+ inline_content = match.group(1)
79
+ if inline_content:
80
+ return inline_content.strip(), match.group(2) or self.DEFAULT_EMOJI
81
+
82
+ block_content = match.group(3)
83
+ if block_content:
84
+ return block_content.strip(), match.group(4) or self.DEFAULT_EMOJI
85
+
86
+ return "", self.DEFAULT_EMOJI
87
+
88
+ async def _convert_to_rich_text(self, content: str):
89
+ return await self._rich_text_converter.to_rich_text(content)
70
90
 
71
- # Create callout with empty rich_text initially
72
- # The actual content will be added as children
91
+ def _build_block(self, rich_text, emoji: str) -> CreateCalloutBlock:
73
92
  callout_data = CreateCalloutData(
74
- rich_text=[],
93
+ rich_text=rich_text,
75
94
  icon=EmojiIcon(emoji=emoji),
76
95
  children=[],
77
96
  )
78
97
  return CreateCalloutBlock(callout=callout_data)
79
98
 
80
- async def _finalize_callout(self, context: BlockParsingContext) -> None:
81
- callout_context = context.parent_stack.pop()
82
- await self._assign_callout_children_if_any(callout_context, context)
83
-
84
- if self._is_nested_in_other_parent_context(context):
85
- self._assign_to_parent_context(context, callout_context)
86
- else:
87
- context.result_blocks.append(callout_context.block)
88
-
89
- def _is_nested_in_other_parent_context(self, context: BlockParsingContext) -> bool:
99
+ def _is_nested_in_parent_context(self, context: BlockParsingContext) -> bool:
90
100
  return bool(context.parent_stack)
91
-
92
- def _assign_to_parent_context(self, context: BlockParsingContext, callout_context: ParentBlockContext) -> None:
93
- parent_context = context.parent_stack[-1]
94
- parent_context.add_child_block(callout_context.block)
95
-
96
- async def _assign_callout_children_if_any(
97
- self, callout_context: ParentBlockContext, context: BlockParsingContext
98
- ) -> None:
99
- all_children = []
100
-
101
- if callout_context.child_lines:
102
- children_text = "\n".join(callout_context.child_lines)
103
- text_blocks = await self._parse_nested_content(children_text, context)
104
- all_children.extend(text_blocks)
105
-
106
- # Add any child blocks
107
- if callout_context.child_blocks:
108
- all_children.extend(callout_context.child_blocks)
109
-
110
- callout_context.block.callout.children = all_children
111
-
112
- def _is_callout_content(self, context: BlockParsingContext) -> bool:
113
- if not context.parent_stack:
114
- return False
115
-
116
- current_parent = context.parent_stack[-1]
117
- if not isinstance(current_parent.block, CreateCalloutBlock):
118
- return False
119
-
120
- return not (self._start_pattern.match(context.line) or self._end_pattern.match(context.line))
121
-
122
- def _add_callout_content(self, context: BlockParsingContext) -> None:
123
- context.parent_stack[-1].add_child_line(context.line)
124
-
125
- async def _parse_nested_content(self, text: str, context: BlockParsingContext) -> list:
126
- if not text.strip():
127
- return []
128
-
129
- return await context.parse_nested_content(text)
@@ -8,7 +8,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 CaptionParser(LineParser):
@@ -3,13 +3,13 @@ from typing import override
3
3
 
4
4
  from notionary.blocks.rich_text.markdown_rich_text_converter import MarkdownRichTextConverter
5
5
  from notionary.blocks.rich_text.models import RichText
6
- from notionary.blocks.schemas import CodeData, CodeLanguage, CreateCodeBlock
6
+ from notionary.blocks.schemas import CodeData, CodingLanguage, CreateCodeBlock
7
7
  from notionary.page.content.parser.parsers.base import BlockParsingContext, LineParser
8
- from notionary.page.content.syntax.service import SyntaxRegistry
8
+ from notionary.page.content.syntax import SyntaxRegistry
9
9
 
10
10
 
11
11
  class CodeParser(LineParser):
12
- DEFAULT_LANGUAGE = CodeLanguage.PLAIN_TEXT
12
+ DEFAULT_LANGUAGE = CodingLanguage.PLAIN_TEXT
13
13
 
14
14
  def __init__(self, syntax_registry: SyntaxRegistry, rich_text_converter: MarkdownRichTextConverter) -> None:
15
15
  super().__init__(syntax_registry)
@@ -67,8 +67,8 @@ class CodeParser(LineParser):
67
67
  code_data = CodeData(rich_text=rich_text, language=language, caption=[])
68
68
  return CreateCodeBlock(code=code_data)
69
69
 
70
- def _parse_language(self, language_str: str | None) -> CodeLanguage:
71
- return CodeLanguage.from_string(language_str, default=self.DEFAULT_LANGUAGE)
70
+ def _parse_language(self, language_str: str | None) -> CodingLanguage:
71
+ return CodingLanguage.from_string(language_str, default=self.DEFAULT_LANGUAGE)
72
72
 
73
73
  async def _create_rich_text_from_code(self, code_lines: list[str]) -> list[RichText]:
74
74
  content = "\n".join(code_lines) if code_lines else ""
@@ -1,12 +1,11 @@
1
1
  from typing import override
2
2
 
3
- from notionary.blocks.schemas import CreateColumnBlock, CreateColumnData, CreateColumnListBlock
4
- from notionary.page.content.parser.context import ParentBlockContext
3
+ from notionary.blocks.schemas import CreateColumnBlock, CreateColumnData
5
4
  from notionary.page.content.parser.parsers.base import (
6
5
  BlockParsingContext,
7
6
  LineParser,
8
7
  )
9
- from notionary.page.content.syntax.service import SyntaxRegistry
8
+ from notionary.page.content.syntax import SyntaxRegistry
10
9
 
11
10
 
12
11
  class ColumnParser(LineParser):
@@ -19,54 +18,23 @@ class ColumnParser(LineParser):
19
18
 
20
19
  @override
21
20
  def _can_handle(self, context: BlockParsingContext) -> bool:
22
- return self._is_column_start(context) or self._is_column_end(context) or self._is_column_content(context)
21
+ return self._is_column_start(context)
23
22
 
24
23
  @override
25
24
  async def _process(self, context: BlockParsingContext) -> None:
26
25
  if self._is_column_start(context):
27
- await self._start_column(context)
28
- elif self._is_column_end(context):
29
- await self._finalize_column(context)
30
- elif self._is_column_content(context):
31
- await self._add_column_content(context)
26
+ await self._process_column(context)
32
27
 
33
28
  def _is_column_start(self, context: BlockParsingContext) -> bool:
34
29
  return self._syntax.regex_pattern.match(context.line) is not None
35
30
 
36
- def _is_column_end(self, context: BlockParsingContext) -> bool:
37
- if not self._syntax.end_regex_pattern.match(context.line):
38
- return False
39
-
40
- if not context.parent_stack:
41
- return False
42
-
43
- current_parent = context.parent_stack[-1]
44
- return isinstance(current_parent.block, CreateColumnBlock)
45
-
46
- def _is_column_content(self, context: BlockParsingContext) -> bool:
47
- if not context.parent_stack:
48
- return False
49
-
50
- current_parent = context.parent_stack[-1]
51
- if not isinstance(current_parent.block, CreateColumnBlock):
52
- return False
53
-
54
- line = context.line.strip()
55
- return not (self._syntax.regex_pattern.match(line) or self._syntax.end_regex_pattern.match(line))
56
-
57
- async def _add_column_content(self, context: BlockParsingContext) -> None:
58
- context.parent_stack[-1].add_child_line(context.line)
59
-
60
- async def _start_column(self, context: BlockParsingContext) -> None:
31
+ async def _process_column(self, context: BlockParsingContext) -> None:
61
32
  block = self._create_column_block(context.line)
62
33
  if not block:
63
34
  return
64
35
 
65
- parent_context = ParentBlockContext(
66
- block=block,
67
- child_lines=[],
68
- )
69
- context.parent_stack.append(parent_context)
36
+ await self._populate_children(block, context)
37
+ context.result_blocks.append(block)
70
38
 
71
39
  def _create_column_block(self, line: str) -> CreateColumnBlock | None:
72
40
  match = self._syntax.regex_pattern.match(line)
@@ -74,7 +42,7 @@ class ColumnParser(LineParser):
74
42
  return None
75
43
 
76
44
  width_ratio = self._parse_width_ratio(match.group(1))
77
- column_data = CreateColumnData(width_ratio=width_ratio)
45
+ column_data = CreateColumnData(width_ratio=width_ratio, children=[])
78
46
 
79
47
  return CreateColumnBlock(column=column_data)
80
48
 
@@ -84,34 +52,25 @@ class ColumnParser(LineParser):
84
52
 
85
53
  try:
86
54
  width_ratio = float(ratio_str)
87
- return width_ratio if self.MIN_WIDTH_RATIO < width_ratio <= self.MAX_WIDTH_RATIO else None
55
+ return width_ratio if self._is_valid_width_ratio(width_ratio) else None
88
56
  except ValueError:
89
57
  return None
90
58
 
91
- async def _finalize_column(self, context: BlockParsingContext) -> None:
92
- column_context = context.parent_stack.pop()
93
- await self._assign_column_children(column_context, context)
94
-
95
- if self._has_column_list_parent(context):
96
- parent = context.parent_stack[-1]
97
- parent.add_child_block(column_context.block)
98
- else:
99
- context.result_blocks.append(column_context.block)
59
+ def _is_valid_width_ratio(self, width_ratio: float) -> bool:
60
+ return self.MIN_WIDTH_RATIO < width_ratio <= self.MAX_WIDTH_RATIO
100
61
 
101
- def _has_column_list_parent(self, context: BlockParsingContext) -> bool:
102
- if not context.parent_stack:
103
- return False
104
- return isinstance(context.parent_stack[-1].block, CreateColumnListBlock)
62
+ async def _populate_children(self, block: CreateColumnBlock, context: BlockParsingContext) -> None:
63
+ parent_indent_level = context.get_line_indentation_level()
64
+ child_lines = context.collect_indented_child_lines(parent_indent_level)
105
65
 
106
- async def _assign_column_children(self, column_context: ParentBlockContext, context: BlockParsingContext) -> None:
107
- all_children = []
108
-
109
- if column_context.child_lines:
110
- children_text = "\n".join(column_context.child_lines)
111
- text_blocks = await context.parse_nested_content(children_text)
112
- all_children.extend(text_blocks)
66
+ if not child_lines:
67
+ return
113
68
 
114
- if column_context.child_blocks:
115
- all_children.extend(column_context.child_blocks)
69
+ child_blocks = await self._parse_indented_children(child_lines, context)
70
+ block.column.children = child_blocks
71
+ context.lines_consumed = len(child_lines)
116
72
 
117
- column_context.block.column.children = all_children
73
+ async def _parse_indented_children(self, child_lines: list[str], context: BlockParsingContext) -> list:
74
+ stripped_lines = context.strip_indentation_level(child_lines, levels=1)
75
+ child_markdown = "\n".join(stripped_lines)
76
+ return await context.parse_nested_markdown(child_markdown)
@@ -2,12 +2,11 @@ from typing import override
2
2
 
3
3
  from notionary.blocks.enums import BlockType
4
4
  from notionary.blocks.schemas import BlockCreatePayload, CreateColumnListBlock, CreateColumnListData
5
- from notionary.page.content.parser.context import ParentBlockContext
6
5
  from notionary.page.content.parser.parsers.base import (
7
6
  BlockParsingContext,
8
7
  LineParser,
9
8
  )
10
- from notionary.page.content.syntax.service import SyntaxRegistry
9
+ from notionary.page.content.syntax import SyntaxRegistry
11
10
 
12
11
 
13
12
  class ColumnListParser(LineParser):
@@ -17,65 +16,66 @@ class ColumnListParser(LineParser):
17
16
 
18
17
  @override
19
18
  def _can_handle(self, context: BlockParsingContext) -> bool:
20
- return self._is_column_list_start(context) or self._is_column_list_end(context)
19
+ return self._is_column_list_start(context)
21
20
 
22
21
  @override
23
22
  async def _process(self, context: BlockParsingContext) -> None:
24
23
  if self._is_column_list_start(context):
25
- await self._start_column_list(context)
26
- elif self._is_column_list_end(context):
27
- await self._finalize_column_list(context)
24
+ await self._process_column_list(context)
28
25
 
29
26
  def _is_column_list_start(self, context: BlockParsingContext) -> bool:
30
27
  return self._syntax.regex_pattern.match(context.line) is not None
31
28
 
32
- def _is_column_list_end(self, context: BlockParsingContext) -> bool:
33
- if not self._syntax.end_regex_pattern.match(context.line):
34
- return False
29
+ async def _process_column_list(self, context: BlockParsingContext) -> None:
30
+ block = self._create_column_list_block()
31
+ await self._populate_columns(block, context)
32
+ context.result_blocks.append(block)
35
33
 
36
- if not context.parent_stack:
37
- return False
34
+ def _create_column_list_block(self) -> CreateColumnListBlock:
35
+ column_list_data = CreateColumnListData(children=[])
36
+ return CreateColumnListBlock(column_list=column_list_data)
38
37
 
39
- current_parent = context.parent_stack[-1]
40
- return isinstance(current_parent.block, CreateColumnListBlock)
38
+ async def _populate_columns(self, block: CreateColumnListBlock, context: BlockParsingContext) -> None:
39
+ parent_indent_level = context.get_line_indentation_level()
40
+ child_lines = self._collect_children_allowing_empty_lines(context, parent_indent_level)
41
41
 
42
- async def _start_column_list(self, context: BlockParsingContext) -> None:
43
- column_list_data = CreateColumnListData()
44
- block = CreateColumnListBlock(column_list=column_list_data)
42
+ if not child_lines:
43
+ return
45
44
 
46
- parent_context = ParentBlockContext(
47
- block=block,
48
- child_lines=[],
49
- )
50
- context.parent_stack.append(parent_context)
45
+ column_blocks = await self._parse_column_children(child_lines, context)
46
+ block.column_list.children = column_blocks
47
+ context.lines_consumed = len(child_lines)
51
48
 
52
- async def _finalize_column_list(self, context: BlockParsingContext) -> None:
53
- column_list_context = context.parent_stack.pop()
54
- await self._assign_column_list_children(column_list_context, context)
49
+ async def _parse_column_children(self, child_lines: list[str], context: BlockParsingContext) -> list:
50
+ stripped_lines = context.strip_indentation_level(child_lines, levels=1)
51
+ child_markdown = "\n".join(stripped_lines)
52
+ parsed_blocks = await context.parse_nested_markdown(child_markdown)
53
+ return self._extract_column_blocks(parsed_blocks)
55
54
 
56
- if context.parent_stack:
57
- parent_context = context.parent_stack[-1]
58
- parent_context.add_child_block(column_list_context.block)
59
- else:
60
- context.result_blocks.append(column_list_context.block)
55
+ def _collect_children_allowing_empty_lines(
56
+ self, context: BlockParsingContext, parent_indent_level: int
57
+ ) -> list[str]:
58
+ child_lines = []
59
+ expected_child_indent = parent_indent_level + 1
60
+ remaining_lines = context.get_remaining_lines()
61
61
 
62
- async def _assign_column_list_children(
63
- self, column_list_context: ParentBlockContext, context: BlockParsingContext
64
- ) -> None:
65
- all_children = []
62
+ for line in remaining_lines:
63
+ if self._should_include_as_child(line, expected_child_indent, context):
64
+ child_lines.append(line)
65
+ else:
66
+ break
66
67
 
67
- if column_list_context.child_lines:
68
- children_text = "\n".join(column_list_context.child_lines)
69
- text_blocks = await context.parse_nested_content(children_text)
70
- all_children.extend(text_blocks)
68
+ return child_lines
71
69
 
72
- if column_list_context.child_blocks:
73
- all_children.extend(column_list_context.child_blocks)
70
+ def _should_include_as_child(self, line: str, expected_indent: int, context: BlockParsingContext) -> bool:
71
+ if not line.strip():
72
+ return True
74
73
 
75
- column_children = self._filter_column_blocks(all_children)
76
- column_list_context.block.column_list.children = column_children
74
+ line_indent = context.get_line_indentation_level(line)
75
+ return line_indent >= expected_indent
77
76
 
78
- def _filter_column_blocks(self, blocks: list[BlockCreatePayload]) -> list:
79
- return [
80
- block for block in blocks if block.type == BlockType.COLUMN and hasattr(block, "column") and block.column
81
- ]
77
+ def _extract_column_blocks(self, blocks: list[BlockCreatePayload]) -> list:
78
+ return [block for block in blocks if self._is_valid_column_block(block)]
79
+
80
+ def _is_valid_column_block(self, block: BlockCreatePayload) -> bool:
81
+ return block.type == BlockType.COLUMN and block.column is not None
@@ -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 DividerParser(LineParser):
@@ -4,7 +4,7 @@ from typing import override
4
4
 
5
5
  from notionary.blocks.schemas import CreateEmbedBlock, EmbedData
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 EmbedParser(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 EquationParser(LineParser):
@@ -9,7 +9,7 @@ from notionary.blocks.schemas import (
9
9
  FileType,
10
10
  )
11
11
  from notionary.page.content.parser.parsers.base import BlockParsingContext, LineParser
12
- from notionary.page.content.syntax.service import SyntaxRegistry
12
+ from notionary.page.content.syntax import SyntaxRegistry
13
13
 
14
14
 
15
15
  class FileParser(LineParser):
@@ -3,6 +3,8 @@ from typing import override
3
3
  from notionary.blocks.rich_text.markdown_rich_text_converter import MarkdownRichTextConverter
4
4
  from notionary.blocks.schemas import (
5
5
  BlockColor,
6
+ BlockCreatePayload,
7
+ BlockType,
6
8
  CreateHeading1Block,
7
9
  CreateHeading2Block,
8
10
  CreateHeading3Block,
@@ -13,10 +15,13 @@ from notionary.page.content.parser.parsers.base import (
13
15
  BlockParsingContext,
14
16
  LineParser,
15
17
  )
16
- from notionary.page.content.syntax.service import SyntaxRegistry
18
+ from notionary.page.content.syntax import SyntaxRegistry
17
19
 
18
20
 
19
21
  class HeadingParser(LineParser):
22
+ MIN_HEADING_LEVEL = 1
23
+ MAX_HEADING_LEVEL = 3
24
+
20
25
  def __init__(self, syntax_registry: SyntaxRegistry, rich_text_converter: MarkdownRichTextConverter) -> None:
21
26
  super().__init__(syntax_registry)
22
27
  self._syntax = syntax_registry.get_heading_syntax()
@@ -31,8 +36,54 @@ class HeadingParser(LineParser):
31
36
  @override
32
37
  async def _process(self, context: BlockParsingContext) -> None:
33
38
  block = await self._create_heading_block(context.line)
34
- if block:
35
- context.result_blocks.append(block)
39
+ if not block:
40
+ return
41
+
42
+ await self._process_nested_children(block, context)
43
+ context.result_blocks.append(block)
44
+
45
+ async def _process_nested_children(self, block: CreateHeadingBlock, context: BlockParsingContext) -> None:
46
+ parent_indent_level = context.get_line_indentation_level()
47
+ child_lines = context.collect_indented_child_lines(parent_indent_level)
48
+
49
+ if not child_lines:
50
+ return
51
+
52
+ child_lines = self._remove_trailing_empty_lines(child_lines)
53
+
54
+ if not child_lines:
55
+ return
56
+
57
+ self._set_heading_toggleable(block, True)
58
+
59
+ stripped_lines = context.strip_indentation_level(child_lines, levels=1)
60
+ child_markdown = "\n".join(stripped_lines)
61
+
62
+ child_blocks = await context.parse_nested_markdown(child_markdown)
63
+ self._set_heading_children(block, child_blocks)
64
+
65
+ context.lines_consumed = len(child_lines)
66
+
67
+ def _set_heading_toggleable(self, block: CreateHeadingBlock, is_toggleable: bool) -> None:
68
+ if block.type == BlockType.HEADING_1:
69
+ block.heading_1.is_toggleable = is_toggleable
70
+ elif block.type == BlockType.HEADING_2:
71
+ block.heading_2.is_toggleable = is_toggleable
72
+ elif block.type == BlockType.HEADING_3:
73
+ block.heading_3.is_toggleable = is_toggleable
74
+
75
+ def _set_heading_children(self, block: CreateHeadingBlock, children: list[BlockCreatePayload]) -> None:
76
+ if block.type == BlockType.HEADING_1:
77
+ block.heading_1.children = children
78
+ elif block.type == BlockType.HEADING_2:
79
+ block.heading_2.children = children
80
+ elif block.type == BlockType.HEADING_3:
81
+ block.heading_3.children = children
82
+
83
+ def _remove_trailing_empty_lines(self, lines: list[str]) -> list[str]:
84
+ while lines and not lines[-1].strip():
85
+ lines.pop()
86
+ return lines
36
87
 
37
88
  async def _create_heading_block(self, line: str) -> CreateHeadingBlock | None:
38
89
  match = self._syntax.regex_pattern.match(line)
@@ -40,16 +91,22 @@ class HeadingParser(LineParser):
40
91
  return None
41
92
 
42
93
  level = len(match.group(1))
43
- if level < 1 or level > 3:
44
- return None
45
-
46
94
  content = match.group(2).strip()
47
- if not content:
95
+
96
+ if not self._is_valid_heading(level, content):
48
97
  return None
49
98
 
99
+ heading_data = await self._build_heading_data(content)
100
+ return self._create_heading_block_by_level(level, heading_data)
101
+
102
+ def _is_valid_heading(self, level: int, content: str) -> bool:
103
+ return self.MIN_HEADING_LEVEL <= level <= self.MAX_HEADING_LEVEL and bool(content)
104
+
105
+ async def _build_heading_data(self, content: str) -> CreateHeadingData:
50
106
  rich_text = await self._rich_text_converter.to_rich_text(content)
51
- heading_data = CreateHeadingData(rich_text=rich_text, color=BlockColor.DEFAULT, is_toggleable=False)
107
+ return CreateHeadingData(rich_text=rich_text, color=BlockColor.DEFAULT, is_toggleable=False, children=[])
52
108
 
109
+ def _create_heading_block_by_level(self, level: int, heading_data: CreateHeadingData) -> CreateHeadingBlock:
53
110
  if level == 1:
54
111
  return CreateHeading1Block(heading_1=heading_data)
55
112
  elif level == 2:
@@ -9,7 +9,7 @@ from notionary.blocks.schemas import (
9
9
  FileType,
10
10
  )
11
11
  from notionary.page.content.parser.parsers.base import BlockParsingContext, LineParser
12
- from notionary.page.content.syntax.service import SyntaxRegistry
12
+ from notionary.page.content.syntax import SyntaxRegistry
13
13
 
14
14
 
15
15
  class ImageParser(LineParser):