notionary 0.2.16__py3-none-any.whl → 0.2.18__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 (137) hide show
  1. notionary/__init__.py +10 -5
  2. notionary/base_notion_client.py +18 -7
  3. notionary/blocks/__init__.py +55 -24
  4. notionary/blocks/audio/__init__.py +7 -0
  5. notionary/blocks/audio/audio_element.py +152 -0
  6. notionary/blocks/audio/audio_markdown_node.py +29 -0
  7. notionary/blocks/audio/audio_models.py +59 -0
  8. notionary/blocks/bookmark/__init__.py +7 -0
  9. notionary/blocks/{bookmark_element.py → bookmark/bookmark_element.py} +20 -65
  10. notionary/blocks/bookmark/bookmark_markdown_node.py +43 -0
  11. notionary/blocks/bulleted_list/__init__.py +7 -0
  12. notionary/blocks/{bulleted_list_element.py → bulleted_list/bulleted_list_element.py} +7 -3
  13. notionary/blocks/bulleted_list/bulleted_list_markdown_node.py +33 -0
  14. notionary/blocks/bulleted_list/bulleted_list_models.py +0 -0
  15. notionary/blocks/callout/__init__.py +7 -0
  16. notionary/blocks/callout/callout_element.py +132 -0
  17. notionary/blocks/callout/callout_markdown_node.py +31 -0
  18. notionary/blocks/callout/callout_models.py +0 -0
  19. notionary/blocks/code/__init__.py +7 -0
  20. notionary/blocks/{code_block_element.py → code/code_element.py} +72 -40
  21. notionary/blocks/code/code_markdown_node.py +43 -0
  22. notionary/blocks/code/code_models.py +0 -0
  23. notionary/blocks/column/__init__.py +5 -0
  24. notionary/blocks/{column_element.py → column/column_element.py} +24 -55
  25. notionary/blocks/column/column_models.py +0 -0
  26. notionary/blocks/divider/__init__.py +7 -0
  27. notionary/blocks/{divider_element.py → divider/divider_element.py} +11 -3
  28. notionary/blocks/divider/divider_markdown_node.py +24 -0
  29. notionary/blocks/divider/divider_models.py +0 -0
  30. notionary/blocks/document/__init__.py +7 -0
  31. notionary/blocks/document/document_element.py +102 -0
  32. notionary/blocks/document/document_markdown_node.py +31 -0
  33. notionary/blocks/document/document_models.py +0 -0
  34. notionary/blocks/embed/__init__.py +7 -0
  35. notionary/blocks/{embed_element.py → embed/embed_element.py} +50 -32
  36. notionary/blocks/embed/embed_markdown_node.py +30 -0
  37. notionary/blocks/embed/embed_models.py +0 -0
  38. notionary/blocks/heading/__init__.py +7 -0
  39. notionary/blocks/{heading_element.py → heading/heading_element.py} +25 -17
  40. notionary/blocks/heading/heading_markdown_node.py +29 -0
  41. notionary/blocks/heading/heading_models.py +0 -0
  42. notionary/blocks/image/__init__.py +7 -0
  43. notionary/blocks/{image_element.py → image/image_element.py} +62 -42
  44. notionary/blocks/image/image_markdown_node.py +33 -0
  45. notionary/blocks/image/image_models.py +0 -0
  46. notionary/blocks/markdown_builder.py +356 -0
  47. notionary/blocks/markdown_node.py +29 -0
  48. notionary/blocks/mention/__init__.py +7 -0
  49. notionary/blocks/{mention_element.py → mention/mention_element.py} +6 -2
  50. notionary/blocks/mention/mention_markdown_node.py +38 -0
  51. notionary/blocks/mention/mention_models.py +0 -0
  52. notionary/blocks/numbered_list/__init__.py +7 -0
  53. notionary/blocks/{numbered_list_element.py → numbered_list/numbered_list_element.py} +10 -6
  54. notionary/blocks/numbered_list/numbered_list_markdown_node.py +29 -0
  55. notionary/blocks/numbered_list/numbered_list_models.py +0 -0
  56. notionary/blocks/paragraph/__init__.py +7 -0
  57. notionary/blocks/{paragraph_element.py → paragraph/paragraph_element.py} +7 -3
  58. notionary/blocks/paragraph/paragraph_markdown_node.py +25 -0
  59. notionary/blocks/paragraph/paragraph_models.py +0 -0
  60. notionary/blocks/quote/__init__.py +7 -0
  61. notionary/blocks/quote/quote_element.py +92 -0
  62. notionary/blocks/quote/quote_markdown_node.py +23 -0
  63. notionary/blocks/quote/quote_models.py +0 -0
  64. notionary/blocks/registry/block_registry.py +17 -3
  65. notionary/blocks/registry/block_registry_builder.py +90 -178
  66. notionary/blocks/shared/__init__.py +0 -0
  67. notionary/blocks/shared/block_client.py +256 -0
  68. notionary/blocks/shared/models.py +710 -0
  69. notionary/blocks/{notion_block_element.py → shared/notion_block_element.py} +8 -5
  70. notionary/blocks/{text_inline_formatter.py → shared/text_inline_formatter.py} +14 -14
  71. notionary/blocks/shared/text_inline_formatter_new.py +139 -0
  72. notionary/blocks/table/__init__.py +7 -0
  73. notionary/blocks/{table_element.py → table/table_element.py} +23 -11
  74. notionary/blocks/table/table_markdown_node.py +40 -0
  75. notionary/blocks/table/table_models.py +0 -0
  76. notionary/blocks/todo/__init__.py +7 -0
  77. notionary/blocks/{todo_element.py → todo/todo_element.py} +8 -4
  78. notionary/blocks/todo/todo_markdown_node.py +31 -0
  79. notionary/blocks/todo/todo_models.py +0 -0
  80. notionary/blocks/toggle/__init__.py +4 -0
  81. notionary/blocks/{toggle_element.py → toggle/toggle_element.py} +7 -3
  82. notionary/blocks/toggle/toggle_markdown_node.py +35 -0
  83. notionary/blocks/toggle/toggle_models.py +0 -0
  84. notionary/blocks/toggleable_heading/__init__.py +9 -0
  85. notionary/blocks/{toggleable_heading_element.py → toggleable_heading/toggleable_heading_element.py} +8 -4
  86. notionary/blocks/toggleable_heading/toggleable_heading_markdown_node.py +43 -0
  87. notionary/blocks/toggleable_heading/toggleable_heading_models.py +0 -0
  88. notionary/blocks/video/__init__.py +7 -0
  89. notionary/blocks/{video_element.py → video/video_element.py} +82 -57
  90. notionary/blocks/video/video_markdown_node.py +30 -0
  91. notionary/database/__init__.py +4 -0
  92. notionary/database/database.py +481 -0
  93. notionary/database/{filter_builder.py → database_filter_builder.py} +27 -29
  94. notionary/database/{notion_database_provider.py → database_provider.py} +4 -4
  95. notionary/database/notion_database.py +45 -18
  96. notionary/file_upload/__init__.py +7 -0
  97. notionary/file_upload/client.py +254 -0
  98. notionary/file_upload/models.py +60 -0
  99. notionary/file_upload/notion_file_upload.py +387 -0
  100. notionary/page/content/markdown_whitespace_processor.py +80 -0
  101. notionary/page/content/notion_text_length_utils.py +87 -0
  102. notionary/page/content/page_content_retriever.py +2 -2
  103. notionary/page/content/page_content_writer.py +97 -148
  104. notionary/page/formatting/line_processor.py +153 -0
  105. notionary/page/formatting/markdown_to_notion_converter.py +103 -424
  106. notionary/page/notion_page.py +13 -14
  107. notionary/page/notion_to_markdown_converter.py +9 -13
  108. notionary/telemetry/views.py +15 -6
  109. notionary/user/__init__.py +11 -0
  110. notionary/user/base_notion_user.py +52 -0
  111. notionary/user/client.py +129 -0
  112. notionary/user/models.py +83 -0
  113. notionary/user/notion_bot_user.py +227 -0
  114. notionary/user/notion_user.py +256 -0
  115. notionary/user/notion_user_manager.py +173 -0
  116. notionary/user/notion_user_provider.py +1 -0
  117. notionary/util/__init__.py +3 -5
  118. notionary/util/factory_decorator.py +0 -33
  119. notionary/util/factory_only.py +37 -0
  120. notionary/util/fuzzy.py +74 -0
  121. notionary/util/logging_mixin.py +12 -12
  122. notionary/workspace.py +38 -3
  123. {notionary-0.2.16.dist-info → notionary-0.2.18.dist-info}/METADATA +2 -1
  124. notionary-0.2.18.dist-info/RECORD +149 -0
  125. notionary/blocks/audio_element.py +0 -144
  126. notionary/blocks/callout_element.py +0 -122
  127. notionary/blocks/notion_block_client.py +0 -26
  128. notionary/blocks/qoute_element.py +0 -169
  129. notionary/page/content/notion_page_content_chunker.py +0 -84
  130. notionary/page/formatting/spacer_rules.py +0 -483
  131. notionary/util/fuzzy_matcher.py +0 -82
  132. notionary-0.2.16.dist-info/RECORD +0 -71
  133. /notionary/{elements/__init__.py → blocks/bookmark/bookmark_models.py} +0 -0
  134. /notionary/database/{database_exceptions.py → exceptions.py} +0 -0
  135. /notionary/util/{singleton_decorator.py → singleton.py} +0 -0
  136. {notionary-0.2.16.dist-info → notionary-0.2.18.dist-info}/LICENSE +0 -0
  137. {notionary-0.2.16.dist-info → notionary-0.2.18.dist-info}/WHEEL +0 -0
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+ from notionary.blocks.markdown_node import MarkdownNode
5
+
6
+
7
+ class DividerMarkdownBlockParams(BaseModel):
8
+ pass
9
+
10
+
11
+ class DividerMarkdownNode(MarkdownNode):
12
+ """
13
+ Programmatic interface for creating Markdown divider lines (---).
14
+ """
15
+
16
+ def __init__(self):
17
+ pass # Keine Attribute notwendig
18
+
19
+ @classmethod
20
+ def from_params(cls, params: DividerMarkdownBlockParams) -> DividerMarkdownNode:
21
+ return cls()
22
+
23
+ def to_markdown(self) -> str:
24
+ return "---"
File without changes
@@ -0,0 +1,7 @@
1
+ from .document_element import DocumentElement
2
+ from .document_markdown_node import DocumentMarkdownNode
3
+
4
+ __all__ = [
5
+ "DocumentElement",
6
+ "DocumentMarkdownNode",
7
+ ]
@@ -0,0 +1,102 @@
1
+ import re
2
+ from typing import Dict, Any, Optional, List
3
+
4
+ from notionary.blocks import NotionBlockElement, NotionBlockResult
5
+ from notionary.blocks import ElementPromptContent, ElementPromptBuilder
6
+
7
+ class DocumentElement(NotionBlockElement):
8
+ """
9
+ Handles conversion between Markdown document embeds and Notion file blocks.
10
+
11
+ Markdown document syntax:
12
+ - [document](https://example.com/document.pdf "Caption")
13
+ - [document](https://example.com/document.pdf)
14
+ """
15
+ # Nur noch die neue Syntax!
16
+ PATTERN = re.compile(
17
+ r'^\[document\]\('
18
+ r'(https?://[^\s")]+)' # URL
19
+ r'(?:\s+"([^"]*)")?' # Optional caption
20
+ r'\)$'
21
+ )
22
+
23
+ @classmethod
24
+ def match_markdown(cls, text: str) -> bool:
25
+ text = text.strip()
26
+ return text.startswith("[document]") and bool(cls.PATTERN.match(text))
27
+
28
+ @classmethod
29
+ def match_notion(cls, block: Dict[str, Any]) -> bool:
30
+ return block.get("type") == "file"
31
+
32
+ @classmethod
33
+ def markdown_to_notion(cls, text: str) -> Optional[List[Dict[str, Any]]]:
34
+ match = cls.PATTERN.match(text.strip())
35
+ if not match:
36
+ return None
37
+ url = match.group(1)
38
+ caption = match.group(2) or ""
39
+ file_block = {
40
+ "type": "file",
41
+ "file": {
42
+ "type": "external",
43
+ "external": {"url": url},
44
+ "caption": [{"type": "text", "text": {"content": caption}}] if caption else [],
45
+ }
46
+ }
47
+ # Für Konsistenz mit anderen Blöcken geben wir ein Array zurück
48
+ empty_paragraph = {"type": "paragraph", "paragraph": {"rich_text": []}}
49
+ return [file_block, empty_paragraph]
50
+
51
+ @classmethod
52
+ def notion_to_markdown(cls, block: Dict[str, Any]) -> Optional[str]:
53
+ if block.get("type") != "file":
54
+ return None
55
+ file_data = block.get("file", {})
56
+ url = ""
57
+ if file_data.get("type") == "external":
58
+ url = file_data.get("external", {}).get("url", "")
59
+ elif file_data.get("type") == "file":
60
+ url = file_data.get("file", {}).get("url", "")
61
+ if not url:
62
+ return None
63
+ caption_list = file_data.get("caption", [])
64
+ caption = cls._extract_text_content(caption_list)
65
+ if caption:
66
+ return f'[document]({url} "{caption}")'
67
+ return f'[document]({url})'
68
+
69
+ @classmethod
70
+ def _extract_text_content(cls, rich_text: List[Dict[str, Any]]) -> str:
71
+ return "".join(
72
+ t.get("text", {}).get("content", "")
73
+ for t in rich_text
74
+ if t.get("type") == "text"
75
+ ) or "".join(t.get("plain_text", "") for t in rich_text if "plain_text" in t)
76
+
77
+ @classmethod
78
+ def is_multiline(cls) -> bool:
79
+ return False
80
+
81
+ @classmethod
82
+ def get_llm_prompt_content(cls) -> ElementPromptContent:
83
+ return (
84
+ ElementPromptBuilder()
85
+ .with_description(
86
+ "Embeds document files from external sources like PDFs, Word docs, Excel files, or cloud storage services."
87
+ )
88
+ .with_usage_guidelines(
89
+ "Use document embeds for sharing contracts, reports, manuals, or any important files."
90
+ )
91
+ .with_syntax('[document](https://example.com/document.pdf "Caption")')
92
+ .with_examples(
93
+ [
94
+ '[document](https://drive.google.com/file/d/1a2b3c4d5e/view "Project Proposal")',
95
+ '[document](https://company.sharepoint.com/reports/q4-2024.xlsx "Q4 Financial Report")',
96
+ '[document](https://cdn.company.com/docs/manual-v2.1.pdf "User Manual")',
97
+ '[document](https://docs.google.com/document/d/1x2y3z4/edit "Meeting Minutes")',
98
+ '[document](https://example.com/contract.pdf)',
99
+ ]
100
+ )
101
+ .build()
102
+ )
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+ from pydantic import BaseModel
5
+ from notionary.blocks.markdown_node import MarkdownNode
6
+
7
+ class DocumentMarkdownNodeParams(BaseModel):
8
+ url: str
9
+ caption: Optional[str] = None
10
+
11
+ class DocumentMarkdownNode(MarkdownNode):
12
+ """
13
+ Programmatic interface for creating Notion-style Markdown document/file embeds.
14
+ Example: [document](https://example.com/file.pdf "My Caption")
15
+ """
16
+
17
+ def __init__(self, url: str, caption: Optional[str] = None):
18
+ self.url = url
19
+ self.caption = caption or ""
20
+
21
+ @classmethod
22
+ def from_params(cls, params: DocumentMarkdownNodeParams) -> DocumentMarkdownNode:
23
+ return cls(url=params.url, caption=params.caption)
24
+
25
+ def to_markdown(self) -> str:
26
+ """
27
+ Convert to markdown as [document](url "caption") or [document](url) if caption is empty.
28
+ """
29
+ if self.caption:
30
+ return f'[document]({self.url} "{self.caption}")'
31
+ return f'[document]({self.url})'
File without changes
@@ -0,0 +1,7 @@
1
+ from .embed_element import EmbedElement
2
+ from .embed_markdown_node import EmbedMarkdownNode
3
+
4
+ __all__ = [
5
+ "EmbedElement",
6
+ "EmbedMarkdownNode",
7
+ ]
@@ -1,31 +1,44 @@
1
1
  import re
