notionary 0.2.21__py3-none-any.whl → 0.2.22__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/blocks/_bootstrap.py +9 -1
- notionary/blocks/audio/audio_element.py +53 -28
- notionary/blocks/audio/audio_markdown_node.py +10 -4
- notionary/blocks/base_block_element.py +15 -3
- notionary/blocks/bookmark/bookmark_element.py +39 -36
- notionary/blocks/bookmark/bookmark_markdown_node.py +16 -17
- notionary/blocks/breadcrumbs/breadcrumb_element.py +2 -2
- notionary/blocks/bulleted_list/bulleted_list_element.py +21 -4
- notionary/blocks/callout/callout_element.py +20 -4
- notionary/blocks/child_database/__init__.py +11 -4
- notionary/blocks/child_database/child_database_element.py +61 -0
- notionary/blocks/child_database/child_database_models.py +7 -14
- notionary/blocks/child_page/child_page_element.py +94 -0
- notionary/blocks/client.py +0 -1
- notionary/blocks/code/code_element.py +51 -2
- notionary/blocks/code/code_markdown_node.py +52 -1
- notionary/blocks/column/column_element.py +9 -3
- notionary/blocks/column/column_list_element.py +18 -3
- notionary/blocks/divider/divider_element.py +3 -11
- notionary/blocks/embed/embed_element.py +27 -6
- notionary/blocks/equation/equation_element.py +94 -41
- notionary/blocks/equation/equation_element_markdown_node.py +8 -9
- notionary/blocks/file/file_element.py +56 -37
- notionary/blocks/file/file_element_markdown_node.py +9 -7
- notionary/blocks/guards.py +22 -0
- notionary/blocks/heading/heading_element.py +23 -4
- notionary/blocks/image_block/image_element.py +43 -38
- notionary/blocks/image_block/image_markdown_node.py +10 -5
- notionary/blocks/mixins/captions/__init__.py +4 -0
- notionary/blocks/mixins/captions/caption_markdown_node_mixin.py +31 -0
- notionary/blocks/mixins/captions/caption_mixin.py +92 -0
- notionary/blocks/models.py +3 -1
- notionary/blocks/numbered_list/numbered_list_element.py +21 -4
- notionary/blocks/paragraph/paragraph_element.py +21 -5
- notionary/blocks/pdf/pdf_element.py +47 -41
- notionary/blocks/pdf/pdf_markdown_node.py +9 -7
- notionary/blocks/quote/quote_element.py +26 -9
- notionary/blocks/quote/quote_markdown_node.py +2 -2
- notionary/blocks/registry/block_registry.py +1 -46
- notionary/blocks/registry/block_registry_builder.py +8 -0
- notionary/blocks/rich_text/name_to_id_resolver.py +205 -0
- notionary/blocks/rich_text/rich_text_models.py +62 -29
- notionary/blocks/rich_text/text_inline_formatter.py +432 -101
- notionary/blocks/syntax_prompt_builder.py +137 -0
- notionary/blocks/table/table_element.py +110 -9
- notionary/blocks/table_of_contents/table_of_contents_element.py +19 -2
- notionary/blocks/todo/todo_element.py +21 -4
- notionary/blocks/toggle/toggle_element.py +19 -3
- notionary/blocks/toggle/toggle_markdown_node.py +1 -1
- notionary/blocks/toggleable_heading/toggleable_heading_element.py +19 -4
- notionary/blocks/types.py +69 -0
- notionary/blocks/video/video_element.py +44 -39
- notionary/blocks/video/video_markdown_node.py +10 -5
- notionary/database/client.py +23 -0
- notionary/file_upload/models.py +2 -2
- notionary/markdown/markdown_builder.py +34 -27
- notionary/page/client.py +26 -6
- notionary/page/notion_page.py +37 -6
- notionary/page/page_content_deleting_service.py +117 -0
- notionary/page/page_content_writer.py +89 -113
- notionary/page/page_context.py +65 -0
- notionary/page/reader/handler/__init__.py +2 -0
- notionary/page/reader/handler/base_block_renderer.py +4 -4
- notionary/page/reader/handler/block_rendering_context.py +5 -0
- notionary/page/reader/handler/line_renderer.py +16 -3
- notionary/page/reader/handler/numbered_list_renderer.py +85 -0
- notionary/page/reader/page_content_retriever.py +17 -5
- notionary/page/writer/handler/__init__.py +2 -0
- notionary/page/writer/handler/code_handler.py +12 -40
- notionary/page/writer/handler/column_handler.py +12 -12
- notionary/page/writer/handler/column_list_handler.py +13 -13
- notionary/page/writer/handler/equation_handler.py +74 -0
- notionary/page/writer/handler/line_handler.py +4 -4
- notionary/page/writer/handler/regular_line_handler.py +31 -37
- notionary/page/writer/handler/table_handler.py +8 -72
- notionary/page/writer/handler/toggle_handler.py +14 -12
- notionary/page/writer/handler/toggleable_heading_handler.py +22 -16
- notionary/page/writer/markdown_to_notion_converter.py +28 -9
- notionary/page/writer/markdown_to_notion_converter_context.py +30 -0
- notionary/page/writer/markdown_to_notion_formatting_post_processor.py +73 -0
- notionary/page/writer/markdown_to_notion_post_processor.py +0 -0
- notionary/page/writer/markdown_to_notion_text_length_post_processor.py +0 -0
- notionary/page/writer/notion_text_length_processor.py +150 -0
- notionary/telemetry/service.py +0 -1
- notionary/user/notion_user_manager.py +22 -95
- notionary/util/concurrency_limiter.py +0 -0
- notionary/workspace.py +4 -4
- notionary-0.2.22.dist-info/METADATA +237 -0
- {notionary-0.2.21.dist-info → notionary-0.2.22.dist-info}/RECORD +92 -77
- notionary/page/markdown_whitespace_processor.py +0 -80
- notionary/page/notion_text_length_utils.py +0 -119
- notionary/user/notion_user_provider.py +0 -1
- notionary-0.2.21.dist-info/METADATA +0 -229
- /notionary/page/reader/handler/{context.py → equation_renderer.py} +0 -0
- {notionary-0.2.21.dist-info → notionary-0.2.22.dist-info}/LICENSE +0 -0
- {notionary-0.2.21.dist-info → notionary-0.2.22.dist-info}/WHEEL +0 -0
@@ -10,32 +10,38 @@ from notionary.blocks.file.file_element_models import (
|
|
10
10
|
FileBlock,
|
11
11
|
FileType,
|
12
12
|
)
|
13
|
+
from notionary.blocks.mixins.captions import CaptionMixin
|
14
|
+
from notionary.blocks.syntax_prompt_builder import BlockElementMarkdownInformation
|
13
15
|
from notionary.blocks.models import Block, BlockCreateResult, BlockType
|
14
|
-
from notionary.blocks.paragraph.paragraph_models import (
|
15
|
-
CreateParagraphBlock,
|
16
|
-
ParagraphBlock,
|
17
|
-
)
|
18
|
-
from notionary.blocks.rich_text.rich_text_models import RichTextObject
|
19
|
-
from notionary.blocks.rich_text.text_inline_formatter import TextInlineFormatter
|
20
16
|
|
21
17
|
|
22
|
-
class FileElement(BaseBlockElement):
|
18
|
+
class FileElement(BaseBlockElement, CaptionMixin):
|
23
19
|
"""
|
24
20
|
Handles conversion between Markdown file embeds and Notion file blocks.
|
25
21
|
|
26
22
|
Markdown file syntax:
|
27
|
-
- [file](https://example.com/document.pdf
|
28
|
-
- [file](https://example.com/document.pdf)
|
23
|
+
- [file](https://example.com/document.pdf) - URL only
|
24
|
+
- [file](https://example.com/document.pdf)(caption:Annual Report) - URL with caption
|
25
|
+
- (caption:Important document)[file](https://example.com/doc.pdf) - caption before URL
|
29
26
|
|
30
27
|
Supports external file URLs with optional captions.
|
31
28
|
"""
|
32
29
|
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
30
|
+
# Simple pattern that matches just the file link, CaptionMixin handles caption separately
|
31
|
+
FILE_PATTERN = re.compile(r"\[file\]\((https?://[^\s\"]+)\)")
|
32
|
+
|
33
|
+
@classmethod
|
34
|
+
def _extract_file_url(cls, text: str) -> Optional[str]:
|
35
|
+
"""Extract file URL from text, handling caption patterns."""
|
36
|
+
# First remove any captions to get clean text for URL extraction
|
37
|
+
clean_text = cls.remove_caption(text)
|
38
|
+
|
39
|
+
# Now extract the URL from clean text
|
40
|
+
match = cls.FILE_PATTERN.search(clean_text)
|
41
|
+
if match:
|
42
|
+
return match.group(1)
|
43
|
+
|
44
|
+
return None
|
39
45
|
|
40
46
|
@classmethod
|
41
47
|
def match_notion(cls, block: Block) -> bool:
|
@@ -43,31 +49,28 @@ class FileElement(BaseBlockElement):
|
|
43
49
|
return block.type == BlockType.FILE and block.file
|
44
50
|
|
45
51
|
@classmethod
|
46
|
-
def markdown_to_notion(cls, text: str) -> BlockCreateResult:
|
47
|
-
"""Convert markdown file link to Notion FileBlock
|
48
|
-
|
49
|
-
|
52
|
+
async def markdown_to_notion(cls, text: str) -> BlockCreateResult:
|
53
|
+
"""Convert markdown file link to Notion FileBlock."""
|
54
|
+
# Use our helper method to extract the URL
|
55
|
+
url = cls._extract_file_url(text.strip())
|
56
|
+
if not url:
|
50
57
|
return None
|
51
58
|
|
52
|
-
|
59
|
+
# Use mixin to extract caption (if present anywhere in text)
|
60
|
+
caption_text = cls.extract_caption(text.strip())
|
61
|
+
caption_rich_text = cls.build_caption_rich_text(caption_text or "")
|
53
62
|
|
54
63
|
# Build FileBlock using FileType enum
|
55
64
|
file_block = FileBlock(
|
56
|
-
type=FileType.EXTERNAL,
|
65
|
+
type=FileType.EXTERNAL,
|
66
|
+
external=ExternalFile(url=url),
|
67
|
+
caption=caption_rich_text,
|
57
68
|
)
|
58
|
-
if caption_text.strip():
|
59
|
-
rt = RichTextObject.from_plain_text(caption_text)
|
60
|
-
file_block.caption = [rt]
|
61
69
|
|
62
|
-
|
63
|
-
|
64
|
-
return [
|
65
|
-
CreateFileBlock(file=file_block),
|
66
|
-
CreateParagraphBlock(paragraph=empty_para),
|
67
|
-
]
|
70
|
+
return CreateFileBlock(file=file_block)
|
68
71
|
|
69
72
|
@classmethod
|
70
|
-
def notion_to_markdown(cls, block: Block) -> Optional[str]:
|
73
|
+
async def notion_to_markdown(cls, block: Block) -> Optional[str]:
|
71
74
|
if block.type != BlockType.FILE or not block.file:
|
72
75
|
return None
|
73
76
|
|
@@ -84,10 +87,26 @@ class FileElement(BaseBlockElement):
|
|
84
87
|
else:
|
85
88
|
return None
|
86
89
|
|
87
|
-
|
88
|
-
|
90
|
+
result = f"[file]({url})"
|
91
|
+
|
92
|
+
# Add caption if present
|
93
|
+
caption_markdown = await cls.format_caption_for_markdown(fb.caption or [])
|
94
|
+
if caption_markdown:
|
95
|
+
result += caption_markdown
|
96
|
+
|
97
|
+
return result
|
89
98
|
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
return
|
99
|
+
@classmethod
|
100
|
+
def get_system_prompt_information(cls) -> Optional[BlockElementMarkdownInformation]:
|
101
|
+
"""Get system prompt information for file blocks."""
|
102
|
+
return BlockElementMarkdownInformation(
|
103
|
+
block_type=cls.__name__,
|
104
|
+
description="File blocks embed downloadable files from external URLs with optional captions",
|
105
|
+
syntax_examples=[
|
106
|
+
"[file](https://example.com/document.pdf)",
|
107
|
+
"[file](https://example.com/document.pdf)(caption:Annual Report)",
|
108
|
+
"(caption:Q1 Data)[file](https://example.com/spreadsheet.xlsx)",
|
109
|
+
"[file](https://example.com/manual.docx)(caption:**User** manual)",
|
110
|
+
],
|
111
|
+
usage_guidelines="Use for linking to downloadable files like PDFs, documents, spreadsheets. Supports various file formats. Caption supports rich text formatting and should describe the file content or purpose.",
|
112
|
+
)
|
@@ -5,6 +5,7 @@ from typing import Optional
|
|
5
5
|
from pydantic import BaseModel
|
6
6
|
|
7
7
|
from notionary.markdown.markdown_node import MarkdownNode
|
8
|
+
from notionary.blocks.mixins.captions import CaptionMarkdownNodeMixin
|
8
9
|
|
9
10
|
|
10
11
|
class FileMarkdownNodeParams(BaseModel):
|
@@ -12,10 +13,9 @@ class FileMarkdownNodeParams(BaseModel):
|
|
12
13
|
caption: Optional[str] = None
|
13
14
|
|
14
15
|
|
15
|
-
class FileMarkdownNode(MarkdownNode):
|
16
|
+
class FileMarkdownNode(MarkdownNode, CaptionMarkdownNodeMixin):
|
16
17
|
"""
|
17
18
|
Programmatic interface for creating Notion-style Markdown file embeds.
|
18
|
-
Example: [file](https://example.com/file.pdf "My Caption")
|
19
19
|
"""
|
20
20
|
|
21
21
|
def __init__(self, url: str, caption: Optional[str] = None):
|
@@ -27,9 +27,11 @@ class FileMarkdownNode(MarkdownNode):
|
|
27
27
|
return cls(url=params.url, caption=params.caption)
|
28
28
|
|
29
29
|
def to_markdown(self) -> str:
|
30
|
+
"""Return the Markdown representation.
|
31
|
+
|
32
|
+
Examples:
|
33
|
+
- [file](https://example.com/document.pdf)
|
34
|
+
- [file](https://example.com/document.pdf)(caption:User manual)
|
30
35
|
"""
|
31
|
-
|
32
|
-
|
33
|
-
if self.caption:
|
34
|
-
return f'[file]({self.url} "{self.caption}")'
|
35
|
-
return f"[file]({self.url})"
|
36
|
+
base_markdown = f"[file]({self.url})"
|
37
|
+
return self.append_caption_to_markdown(base_markdown, self.caption)
|
@@ -0,0 +1,22 @@
|
|
1
|
+
from typing import Protocol
|
2
|
+
|
3
|
+
from notionary.blocks.models import BlockCreateRequest
|
4
|
+
from notionary.blocks.rich_text.rich_text_models import RichTextObject
|
5
|
+
|
6
|
+
|
7
|
+
class HasRichText(Protocol):
|
8
|
+
"""Protocol for objects that have a rich_text attribute."""
|
9
|
+
|
10
|
+
rich_text: list[RichTextObject]
|
11
|
+
|
12
|
+
|
13
|
+
class HasChildren(Protocol):
|
14
|
+
"""Protocol for objects that have children blocks."""
|
15
|
+
|
16
|
+
children: list[BlockCreateRequest]
|
17
|
+
|
18
|
+
|
19
|
+
class HasRichTextAndChildren(HasRichText, HasChildren, Protocol):
|
20
|
+
"""Protocol for objects that have both rich_text and children."""
|
21
|
+
|
22
|
+
pass
|
@@ -10,6 +10,7 @@ from notionary.blocks.heading.heading_models import (
|
|
10
10
|
CreateHeading3Block,
|
11
11
|
HeadingBlock,
|
12
12
|
)
|
13
|
+
from notionary.blocks.syntax_prompt_builder import BlockElementMarkdownInformation
|
13
14
|
from notionary.blocks.models import Block, BlockCreateResult, BlockType
|
14
15
|
from notionary.blocks.rich_text.text_inline_formatter import TextInlineFormatter
|
15
16
|
from notionary.blocks.types import BlockColor
|
@@ -33,7 +34,7 @@ class HeadingElement(BaseBlockElement):
|
|
33
34
|
)
|
34
35
|
|
35
36
|
@classmethod
|
36
|
-
def markdown_to_notion(cls, text: str) -> BlockCreateResult:
|
37
|
+
async def markdown_to_notion(cls, text: str) -> BlockCreateResult:
|
37
38
|
"""Convert markdown headings (#, ##, ###) to Notion HeadingBlock."""
|
38
39
|
match = cls.PATTERN.match(text.strip())
|
39
40
|
if not match:
|
@@ -47,7 +48,7 @@ class HeadingElement(BaseBlockElement):
|
|
47
48
|
if not content:
|
48
49
|
return None
|
49
50
|
|
50
|
-
rich_text = TextInlineFormatter.parse_inline_formatting(content)
|
51
|
+
rich_text = await TextInlineFormatter.parse_inline_formatting(content)
|
51
52
|
heading_content = HeadingBlock(
|
52
53
|
rich_text=rich_text, color=BlockColor.DEFAULT, is_toggleable=False
|
53
54
|
)
|
@@ -60,7 +61,7 @@ class HeadingElement(BaseBlockElement):
|
|
60
61
|
return CreateHeading3Block(heading_3=heading_content)
|
61
62
|
|
62
63
|
@classmethod
|
63
|
-
def notion_to_markdown(cls, block: Block) -> Optional[str]:
|
64
|
+
async def notion_to_markdown(cls, block: Block) -> Optional[str]:
|
64
65
|
# Only handle heading blocks via BlockType enum
|
65
66
|
if block.type not in (
|
66
67
|
BlockType.HEADING_1,
|
@@ -85,9 +86,27 @@ class HeadingElement(BaseBlockElement):
|
|
85
86
|
if not heading_data.rich_text:
|
86
87
|
return None
|
87
88
|
|
88
|
-
text = TextInlineFormatter.extract_text_with_formatting(
|
89
|
+
text = await TextInlineFormatter.extract_text_with_formatting(
|
90
|
+
heading_data.rich_text
|
91
|
+
)
|
89
92
|
if not text:
|
90
93
|
return None
|
91
94
|
|
92
95
|
# Use hash-style for all heading levels
|
93
96
|
return f"{('#' * level)} {text}"
|
97
|
+
|
98
|
+
@classmethod
|
99
|
+
def get_system_prompt_information(cls) -> Optional[BlockElementMarkdownInformation]:
|
100
|
+
"""Get system prompt information for heading blocks."""
|
101
|
+
return BlockElementMarkdownInformation(
|
102
|
+
block_type=cls.__name__,
|
103
|
+
description="Heading blocks create hierarchical document structure with different levels",
|
104
|
+
syntax_examples=[
|
105
|
+
"# Heading Level 1",
|
106
|
+
"## Heading Level 2",
|
107
|
+
"### Heading Level 3",
|
108
|
+
"# Heading with **bold text**",
|
109
|
+
"## Heading with *italic text*",
|
110
|
+
],
|
111
|
+
usage_guidelines="Use # for main titles, ## for sections, ### for subsections. Supports inline formatting. Only levels 1-3 are supported in Notion.",
|
112
|
+
)
|
@@ -6,60 +6,52 @@ from typing import Optional
|
|
6
6
|
from notionary.blocks.base_block_element import BaseBlockElement
|
7
7
|
from notionary.blocks.file.file_element_models import ExternalFile, FileType
|
8
8
|
from notionary.blocks.image_block.image_models import CreateImageBlock, FileBlock
|
9
|
+
from notionary.blocks.mixins.captions import CaptionMixin
|
10
|
+
from notionary.blocks.syntax_prompt_builder import BlockElementMarkdownInformation
|
9
11
|
from notionary.blocks.models import Block, BlockCreateResult, BlockType
|
10
|
-
from notionary.blocks.paragraph.paragraph_models import (
|
11
|
-
CreateParagraphBlock,
|
12
|
-
ParagraphBlock,
|
13
|
-
)
|
14
|
-
from notionary.blocks.rich_text.rich_text_models import RichTextObject
|
15
|
-
from notionary.blocks.rich_text.text_inline_formatter import TextInlineFormatter
|
16
12
|
|
17
13
|
|
18
|
-
class ImageElement(BaseBlockElement):
|
14
|
+
class ImageElement(BaseBlockElement, CaptionMixin):
|
19
15
|
"""
|
20
16
|
Handles conversion between Markdown images and Notion image blocks.
|
21
17
|
|
22
18
|
Markdown image syntax:
|
23
19
|
- [image](https://example.com/image.jpg) - URL only
|
24
|
-
- [image](https://example.com/image.jpg
|
20
|
+
- [image](https://example.com/image.jpg)(caption:This is a caption) - URL with caption
|
21
|
+
- (caption:Profile picture)[image](https://example.com/avatar.jpg) - caption before URL
|
25
22
|
"""
|
26
23
|
|
27
|
-
|
28
|
-
|
29
|
-
r"(https?://[^\s\"]+)" # URL (exclude whitespace and ")
|
30
|
-
r"(?:\s+\"([^\"]+)\")?" # optional caption
|
31
|
-
r"\)$"
|
32
|
-
)
|
24
|
+
# Flexible pattern that can handle caption in any position
|
25
|
+
IMAGE_PATTERN = re.compile(r"\[image\]\((https?://[^\s\"]+)\)")
|
33
26
|
|
34
27
|
@classmethod
|
35
28
|
def match_notion(cls, block: Block) -> bool:
|
36
29
|
return block.type == BlockType.IMAGE and block.image
|
37
30
|
|
38
31
|
@classmethod
|
39
|
-
def markdown_to_notion(cls, text: str) -> BlockCreateResult:
|
40
|
-
"""Convert markdown image syntax to Notion ImageBlock
|
41
|
-
|
42
|
-
|
32
|
+
async def markdown_to_notion(cls, text: str) -> BlockCreateResult:
|
33
|
+
"""Convert markdown image syntax to Notion ImageBlock."""
|
34
|
+
clean_text = cls.remove_caption(text.strip())
|
35
|
+
|
36
|
+
# Use our own regex to find the image URL
|
37
|
+
image_match = cls.IMAGE_PATTERN.search(clean_text)
|
38
|
+
if not image_match:
|
43
39
|
return None
|
44
40
|
|
45
|
-
url
|
41
|
+
url = image_match.group(1)
|
42
|
+
|
43
|
+
caption_text = cls.extract_caption(text.strip())
|
44
|
+
caption_rich_text = cls.build_caption_rich_text(caption_text or "")
|
45
|
+
|
46
46
|
# Build ImageBlock
|
47
47
|
image_block = FileBlock(
|
48
|
-
type="external", external=ExternalFile(url=url), caption=
|
48
|
+
type="external", external=ExternalFile(url=url), caption=caption_rich_text
|
49
49
|
)
|
50
|
-
if caption_text.strip():
|
51
|
-
rt = RichTextObject.from_plain_text(caption_text.strip())
|
52
|
-
image_block.caption = [rt]
|
53
|
-
|
54
|
-
empty_para = ParagraphBlock(rich_text=[])
|
55
50
|
|
56
|
-
return
|
57
|
-
CreateImageBlock(image=image_block),
|
58
|
-
CreateParagraphBlock(paragraph=empty_para),
|
59
|
-
]
|
51
|
+
return CreateImageBlock(image=image_block)
|
60
52
|
|
61
53
|
@classmethod
|
62
|
-
def notion_to_markdown(cls, block: Block) -> Optional[str]:
|
54
|
+
async def notion_to_markdown(cls, block: Block) -> Optional[str]:
|
63
55
|
if block.type != BlockType.IMAGE or not block.image:
|
64
56
|
return None
|
65
57
|
|
@@ -72,13 +64,26 @@ class ImageElement(BaseBlockElement):
|
|
72
64
|
else:
|
73
65
|
return None
|
74
66
|
|
75
|
-
|
76
|
-
if not captions:
|
77
|
-
return f"[image]({url})"
|
67
|
+
result = f"[image]({url})"
|
78
68
|
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
69
|
+
# Add caption if present
|
70
|
+
caption_markdown = await cls.format_caption_for_markdown(fo.caption or [])
|
71
|
+
if caption_markdown:
|
72
|
+
result += caption_markdown
|
73
|
+
|
74
|
+
return result
|
83
75
|
|
84
|
-
|
76
|
+
@classmethod
|
77
|
+
def get_system_prompt_information(cls) -> Optional[BlockElementMarkdownInformation]:
|
78
|
+
"""Get system prompt information for image blocks."""
|
79
|
+
return BlockElementMarkdownInformation(
|
80
|
+
block_type=cls.__name__,
|
81
|
+
description="Image blocks display images from external URLs with optional captions",
|
82
|
+
syntax_examples=[
|
83
|
+
"[image](https://example.com/photo.jpg)",
|
84
|
+
"[image](https://example.com/diagram.png)(caption:Architecture Diagram)",
|
85
|
+
"(caption:Sales Chart)[image](https://example.com/chart.svg)",
|
86
|
+
"[image](https://example.com/screenshot.png)(caption:Dashboard **overview**)",
|
87
|
+
],
|
88
|
+
usage_guidelines="Use for displaying images from external URLs. Supports common image formats (jpg, png, gif, svg, webp). Caption supports rich text formatting and describes the image content.",
|
89
|
+
)
|
@@ -5,6 +5,7 @@ from typing import Optional
|
|
5
5
|
from pydantic import BaseModel
|
6
6
|
|
7
7
|
from notionary.markdown.markdown_node import MarkdownNode
|
8
|
+
from notionary.blocks.mixins.captions import CaptionMarkdownNodeMixin
|
8
9
|
|
9
10
|
|
10
11
|
class ImageMarkdownBlockParams(BaseModel):
|
@@ -12,10 +13,9 @@ class ImageMarkdownBlockParams(BaseModel):
|
|
12
13
|
caption: Optional[str] = None
|
13
14
|
|
14
15
|
|
15
|
-
class ImageMarkdownNode(MarkdownNode):
|
16
|
+
class ImageMarkdownNode(MarkdownNode, CaptionMarkdownNodeMixin):
|
16
17
|
"""
|
17
18
|
Programmatic interface for creating Notion-style image blocks.
|
18
|
-
Example: [image](https://example.com/image.jpg "Optional caption")
|
19
19
|
"""
|
20
20
|
|
21
21
|
def __init__(
|
@@ -30,6 +30,11 @@ class ImageMarkdownNode(MarkdownNode):
|
|
30
30
|
return cls(url=params.url, caption=params.caption)
|
31
31
|
|
32
32
|
def to_markdown(self) -> str:
|
33
|
-
|
34
|
-
|
35
|
-
|
33
|
+
"""Return the Markdown representation.
|
34
|
+
|
35
|
+
Examples:
|
36
|
+
- [image](https://example.com/screenshot.png)
|
37
|
+
- [image](https://example.com/screenshot.png)(caption:Dashboard overview)
|
38
|
+
"""
|
39
|
+
base_markdown = f"[image]({self.url})"
|
40
|
+
return self.append_caption_to_markdown(base_markdown, self.caption)
|
@@ -0,0 +1,31 @@
|
|
1
|
+
from typing import Optional
|
2
|
+
|
3
|
+
|
4
|
+
class CaptionMarkdownNodeMixin:
|
5
|
+
"""Mixin to add caption functionality to MarkdownNode classes."""
|
6
|
+
|
7
|
+
@classmethod
|
8
|
+
def append_caption_to_markdown(
|
9
|
+
cls, base_markdown: str, caption: Optional[str]
|
10
|
+
) -> str:
|
11
|
+
"""
|
12
|
+
Append caption to existing markdown if caption is present.
|
13
|
+
Returns: base_markdown + "(caption:...)" or just base_markdown
|
14
|
+
"""
|
15
|
+
if not caption:
|
16
|
+
return base_markdown
|
17
|
+
return f"{base_markdown}(caption:{caption})"
|
18
|
+
|
19
|
+
@classmethod
|
20
|
+
def format_caption_for_markdown(cls, caption: Optional[str]) -> str:
|
21
|
+
"""
|
22
|
+
Format caption text for markdown output.
|
23
|
+
Returns: "(caption:...)" or empty string
|
24
|
+
"""
|
25
|
+
if not caption:
|
26
|
+
return ""
|
27
|
+
return f"(caption:{caption})"
|
28
|
+
|
29
|
+
def has_caption(self) -> bool:
|
30
|
+
"""Check if this node has a caption."""
|
31
|
+
return hasattr(self, "caption") and bool(getattr(self, "caption", None))
|
@@ -0,0 +1,92 @@
|
|
1
|
+
from typing import Optional
|
2
|
+
import re
|
3
|
+
from notionary.blocks.rich_text.rich_text_models import RichTextObject
|
4
|
+
from notionary.blocks.rich_text.text_inline_formatter import TextInlineFormatter
|
5
|
+
|
6
|
+
|
7
|
+
class CaptionMixin:
|
8
|
+
"""Mixin to add caption parsing functionality to block elements."""
|
9
|
+
|
10
|
+
# Generic caption pattern - finds caption anywhere in text
|
11
|
+
CAPTION_PATTERN = re.compile(r"\(caption:([^)]*)\)")
|
12
|
+
|
13
|
+
@classmethod
|
14
|
+
def extract_caption(cls, text: str) -> Optional[str]:
|
15
|
+
"""
|
16
|
+
Extract caption text from anywhere in the input text.
|
17
|
+
Returns only the caption content, preserving parentheses in content.
|
18
|
+
"""
|
19
|
+
# Look for (caption: followed by content followed by )
|
20
|
+
# Handle cases where caption content contains parentheses
|
21
|
+
caption_start = text.find("(caption:")
|
22
|
+
if caption_start == -1:
|
23
|
+
return None
|
24
|
+
|
25
|
+
# Find the matching closing parenthesis
|
26
|
+
# Start after "(caption:"
|
27
|
+
content_start = caption_start + 9 # len("(caption:")
|
28
|
+
paren_count = 1
|
29
|
+
pos = content_start
|
30
|
+
|
31
|
+
while pos < len(text) and paren_count > 0:
|
32
|
+
if text[pos] == "(":
|
33
|
+
paren_count += 1
|
34
|
+
elif text[pos] == ")":
|
35
|
+
paren_count -= 1
|
36
|
+
pos += 1
|
37
|
+
|
38
|
+
if paren_count == 0:
|
39
|
+
# Found matching closing parenthesis
|
40
|
+
return text[content_start : pos - 1]
|
41
|
+
|
42
|
+
return None
|
43
|
+
|
44
|
+
@classmethod
|
45
|
+
def remove_caption(cls, text: str) -> str:
|
46
|
+
"""
|
47
|
+
Remove caption from text and return clean text.
|
48
|
+
Uses the same balanced parentheses logic as extract_caption.
|
49
|
+
"""
|
50
|
+
caption_start = text.find("(caption:")
|
51
|
+
if caption_start == -1:
|
52
|
+
return text.strip()
|
53
|
+
|
54
|
+
# Find the matching closing parenthesis
|
55
|
+
content_start = caption_start + 9 # len("(caption:")
|
56
|
+
paren_count = 1
|
57
|
+
pos = content_start
|
58
|
+
|
59
|
+
while pos < len(text) and paren_count > 0:
|
60
|
+
if text[pos] == "(":
|
61
|
+
paren_count += 1
|
62
|
+
elif text[pos] == ")":
|
63
|
+
paren_count -= 1
|
64
|
+
pos += 1
|
65
|
+
|
66
|
+
if paren_count == 0:
|
67
|
+
# Remove the entire caption including the outer parentheses
|
68
|
+
return (text[:caption_start] + text[pos:]).strip()
|
69
|
+
|
70
|
+
# Fallback to regex-based removal if balanced parsing fails
|
71
|
+
return cls.CAPTION_PATTERN.sub("", text).strip()
|
72
|
+
|
73
|
+
@classmethod
|
74
|
+
def build_caption_rich_text(cls, caption_text: str) -> list[RichTextObject]:
|
75
|
+
"""Return caption as canonical rich text list (with annotations)."""
|
76
|
+
if not caption_text:
|
77
|
+
return []
|
78
|
+
# IMPORTANT: use the same formatter used elsewhere in the app
|
79
|
+
return [RichTextObject.for_caption(caption_text)]
|
80
|
+
|
81
|
+
@classmethod
|
82
|
+
async def format_caption_for_markdown(
|
83
|
+
cls, caption_list: list[RichTextObject]
|
84
|
+
) -> str:
|
85
|
+
"""Convert rich text caption back to markdown format."""
|
86
|
+
if not caption_list:
|
87
|
+
return ""
|
88
|
+
# Preserve markdown formatting (bold, italic, etc.)
|
89
|
+
caption_text = await TextInlineFormatter.extract_text_with_formatting(
|
90
|
+
caption_list
|
91
|
+
)
|
92
|
+
return f"(caption:{caption_text})" if caption_text else ""
|
notionary/blocks/models.py
CHANGED
@@ -48,6 +48,7 @@ if TYPE_CHECKING:
|
|
48
48
|
from notionary.blocks.todo import CreateToDoBlock, ToDoBlock
|
49
49
|
from notionary.blocks.toggle import CreateToggleBlock, ToggleBlock
|
50
50
|
from notionary.blocks.video import CreateVideoBlock
|
51
|
+
from notionary.blocks.child_database import ChildDatabaseBlock
|
51
52
|
|
52
53
|
|
53
54
|
class BlockChildrenResponse(BaseModel):
|
@@ -131,6 +132,7 @@ class Block(BaseModel):
|
|
131
132
|
video: Optional[FileBlock] = None
|
132
133
|
pdf: Optional[FileBlock] = None
|
133
134
|
table_of_contents: Optional[TableOfContentsBlock] = None
|
135
|
+
child_database: Optional[ChildDatabaseBlock] = None
|
134
136
|
|
135
137
|
def get_block_content(self) -> Optional[Any]:
|
136
138
|
"""Get the content object for this block based on its type."""
|
@@ -165,7 +167,7 @@ if TYPE_CHECKING:
|
|
165
167
|
CreatePdfBlock,
|
166
168
|
CreateTableBlock,
|
167
169
|
]
|
168
|
-
BlockCreateResult =
|
170
|
+
BlockCreateResult = Union[BlockCreateRequest]
|
169
171
|
else:
|
170
172
|
# at runtime there are no typings anyway
|
171
173
|
BlockCreateRequest = Any
|
@@ -4,6 +4,7 @@ import re
|
|
4
4
|
from typing import Optional
|
5
5
|
|
6
6
|
from notionary.blocks.base_block_element import BaseBlockElement
|
7
|
+
from notionary.blocks.syntax_prompt_builder import BlockElementMarkdownInformation
|
7
8
|
from notionary.blocks.models import Block, BlockCreateResult, BlockType
|
8
9
|
from notionary.blocks.numbered_list.numbered_list_models import (
|
9
10
|
CreateNumberedListItemBlock,
|
@@ -23,14 +24,14 @@ class NumberedListElement(BaseBlockElement):
|
|
23
24
|
return block.type == BlockType.NUMBERED_LIST_ITEM and block.numbered_list_item
|
24
25
|
|
25
26
|
@classmethod
|
26
|
-
def markdown_to_notion(cls, text: str) -> BlockCreateResult:
|
27
|
+
async def markdown_to_notion(cls, text: str) -> BlockCreateResult:
|
27
28
|
"""Convert markdown numbered list item to Notion NumberedListItemBlock."""
|
28
29
|
match = cls.PATTERN.match(text.strip())
|
29
30
|
if not match:
|
30
31
|
return None
|
31
32
|
|
32
33
|
content = match.group(2)
|
33
|
-
rich_text = TextInlineFormatter.parse_inline_formatting(content)
|
34
|
+
rich_text = await TextInlineFormatter.parse_inline_formatting(content)
|
34
35
|
|
35
36
|
numbered_list_content = NumberedListItemBlock(
|
36
37
|
rich_text=rich_text, color=BlockColor.DEFAULT
|
@@ -39,10 +40,26 @@ class NumberedListElement(BaseBlockElement):
|
|
39
40
|
|
40
41
|
# FIX: Roundtrip conversions will never work this way here
|
41
42
|
@classmethod
|
42
|
-
def notion_to_markdown(cls, block: Block) -> Optional[str]:
|
43
|
+
async def notion_to_markdown(cls, block: Block) -> Optional[str]:
|
43
44
|
if block.type != BlockType.NUMBERED_LIST_ITEM or not block.numbered_list_item:
|
44
45
|
return None
|
45
46
|
|
46
47
|
rich = block.numbered_list_item.rich_text
|
47
|
-
content = TextInlineFormatter.extract_text_with_formatting(rich)
|
48
|
+
content = await TextInlineFormatter.extract_text_with_formatting(rich)
|
48
49
|
return f"1. {content}"
|
50
|
+
|
51
|
+
@classmethod
|
52
|
+
def get_system_prompt_information(cls) -> Optional[BlockElementMarkdownInformation]:
|
53
|
+
"""Get system prompt information for numbered list blocks."""
|
54
|
+
return BlockElementMarkdownInformation(
|
55
|
+
block_type=cls.__name__,
|
56
|
+
description="Numbered list items create ordered lists with sequential numbering",
|
57
|
+
syntax_examples=[
|
58
|
+
"1. First item",
|
59
|
+
"2. Second item",
|
60
|
+
"3. Third item",
|
61
|
+
"1. Item with **bold text**",
|
62
|
+
"1. Item with *italic text*",
|
63
|
+
],
|
64
|
+
usage_guidelines="Use numbers followed by periods to create ordered lists. Supports inline formatting like bold, italic, and links. Numbering is automatically handled by Notion.",
|
65
|
+
)
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
3
3
|
from typing import Optional
|
4
4
|
|
5
5
|
from notionary.blocks.base_block_element import BaseBlockElement
|
6
|
+
from notionary.blocks.syntax_prompt_builder import BlockElementMarkdownInformation
|
6
7
|
from notionary.blocks.models import Block, BlockCreateResult
|
7
8
|
from notionary.blocks.paragraph.paragraph_models import (
|
8
9
|
CreateParagraphBlock,
|
@@ -22,21 +23,36 @@ class ParagraphElement(BaseBlockElement):
|
|
22
23
|
return block.type == "paragraph" and block.paragraph
|
23
24
|
|
24
25
|
@classmethod
|
25
|
-
def markdown_to_notion(cls, text: str) -> BlockCreateResult:
|
26
|
+
async def markdown_to_notion(cls, text: str) -> BlockCreateResult:
|
26
27
|
"""Convert markdown text to a Notion ParagraphBlock."""
|
27
28
|
if not text.strip():
|
28
29
|
return None
|
29
30
|
|
30
|
-
rich = TextInlineFormatter.parse_inline_formatting(text)
|
31
|
+
rich = await TextInlineFormatter.parse_inline_formatting(text)
|
31
32
|
|
32
33
|
paragraph_content = ParagraphBlock(rich_text=rich, color=BlockColor.DEFAULT)
|
33
34
|
return CreateParagraphBlock(paragraph=paragraph_content)
|
34
35
|
|
35
36
|
@classmethod
|
36
|
-
def notion_to_markdown(cls, block: Block) -> Optional[str]:
|
37
|
-
if block.type !=
|
37
|
+
async def notion_to_markdown(cls, block: Block) -> Optional[str]:
|
38
|
+
if block.type != BlockType.PARAGRAPH or not block.paragraph:
|
38
39
|
return None
|
39
40
|
|
40
41
|
rich_list = block.paragraph.rich_text
|
41
|
-
markdown = TextInlineFormatter.extract_text_with_formatting(rich_list)
|
42
|
+
markdown = await TextInlineFormatter.extract_text_with_formatting(rich_list)
|
42
43
|
return markdown or None
|
44
|
+
|
45
|
+
@classmethod
|
46
|
+
def get_system_prompt_information(cls) -> Optional[BlockElementMarkdownInformation]:
|
47
|
+
"""Get system prompt information for paragraph blocks."""
|
48
|
+
return BlockElementMarkdownInformation(
|
49
|
+
block_type=cls.__name__,
|
50
|
+
description="Paragraph blocks contain regular text content with optional inline formatting",
|
51
|
+
syntax_examples=[
|
52
|
+
"This is a simple paragraph.",
|
53
|
+
"Paragraph with **bold text** and *italic text*.",
|
54
|
+
"Paragraph with [link](https://example.com) and `code`.",
|
55
|
+
"Multiple sentences in one paragraph. Each sentence flows naturally.",
|
56
|
+
],
|
57
|
+
usage_guidelines="Use for regular text content. Supports inline formatting: **bold**, *italic*, `code`, [links](url). Default block type for plain text.",
|
58
|
+
)
|