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
@@ -0,0 +1,156 @@
1
+ import re
2
+ from enum import IntEnum
3
+ from typing import override
4
+
5
+ from notionary.page.content.renderer.post_processing.port import PostProcessor
6
+ from notionary.page.content.syntax.grammar import MarkdownGrammar
7
+
8
+
9
+ class _NumberingStyle(IntEnum):
10
+ NUMERIC = 0
11
+ ALPHABETIC = 1
12
+ ROMAN = 2
13
+
14
+
15
+ class _ListNumberingState:
16
+ def __init__(self):
17
+ self._counters_by_level: dict[int, int] = {}
18
+ self._current_level = -1
19
+
20
+ def advance_to_level(self, new_level: int) -> None:
21
+ self._forget_deeper_levels_than(new_level)
22
+ self._increment_counter_at_level(new_level)
23
+ self._current_level = new_level
24
+
25
+ def get_number_for_current_level(self) -> str:
26
+ counter = self._counters_by_level[self._current_level]
27
+ style = self._determine_numbering_style(self._current_level)
28
+ return self._format_number(counter, style)
29
+
30
+ def reset(self) -> None:
31
+ self._counters_by_level.clear()
32
+ self._current_level = -1
33
+
34
+ def _forget_deeper_levels_than(self, level: int) -> None:
35
+ self._counters_by_level = {
36
+ existing_level: count
37
+ for existing_level, count in self._counters_by_level.items()
38
+ if existing_level <= level
39
+ }
40
+
41
+ def _increment_counter_at_level(self, level: int) -> None:
42
+ self._counters_by_level[level] = self._counters_by_level.get(level, 0) + 1
43
+
44
+ def _determine_numbering_style(self, nesting_level: int) -> _NumberingStyle:
45
+ return _NumberingStyle(nesting_level % 3)
46
+
47
+ def _format_number(self, counter: int, style: _NumberingStyle) -> str:
48
+ if style == _NumberingStyle.NUMERIC:
49
+ return str(counter)
50
+ elif style == _NumberingStyle.ALPHABETIC:
51
+ return self._to_alphabetic(counter)
52
+ else:
53
+ return self._to_roman(counter)
54
+
55
+ def _to_alphabetic(self, number: int) -> str:
56
+ result = ""
57
+ number -= 1
58
+ while number >= 0:
59
+ result = chr(ord("a") + (number % 26)) + result
60
+ number = number // 26 - 1
61
+ return result
62
+
63
+ def _to_roman(self, number: int) -> str:
64
+ conversions = [
65
+ (1000, "m"),
66
+ (900, "cm"),
67
+ (500, "d"),
68
+ (400, "cd"),
69
+ (100, "c"),
70
+ (90, "xc"),
71
+ (50, "l"),
72
+ (40, "xl"),
73
+ (10, "x"),
74
+ (9, "ix"),
75
+ (5, "v"),
76
+ (4, "iv"),
77
+ (1, "i"),
78
+ ]
79
+
80
+ result = ""
81
+ for arabic, roman in conversions:
82
+ while number >= arabic:
83
+ result += roman
84
+ number -= arabic
85
+ return result
86
+
87
+
88
+ class NumberedListPlaceholderReplacerPostProcessor(PostProcessor):
89
+ def __init__(self, markdown_grammar: MarkdownGrammar | None = None) -> None:
90
+ self._markdown_grammar = markdown_grammar or MarkdownGrammar()
91
+ self._spaces_per_nesting_level = self._markdown_grammar.spaces_per_nesting_level
92
+ self._numbered_list_placeholder = self._markdown_grammar.numbered_list_placeholder
93
+
94
+ @override
95
+ def process(self, markdown_text: str) -> str:
96
+ lines = markdown_text.splitlines()
97
+ return self._convert_placeholder_lists_to_numbered_lists(lines)
98
+
99
+ def _convert_placeholder_lists_to_numbered_lists(self, lines: list[str]) -> str:
100
+ result = []
101
+ list_state = _ListNumberingState()
102
+
103
+ for line_index, line in enumerate(lines):
104
+ if self._is_placeholder_list_item(line):
105
+ numbered_line = self._convert_to_numbered_item(line, list_state)
106
+ result.append(numbered_line)
107
+ elif self._is_blank_between_list_items(lines, line_index, result):
108
+ continue
109
+ else:
110
+ list_state.reset()
111
+ result.append(line)
112
+
113
+ return "\n".join(result)
114
+
115
+ def _convert_to_numbered_item(self, line: str, state: _ListNumberingState) -> str:
116
+ indentation = self._extract_indentation(line)
117
+ content = self._extract_content(line)
118
+ nesting_level = self._calculate_nesting_level(indentation)
119
+
120
+ state.advance_to_level(nesting_level)
121
+ number = state.get_number_for_current_level()
122
+
123
+ return f"{indentation}{number}. {content}"
124
+
125
+ def _calculate_nesting_level(self, indentation: str) -> int:
126
+ return len(indentation) // self._spaces_per_nesting_level
127
+
128
+ def _extract_indentation(self, line: str) -> str:
129
+ match = re.match(rf"^(\s*){re.escape(self._numbered_list_placeholder)}\.", line)
130
+ return match.group(1) if match else ""
131
+
132
+ def _extract_content(self, line: str) -> str:
133
+ match = re.match(rf"^\s*{re.escape(self._numbered_list_placeholder)}\.\s*(.*)", line)
134
+ return match.group(1) if match else ""
135
+
136
+ def _is_placeholder_list_item(self, line: str) -> bool:
137
+ return bool(re.match(rf"^\s*{re.escape(self._numbered_list_placeholder)}\.", line))
138
+
139
+ def _is_blank_between_list_items(self, lines: list[str], current_index: int, processed_lines: list[str]) -> bool:
140
+ if not self._is_blank(lines[current_index]):
141
+ return False
142
+
143
+ previous_line_was_list_item = processed_lines and self._looks_like_numbered_list_item(processed_lines[-1])
144
+ if not previous_line_was_list_item:
145
+ return False
146
+
147
+ next_line_is_list_item = current_index + 1 < len(lines) and self._is_placeholder_list_item(
148
+ lines[current_index + 1]
149
+ )
150
+ return next_line_is_list_item
151
+
152
+ def _is_blank(self, line: str) -> bool:
153
+ return not line.strip()
154
+
155
+ def _looks_like_numbered_list_item(self, line: str) -> bool:
156
+ return bool(re.match(r"^\s*(\d+|[a-z]+|[ivxlcdm]+)\.\s+", line))
@@ -23,7 +23,6 @@ from .table_of_contents import TableOfContentsRenderer
23
23
  from .table_row import TableRowHandler