2
-
3
2
  from typing import Dict, Any, Optional, List
3
+
4
4
  from notionary.blocks import NotionBlockElement
5
- from notionary.blocks import ElementPromptContent, ElementPromptBuilder
5
+ from notionary.blocks import (
6
+ ElementPromptContent,
7
+ ElementPromptBuilder,
8
+ NotionBlockResult,
9
+ )
6
10
 
7
11
 
8
12
  class EmbedElement(NotionBlockElement):
9
13
  """
10
14
  Handles conversion between Markdown embeds and Notion embed blocks.
11
15
 
12
- Markdown embed syntax (custom format):
13
- - <embed:Caption>(https://example.com) - Basic embed with caption
14
- - <embed>(https://example.com) - Embed without caption
16
+ Markdown embed syntax:
17
+ - [embed](https://example.com) - Simple embed with URL only
18
+ - [embed](https://example.com "Caption") - Embed with URL and caption
19
+
20
+ Where:
21
+ - URL is the required embed URL
22
+ - Caption is an optional descriptive text (enclosed in quotes)
15
23
 
16
24
  Supports various URL types including websites, PDFs, Google Maps, Google Drive,
17
25
  Twitter/X posts, and other sources that Notion can embed.
18
26
  """
19
27
 
28
+ # Regex pattern for embed syntax with optional caption
20
29
  PATTERN = re.compile(
21
- r"^<embed(?:\:(.*?))?>(?:\s*)" + r'\((https?://[^\s"]+)' + r"\)$"
30
+ r"^\[embed\]\(" # [embed]( prefix
31
+ + r'(https?://[^\s"]+)' # URL (required)
32
+ + r'(?:\s+"([^"]+)")?' # Optional caption in quotes
33
+ + r"\)$" # closing parenthesis
22
34
  )
