notionary 0.2.19__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.
Files changed (220) hide show
  1. notionary/__init__.py +8 -4
  2. notionary/base_notion_client.py +3 -1
  3. notionary/blocks/__init__.py +2 -91
  4. notionary/blocks/_bootstrap.py +271 -0
  5. notionary/blocks/audio/__init__.py +8 -2
  6. notionary/blocks/audio/audio_element.py +69 -106
  7. notionary/blocks/audio/audio_markdown_node.py +13 -5
  8. notionary/blocks/audio/audio_models.py +6 -55
  9. notionary/blocks/base_block_element.py +42 -0
  10. notionary/blocks/bookmark/__init__.py +9 -2
  11. notionary/blocks/bookmark/bookmark_element.py +49 -139
  12. notionary/blocks/bookmark/bookmark_markdown_node.py +19 -18
  13. notionary/blocks/bookmark/bookmark_models.py +15 -0
  14. notionary/blocks/breadcrumbs/__init__.py +17 -0
  15. notionary/blocks/breadcrumbs/breadcrumb_element.py +39 -0
  16. notionary/blocks/breadcrumbs/breadcrumb_markdown_node.py +32 -0
  17. notionary/blocks/breadcrumbs/breadcrumb_models.py +12 -0
  18. notionary/blocks/bulleted_list/__init__.py +12 -2
  19. notionary/blocks/bulleted_list/bulleted_list_element.py +55 -53
  20. notionary/blocks/bulleted_list/bulleted_list_markdown_node.py +2 -1
  21. notionary/blocks/bulleted_list/bulleted_list_models.py +18 -0
  22. notionary/blocks/callout/__init__.py +9 -2
  23. notionary/blocks/callout/callout_element.py +53 -86
  24. notionary/blocks/callout/callout_markdown_node.py +3 -1
  25. notionary/blocks/callout/callout_models.py +33 -0
  26. notionary/blocks/child_database/__init__.py +14 -0
  27. notionary/blocks/child_database/child_database_element.py +61 -0
  28. notionary/blocks/child_database/child_database_models.py +12 -0
  29. notionary/blocks/child_page/__init__.py +9 -0
  30. notionary/blocks/child_page/child_page_element.py +94 -0
  31. notionary/blocks/child_page/child_page_models.py +12 -0
  32. notionary/blocks/{shared/block_client.py → client.py} +54 -54
  33. notionary/blocks/code/__init__.py +6 -2
  34. notionary/blocks/code/code_element.py +96 -181
  35. notionary/blocks/code/code_markdown_node.py +64 -13
  36. notionary/blocks/code/code_models.py +94 -0
  37. notionary/blocks/column/__init__.py +25 -1
  38. notionary/blocks/column/column_element.py +44 -312
  39. notionary/blocks/column/column_list_element.py +52 -0
  40. notionary/blocks/column/column_list_markdown_node.py +50 -0
  41. notionary/blocks/column/column_markdown_node.py +59 -0
  42. notionary/blocks/column/column_models.py +26 -0
  43. notionary/blocks/divider/__init__.py +9 -2
  44. notionary/blocks/divider/divider_element.py +18 -49
  45. notionary/blocks/divider/divider_markdown_node.py +2 -1
  46. notionary/blocks/divider/divider_models.py +12 -0
  47. notionary/blocks/embed/__init__.py +9 -2
  48. notionary/blocks/embed/embed_element.py +65 -111
  49. notionary/blocks/embed/embed_markdown_node.py +3 -1
  50. notionary/blocks/embed/embed_models.py +14 -0
  51. notionary/blocks/equation/__init__.py +14 -0
  52. notionary/blocks/equation/equation_element.py +133 -0
  53. notionary/blocks/equation/equation_element_markdown_node.py +35 -0
  54. notionary/blocks/equation/equation_models.py +11 -0
  55. notionary/blocks/file/__init__.py +25 -0
  56. notionary/blocks/file/file_element.py +112 -0
  57. notionary/blocks/file/file_element_markdown_node.py +37 -0
  58. notionary/blocks/file/file_element_models.py +39 -0
  59. notionary/blocks/guards.py +22 -0
  60. notionary/blocks/heading/__init__.py +16 -2
  61. notionary/blocks/heading/heading_element.py +83 -69
  62. notionary/blocks/heading/heading_markdown_node.py +2 -1
  63. notionary/blocks/heading/heading_models.py +29 -0
  64. notionary/blocks/image_block/__init__.py +13 -0
  65. notionary/blocks/image_block/image_element.py +89 -0
  66. notionary/blocks/{image → image_block}/image_markdown_node.py +13 -6
  67. notionary/blocks/image_block/image_models.py +10 -0
  68. notionary/blocks/mixins/captions/__init__.py +4 -0
  69. notionary/blocks/mixins/captions/caption_markdown_node_mixin.py +31 -0
  70. notionary/blocks/mixins/captions/caption_mixin.py +92 -0
  71. notionary/blocks/models.py +174 -0
  72. notionary/blocks/numbered_list/__init__.py +12 -2
  73. notionary/blocks/numbered_list/numbered_list_element.py +48 -56
  74. notionary/blocks/numbered_list/numbered_list_markdown_node.py +3 -1
  75. notionary/blocks/numbered_list/numbered_list_models.py +17 -0
  76. notionary/blocks/paragraph/__init__.py +12 -2
  77. notionary/blocks/paragraph/paragraph_element.py +40 -66
  78. notionary/blocks/paragraph/paragraph_markdown_node.py +2 -1
  79. notionary/blocks/paragraph/paragraph_models.py +16 -0
  80. notionary/blocks/pdf/__init__.py +13 -0
  81. notionary/blocks/pdf/pdf_element.py +97 -0
  82. notionary/blocks/pdf/pdf_markdown_node.py +37 -0
  83. notionary/blocks/pdf/pdf_models.py +11 -0
  84. notionary/blocks/quote/__init__.py +11 -2
  85. notionary/blocks/quote/quote_element.py +45 -62
  86. notionary/blocks/quote/quote_markdown_node.py +6 -3
  87. notionary/blocks/quote/quote_models.py +18 -0
  88. notionary/blocks/registry/__init__.py +4 -0
  89. notionary/blocks/registry/block_registry.py +60 -121
  90. notionary/blocks/registry/block_registry_builder.py +115 -59
  91. notionary/blocks/rich_text/__init__.py +33 -0
  92. notionary/blocks/rich_text/name_to_id_resolver.py +205 -0
  93. notionary/blocks/rich_text/rich_text_models.py +221 -0
  94. notionary/blocks/rich_text/text_inline_formatter.py +456 -0
  95. notionary/blocks/syntax_prompt_builder.py +137 -0
  96. notionary/blocks/table/__init__.py +16 -2
  97. notionary/blocks/table/table_element.py +136 -228
  98. notionary/blocks/table/table_markdown_node.py +2 -1
  99. notionary/blocks/table/table_models.py +28 -0
  100. notionary/blocks/table_of_contents/__init__.py +19 -0
  101. notionary/blocks/table_of_contents/table_of_contents_element.py +68 -0
  102. notionary/blocks/table_of_contents/table_of_contents_markdown_node.py +35 -0
  103. notionary/blocks/table_of_contents/table_of_contents_models.py +18 -0
  104. notionary/blocks/todo/__init__.py +9 -2
  105. notionary/blocks/todo/todo_element.py +52 -92
  106. notionary/blocks/todo/todo_markdown_node.py +2 -1
  107. notionary/blocks/todo/todo_models.py +19 -0
  108. notionary/blocks/toggle/__init__.py +13 -3
  109. notionary/blocks/toggle/toggle_element.py +69 -260
  110. notionary/blocks/toggle/toggle_markdown_node.py +25 -15
  111. notionary/blocks/toggle/toggle_models.py +17 -0
  112. notionary/blocks/toggleable_heading/__init__.py +6 -2
  113. notionary/blocks/toggleable_heading/toggleable_heading_element.py +86 -241
  114. notionary/blocks/toggleable_heading/toggleable_heading_markdown_node.py +26 -18
  115. notionary/blocks/types.py +130 -0
  116. notionary/blocks/video/__init__.py +8 -2
  117. notionary/blocks/video/video_element.py +70 -141
  118. notionary/blocks/video/video_element_models.py +10 -0
  119. notionary/blocks/video/video_markdown_node.py +13 -6
  120. notionary/database/client.py +26 -8
  121. notionary/database/database.py +13 -14
  122. notionary/database/database_filter_builder.py +2 -2
  123. notionary/database/database_provider.py +5 -4
  124. notionary/database/models.py +337 -0
  125. notionary/database/notion_database.py +6 -7
  126. notionary/file_upload/client.py +5 -7
  127. notionary/file_upload/models.py +3 -2
  128. notionary/file_upload/notion_file_upload.py +2 -3
  129. notionary/markdown/markdown_builder.py +729 -0
  130. notionary/markdown/markdown_document_model.py +228 -0
  131. notionary/{blocks → markdown}/markdown_node.py +1 -0
  132. notionary/models/notion_database_response.py +0 -338
  133. notionary/page/client.py +34 -15
  134. notionary/page/models.py +327 -0
  135. notionary/page/notion_page.py +136 -58
  136. notionary/page/{content/page_content_writer.py → page_content_deleting_service.py} +25 -59
  137. notionary/page/page_content_writer.py +177 -0
  138. notionary/page/page_context.py +65 -0
  139. notionary/page/reader/handler/__init__.py +19 -0
  140. notionary/page/reader/handler/base_block_renderer.py +44 -0
  141. notionary/page/reader/handler/block_processing_context.py +35 -0
  142. notionary/page/reader/handler/block_rendering_context.py +48 -0
  143. notionary/page/reader/handler/column_list_renderer.py +51 -0
  144. notionary/page/reader/handler/column_renderer.py +60 -0
  145. notionary/page/reader/handler/line_renderer.py +73 -0
  146. notionary/page/reader/handler/numbered_list_renderer.py +85 -0
  147. notionary/page/reader/handler/toggle_renderer.py +69 -0
  148. notionary/page/reader/handler/toggleable_heading_renderer.py +89 -0
  149. notionary/page/reader/page_content_retriever.py +81 -0
  150. notionary/page/search_filter_builder.py +2 -1
  151. notionary/page/writer/handler/__init__.py +24 -0
  152. notionary/page/writer/handler/code_handler.py +72 -0
  153. notionary/page/writer/handler/column_handler.py +141 -0
  154. notionary/page/writer/handler/column_list_handler.py +139 -0
  155. notionary/page/writer/handler/equation_handler.py +74 -0
  156. notionary/page/writer/handler/line_handler.py +35 -0
  157. notionary/page/writer/handler/line_processing_context.py +54 -0
  158. notionary/page/writer/handler/regular_line_handler.py +86 -0
  159. notionary/page/writer/handler/table_handler.py +66 -0
  160. notionary/page/writer/handler/toggle_handler.py +155 -0
  161. notionary/page/writer/handler/toggleable_heading_handler.py +173 -0
  162. notionary/page/writer/markdown_to_notion_converter.py +95 -0
  163. notionary/page/writer/markdown_to_notion_converter_context.py +30 -0
  164. notionary/page/writer/markdown_to_notion_formatting_post_processor.py +73 -0
  165. notionary/page/writer/notion_text_length_processor.py +150 -0
  166. notionary/telemetry/__init__.py +2 -2
  167. notionary/telemetry/service.py +3 -3
  168. notionary/user/__init__.py +2 -2
  169. notionary/user/base_notion_user.py +2 -1
  170. notionary/user/client.py +2 -3
  171. notionary/user/models.py +1 -0
  172. notionary/user/notion_bot_user.py +4 -5
  173. notionary/user/notion_user.py +3 -4
  174. notionary/user/notion_user_manager.py +23 -95
  175. notionary/util/__init__.py +3 -2
  176. notionary/util/fuzzy.py +2 -1
  177. notionary/util/logging_mixin.py +2 -2
  178. notionary/util/singleton_metaclass.py +1 -1
  179. notionary/workspace.py +6 -5
  180. notionary-0.2.22.dist-info/METADATA +237 -0
  181. notionary-0.2.22.dist-info/RECORD +200 -0
  182. notionary/blocks/document/__init__.py +0 -7
  183. notionary/blocks/document/document_element.py +0 -102
  184. notionary/blocks/document/document_markdown_node.py +0 -31
  185. notionary/blocks/image/__init__.py +0 -7
  186. notionary/blocks/image/image_element.py +0 -151
  187. notionary/blocks/markdown_builder.py +0 -356
  188. notionary/blocks/mention/__init__.py +0 -7
  189. notionary/blocks/mention/mention_element.py +0 -229
  190. notionary/blocks/mention/mention_markdown_node.py +0 -38
  191. notionary/blocks/prompts/element_prompt_builder.py +0 -83
  192. notionary/blocks/prompts/element_prompt_content.py +0 -41
  193. notionary/blocks/shared/models.py +0 -713
  194. notionary/blocks/shared/notion_block_element.py +0 -37
  195. notionary/blocks/shared/text_inline_formatter.py +0 -262
  196. notionary/blocks/shared/text_inline_formatter_new.py +0 -139
  197. notionary/database/models/page_result.py +0 -10
  198. notionary/models/notion_block_response.py +0 -264
  199. notionary/models/notion_page_response.py +0 -78
  200. notionary/models/search_response.py +0 -0
  201. notionary/page/__init__.py +0 -0
  202. notionary/page/content/markdown_whitespace_processor.py +0 -80
  203. notionary/page/content/notion_text_length_utils.py +0 -87
  204. notionary/page/content/page_content_retriever.py +0 -60
  205. notionary/page/formatting/line_processor.py +0 -153
  206. notionary/page/formatting/markdown_to_notion_converter.py +0 -153
  207. notionary/page/markdown_syntax_prompt_generator.py +0 -114
  208. notionary/page/notion_to_markdown_converter.py +0 -179
  209. notionary/page/properites/property_value_extractor.py +0 -0
  210. notionary/user/notion_user_provider.py +0 -1
  211. notionary-0.2.19.dist-info/METADATA +0 -225
  212. notionary-0.2.19.dist-info/RECORD +0 -150
  213. /notionary/{blocks/document/document_models.py → markdown/___init__.py} +0 -0
  214. /notionary/{blocks/image/image_models.py → markdown/makdown_document_model.py} +0 -0
  215. /notionary/{blocks/mention/mention_models.py → page/reader/handler/equation_renderer.py} +0 -0
  216. /notionary/{blocks/shared/__init__.py → page/writer/markdown_to_notion_post_processor.py} +0 -0
  217. /notionary/{blocks/toggleable_heading/toggleable_heading_models.py → page/writer/markdown_to_notion_text_length_post_processor.py} +0 -0
  218. /notionary/{elements/__init__.py → util/concurrency_limiter.py} +0 -0
  219. {notionary-0.2.19.dist-info → notionary-0.2.22.dist-info}/LICENSE +0 -0
  220. {notionary-0.2.19.dist-info → notionary-0.2.22.dist-info}/WHEEL +0 -0
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Optional
5
+
6
+ from notionary.blocks.base_block_element import BaseBlockElement
7
+ from notionary.blocks.file.file_element_models import (
8
+ CreateFileBlock,
9
+ ExternalFile,
10
+ FileBlock,
11
+ FileType,
12
+ )
13
+ from notionary.blocks.mixins.captions import CaptionMixin
14
+ from notionary.blocks.syntax_prompt_builder import BlockElementMarkdownInformation
15
+ from notionary.blocks.models import Block, BlockCreateResult, BlockType
16
+
17
+
18
+ class FileElement(BaseBlockElement, CaptionMixin):
19
+ """
20
+ Handles conversion between Markdown file embeds and Notion file blocks.
21
+
22
+ Markdown file syntax:
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
26
+
27
+ Supports external file URLs with optional captions.
28
+ """
29
+
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
45
+
46
+ @classmethod
47
+ def match_notion(cls, block: Block) -> bool:
48
+ # Notion file block covers files
49
+ return block.type == BlockType.FILE and block.file
50
+
51
+ @classmethod
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:
57
+ return None
58
+
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 "")
62
+
63
+ # Build FileBlock using FileType enum
64
+ file_block = FileBlock(
65
+ type=FileType.EXTERNAL,
66
+ external=ExternalFile(url=url),
67
+ caption=caption_rich_text,
68
+ )
69
+
70
+ return CreateFileBlock(file=file_block)
71
+
72
+ @classmethod
73
+ async def notion_to_markdown(cls, block: Block) -> Optional[str]:
74
+ if block.type != BlockType.FILE or not block.file:
75
+ return None
76
+
77
+ fb: FileBlock = block.file
78
+
79
+ # Determine URL (only external and file types are valid for Markdown)
80
+ if fb.type == FileType.EXTERNAL and fb.external:
81
+ url = fb.external.url
82
+ elif fb.type == FileType.FILE and fb.file:
83
+ url = fb.file.url
84
+ elif fb.type == FileType.FILE_UPLOAD:
85
+ # Uploaded file has no stable URL for Markdown
86
+ return None
87
+ else:
88
+ return None
89
+
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
98
+
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
+ )
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from notionary.markdown.markdown_node import MarkdownNode
8
+ from notionary.blocks.mixins.captions import CaptionMarkdownNodeMixin
9
+
10
+
11
+ class FileMarkdownNodeParams(BaseModel):
12
+ url: str
13
+ caption: Optional[str] = None
14
+
15
+
16
+ class FileMarkdownNode(MarkdownNode, CaptionMarkdownNodeMixin):
17
+ """
18
+ Programmatic interface for creating Notion-style Markdown file embeds.
19
+ """
20
+
21
+ def __init__(self, url: str, caption: Optional[str] = None):
22
+ self.url = url
23
+ self.caption = caption or ""
24
+
25
+ @classmethod
26
+ def from_params(cls, params: FileMarkdownNodeParams) -> FileMarkdownNode:
27
+ return cls(url=params.url, caption=params.caption)
28
+
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)
35
+ """
36
+ base_markdown = f"[file]({self.url})"
37
+ return self.append_caption_to_markdown(base_markdown, self.caption)
@@ -0,0 +1,39 @@
1
+ from enum import Enum
2
+ from typing import Literal, Optional
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ from notionary.blocks.rich_text.rich_text_models import RichTextObject
7
+
8
+
9
+ class FileType(str, Enum):
10
+ EXTERNAL = "external"
11
+ FILE = "file"
12
+ FILE_UPLOAD = "file_upload"
13
+
14
+
15
+ class ExternalFile(BaseModel):
16
+ url: str
17
+
18
+
19
+ class NotionHostedFile(BaseModel):
20
+ url: str
21
+ expiry_time: str
22
+
23
+
24
+ class FileUploadFile(BaseModel):
25
+ id: str
26
+
27
+
28
+ class FileBlock(BaseModel):
29
+ caption: list[RichTextObject] = Field(default_factory=list)
30
+ type: FileType
31
+ external: Optional[ExternalFile] = None
32
+ file: Optional[NotionHostedFile] = None
33
+ file_upload: Optional[FileUploadFile] = None
34
+ name: Optional[str] = None
35
+
36
+
37
+ class CreateFileBlock(BaseModel):
38
+ type: Literal["file"] = "file"
39
+ file: FileBlock
@@ -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
@@ -1,7 +1,21 @@
1
- from .heading_element import HeadingElement
2
- from .heading_markdown_node import HeadingMarkdownNode
1
+ from notionary.blocks.heading.heading_element import HeadingElement
2
+ from notionary.blocks.heading.heading_markdown_node import (
3
+ HeadingMarkdownBlockParams,
4
+ HeadingMarkdownNode,
5
+ )
6
+ from notionary.blocks.heading.heading_models import (
7
+ CreateHeading1Block,
8
+ CreateHeading2Block,
9
+ CreateHeading3Block,
10
+ HeadingBlock,
11
+ )
3
12
 
4
13
  __all__ = [
5
14
  "HeadingElement",
15
+ "HeadingBlock",
16
+ "CreateHeading1Block",
17
+ "CreateHeading2Block",
18
+ "CreateHeading3Block",
6
19
  "HeadingMarkdownNode",
20
+ "HeadingMarkdownBlockParams",
7
21
  ]
@@ -1,98 +1,112 @@
1
- import re
2
- from typing import Dict, Any, Optional
1
+ from __future__ import annotations
3
2
 
4
- from notionary.blocks import NotionBlockElement
5
- from notionary.blocks import (
6
- ElementPromptContent,
7
- ElementPromptBuilder,
8
- NotionBlockResult,
3
+ import re
4
+ from typing import Optional, cast
5
+
6
+ from notionary.blocks.base_block_element import BaseBlockElement
7
+ from notionary.blocks.heading.heading_models import (
8
+ CreateHeading1Block,
9
+ CreateHeading2Block,
10
+ CreateHeading3Block,
11
+ HeadingBlock,
9
12
  )
10
- from notionary.blocks.shared.text_inline_formatter import TextInlineFormatter
13
+ from notionary.blocks.syntax_prompt_builder import BlockElementMarkdownInformation
14
+ from notionary.blocks.models import Block, BlockCreateResult, BlockType
15
+ from notionary.blocks.rich_text.text_inline_formatter import TextInlineFormatter
16
+ from notionary.blocks.types import BlockColor
11
17
 
12
18
 
13
- class HeadingElement(NotionBlockElement):
19
+ class HeadingElement(BaseBlockElement):
14
20
  """Handles conversion between Markdown headings and Notion heading blocks."""
15
21
 
16
- # Pattern: #, ## oder ###, dann mind. 1 Leerzeichen/Tab, dann mind. 1 sichtbares Zeichen (kein Whitespace-only)
17
22
  PATTERN = re.compile(r"^(#{1,3})[ \t]+(.+)$")
18
23
 
19
24
  @classmethod
20
- def match_markdown(cls, text: str) -> bool:
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
27
-
28
- @classmethod
29
- def match_notion(cls, block: Dict[str, Any]) -> bool:
30
- """Check if block is a Notion heading."""
31
- block_type: str = block.get("type", "")
32
- return block_type.startswith("heading_") and block_type[-1] in "123"
25
+ def match_notion(cls, block: Block) -> bool:
26
+ return (
27
+ block.type
28
+ in (
29
+ BlockType.HEADING_1,
30
+ BlockType.HEADING_2,
31
+ BlockType.HEADING_3,
32
+ )
33
+ and getattr(block, block.type.value) is not None
34
+ )
33
35
 
34
36
  @classmethod
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)
37
+ async def markdown_to_notion(cls, text: str) -> BlockCreateResult:
38
+ """Convert markdown headings (#, ##, ###) to Notion HeadingBlock."""
39
+ match = cls.PATTERN.match(text.strip())
38
40
  if not match:
39
41
  return None
40
42
 
41
43
  level = len(match.group(1))
42
- if not 1 <= level <= 3:
44
+ if level < 1 or level > 3:
43
45
  return None
44
46
 
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
47
+ content = match.group(2).strip()
48
+ if not content:
49
+ return None
48
50
 
49
- header_block = {
50
- "type": f"heading_{level}",
51
- f"heading_{level}": {
52
- "rich_text": TextInlineFormatter.parse_inline_formatting(content)
53
- },
54
- }
55
- return [header_block]
51
+ rich_text = await TextInlineFormatter.parse_inline_formatting(content)
52
+ heading_content = HeadingBlock(
53
+ rich_text=rich_text, color=BlockColor.DEFAULT, is_toggleable=False
54
+ )
56
55
 
57
- @classmethod
58
- def notion_to_markdown(cls, block: Dict[str, Any]) -> Optional[str]:
59
- """Convert Notion heading block to markdown heading."""
60
- block_type = block.get("type", "")
56
+ if level == 1:
57
+ return CreateHeading1Block(heading_1=heading_content)
58
+ elif level == 2:
59
+ return CreateHeading2Block(heading_2=heading_content)
60
+ else:
61
+ return CreateHeading3Block(heading_3=heading_content)
61
62
 
62
- if not block_type.startswith("heading_"):
63
+ @classmethod
64
+ async def notion_to_markdown(cls, block: Block) -> Optional[str]:
65
+ # Only handle heading blocks via BlockType enum
66
+ if block.type not in (
67
+ BlockType.HEADING_1,
68
+ BlockType.HEADING_2,
69
+ BlockType.HEADING_3,
70
+ ):
63
71
  return None
64
72
 
65
- try:
66
- level = int(block_type[-1])
67
- if not 1 <= level <= 3:
68
- return None
69
- except ValueError:
73
+ # Determine heading level from enum
74
+ if block.type == BlockType.HEADING_1:
75
+ level = 1
76
+ elif block.type == BlockType.HEADING_2:
77
+ level = 2
78
+ else:
79
+ level = 3
80
+
81
+ heading_obj = getattr(block, block.type.value)
82
+ if not heading_obj:
70
83
  return None
71
84
 
72
- heading_data = block.get(block_type, {})
73
- rich_text = heading_data.get("rich_text", [])
85
+ heading_data = cast(HeadingBlock, heading_obj)
86
+ if not heading_data.rich_text:
87
+ return None
74
88
 
75
- text = TextInlineFormatter.extract_text_with_formatting(rich_text)
76
- prefix = "#" * level
77
- return f"{prefix} {text}" if text else None
89
+ text = await TextInlineFormatter.extract_text_with_formatting(
90
+ heading_data.rich_text
91
+ )
92
+ if not text:
93
+ return None
78
94
 
79
- @classmethod
80
- def is_multiline(cls) -> bool:
81
- return False
95
+ # Use hash-style for all heading levels
96
+ return f"{('#' * level)} {text}"
82
97
 
83
98
  @classmethod
84
- def get_llm_prompt_content(cls) -> ElementPromptContent:
85
- return (
86
- ElementPromptBuilder()
87
- .with_description(
88
- "Use Markdown headings (#, ##, ###) to structure content hierarchically."
89
- )
90
- .with_usage_guidelines(
91
- "Use to group content into sections and define a visual hierarchy."
92
- )
93
- .with_avoidance_guidelines(
94
- "Only H1-H3 syntax is supported. H4 and deeper heading levels are not available."
95
- )
96
- .with_standard_markdown()
97
- .build()
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.",
98
112
  )
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from pydantic import BaseModel
4
- from notionary.blocks.markdown_node import MarkdownNode
4
+
5
+ from notionary.markdown.markdown_node import MarkdownNode
5
6
 
6
7
 
7
8
  class HeadingMarkdownBlockParams(BaseModel):
@@ -0,0 +1,29 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from notionary.blocks.models import Block
6
+ from notionary.blocks.rich_text.rich_text_models import RichTextObject
7
+ from notionary.blocks.types import BlockColor
8
+
9
+
10
+ class HeadingBlock(BaseModel):
11
+ rich_text: list[RichTextObject]
12
+ color: BlockColor = BlockColor.DEFAULT
13
+ is_toggleable: bool = False
14
+ children: list[Block] = Field(default_factory=list)
15
+
16
+
17
+ class CreateHeading1Block(BaseModel):
18
+ type: Literal["heading_1"] = "heading_1"
19
+ heading_1: HeadingBlock
20
+
21
+
22
+ class CreateHeading2Block(BaseModel):
23
+ type: Literal["heading_2"] = "heading_2"
24
+ heading_2: HeadingBlock
25
+
26
+
27
+ class CreateHeading3Block(BaseModel):
28
+ type: Literal["heading_3"] = "heading_3"
29
+ heading_3: HeadingBlock
@@ -0,0 +1,13 @@
1
+ from notionary.blocks.image_block.image_element import ImageElement
2
+ from notionary.blocks.image_block.image_markdown_node import (
3
+ ImageMarkdownBlockParams,
4
+ ImageMarkdownNode,
5
+ )
6
+ from notionary.blocks.image_block.image_models import CreateImageBlock
7
+
8
+ __all__ = [
9
+ "ImageElement",
10
+ "CreateImageBlock",
11
+ "ImageMarkdownNode",
12
+ "ImageMarkdownBlockParams",
13
+ ]
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Optional
5
+
6
+ from notionary.blocks.base_block_element import BaseBlockElement
7
+ from notionary.blocks.file.file_element_models import ExternalFile, FileType
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
11
+ from notionary.blocks.models import Block, BlockCreateResult, BlockType
12
+
13
+
14
+ class ImageElement(BaseBlockElement, CaptionMixin):
15
+ """
16
+ Handles conversion between Markdown images and Notion image blocks.
17
+
18
+ Markdown image syntax:
19
+ - [image](https://example.com/image.jpg) - URL only
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
22
+ """
23
+
24
+ # Flexible pattern that can handle caption in any position
25
+ IMAGE_PATTERN = re.compile(r"\[image\]\((https?://[^\s\"]+)\)")
26
+
27
+ @classmethod
28
+ def match_notion(cls, block: Block) -> bool:
29
+ return block.type == BlockType.IMAGE and block.image
30
+
31
+ @classmethod
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:
39
+ return None
40
+
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
+ # Build ImageBlock
47
+ image_block = FileBlock(
48
+ type="external", external=ExternalFile(url=url), caption=caption_rich_text
49
+ )
50
+
51
+ return CreateImageBlock(image=image_block)
52
+
53
+ @classmethod
54
+ async def notion_to_markdown(cls, block: Block) -> Optional[str]:
55
+ if block.type != BlockType.IMAGE or not block.image:
56
+ return None
57
+
58
+ fo = block.image
59
+
60
+ if fo.type == FileType.EXTERNAL and fo.external:
61
+ url = fo.external.url
62
+ elif fo.type == FileType.FILE and fo.file:
63
+ url = fo.file.url
64
+ else:
65
+ return None
66
+
67
+ result = f"[image]({url})"
68
+
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
75
+
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
+ )
@@ -1,8 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from typing import Optional
4
+
4
5
  from pydantic import BaseModel