24
24
  from .todo import TodoRenderer
25
25
  from .toggle import ToggleRenderer
26
- from .toggleable_heading import ToggleableHeadingRenderer
27
26
  from .video import VideoRenderer
28
27
 
29
28
  __all__ = [
@@ -52,6 +51,5 @@ __all__ = [
52
51
  "TableRowHandler",
53
52
  "TodoRenderer",
54
53
  "ToggleRenderer",
55
- "ToggleableHeadingRenderer",
56
54
  "VideoRenderer",
57
55
  ]
@@ -4,7 +4,7 @@ from abc import ABC, abstractmethod
4
4
 
5
5
  from notionary.blocks.schemas import Block
6
6
  from notionary.page.content.renderer.context import MarkdownRenderingContext
7
- from notionary.page.content.syntax.service import SyntaxRegistry
7
+ from notionary.page.content.syntax import SyntaxRegistry
8
8
 
9
9
 
10
10
  class BlockRenderer(ABC):
@@ -4,7 +4,7 @@ from notionary.blocks.rich_text.rich_text_markdown_converter import RichTextToMa
4
4
  from notionary.blocks.schemas import Block, BlockType
5
5
  from notionary.page.content.renderer.context import MarkdownRenderingContext
6
6
  from notionary.page.content.renderer.renderers.base import BlockRenderer
7
- from notionary.page.content.syntax.service import SyntaxRegistry
7
+ from notionary.page.content.syntax import SyntaxRegistry
8
8
 
9
9
 
10
10
  class BulletedListRenderer(BlockRenderer):
@@ -4,7 +4,7 @@ from notionary.blocks.rich_text.rich_text_markdown_converter import RichTextToMa
4
4
  from notionary.blocks.schemas import Block, BlockType
5
5
  from notionary.page.content.renderer.context import MarkdownRenderingContext
6
6
  from notionary.page.content.renderer.renderers.base import BlockRenderer
7
- from notionary.page.content.syntax.service import SyntaxRegistry
7
+ from notionary.page.content.syntax import SyntaxRegistry
8
8
 
9
9
 
10
10
  class CalloutRenderer(BlockRenderer):
@@ -22,37 +22,22 @@ class CalloutRenderer(BlockRenderer):
22
22
 
23
23
  @override
24
24
  async def _process(self, context: MarkdownRenderingContext) -> None:
25
- icon = await self._extract_callout_icon(context.block)
26
25
  content = await self._extract_callout_content(context.block)
27
26
 
28
27
  if not content:
29
28
  context.markdown_result = ""
30
29
  return
31
30
 
32
- syntax = self._syntax_registry.get_callout_syntax()
33
-
34
- # Build callout structure
35
- # Extract just the base part before the regex pattern
36
- callout_type = syntax.start_delimiter.split()[1] # Gets "callout" from "::: callout"
37
- callout_header = f"{self._syntax_registry.MULTI_LINE_BLOCK_DELIMITER} {callout_type}"
38
- if icon:
39
- callout_header = f"{self._syntax_registry.MULTI_LINE_BLOCK_DELIMITER} {callout_type} {icon}"
31
+ icon = await self._extract_callout_icon(context.block)
40
32
 
41
- if context.indent_level > 0:
42
- callout_header = context.indent_text(callout_header)
33
+ callout_start_delimiter = self._syntax_registry.get_callout_syntax().start_delimiter
43
34
 
44
- # Process children if they exist
45
- children_markdown = await context.render_children()
35
+ result = f'{callout_start_delimiter}({content} "{icon}")' if icon else f"{callout_start_delimiter}({content})"
46
36
 
47
- callout_end = syntax.end_delimiter
48
37
  if context.indent_level > 0:
49
- callout_end = context.indent_text(callout_end)
38
+ result = context.indent_text(result)
50
39
 
51
- # Combine content
52
- if children_markdown:
53
- context.markdown_result = f"{callout_header}\n{content}\n{children_markdown}\n{callout_end}"
54
- else:
55
- context.markdown_result = f"{callout_header}\n{content}\n{callout_end}"
40
+ context.markdown_result = result
56
41
 
57
42
  async def _extract_callout_icon(self, block: Block) -> str:
58
43
  if not block.callout or not block.callout.icon:
@@ -5,7 +5,7 @@ from notionary.blocks.rich_text.rich_text_markdown_converter import RichTextToMa
5
5
  from notionary.blocks.schemas import Block
6
6
  from notionary.page.content.renderer.context import MarkdownRenderingContext
7
7
  from notionary.page.content.renderer.renderers.base import BlockRenderer
8
- from notionary.page.content.syntax.service import SyntaxRegistry
8
+ from notionary.page.content.syntax import SyntaxRegistry
9
9
 
10
10
 
11
11
  class CaptionedBlockRenderer(BlockRenderer):
@@ -13,32 +13,41 @@ class ColumnRenderer(BlockRenderer):
13
13
 
14
14
  @override
15
15
  async def _process(self, context: MarkdownRenderingContext) -> None:
16
- column_start = self._extract_column_start(context.block)
16
+ column_start = self._format_column_start(context.block, context.indent_level)
17
+ children_markdown = await self._render_children_with_indentation(context)
17
18
 
18
- if context.indent_level > 0:
19
- column_start = context.indent_text(column_start)
19
+ if children_markdown:
20
+ context.markdown_result = f"{column_start}\n{children_markdown}"
21
+ else:
22
+ context.markdown_result = column_start
20
23
 
21
- children_markdown = await context.render_children()
24
+ def _format_column_start(self, block: Block, indent_level: int) -> str:
25
+ column_start = self._build_column_start_tag(block)
22
26
 
23
- syntax = self._syntax_registry.get_column_syntax()
24
- column_end = syntax.end_delimiter
25
- if context.indent_level > 0:
26
- column_end = context.indent_text(column_end, spaces=context.indent_level * 4)
27
+ if indent_level > 0:
28
+ indent = " " * indent_level
29
+ column_start = f"{indent}{column_start}"
27
30
 
28
- if children_markdown:
29
- context.markdown_result = f"{column_start}\n{children_markdown}\n{column_end}"
30
- else:
31
- context.markdown_result = f"{column_start}\n{column_end}"
31
+ return column_start
32
32
 
33
- def _extract_column_start(self, block: Block) -> str:
34
- syntax = self._syntax_registry.get_column_syntax()
35
- base_start = syntax.start_delimiter
33
+ def _build_column_start_tag(self, block: Block) -> str:
34
+ delimiter = self._syntax_registry.get_column_syntax().start_delimiter
36
35
 
37
36
  if not block.column:
38
- return base_start
37
+ return delimiter
39
38
 
40
39
  width_ratio = block.column.width_ratio
41
40
  if width_ratio:
42
- return f"{base_start} {width_ratio}"
43
- else:
44
- return base_start
41
+ return f"{delimiter} {width_ratio}"
42
+
43
+ return delimiter
44
+
45
+ async def _render_children_with_indentation(self, context: MarkdownRenderingContext) -> str:
46
+ original_indent = context.indent_level
47
+ context.indent_level += 1
48
+
49
+ children_markdown = await context.render_children()
50
+
51
+ context.indent_level = original_indent
52
+
53
+ return children_markdown
@@ -13,19 +13,32 @@ class ColumnListRenderer(BlockRenderer):
13
13
 
14
14
  @override
15
15
  async def _process(self, context: MarkdownRenderingContext) -> None:
16
- syntax = self._syntax_registry.get_column_list_syntax()
17
- column_list_start = syntax.start_delimiter
16
+ column_list_start = self._format_column_list_start(context.indent_level)
17
+ children_markdown = await self._render_children_with_indentation(context)
18
18
 
19
- if context.indent_level > 0:
20
- column_list_start = context.indent_text(column_list_start)
19
+ if children_markdown:
20
+ context.markdown_result = f"{column_list_start}\n{children_markdown}"
21
+ else:
22
+ context.markdown_result = column_list_start
23
+
24
+ def _format_column_list_start(self, indent_level: int) -> str:
25
+ delimiter = self._get_column_list_delimiter()
26
+
27
+ if indent_level > 0:
28
+ indent = " " * indent_level
29
+ return f"{indent}{delimiter}"
30
+
31
+ return delimiter
32
+
33
+ def _get_column_list_delimiter(self) -> str:
34
+ return self._syntax_registry.get_column_list_syntax().start_delimiter
35
+
36
+ async def _render_children_with_indentation(self, context: MarkdownRenderingContext) -> str:
37
+ original_indent = context.indent_level
38
+ context.indent_level += 1
21
39
 
22
40
  children_markdown = await context.render_children()
23
41
 
24
- column_list_end = syntax.end_delimiter
25
- if context.indent_level > 0:
26
- column_list_end = context.indent_text(column_list_end)
42
+ context.indent_level = original_indent
27
43
 
28
- if children_markdown:
29
- context.markdown_result = f"{column_list_start}\n{children_markdown}\n{column_list_end}"
30
- else:
31
- context.markdown_result = f"{column_list_start}\n{column_list_end}"
44
+ return children_markdown
@@ -1,13 +1,16 @@
1
1
  from typing import override
2
2
 
3
3
  from notionary.blocks.rich_text.rich_text_markdown_converter import RichTextToMarkdownConverter
4
- from notionary.blocks.schemas import Block, BlockType
4
+ from notionary.blocks.schemas import Block, BlockType, HeadingData
5
5
  from notionary.page.content.renderer.context import MarkdownRenderingContext
6
6
  from notionary.page.content.renderer.renderers.base import BlockRenderer
7
- from notionary.page.content.syntax.service import SyntaxRegistry
7
+ from notionary.page.content.syntax import SyntaxRegistry
8
8
 
9
9
 
10
10
  class HeadingRenderer(BlockRenderer):
11
+ MIN_HEADING_LEVEL = 1
12
+ MAX_HEADING_LEVEL = 3
13
+
11
14
  def __init__(
12
15
  self,
13
16
  syntax_registry: SyntaxRegistry | None = None,
@@ -19,29 +22,47 @@ class HeadingRenderer(BlockRenderer):
19
22
 
20
23
  @override
21
24
  def _can_handle(self, block: Block) -> bool:
22
- if block.type == BlockType.HEADING_1:
23
- return not block.heading_1.is_toggleable
24
- if block.type == BlockType.HEADING_2:
25
- return not block.heading_2.is_toggleable
26
- if block.type == BlockType.HEADING_3:
27
- return not block.heading_3.is_toggleable
28
-
29
- return False
25
+ return block.type in (BlockType.HEADING_1, BlockType.HEADING_2, BlockType.HEADING_3)
30
26
 
31
27
  @override
32
28
  async def _process(self, context: MarkdownRenderingContext) -> None:
33
29
  level = self._get_heading_level(context.block)
34
30
  title = await self._get_heading_title(context.block)
35
31
 
36
- if not title or level == 0:
32
+ if not self._is_valid_heading(level, title):
37
33
  return
38
34
 
39
- heading_markdown = f"{self._syntax.start_delimiter * level} {title}"
35
+ heading_markdown = self._format_heading(level, title, context.indent_level)
36
+
37
+ if self._is_toggleable(context.block):
38
+ context.markdown_result = await self._render_toggleable_heading(heading_markdown, context)
39
+ else:
40
+ context.markdown_result = heading_markdown
41
+
42
+ def _is_valid_heading(self, level: int, title: str) -> bool:
43
+ return self.MIN_HEADING_LEVEL <= level <= self.MAX_HEADING_LEVEL and bool(title)
44
+
45
+ def _format_heading(self, level: int, title: str, indent_level: int) -> str:
46
+ heading_prefix = self._syntax.start_delimiter * level
47
+ heading_markdown = f"{heading_prefix} {title}"
48
+
49
+ if indent_level > 0:
50
+ indent = " " * indent_level
51
+ heading_markdown = f"{indent}{heading_markdown}"
52
+
53
+ return heading_markdown
54
+
55
+ async def _render_toggleable_heading(self, heading_markdown: str, context: MarkdownRenderingContext) -> str:
56
+ original_indent = context.indent_level
57
+ context.indent_level += 1
58
+
59
+ children_markdown = await context.render_children()
40
60
 
41
- if context.indent_level > 0:
42
- heading_markdown = context.indent_text(heading_markdown)
61
+ context.indent_level = original_indent
43
62
 
44
- context.markdown_result = heading_markdown
63
+ if children_markdown:
64
+ return f"{heading_markdown}\n{children_markdown}"
65
+ return heading_markdown
45
66
 
46
67
  def _get_heading_level(self, block: Block) -> int:
47
68
  if block.type == BlockType.HEADING_1:
@@ -50,20 +71,25 @@ class HeadingRenderer(BlockRenderer):
50
71
  return 2
51
72
  elif block.type == BlockType.HEADING_3:
52
73
  return 3
53
- else:
54
- return 0
74
+ return 0
75
+
76
+ def _is_toggleable(self, block: Block) -> bool:
77
+ heading_data = self._get_heading_data(block)
78
+ return heading_data.is_toggleable if heading_data else False
55
79
 
56
80
  async def _get_heading_title(self, block: Block) -> str:
57
- if block.type == BlockType.HEADING_1:
58
- heading_content = block.heading_1
59
- elif block.type == BlockType.HEADING_2:
60
- heading_content = block.heading_2
61
- elif block.type == BlockType.HEADING_3:
62
- heading_content = block.heading_3
63
- else:
64
- return ""
81
+ heading_data = self._get_heading_data(block)
65
82
 
66
- if not heading_content or not heading_content.rich_text:
83
+ if not heading_data or not heading_data.rich_text:
67
84
  return ""
68
85
 
69
- return await self._rich_text_markdown_converter.to_markdown(heading_content.rich_text)
86
+ return await self._rich_text_markdown_converter.to_markdown(heading_data.rich_text)
87
+
88
+ def _get_heading_data(self, block: Block) -> HeadingData | None:
89
+ if block.type == BlockType.HEADING_1:
90
+ return block.heading_1
91
+ elif block.type == BlockType.HEADING_2:
92
+ return block.heading_2
93
+ elif block.type == BlockType.HEADING_3:
94
+ return block.heading_3
95
+ return None
@@ -6,21 +6,22 @@ from notionary.blocks.rich_text.rich_text_markdown_converter import (
6
6
  from notionary.blocks.schemas import Block, BlockType
7
7
  from notionary.page.content.renderer.context import MarkdownRenderingContext
8
8
  from notionary.page.content.renderer.renderers.base import BlockRenderer
9
- from notionary.page.content.syntax.service import SyntaxRegistry
9
+ from notionary.page.content.syntax import MarkdownGrammar, SyntaxRegistry
10
10
 
11
11
 
12
12
  class NumberedListRenderer(BlockRenderer):
13
- # Placeholder for numbered list fixer (post processing)
14
- NUMBERED_LIST_PLACEHOLDER = "__NUM__"
15
-
16
13
  def __init__(
17
14
  self,
18
15
  syntax_registry: SyntaxRegistry | None = None,
19
16
  rich_text_markdown_converter: RichTextToMarkdownConverter | None = None,
17
+ markdown_grammar: MarkdownGrammar | None = None,
20
18
  ) -> None:
21
19
  super().__init__(syntax_registry=syntax_registry)
22
20
  self._rich_text_markdown_converter = rich_text_markdown_converter or RichTextToMarkdownConverter()
23
21
 
22
+ markdown_grammar = markdown_grammar or MarkdownGrammar()
23
+ self._numbered_list_placeholder = markdown_grammar.numbered_list_placeholder
24
+
24
25
  @override
25
26
  def _can_handle(self, block: Block) -> bool:
26
27
  return block.type == BlockType.NUMBERED_LIST_ITEM
@@ -31,7 +32,7 @@ class NumberedListRenderer(BlockRenderer):
31
32
  rich_text = list_item_data.rich_text if list_item_data else []
32
33
  content = await self._rich_text_markdown_converter.to_markdown(rich_text)
33
34
 
34
- item_line = context.indent_text(f"{self.NUMBERED_LIST_PLACEHOLDER}. {content}")
35
+ item_line = context.indent_text(f"{self._numbered_list_placeholder}. {content}")
35
36
 
36
37
  children_markdown = await context.render_children_with_additional_indent(1)
37
38
 
@@ -4,7 +4,7 @@ from notionary.blocks.rich_text.rich_text_markdown_converter import RichTextToMa
4
4
  from notionary.blocks.schemas import Block, BlockType
5
5
  from notionary.page.content.renderer.context import MarkdownRenderingContext
6
6
  from notionary.page.content.renderer.renderers.base import BlockRenderer
7
- from notionary.page.content.syntax.service import SyntaxRegistry
7
+ from notionary.page.content.syntax import SyntaxRegistry
8
8
 
9
9
 
10
10
  class QuoteRenderer(BlockRenderer):
@@ -5,7 +5,7 @@ from notionary.blocks.rich_text.rich_text_markdown_converter import RichTextToMa
5
5
  from notionary.blocks.schemas import Block
6
6
  from notionary.page.content.renderer.context import MarkdownRenderingContext
7
7
  from notionary.page.content.renderer.renderers.base import BlockRenderer
8
- from notionary.page.content.syntax.service import SyntaxRegistry
8
+ from notionary.page.content.syntax import SyntaxRegistry
9
9
 
10
10
 
11
11
  class TodoRenderer(BlockRenderer):
@@ -5,7 +5,7 @@ from notionary.blocks.rich_text.rich_text_markdown_converter import RichTextToMa
5
5
  from notionary.blocks.schemas import Block
6
6
  from notionary.page.content.renderer.context import MarkdownRenderingContext
7
7
  from notionary.page.content.renderer.renderers.base import BlockRenderer
8
- from notionary.page.content.syntax.service import SyntaxRegistry
8
+ from notionary.page.content.syntax import SyntaxRegistry
9
9
 
10
10
 
11
11
  class ToggleRenderer(BlockRenderer):
@@ -34,16 +34,15 @@ class ToggleRenderer(BlockRenderer):
34
34
  if context.indent_level > 0:
35
35
  toggle_start = context.indent_text(toggle_start)
36
36
 
37
+ original_indent = context.indent_level
38
+ context.indent_level += 1
37
39
  children_markdown = await context.render_children()
38
-
39
- toggle_end = syntax.end_delimiter
40
- if context.indent_level > 0:
41
- toggle_end = context.indent_text(toggle_end)
40
+ context.indent_level = original_indent
42
41
 
43
42
  if children_markdown:
44
- context.markdown_result = f"{toggle_start}\n{children_markdown}\n{toggle_end}"
43
+ context.markdown_result = f"{toggle_start}\n{children_markdown}"
45
44
  else:
46
- context.markdown_result = f"{toggle_start}\n{toggle_end}"
45
+ context.markdown_result = toggle_start
47
46
 
48
47
  async def _extract_toggle_title(self, block: Block) -> str:
49
48
  if not block.toggle or not block.toggle.rich_text:
@@ -6,7 +6,7 @@ from notionary.blocks.schemas import Block
6
6
  from notionary.page.content.markdown.builder import MarkdownBuilder
7
7
  from notionary.page.content.parser.service import MarkdownToNotionConverter
8
8
  from notionary.page.content.renderer.service import NotionToMarkdownConverter
9
- from notionary.utils.async_retry import async_retry
9
+ from notionary.utils.decorators import async_retry, time_execution_async
10
10
  from notionary.utils.mixins.logging import LoggingMixin
11
11
 
12
12
 
@@ -23,10 +23,12 @@ class PageContentService(LoggingMixin):
23
23
  self._markdown_converter = markdown_converter
24
24
  self._notion_to_markdown_converter = notion_to_markdown_converter
25
25
 
26
+ @time_execution_async()
26
27
  async def get_as_markdown(self) -> str:
27
28
  blocks = await self._block_client.get_block_tree(parent_block_id=self._page_id)
28
29
  return await self._notion_to_markdown_converter.convert(blocks=blocks)
29
30
 
31
+ @time_execution_async()
30
32
  async def clear(self) -> None:
31
33
  children_response = await self._block_client.get_block_children(block_id=self._page_id)
32
34
 
@@ -41,6 +43,7 @@ class PageContentService(LoggingMixin):
41
43
  self.logger.debug("Deleting block: %s", block.id)
42
44
  await self._block_client.delete_block(block.id)
43
45
 
46
+ @time_execution_async()
44
47
  async def append_markdown(self, content: str | Callable[[MarkdownBuilder], MarkdownBuilder]) -> None:
45
48
  markdown = self._extract_markdown(content)
46
49
  if not markdown:
@@ -0,0 +1,4 @@
1
+ from .grammar import MarkdownGrammar
2
+ from .registry import SyntaxRegistry
3
+
4
+ __all__ = ["MarkdownGrammar", "SyntaxRegistry"]
@@ -0,0 +1,10 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class MarkdownGrammar:
6
+ spaces_per_nesting_level = 4
7
+ numbered_list_placeholder = "__NUM__"
8
+ column_delimiter = ":::"
9
+ toggle_delimiter = "+++"
10
+ table_delimiter = "|"
@@ -64,5 +64,3 @@ class SyntaxDefinition:
64
64
  end_delimiter: str
65
65
  regex_pattern: re.Pattern
66
66
  end_regex_pattern: re.Pattern | None = None
67
- is_multiline_block: bool = False
68
- is_inline: bool = False