23
35
 
24
36
  @classmethod
25
37
  def match_markdown(cls, text: str) -> bool:
26
38
  """Check if text is a markdown embed."""
27
- text = text.strip()
28
- return text.startswith("<embed") and bool(EmbedElement.PATTERN.match(text))
39
+ return text.strip().startswith("[embed]") and bool(
40
+ EmbedElement.PATTERN.match(text.strip())
41
+ )
29
42
 
30
43
  @classmethod
31
44
  def match_notion(cls, block: Dict[str, Any]) -> bool:
@@ -33,31 +46,33 @@ class EmbedElement(NotionBlockElement):
33
46
  return block.get("type") == "embed"
34
47
 
35
48
  @classmethod
36
- def markdown_to_notion(cls, text: str) -> Optional[Dict[str, Any]]:
49
+ def markdown_to_notion(cls, text: str) -> NotionBlockResult:
37
50
  """Convert markdown embed to Notion embed block."""
38
51
  embed_match = EmbedElement.PATTERN.match(text.strip())
39
52
  if not embed_match:
40
53
  return None
41
54
 
42
- caption = embed_match.group(1) or ""
43
- url = embed_match.group(2)
55
+ url = embed_match.group(1)
56
+ caption = embed_match.group(2)
44
57
 
45
58
  if not url:
46
59
  return None
47
60
 
48
- # Prepare the embed block
49
- embed_block = {
50
- "type": "embed",
51
- "embed": {"url": url},
52
- }
61
+ embed_data = {"url": url}
53
62
 
54
63
  # Add caption if provided
55
64
  if caption:
56
- embed_block["embed"]["caption"] = [
57
- {"type": "text", "text": {"content": caption}}
58
- ]
65
+ embed_data["caption"] = [{"type": "text", "text": {"content": caption}}]
66
+ else:
67
+ embed_data["caption"] = []
59
68
 
60
- return embed_block
69
+ # Prepare the embed block
70
+ embed_block = {"type": "embed", "embed": embed_data}
71
+
72
+ # Add empty paragraph after embed
73
+ empty_paragraph = {"type": "paragraph", "paragraph": {"rich_text": []}}
74
+
75
+ return [embed_block, empty_paragraph]
61
76
 
62
77
  @classmethod
63
78
  def notion_to_markdown(cls, block: Dict[str, Any]) -> Optional[str]:
@@ -71,16 +86,19 @@ class EmbedElement(NotionBlockElement):
71
86
  if not url:
72
87
  return None
73
88
 
74
- # Extract caption if available
75
- caption = ""
76
89
  caption_rich_text = embed_data.get("caption", [])