5
- from notionary.blocks.markdown_node import MarkdownNode
6
+
7
+ from notionary.markdown.markdown_node import MarkdownNode
8
+ from notionary.blocks.mixins.captions import CaptionMarkdownNodeMixin
6
9
 
7
10
 
8
11
  class ImageMarkdownBlockParams(BaseModel):
@@ -10,10 +13,9 @@ class ImageMarkdownBlockParams(BaseModel):
10
13
  caption: Optional[str] = None
11
14
 
12
15
 
13
- class ImageMarkdownNode(MarkdownNode):
16
+ class ImageMarkdownNode(MarkdownNode, CaptionMarkdownNodeMixin):
14
17
  """
15
18
  Programmatic interface for creating Notion-style image blocks.
16
- Example: [image](https://example.com/image.jpg "Optional caption")
17
19
  """
18
20
 
19
21
  def __init__(
@@ -28,6 +30,11 @@ class ImageMarkdownNode(MarkdownNode):
28
30
  return cls(url=params.url, caption=params.caption)
29
31
 
30
32
  def to_markdown(self) -> str:
31
- if self.caption:
32
- return f'[image]({self.url} "{self.caption}")'
33
- 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,10 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from notionary.blocks.file.file_element_models import FileBlock
6
+
7
+
8
+ class CreateImageBlock(BaseModel):
9
+ type: Literal["image"] = "image"
10
+ image: FileBlock
@@ -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))