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.
- notionary/__init__.py +3 -2
- notionary/blocks/__init__.py +54 -25
- notionary/blocks/audio/__init__.py +7 -0
- notionary/blocks/audio/audio_element.py +152 -0
- notionary/blocks/audio/audio_markdown_node.py +29 -0
- notionary/blocks/audio/audio_models.py +59 -0
- notionary/blocks/bookmark/__init__.py +7 -0
- notionary/blocks/{bookmark_element.py → bookmark/bookmark_element.py} +20 -65
- notionary/blocks/bookmark/bookmark_markdown_node.py +43 -0
- notionary/blocks/bookmark/bookmark_models.py +0 -0
- notionary/blocks/bulleted_list/__init__.py +7 -0
- notionary/blocks/{bulleted_list_element.py → bulleted_list/bulleted_list_element.py} +7 -3
- notionary/blocks/bulleted_list/bulleted_list_markdown_node.py +33 -0
- notionary/blocks/bulleted_list/bulleted_list_models.py +0 -0
- notionary/blocks/callout/__init__.py +7 -0
- notionary/blocks/callout/callout_element.py +132 -0
- notionary/blocks/callout/callout_markdown_node.py +31 -0
- notionary/blocks/callout/callout_models.py +0 -0
- notionary/blocks/code/__init__.py +7 -0
- notionary/blocks/{code_block_element.py → code/code_element.py} +72 -40
- notionary/blocks/code/code_markdown_node.py +43 -0
- notionary/blocks/code/code_models.py +0 -0
- notionary/blocks/column/__init__.py +5 -0
- notionary/blocks/{column_element.py → column/column_element.py} +24 -55
- notionary/blocks/column/column_models.py +0 -0
- notionary/blocks/divider/__init__.py +7 -0
- notionary/blocks/{divider_element.py → divider/divider_element.py} +11 -3
- notionary/blocks/divider/divider_markdown_node.py +24 -0
- notionary/blocks/divider/divider_models.py +0 -0
- notionary/blocks/document/__init__.py +7 -0
- notionary/blocks/document/document_element.py +102 -0
- notionary/blocks/document/document_markdown_node.py +31 -0
- notionary/blocks/document/document_models.py +0 -0
- notionary/blocks/embed/__init__.py +7 -0
- notionary/blocks/{embed_element.py → embed/embed_element.py} +50 -32
- notionary/blocks/embed/embed_markdown_node.py +30 -0
- notionary/blocks/embed/embed_models.py +0 -0
- notionary/blocks/heading/__init__.py +7 -0
- notionary/blocks/{heading_element.py → heading/heading_element.py} +25 -17
- notionary/blocks/heading/heading_markdown_node.py +29 -0
- notionary/blocks/heading/heading_models.py +0 -0
- notionary/blocks/image/__init__.py +7 -0
- notionary/blocks/{image_element.py → image/image_element.py} +62 -42
- notionary/blocks/image/image_markdown_node.py +33 -0
- notionary/blocks/image/image_models.py +0 -0
- notionary/blocks/markdown_builder.py +356 -0
- notionary/blocks/markdown_node.py +29 -0
- notionary/blocks/mention/__init__.py +7 -0
- notionary/blocks/{mention_element.py → mention/mention_element.py} +6 -2
- notionary/blocks/mention/mention_markdown_node.py +38 -0
- notionary/blocks/mention/mention_models.py +0 -0
- notionary/blocks/numbered_list/__init__.py +7 -0
- notionary/blocks/{numbered_list_element.py → numbered_list/numbered_list_element.py} +10 -6
- notionary/blocks/numbered_list/numbered_list_markdown_node.py +29 -0
- notionary/blocks/numbered_list/numbered_list_models.py +0 -0
- notionary/blocks/paragraph/__init__.py +7 -0
- notionary/blocks/{paragraph_element.py → paragraph/paragraph_element.py} +7 -3
- notionary/blocks/paragraph/paragraph_markdown_node.py +25 -0
- notionary/blocks/paragraph/paragraph_models.py +0 -0
- notionary/blocks/quote/__init__.py +7 -0
- notionary/blocks/quote/quote_element.py +92 -0
- notionary/blocks/quote/quote_markdown_node.py +23 -0
- notionary/blocks/quote/quote_models.py +0 -0
- notionary/blocks/registry/block_registry.py +17 -3
- notionary/blocks/registry/block_registry_builder.py +90 -178
- notionary/blocks/shared/__init__.py +0 -0
- notionary/blocks/shared/block_client.py +256 -0
- notionary/blocks/shared/models.py +713 -0
- notionary/blocks/{notion_block_element.py → shared/notion_block_element.py} +8 -5
- notionary/blocks/{text_inline_formatter.py → shared/text_inline_formatter.py} +14 -14
- notionary/blocks/shared/text_inline_formatter_new.py +139 -0
- notionary/blocks/table/__init__.py +7 -0
- notionary/blocks/{table_element.py → table/table_element.py} +23 -11
- notionary/blocks/table/table_markdown_node.py +40 -0
- notionary/blocks/table/table_models.py +0 -0
- notionary/blocks/todo/__init__.py +7 -0
- notionary/blocks/{todo_element.py → todo/todo_element.py} +8 -4
- notionary/blocks/todo/todo_markdown_node.py +31 -0
- notionary/blocks/todo/todo_models.py +0 -0
- notionary/blocks/toggle/__init__.py +4 -0
- notionary/blocks/{toggle_element.py → toggle/toggle_element.py} +7 -3
- notionary/blocks/toggle/toggle_markdown_node.py +35 -0
- notionary/blocks/toggle/toggle_models.py +0 -0
- notionary/blocks/toggleable_heading/__init__.py +9 -0
- notionary/blocks/{toggleable_heading_element.py → toggleable_heading/toggleable_heading_element.py} +8 -4
- notionary/blocks/toggleable_heading/toggleable_heading_markdown_node.py +43 -0
- notionary/blocks/toggleable_heading/toggleable_heading_models.py +0 -0
- notionary/blocks/video/__init__.py +7 -0
- notionary/blocks/{video_element.py → video/video_element.py} +82 -57
- notionary/blocks/video/video_markdown_node.py +30 -0
- notionary/file_upload/notion_file_upload.py +1 -1
- notionary/page/content/markdown_whitespace_processor.py +80 -0
- notionary/page/content/notion_text_length_utils.py +87 -0
- notionary/page/content/page_content_retriever.py +18 -10
- notionary/page/content/page_content_writer.py +97 -148
- notionary/page/formatting/line_processor.py +153 -0
- notionary/page/formatting/markdown_to_notion_converter.py +104 -425
- notionary/page/notion_page.py +9 -11
- notionary/page/notion_to_markdown_converter.py +9 -13
- notionary/util/factory_decorator.py +0 -0
- notionary/workspace.py +0 -1
- {notionary-0.2.17.dist-info → notionary-0.2.19.dist-info}/METADATA +1 -1
- notionary-0.2.19.dist-info/RECORD +150 -0
- notionary/blocks/audio_element.py +0 -144
- notionary/blocks/callout_element.py +0 -122
- notionary/blocks/document_element.py +0 -194
- notionary/blocks/notion_block_client.py +0 -26
- notionary/blocks/qoute_element.py +0 -169
- notionary/page/content/notion_page_content_chunker.py +0 -84
- notionary/page/formatting/spacer_rules.py +0 -483
- notionary-0.2.17.dist-info/RECORD +0 -85
- {notionary-0.2.17.dist-info → notionary-0.2.19.dist-info}/LICENSE +0 -0
- {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
|
@@ -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
|
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
|
13
|
-
-
|
14
|
-
-
|
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"
|
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
|
-
|
28
|
-
|
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) ->
|
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
|
-
|
43
|
-
|
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
|
-
|
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
|
-
|
57
|
-
|
58
|
-
]
|
65
|
+
embed_data["caption"] = [{"type": "text", "text": {"content": caption}}]
|
66
|
+
else:
|
67
|
+
embed_data["caption"] = []
|
59
68
|
|
60
|
-
|
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
|
-
|
78
|
-
|
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
|
99
|
+
return f'[embed]({url} "{caption}")'
|
82
100
|
|
83
|
-
return f"
|
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(
|
133
|
+
.with_syntax('[embed](https://example.com "Optional caption")')
|
116
134
|
.with_examples(
|
117
135
|
[
|
118
|
-
"
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
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
|
@@ -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
|
6
|
-
|
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
|
-
|
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
|
-
|
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) ->
|
27
|
-
"""Convert markdown heading to Notion heading block."""
|
28
|
-
|
29
|
-
if not
|
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(
|
41
|
+
level = len(match.group(1))
|
33
42
|
if not 1 <= level <= 3:
|
34
43
|
return None
|
35
44
|
|
36
|
-
content =
|
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
|
-
|
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
|
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
|
@@ -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
|
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
|
-
-
|
14
|
-
-
|
15
|
-
|
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
|
25
|
+
# Regex pattern for image syntax with optional caption
|
19
26
|
PATTERN = re.compile(
|
20
|
-
r"
|
21
|
-
+ r'
|
22
|
-
+ r'(?:\s+"([^"]+)")?' # Optional
|
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("
|
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) ->
|
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
|
-
|
46
|
-
|
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
|
-
|
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
|
-
|
60
|
-
|
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
|
-
|
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
|
-
|
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(
|
141
|
+
.with_syntax('[image](https://example.com/image.jpg "Optional caption")')
|
123
142
|
.with_examples(
|
124
143
|
[
|
125
|
-
"
|
126
|
-
|
127
|
-
'
|
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()
|