77
- if caption_rich_text:
78
- caption = EmbedElement._extract_text_content(caption_rich_text)
90
+
91
+ if not caption_rich_text:
92
+ # Simple embed with URL only
93
+ return f"[embed]({url})"
94
+
95
+ # Extract caption text
96
+ caption = EmbedElement._extract_text_content(caption_rich_text)
79
97
 
80
98
  if caption:
81
- return f"<embed:{caption}>({url})"
99
+ return f'[embed]({url} "{caption}")'
82
100
 
83
- return f"<embed>({url})"
101
+ return f"[embed]({url})"
84
102
 
85
103
  @classmethod
86
104
  def is_multiline(cls) -> bool:
@@ -112,14 +130,14 @@ class EmbedElement(NotionBlockElement):
112
130
  "Use embeds when you want to include external content that isn't just a video or image. "
113
131
  "Embeds are great for interactive content, reference materials, or live data sources."
114
132
  )
115
- .with_syntax("<embed:Caption>(https://example.com)")
133
+ .with_syntax('[embed](https://example.com "Optional caption")')
116
134
  .with_examples(
117
135
  [
118
- "<embed:Course materials>(https://drive.google.com/file/d/123456/view)",
119
- "<embed:Our office location>(https://www.google.com/maps?q=San+Francisco)",
120
- "<embed:Latest announcement>(https://twitter.com/NotionHQ/status/1234567890)",
121
- "<embed:Project documentation>(https://github.com/username/repo)",
122
- "<embed>(https://example.com/important-reference.pdf)",
136
+ "[embed](https://drive.google.com/file/d/123456/view)",
137
+ '[embed](https://www.google.com/maps?q=San+Francisco "Our office location")',
138
+ '[embed](https://twitter.com/NotionHQ/status/1234567890 "Latest announcement")',
139
+ '[embed](https://github.com/username/repo "Project documentation")',
140
+ '[embed](https://example.com/important-reference.pdf "Course materials")',
123
141
  ]
124
142
  )
