notionary 0.2.21__py3-none-any.whl → 0.2.23__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 (100) hide show
  1. notionary/blocks/_bootstrap.py +9 -1
  2. notionary/blocks/audio/audio_element.py +53 -28
  3. notionary/blocks/audio/audio_markdown_node.py +10 -4
  4. notionary/blocks/base_block_element.py +15 -3
  5. notionary/blocks/bookmark/bookmark_element.py +39 -36
  6. notionary/blocks/bookmark/bookmark_markdown_node.py +16 -17
  7. notionary/blocks/breadcrumbs/breadcrumb_element.py +2 -2
  8. notionary/blocks/bulleted_list/bulleted_list_element.py +21 -4
  9. notionary/blocks/callout/callout_element.py +20 -4
  10. notionary/blocks/child_database/__init__.py +11 -4
  11. notionary/blocks/child_database/child_database_element.py +59 -0
  12. notionary/blocks/child_database/child_database_models.py +7 -14
  13. notionary/blocks/child_page/child_page_element.py +94 -0
  14. notionary/blocks/client.py +0 -1
  15. notionary/blocks/code/code_element.py +51 -2
  16. notionary/blocks/code/code_markdown_node.py +52 -1
  17. notionary/blocks/column/column_element.py +9 -3
  18. notionary/blocks/column/column_list_element.py +18 -3
  19. notionary/blocks/divider/divider_element.py +3 -11
  20. notionary/blocks/embed/embed_element.py +27 -6
  21. notionary/blocks/equation/equation_element.py +94 -41
  22. notionary/blocks/equation/equation_element_markdown_node.py +8 -9
  23. notionary/blocks/file/file_element.py +56 -37
  24. notionary/blocks/file/file_element_markdown_node.py +9 -7
  25. notionary/blocks/guards.py +22 -0
  26. notionary/blocks/heading/heading_element.py +23 -4
  27. notionary/blocks/image_block/image_element.py +43 -38
  28. notionary/blocks/image_block/image_markdown_node.py +10 -5
  29. notionary/blocks/mixins/captions/__init__.py +4 -0
  30. notionary/blocks/mixins/captions/caption_markdown_node_mixin.py +31 -0
  31. notionary/blocks/mixins/captions/caption_mixin.py +92 -0
  32. notionary/blocks/models.py +3 -1
  33. notionary/blocks/numbered_list/numbered_list_element.py +21 -4
  34. notionary/blocks/paragraph/paragraph_element.py +21 -5
  35. notionary/blocks/pdf/pdf_element.py +47 -41
  36. notionary/blocks/pdf/pdf_markdown_node.py +9 -7
  37. notionary/blocks/quote/quote_element.py +26 -9
  38. notionary/blocks/quote/quote_markdown_node.py +2 -2
  39. notionary/blocks/registry/block_registry.py +1 -46
  40. notionary/blocks/registry/block_registry_builder.py +8 -0
  41. notionary/blocks/rich_text/rich_text_models.py +62 -29
  42. notionary/blocks/rich_text/text_inline_formatter.py +432 -101
  43. notionary/blocks/syntax_prompt_builder.py +137 -0
  44. notionary/blocks/table/table_element.py +110 -9
  45. notionary/blocks/table_of_contents/table_of_contents_element.py +19 -2
  46. notionary/blocks/todo/todo_element.py +21 -4
  47. notionary/blocks/toggle/toggle_element.py +19 -3
  48. notionary/blocks/toggle/toggle_markdown_node.py +1 -1
  49. notionary/blocks/toggleable_heading/toggleable_heading_element.py +19 -4
  50. notionary/blocks/types.py +69 -0
  51. notionary/blocks/video/video_element.py +44 -39
  52. notionary/blocks/video/video_markdown_node.py +10 -5
  53. notionary/comments/__init__.py +26 -0
  54. notionary/comments/client.py +211 -0
  55. notionary/comments/models.py +129 -0
  56. notionary/database/client.py +23 -0
  57. notionary/file_upload/models.py +2 -2
  58. notionary/markdown/markdown_builder.py +34 -27
  59. notionary/page/client.py +21 -6
  60. notionary/page/notion_page.py +77 -2
  61. notionary/page/page_content_deleting_service.py +117 -0
  62. notionary/page/page_content_writer.py +89 -113
  63. notionary/page/page_context.py +64 -0
  64. notionary/page/reader/handler/__init__.py +2 -0
  65. notionary/page/reader/handler/base_block_renderer.py +4 -4
  66. notionary/page/reader/handler/block_rendering_context.py +5 -0
  67. notionary/page/reader/handler/line_renderer.py +16 -3
  68. notionary/page/reader/handler/numbered_list_renderer.py +85 -0
  69. notionary/page/reader/page_content_retriever.py +17 -5
  70. notionary/page/writer/handler/__init__.py +2 -0
  71. notionary/page/writer/handler/code_handler.py +12 -40
  72. notionary/page/writer/handler/column_handler.py +12 -12
  73. notionary/page/writer/handler/column_list_handler.py +13 -13
  74. notionary/page/writer/handler/equation_handler.py +74 -0
  75. notionary/page/writer/handler/line_handler.py +4 -4
  76. notionary/page/writer/handler/regular_line_handler.py +31 -37
  77. notionary/page/writer/handler/table_handler.py +8 -72
  78. notionary/page/writer/handler/toggle_handler.py +14 -12
  79. notionary/page/writer/handler/toggleable_heading_handler.py +22 -16
  80. notionary/page/writer/markdown_to_notion_converter.py +28 -9
  81. notionary/page/writer/markdown_to_notion_converter_context.py +30 -0
  82. notionary/page/writer/markdown_to_notion_formatting_post_processor.py +73 -0
  83. notionary/page/writer/markdown_to_notion_post_processor.py +0 -0
  84. notionary/page/writer/markdown_to_notion_text_length_post_processor.py +0 -0
  85. notionary/page/writer/notion_text_length_processor.py +150 -0
  86. notionary/shared/__init__.py +5 -0
  87. notionary/shared/name_to_id_resolver.py +203 -0
  88. notionary/telemetry/service.py +0 -1
  89. notionary/user/notion_user_manager.py +22 -95
  90. notionary/util/concurrency_limiter.py +0 -0
  91. notionary/workspace.py +4 -4
  92. notionary-0.2.23.dist-info/METADATA +235 -0
  93. {notionary-0.2.21.dist-info → notionary-0.2.23.dist-info}/RECORD +96 -77
  94. notionary/page/markdown_whitespace_processor.py +0 -80
  95. notionary/page/notion_text_length_utils.py +0 -119
  96. notionary/user/notion_user_provider.py +0 -1
  97. notionary-0.2.21.dist-info/METADATA +0 -229
  98. /notionary/page/reader/handler/{context.py → equation_renderer.py} +0 -0
  99. {notionary-0.2.21.dist-info → notionary-0.2.23.dist-info}/LICENSE +0 -0
  100. {notionary-0.2.21.dist-info → notionary-0.2.23.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 "Caption")
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
- PATTERN = re.compile(
34
- r"^\[file\]\(" # prefix
35
- r'(https?://[^\s\)"]+)' # URL
36
- r'(?:\s+"([^"]*)")?' # optional caption
37
- r"\)$"
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 followed by an empty paragraph."""
48
- match = cls.PATTERN.match(text.strip())
49
- if not match:
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
- url, caption_text = match.group(1), match.group(2) or ""
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, external=ExternalFile(url=url), caption=[]
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
- empty_para = ParagraphBlock(rich_text=[])
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
- if not fb.caption:
88
- return f"[file]({url})"
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
- caption_md = TextInlineFormatter.extract_text_with_formatting(fb.caption)
91
- if caption_md:
92
- return f'[file]({url} "{caption_md}")'
93
- return f"[file]({url})"
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
- Convert to markdown as [file](url "caption") or [file](url) if caption is empty.
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(heading_data.rich_text)
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 "Caption") - URL + caption
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
- PATTERN = re.compile(
28
- r"^\[image\]\(" # prefix
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 followed by an empty paragraph."""
41
- match = cls.PATTERN.match(text.strip())
42
- if not match:
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, caption_text = match.group(1), match.group(2) or ""
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
- captions = fo.caption or []
76
- if not captions:
77
- return f"[image]({url})"
67
+ result = f"[image]({url})"
78
68
 
79
- caption_text = "".join(
80
- (rt.plain_text or TextInlineFormatter.extract_text_with_formatting([rt]))
81
- for rt in captions
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
- return f'[image]({url} "{caption_text}")'
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
- if self.caption:
34
- return f'[image]({self.url} "{self.caption}")'
35
- return f"[image]({self.url})"
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,4 @@
1
+ from .caption_mixin import CaptionMixin
2
+ from .caption_markdown_node_mixin import CaptionMarkdownNodeMixin
3
+
4
+ __all__ = ["CaptionMixin", "CaptionMarkdownNodeMixin"]
@@ -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 ""
@@ -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 = Optional[Union[list[BlockCreateRequest], BlockCreateRequest]]
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 != "paragraph" or not block.paragraph:
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
+ )