notionary 0.2.17__py3-none-any.whl → 0.2.19__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 (113) hide show
  1. notionary/__init__.py +3 -2
  2. notionary/blocks/__init__.py +54 -25
  3. notionary/blocks/audio/__init__.py +7 -0
  4. notionary/blocks/audio/audio_element.py +152 -0
  5. notionary/blocks/audio/audio_markdown_node.py +29 -0
  6. notionary/blocks/audio/audio_models.py +59 -0
  7. notionary/blocks/bookmark/__init__.py +7 -0
  8. notionary/blocks/{bookmark_element.py → bookmark/bookmark_element.py} +20 -65
  9. notionary/blocks/bookmark/bookmark_markdown_node.py +43 -0
  10. notionary/blocks/bookmark/bookmark_models.py +0 -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 +713 -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/file_upload/notion_file_upload.py +1 -1
  92. notionary/page/content/markdown_whitespace_processor.py +80 -0
  93. notionary/page/content/notion_text_length_utils.py +87 -0
  94. notionary/page/content/page_content_retriever.py +18 -10
  95. notionary/page/content/page_content_writer.py +97 -148
  96. notionary/page/formatting/line_processor.py +153 -0
  97. notionary/page/formatting/markdown_to_notion_converter.py +104 -425
  98. notionary/page/notion_page.py +9 -11
  99. notionary/page/notion_to_markdown_converter.py +9 -13
  100. notionary/util/factory_decorator.py +0 -0
  101. notionary/workspace.py +0 -1
  102. {notionary-0.2.17.dist-info → notionary-0.2.19.dist-info}/METADATA +1 -1
  103. notionary-0.2.19.dist-info/RECORD +150 -0
  104. notionary/blocks/audio_element.py +0 -144
  105. notionary/blocks/callout_element.py +0 -122
  106. notionary/blocks/document_element.py +0 -194
  107. notionary/blocks/notion_block_client.py +0 -26
  108. notionary/blocks/qoute_element.py +0 -169
  109. notionary/page/content/notion_page_content_chunker.py +0 -84
  110. notionary/page/formatting/spacer_rules.py +0 -483
  111. notionary-0.2.17.dist-info/RECORD +0 -85
  112. {notionary-0.2.17.dist-info → notionary-0.2.19.dist-info}/LICENSE +0 -0
  113. {notionary-0.2.17.dist-info → notionary-0.2.19.dist-info}/WHEEL +0 -0
@@ -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
+ ]
@@ -1,8 +1,12 @@
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 ImageElement(NotionBlockElement):
@@ -10,23 +14,26 @@ class ImageElement(NotionBlockElement):
10
14
  Handles conversion between Markdown images and Notion image blocks.
11
15
 
12
16
  Markdown image syntax:
13
- - ![Caption](https://example.com/image.jpg) - Basic image with caption
14
- - ![](https://example.com/image.jpg) - Image without caption
15
- - ![Caption](https://example.com/image.jpg "alt text") - Image with caption and alt text
17
+ - [image](https://example.com/image.jpg) - Simple image with URL only
18
+ - [image](https://example.com/image.jpg "Caption") - Image with URL and caption
19
+
20
+ Where:
21
+ - URL is the required image URL
22
+ - Caption is an optional descriptive text (enclosed in quotes)
16
23
  """
17
24
 
18
- # Regex pattern for image syntax with optional alt text
25
+ # Regex pattern for image syntax with optional caption
19
26
  PATTERN = re.compile(
20
- r"^\!\[(.*?)\]" # ![Caption] part
21
- + r'\((https?://[^\s"]+)' # (URL part
22
- + r'(?:\s+"([^"]+)")?' # Optional alt text in quotes
27
+ r"^\[image\]\(" # [image]( prefix
28
+ + r'(https?://[^\s"]+)' # URL (required)
29
+ + r'(?:\s+"([^"]+)")?' # Optional caption in quotes
23
30
  + r"\)$" # closing parenthesis
24
31
  )
25
32
 
26
33
  @classmethod
27
34
  def match_markdown(cls, text: str) -> bool:
28
35
  """Check if text is a markdown image."""
29
- return text.strip().startswith("![") and bool(
36
+ return text.strip().startswith("[image]") and bool(
30
37
  ImageElement.PATTERN.match(text.strip())
31
38
  )
32
39
 
@@ -36,31 +43,33 @@ class ImageElement(NotionBlockElement):
36
43
  return block.get("type") == "image"
37
44
 
38
45
  @classmethod
39
- def markdown_to_notion(cls, text: str) -> Optional[Dict[str, Any]]:
46
+ def markdown_to_notion(cls, text: str) -> NotionBlockResult:
40
47
  """Convert markdown image to Notion image block."""
41
48
  image_match = ImageElement.PATTERN.match(text.strip())
42
49
  if not image_match:
43
50
  return None
44
51
 
45
- caption = image_match.group(1)
46
- url = image_match.group(2)
52
+ url = image_match.group(1)
53
+ caption = image_match.group(2)
47
54
 
48
55
  if not url:
49
56
  return None
50
57
 
51
- # Prepare the image block
52
- image_block = {
53
- "type": "image",
54
- "image": {"type": "external", "external": {"url": url}},
55
- }
58
+ image_data = {"type": "external", "external": {"url": url}}
56
59
 
57
60
  # Add caption if provided
58
61
  if caption:
59
- image_block["image"]["caption"] = [
60
- {"type": "text", "text": {"content": caption}}
61
- ]
62
+ image_data["caption"] = [{"type": "text", "text": {"content": caption}}]
63
+ else:
64
+ image_data["caption"] = []
65
+
66
+ # Prepare the image block
67
+ image_block = {"type": "image", "image": image_data}
68
+
69
+ # Add empty paragraph after image
70
+ empty_paragraph = {"type": "paragraph", "paragraph": {"rich_text": []}}
62
71
 
63
- return image_block
72
+ return [image_block, empty_paragraph]
64
73
 
65
74
  @classmethod
66
75
  def notion_to_markdown(cls, block: Dict[str, Any]) -> Optional[str]:
@@ -71,23 +80,37 @@ class ImageElement(NotionBlockElement):
71
80
  image_data = block.get("image", {})
72
81
 
73
82
  # Handle both external and file (uploaded) images
74
- if image_data.get("type") == "external":
75
- url = image_data.get("external", {}).get("url", "")
76
- elif image_data.get("type") == "file":
77
- url = image_data.get("file", {}).get("url", "")
78
- else:
79
- return None
80
-
83
+ url = ImageElement._extract_image_url(image_data)
81
84
  if not url:
82
85
  return None
83
86
 
84
- # Extract caption if available
85
- caption = ""
86
87
  caption_rich_text = image_data.get("caption", [])
87
- if caption_rich_text:
88
- caption = ImageElement._extract_text_content(caption_rich_text)
89
88
 
90
- return f"![{caption}]({url})"
89
+ if not caption_rich_text:
90
+ # Simple image with URL only
91
+ return f"[image]({url})"
92
+
93
+ # Extract caption text
94
+ caption = ImageElement._extract_text_content(caption_rich_text)
95
+
96
+ if caption:
97
+ return f'[image]({url} "{caption}")'
98
+
99
+ return f"[image]({url})"
100
+
101
+ @classmethod
102
+ def is_multiline(cls) -> bool:
103
+ """Images are single-line elements."""
104
+ return False
105
+
106
+ @classmethod
107
+ def _extract_image_url(cls, image_data: Dict[str, Any]) -> str:
108
+ """Extract URL from image data, handling both external and uploaded images."""
109
+ if image_data.get("type") == "external":
110
+ return image_data.get("external", {}).get("url", "")
111
+ elif image_data.get("type") == "file":
112
+ return image_data.get("file", {}).get("url", "")
113
+ return ""
91
114
 
92
115
  @classmethod
93
116
  def _extract_text_content(cls, rich_text: List[Dict[str, Any]]) -> str:
@@ -100,10 +123,6 @@ class ImageElement(NotionBlockElement):
100
123
  result += text_obj.get("plain_text", "")
101
124
  return result
102
125
 
103
- @classmethod
104
- def is_multiline(cls) -> bool:
105
- return False
106
-
107
126
  @classmethod
108
127
  def get_llm_prompt_content(cls) -> ElementPromptContent:
109
128
  """
@@ -119,12 +138,13 @@ class ImageElement(NotionBlockElement):
119
138
  "that enhance your document. Images can make complex information easier to understand, create visual interest, "
120
139
  "or provide evidence for your points."
121
140
  )
122
- .with_syntax("![Caption](https://example.com/image.jpg)")
141
+ .with_syntax('[image](https://example.com/image.jpg "Optional caption")')
123
142
  .with_examples(
124
143
  [
125
- "![Data visualization showing monthly trends](https://example.com/chart.png)",
126
- "![](https://example.com/screenshot.jpg)",
127
- '![Company logo](https://company.com/logo.png "Company Inc. logo")',
144
+ "[image](https://example.com/chart.png)",
145
+ '[image](https://example.com/screenshot.jpg "Data visualization showing monthly trends")',
146
+ '[image](https://company.com/logo.png "Company Inc. logo")',
147
+ '[image](https://example.com/diagram.jpg "System architecture overview")',
128
148
  ]
129
149
  )
130
150
  .build()