125
143
  .build()
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+ from pydantic import BaseModel
5
+ from notionary.blocks.markdown_node import MarkdownNode
6
+
7
+
8
+ class EmbedMarkdownBlockParams(BaseModel):
9
+ url: str
10
+ caption: Optional[str] = None
11
+
12
+
13
+ class EmbedMarkdownNode(MarkdownNode):
14
+ """
15
+ Programmatic interface for creating Notion-style Markdown embed blocks.
16
+ Example: [embed](https://example.com "Optional caption")
17
+ """
18
+
19
+ def __init__(self, url: str, caption: Optional[str] = None):
20
+ self.url = url
21
+ self.caption = caption
22
+
23
+ @classmethod
24
+ def from_params(cls, params: EmbedMarkdownBlockParams) -> EmbedMarkdownNode:
25
+ return cls(url=params.url, caption=params.caption)
26
+
27
+ def to_markdown(self) -> str:
28
+ if self.caption:
29
+ return f'[embed]({self.url} "{self.caption}")'
30
+ return f"[embed]({self.url})"
File without changes
@@ -0,0 +1,7 @@
1
+ from .heading_element import HeadingElement
2
+ from .heading_markdown_node import HeadingMarkdownNode
3
+
4
+ __all__ = [
5
+ "HeadingElement",
6
+ "HeadingMarkdownNode",
7
+ ]
@@ -2,19 +2,28 @@ import re
2
2
  from typing import Dict, Any, Optional
3
3
 
4
4
  from notionary.blocks import NotionBlockElement
5
- from notionary.blocks import ElementPromptContent, ElementPromptBuilder
6
- from notionary.blocks.text_inline_formatter import TextInlineFormatter
5
+ from notionary.blocks import (
6
+ ElementPromptContent,
7
+ ElementPromptBuilder,
8
+ NotionBlockResult,
9
+ )
10
+ from notionary.blocks.shared.text_inline_formatter import TextInlineFormatter
7
11
 
8
12
 
9
13
  class HeadingElement(NotionBlockElement):
10
14
  """Handles conversion between Markdown headings and Notion heading blocks."""
11
15
 
12
- PATTERN = re.compile(r"^(#{1,3})\s(.+)$")
16
+ # Pattern: #, ## oder ###, dann mind. 1 Leerzeichen/Tab, dann mind. 1 sichtbares Zeichen (kein Whitespace-only)
17
+ PATTERN = re.compile(r"^(#{1,3})[ \t]+(.+)$")
13
18
 
14
19
  @classmethod
15
20
  def match_markdown(cls, text: str) -> bool:
16
- """Check if text is a markdown heading."""
17
- return bool(HeadingElement.PATTERN.match(text))
21
+ """Check if text is a markdown heading with non-empty content."""
22
+ match = cls.PATTERN.match(text)
23
+ if not match:
24
+ return False
25
+ content = match.group(2)
26
+ return bool(content.strip()) # Reject headings with only whitespace
18
27
 
19
28
  @classmethod
20
29
  def match_notion(cls, block: Dict[str, Any]) -> bool:
@@ -23,24 +32,27 @@ class HeadingElement(NotionBlockElement):
23
32
  return block_type.startswith("heading_") and block_type[-1] in "123"
24
33
 
25
34
  @classmethod
26
- def markdown_to_notion(cls, text: str) -> Optional[Dict[str, Any]]:
27
- """Convert markdown heading to Notion heading block."""
28
- header_match = HeadingElement.PATTERN.match(text)
29
- if not header_match:
35
+ def markdown_to_notion(cls, text: str) -> NotionBlockResult:
36
+ """Convert markdown heading to Notion heading block with preceding empty paragraph."""
37
+ match = cls.PATTERN.match(text)
38
+ if not match:
30
39
  return None
31
40
 
32
- level = len(header_match.group(1))
41
+ level = len(match.group(1))
33
42
  if not 1 <= level <= 3:
34
43
  return None
35
44
 
36
- content = header_match.group(2)
45
+ content = match.group(2).lstrip() # Entferne führende Leerzeichen im Content
46
+ if not content.strip():
47
+ return None # Leerer Inhalt nach Entfernen der Whitespaces
37
48
 
38
- return {
49
+ header_block = {
39
50
  "type": f"heading_{level}",
40
51
  f"heading_{level}": {
41
52
  "rich_text": TextInlineFormatter.parse_inline_formatting(content)
42
53
  },
43
54
  }
55
+ return [header_block]
44
56
 
45
57
  @classmethod
46
58
  def notion_to_markdown(cls, block: Dict[str, Any]) -> Optional[str]:
@@ -52,7 +64,6 @@ class HeadingElement(NotionBlockElement):
52
64
 
53
65
  try:
54
66
  level = int(block_type[-1])
55
- # Only allow levels 1-3
56
67
  if not 1 <= level <= 3:
57
68
  return None
58
69
  except ValueError:
@@ -63,7 +74,7 @@ class HeadingElement(NotionBlockElement):
63
74
 
64
75
  text = TextInlineFormatter.extract_text_with_formatting(rich_text)
65
76
  prefix = "#" * level
66
- return f"{prefix} {text or ''}"
77
+ return f"{prefix} {text}" if text else None
67
78
 
68
79
  @classmethod
69
80
  def is_multiline(cls) -> bool:
@@ -71,9 +82,6 @@ class HeadingElement(NotionBlockElement):
71
82
 
72
83
  @classmethod
73
84
  def get_llm_prompt_content(cls) -> ElementPromptContent:
74
- """
75
- Returns structured LLM prompt metadata for the heading element.
76
- """
77
85
  return (
78
86
  ElementPromptBuilder()
79
87
  .with_description(
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+ from notionary.blocks.markdown_node import MarkdownNode
5
+
6
+
7
+ class HeadingMarkdownBlockParams(BaseModel):
8
+ text: str
9
+ level: int = 1
10
+
11
+
12
+ class HeadingMarkdownNode(MarkdownNode):
13
+ """
14
+ Programmatic interface for creating Markdown headings (H1-H3).
15
+ Example: # Heading 1, ## Heading 2, ### Heading 3
16
+ """
17
+
18
+ def __init__(self, text: str, level: int = 1):
19
+ if not (1 <= level <= 3):
20
+ raise ValueError("Only heading levels 1-3 are supported (H1, H2, H3)")
21
+ self.text = text
22
+ self.level = level
23
+
24
+ @classmethod
25
+ def from_params(cls, params: HeadingMarkdownBlockParams) -> HeadingMarkdownNode:
26
+ return cls(text=params.text, level=params.level)
27
+
28
+ def to_markdown(self) -> str:
29
+ return f"{'#' * self.level} {self.text}"
File without changes
@@ -0,0 +1,7 @@
1
+ from .image_element import ImageElement
2
+ from .image_markdown_node import ImageMarkdownNode
3
+
4
+ __all__ = [
5
+ "ImageElement",
6
+ "ImageMarkdownNode",
7
+ ]