notionary 0.2.19__py3-none-any.whl → 0.2.21__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 (205) 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 +263 -0
  5. notionary/blocks/audio/__init__.py +8 -2
  6. notionary/blocks/audio/audio_element.py +42 -104
  7. notionary/blocks/audio/audio_markdown_node.py +3 -1
  8. notionary/blocks/audio/audio_models.py +6 -55
  9. notionary/blocks/base_block_element.py +30 -0
  10. notionary/blocks/bookmark/__init__.py +9 -2
  11. notionary/blocks/bookmark/bookmark_element.py +46 -139
  12. notionary/blocks/bookmark/bookmark_markdown_node.py +3 -1
  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 +40 -55
  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 +40 -89
  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 +7 -0
  27. notionary/blocks/child_database/child_database_models.py +19 -0
  28. notionary/blocks/child_page/__init__.py +9 -0
  29. notionary/blocks/child_page/child_page_models.py +12 -0
  30. notionary/blocks/{shared/block_client.py → client.py} +55 -54
  31. notionary/blocks/code/__init__.py +6 -2
  32. notionary/blocks/code/code_element.py +53 -187
  33. notionary/blocks/code/code_markdown_node.py +13 -13
  34. notionary/blocks/code/code_models.py +94 -0
  35. notionary/blocks/column/__init__.py +25 -1
  36. notionary/blocks/column/column_element.py +40 -314
  37. notionary/blocks/column/column_list_element.py +37 -0
  38. notionary/blocks/column/column_list_markdown_node.py +50 -0
  39. notionary/blocks/column/column_markdown_node.py +59 -0
  40. notionary/blocks/column/column_models.py +26 -0
  41. notionary/blocks/divider/__init__.py +9 -2
  42. notionary/blocks/divider/divider_element.py +26 -49
  43. notionary/blocks/divider/divider_markdown_node.py +2 -1
  44. notionary/blocks/divider/divider_models.py +12 -0
  45. notionary/blocks/embed/__init__.py +9 -2
  46. notionary/blocks/embed/embed_element.py +47 -114
  47. notionary/blocks/embed/embed_markdown_node.py +3 -1
  48. notionary/blocks/embed/embed_models.py +14 -0
  49. notionary/blocks/equation/__init__.py +14 -0
  50. notionary/blocks/equation/equation_element.py +80 -0
  51. notionary/blocks/equation/equation_element_markdown_node.py +36 -0
  52. notionary/blocks/equation/equation_models.py +11 -0
  53. notionary/blocks/file/__init__.py +25 -0
  54. notionary/blocks/file/file_element.py +93 -0
  55. notionary/blocks/file/file_element_markdown_node.py +35 -0
  56. notionary/blocks/file/file_element_models.py +39 -0
  57. notionary/blocks/heading/__init__.py +16 -2
  58. notionary/blocks/heading/heading_element.py +67 -72
  59. notionary/blocks/heading/heading_markdown_node.py +2 -1
  60. notionary/blocks/heading/heading_models.py +29 -0
  61. notionary/blocks/image_block/__init__.py +13 -0
  62. notionary/blocks/image_block/image_element.py +84 -0
  63. notionary/blocks/{image → image_block}/image_markdown_node.py +3 -1
  64. notionary/blocks/image_block/image_models.py +10 -0
  65. notionary/blocks/models.py +172 -0
  66. notionary/blocks/numbered_list/__init__.py +12 -2
  67. notionary/blocks/numbered_list/numbered_list_element.py +33 -58
  68. notionary/blocks/numbered_list/numbered_list_markdown_node.py +3 -1
  69. notionary/blocks/numbered_list/numbered_list_models.py +17 -0
  70. notionary/blocks/paragraph/__init__.py +12 -2
  71. notionary/blocks/paragraph/paragraph_element.py +27 -69
  72. notionary/blocks/paragraph/paragraph_markdown_node.py +2 -1
  73. notionary/blocks/paragraph/paragraph_models.py +16 -0
  74. notionary/blocks/pdf/__init__.py +13 -0
  75. notionary/blocks/pdf/pdf_element.py +91 -0
  76. notionary/blocks/pdf/pdf_markdown_node.py +35 -0
  77. notionary/blocks/pdf/pdf_models.py +11 -0
  78. notionary/blocks/quote/__init__.py +11 -2
  79. notionary/blocks/quote/quote_element.py +31 -65
  80. notionary/blocks/quote/quote_markdown_node.py +4 -1
  81. notionary/blocks/quote/quote_models.py +18 -0
  82. notionary/blocks/registry/__init__.py +4 -0
  83. notionary/blocks/registry/block_registry.py +75 -91
  84. notionary/blocks/registry/block_registry_builder.py +107 -59
  85. notionary/blocks/rich_text/__init__.py +33 -0
  86. notionary/blocks/rich_text/rich_text_models.py +188 -0
  87. notionary/blocks/rich_text/text_inline_formatter.py +125 -0
  88. notionary/blocks/table/__init__.py +16 -2
  89. notionary/blocks/table/table_element.py +48 -241
  90. notionary/blocks/table/table_markdown_node.py +2 -1
  91. notionary/blocks/table/table_models.py +28 -0
  92. notionary/blocks/table_of_contents/__init__.py +19 -0
  93. notionary/blocks/table_of_contents/table_of_contents_element.py +51 -0
  94. notionary/blocks/table_of_contents/table_of_contents_markdown_node.py +35 -0
  95. notionary/blocks/table_of_contents/table_of_contents_models.py +18 -0
  96. notionary/blocks/todo/__init__.py +9 -2
  97. notionary/blocks/todo/todo_element.py +38 -95
  98. notionary/blocks/todo/todo_markdown_node.py +2 -1
  99. notionary/blocks/todo/todo_models.py +19 -0
  100. notionary/blocks/toggle/__init__.py +13 -3
  101. notionary/blocks/toggle/toggle_element.py +57 -264
  102. notionary/blocks/toggle/toggle_markdown_node.py +24 -14
  103. notionary/blocks/toggle/toggle_models.py +17 -0
  104. notionary/blocks/toggleable_heading/__init__.py +6 -2
  105. notionary/blocks/toggleable_heading/toggleable_heading_element.py +74 -244
  106. notionary/blocks/toggleable_heading/toggleable_heading_markdown_node.py +26 -18
  107. notionary/blocks/types.py +61 -0
  108. notionary/blocks/video/__init__.py +8 -2
  109. notionary/blocks/video/video_element.py +67 -143
  110. notionary/blocks/video/video_element_models.py +10 -0
  111. notionary/blocks/video/video_markdown_node.py +3 -1
  112. notionary/database/client.py +3 -8
  113. notionary/database/database.py +13 -14
  114. notionary/database/database_filter_builder.py +2 -2
  115. notionary/database/database_provider.py +5 -4
  116. notionary/database/models.py +337 -0
  117. notionary/database/notion_database.py +6 -7
  118. notionary/file_upload/client.py +5 -7
  119. notionary/file_upload/models.py +2 -1
  120. notionary/file_upload/notion_file_upload.py +2 -3
  121. notionary/markdown/markdown_builder.py +722 -0
  122. notionary/markdown/markdown_document_model.py +228 -0
  123. notionary/{blocks → markdown}/markdown_node.py +1 -0
  124. notionary/models/notion_database_response.py +0 -338
  125. notionary/page/client.py +9 -10
  126. notionary/page/models.py +327 -0
  127. notionary/page/notion_page.py +99 -52
  128. notionary/page/notion_text_length_utils.py +119 -0
  129. notionary/page/{content/page_content_writer.py → page_content_writer.py} +88 -38
  130. notionary/page/reader/handler/__init__.py +17 -0
  131. notionary/page/reader/handler/base_block_renderer.py +44 -0
  132. notionary/page/reader/handler/block_processing_context.py +35 -0
  133. notionary/page/reader/handler/block_rendering_context.py +43 -0
  134. notionary/page/reader/handler/column_list_renderer.py +51 -0
  135. notionary/page/reader/handler/column_renderer.py +60 -0
  136. notionary/page/reader/handler/line_renderer.py +60 -0
  137. notionary/page/reader/handler/toggle_renderer.py +69 -0
  138. notionary/page/reader/handler/toggleable_heading_renderer.py +89 -0
  139. notionary/page/reader/page_content_retriever.py +69 -0
  140. notionary/page/search_filter_builder.py +2 -1
  141. notionary/page/writer/handler/__init__.py +22 -0
  142. notionary/page/writer/handler/code_handler.py +100 -0
  143. notionary/page/writer/handler/column_handler.py +141 -0
  144. notionary/page/writer/handler/column_list_handler.py +139 -0
  145. notionary/page/writer/handler/line_handler.py +35 -0
  146. notionary/page/writer/handler/line_processing_context.py +54 -0
  147. notionary/page/writer/handler/regular_line_handler.py +92 -0
  148. notionary/page/writer/handler/table_handler.py +130 -0
  149. notionary/page/writer/handler/toggle_handler.py +153 -0
  150. notionary/page/writer/handler/toggleable_heading_handler.py +167 -0
  151. notionary/page/writer/markdown_to_notion_converter.py +76 -0
  152. notionary/telemetry/__init__.py +2 -2
  153. notionary/telemetry/service.py +4 -3
  154. notionary/user/__init__.py +2 -2
  155. notionary/user/base_notion_user.py +2 -1
  156. notionary/user/client.py +2 -3
  157. notionary/user/models.py +1 -0
  158. notionary/user/notion_bot_user.py +4 -5
  159. notionary/user/notion_user.py +3 -4
  160. notionary/user/notion_user_manager.py +3 -2
  161. notionary/user/notion_user_provider.py +1 -1
  162. notionary/util/__init__.py +3 -2
  163. notionary/util/fuzzy.py +2 -1
  164. notionary/util/logging_mixin.py +2 -2
  165. notionary/util/singleton_metaclass.py +1 -1
  166. notionary/workspace.py +3 -2
  167. {notionary-0.2.19.dist-info → notionary-0.2.21.dist-info}/METADATA +12 -8
  168. notionary-0.2.21.dist-info/RECORD +185 -0
  169. notionary/blocks/document/__init__.py +0 -7
  170. notionary/blocks/document/document_element.py +0 -102
  171. notionary/blocks/document/document_markdown_node.py +0 -31
  172. notionary/blocks/image/__init__.py +0 -7
  173. notionary/blocks/image/image_element.py +0 -151
  174. notionary/blocks/markdown_builder.py +0 -356
  175. notionary/blocks/mention/__init__.py +0 -7
  176. notionary/blocks/mention/mention_element.py +0 -229
  177. notionary/blocks/mention/mention_markdown_node.py +0 -38
  178. notionary/blocks/prompts/element_prompt_builder.py +0 -83
  179. notionary/blocks/prompts/element_prompt_content.py +0 -41
  180. notionary/blocks/shared/__init__.py +0 -0
  181. notionary/blocks/shared/models.py +0 -713
  182. notionary/blocks/shared/notion_block_element.py +0 -37
  183. notionary/blocks/shared/text_inline_formatter.py +0 -262
  184. notionary/blocks/shared/text_inline_formatter_new.py +0 -139
  185. notionary/blocks/toggleable_heading/toggleable_heading_models.py +0 -0
  186. notionary/database/models/page_result.py +0 -10
  187. notionary/elements/__init__.py +0 -0
  188. notionary/models/notion_block_response.py +0 -264
  189. notionary/models/notion_page_response.py +0 -78
  190. notionary/models/search_response.py +0 -0
  191. notionary/page/__init__.py +0 -0
  192. notionary/page/content/notion_text_length_utils.py +0 -87
  193. notionary/page/content/page_content_retriever.py +0 -60
  194. notionary/page/formatting/line_processor.py +0 -153
  195. notionary/page/formatting/markdown_to_notion_converter.py +0 -153
  196. notionary/page/markdown_syntax_prompt_generator.py +0 -114
  197. notionary/page/notion_to_markdown_converter.py +0 -179
  198. notionary/page/properites/property_value_extractor.py +0 -0
  199. notionary-0.2.19.dist-info/RECORD +0 -150
  200. /notionary/{blocks/document/document_models.py → markdown/___init__.py} +0 -0
  201. /notionary/{blocks/image/image_models.py → markdown/makdown_document_model.py} +0 -0
  202. /notionary/page/{content/markdown_whitespace_processor.py → markdown_whitespace_processor.py} +0 -0
  203. /notionary/{blocks/mention/mention_models.py → page/reader/handler/context.py} +0 -0
  204. {notionary-0.2.19.dist-info → notionary-0.2.21.dist-info}/LICENSE +0 -0
  205. {notionary-0.2.19.dist-info → notionary-0.2.21.dist-info}/WHEEL +0 -0
@@ -1,7 +1,14 @@
1
- from .embed_element import EmbedElement
2
- from .embed_markdown_node import EmbedMarkdownNode
1
+ from notionary.blocks.embed.embed_element import EmbedElement
2
+ from notionary.blocks.embed.embed_markdown_node import (
3
+ EmbedMarkdownBlockParams,
4
+ EmbedMarkdownNode,
5
+ )
6
+ from notionary.blocks.embed.embed_models import CreateEmbedBlock, EmbedBlock
3
7
 
4
8
  __all__ = [
5
9
  "EmbedElement",
10
+ "EmbedBlock",
11
+ "CreateEmbedBlock",
6
12
  "EmbedMarkdownNode",
13
+ "EmbedMarkdownBlockParams",
7
14
  ]
@@ -1,144 +1,77 @@
1
- import re
2
- from typing import Dict, Any, Optional, List
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
5
+
6
+ from notionary.blocks.base_block_element import BaseBlockElement
7
+ from notionary.blocks.embed.embed_models import CreateEmbedBlock, EmbedBlock
8
+ from notionary.blocks.file.file_element_models import (
9
+ ExternalFile,
10
+ FileUploadFile,
11
+ NotionHostedFile,
9
12
  )
13
+ from notionary.blocks.models import Block, BlockCreateResult, BlockType
14
+ from notionary.blocks.rich_text.rich_text_models import RichTextObject
15
+ from notionary.blocks.rich_text.text_inline_formatter import TextInlineFormatter
10
16
 
11
17
 
12
- class EmbedElement(NotionBlockElement):
18
+ class EmbedElement(BaseBlockElement):
13
19
  """
14
20
  Handles conversion between Markdown embeds and Notion embed blocks.
15
21
 
16
22
  Markdown embed syntax:
17
- - [embed](https://example.com) - Simple embed with URL only
18
- - [embed](https://example.com "Caption") - Embed with URL and caption
19
-
20
- Where:
21
- - URL is the required embed URL
22
- - Caption is an optional descriptive text (enclosed in quotes)
23
-
24
- Supports various URL types including websites, PDFs, Google Maps, Google Drive,
25
- Twitter/X posts, and other sources that Notion can embed.
23
+ - [embed](https://example.com) - URL only
24
+ - [embed](https://example.com "Caption") - URL + caption
26
25
  """
27
26
 
28
- # Regex pattern for embed syntax with optional caption
29
27
  PATTERN = re.compile(
30
- r"^\[embed\]\(" # [embed]( prefix
31
- + r'(https?://[^\s"]+)' # URL (required)
32
- + r'(?:\s+"([^"]+)")?' # Optional caption in quotes
33
- + r"\)$" # closing parenthesis
28
+ r"^\[embed\]\(" # prefix
29
+ r"(https?://[^\s\"]+)" # URL
30
+ r"(?:\s+\"([^\"]+)\")?" # optional caption
31
+ r"\)$"
34
32
  )
35
33
 
36
34
  @classmethod
37
- def match_markdown(cls, text: str) -> bool:
38
- """Check if text is a markdown embed."""
39
- return text.strip().startswith("[embed]") and bool(
40
- EmbedElement.PATTERN.match(text.strip())
41
- )
42
-
43
- @classmethod
44
- def match_notion(cls, block: Dict[str, Any]) -> bool:
45
- """Check if block is a Notion embed."""
46
- return block.get("type") == "embed"
35
+ def match_notion(cls, block: Block) -> bool:
36
+ return block.type == BlockType.EMBED and block.embed
47
37
 
48
38
  @classmethod
49
- def markdown_to_notion(cls, text: str) -> NotionBlockResult:
50
- """Convert markdown embed to Notion embed block."""
51
- embed_match = EmbedElement.PATTERN.match(text.strip())
52
- if not embed_match:
39
+ def markdown_to_notion(cls, text: str) -> BlockCreateResult:
40
+ """Convert markdown embed syntax to Notion EmbedBlock."""
41
+ match = cls.PATTERN.match(text.strip())
42
+ if not match:
53
43
  return None
54
44
 
55
- url = embed_match.group(1)
56
- caption = embed_match.group(2)
57
-
58
- if not url:
59
- return None
60
-
61
- embed_data = {"url": url}
62
-
63
- # Add caption if provided
64
- if caption:
65
- embed_data["caption"] = [{"type": "text", "text": {"content": caption}}]
66
- else:
67
- embed_data["caption"] = []
68
-
69
- # Prepare the embed block
70
- embed_block = {"type": "embed", "embed": embed_data}
45
+ url, rich_text = match.group(1), match.group(2) or ""
71
46
 
72
- # Add empty paragraph after embed
73
- empty_paragraph = {"type": "paragraph", "paragraph": {"rich_text": []}}
47
+ # Build EmbedBlock
48
+ embed_block = EmbedBlock(url=url, caption=[])
49
+ if rich_text.strip():
50
+ rich_text_obj = RichTextObject.from_plain_text(rich_text.strip())
51
+ embed_block.caption = [rich_text_obj]
74
52
 
75
- return [embed_block, empty_paragraph]
53
+ return CreateEmbedBlock(embed=embed_block)
76
54
 
77
55
  @classmethod
78
- def notion_to_markdown(cls, block: Dict[str, Any]) -> Optional[str]:
79
- """Convert Notion embed block to markdown embed."""
80
- if block.get("type") != "embed":
56
+ def notion_to_markdown(cls, block: Block) -> Optional[str]:
57
+ if block.type != BlockType.EMBED or not block.embed:
81
58
  return None
82
59
 
83
- embed_data = block.get("embed", {})
84
- url = embed_data.get("url", "")
60
+ fo = block.embed
85
61
 
86
- if not url:
62
+ if isinstance(fo, (ExternalFile, NotionHostedFile)):
63
+ url = fo.url
64
+ elif isinstance(fo, FileUploadFile):
65
+ return None
66
+ else:
87
67
  return None
88
68
 
89
- caption_rich_text = embed_data.get("caption", [])
90
-
91
- if not caption_rich_text:
92
- # Simple embed with URL only
69
+ if not fo.caption:
93
70
  return f"[embed]({url})"
94
71
 
95
- # Extract caption text
96
- caption = EmbedElement._extract_text_content(caption_rich_text)
97
-
98
- if caption:
99
- return f'[embed]({url} "{caption}")'
100
-
101
- return f"[embed]({url})"
102
-
103
- @classmethod
104
- def is_multiline(cls) -> bool:
105
- """Embeds are single-line elements."""
106
- return False
107
-
108
- @classmethod
109
- def _extract_text_content(cls, rich_text: List[Dict[str, Any]]) -> str:
110
- """Extract plain text content from Notion rich_text elements."""
111
- result = ""
112
- for text_obj in rich_text:
113
- if text_obj.get("type") == "text":
114
- result += text_obj.get("text", {}).get("content", "")
115
- elif "plain_text" in text_obj:
116
- result += text_obj.get("plain_text", "")
117
- return result
118
-
119
- @classmethod
120
- def get_llm_prompt_content(cls) -> ElementPromptContent:
121
- """
122
- Returns structured LLM prompt metadata for the embed element.
123
- """
124
- return (
125
- ElementPromptBuilder()
126
- .with_description(
127
- "Embeds external content from websites, PDFs, Google Maps, and other sources directly in your document."
128
- )
129
- .with_usage_guidelines(
130
- "Use embeds when you want to include external content that isn't just a video or image. "
131
- "Embeds are great for interactive content, reference materials, or live data sources."
132
- )
133
- .with_syntax('[embed](https://example.com "Optional caption")')
134
- .with_examples(
135
- [
136
- "[embed](https://drive.google.com/file/d/123456/view)",
137
- '[embed](https://www.google.com/maps?q=San+Francisco "Our office location")',
138
- '[embed](https://twitter.com/NotionHQ/status/1234567890 "Latest announcement")',
139
- '[embed](https://github.com/username/repo "Project documentation")',
140
- '[embed](https://example.com/important-reference.pdf "Course materials")',
141
- ]
142
- )
143
- .build()
72
+ text = "".join(
73
+ rt.plain_text or TextInlineFormatter.extract_text_with_formatting([rt])
74
+ for rt in fo.caption
144
75
  )
76
+
77
+ return f'[embed]({url} "{text}")'
@@ -1,8 +1,10 @@
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
6
8
 
7
9
 
8
10
  class EmbedMarkdownBlockParams(BaseModel):
@@ -0,0 +1,14 @@
1
+ from pydantic import BaseModel, Field
2
+ from typing_extensions import Literal
3
+
4
+ from notionary.blocks.rich_text.rich_text_models import RichTextObject
5
+
6
+
7
+ class EmbedBlock(BaseModel):
8
+ url: str
9
+ caption: list[RichTextObject] = Field(default_factory=list)
10
+
11
+
12
+ class CreateEmbedBlock(BaseModel):
13
+ type: Literal["embed"] = "embed"
14
+ embed: EmbedBlock
@@ -0,0 +1,14 @@
1
+ from notionary.blocks.equation.equation_element import EquationElement
2
+ from notionary.blocks.equation.equation_element_markdown_node import (
3
+ EquationMarkdownBlockParams,
4
+ EquationMarkdownNode,
5
+ )
6
+ from notionary.blocks.equation.equation_models import CreateEquationBlock, EquationBlock
7
+
8
+ __all__ = [
9
+ "EquationElement",
10
+ "EquationBlock",
11
+ "CreateEquationBlock",
12
+ "EquationMarkdownNode",
13
+ "EquationMarkdownBlockParams",
14
+ ]
@@ -0,0 +1,80 @@
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.equation.equation_models import CreateEquationBlock, EquationBlock
8
+ from notionary.blocks.models import Block, BlockCreateResult
9
+ from notionary.blocks.types import BlockType
10
+
11
+
12
+ class EquationElement(BaseBlockElement):
13
+ """
14
+ Only supports bracket style (analog zu [video]):
15
+
16
+ - [equation](E = mc^2) # unquoted: keine ')' oder Newlines
17
+ - [equation]("E = mc^2 + \\frac{a}{b}") # quoted: erlaubt ')' & Newlines & \"
18
+
19
+ No $$...$$ parsing.
20
+ """
21
+
22
+ _QUOTED_PATTERN = re.compile(
23
+ r'^\[equation\]\(\s*"(?P<quoted_expr>(?:[^"\\]|\\.)*)"\s*\)$',
24
+ re.DOTALL,
25
+ )
26
+
27
+ _UNQUOTED_PATTERN = re.compile(
28
+ r"^\[equation\]\(\s*(?P<unquoted_expr>[^)\r\n]+?)\s*\)$"
29
+ )
30
+
31
+ @classmethod
32
+ def match_notion(cls, block: Block) -> bool:
33
+ return block.type == BlockType.EQUATION and block.equation
34
+
35
+ @classmethod
36
+ def markdown_to_notion(cls, text: str) -> BlockCreateResult:
37
+ input_text = text.strip()
38
+
39
+ # Try quoted form first: [equation]("...")
40
+ if quoted_match := cls._QUOTED_PATTERN.match(input_text):
41
+ raw_expression = quoted_match.group("quoted_expr")
42
+ # Unescape \" and \\ for Notion
43
+ unescaped_expression = raw_expression.encode("utf-8").decode(
44
+ "unicode_escape"
45
+ )
46
+ unescaped_expression = unescaped_expression.replace('\\"', '"')
47
+ final_expression = unescaped_expression.strip()
48
+
49
+ return (
50
+ CreateEquationBlock(equation=EquationBlock(expression=final_expression))
51
+ if final_expression
52
+ else None
53
+ )
54
+
55
+ # Try unquoted form: [equation](...)
56
+ if unquoted_match := cls._UNQUOTED_PATTERN.match(input_text):
57
+ raw_expression = unquoted_match.group("unquoted_expr").strip()
58
+ return (
59
+ CreateEquationBlock(equation=EquationBlock(expression=raw_expression))
60
+ if raw_expression
61
+ else None
62
+ )
63
+
64
+ return None
65
+
66
+ @classmethod
67
+ def notion_to_markdown(cls, block: Block) -> Optional[str]:
68
+ if block.type != BlockType.EQUATION or not block.equation:
69
+ return None
70
+
71
+ expression = (block.equation.expression or "").strip()
72
+ if not expression:
73
+ return None
74
+
75
+ # Use quoted form if expression contains risky characters
76
+ if ("\n" in expression) or (")" in expression) or ('"' in expression):
77
+ escaped_expression = expression.replace("\\", "\\\\").replace('"', r"\"")
78
+ return f'[equation]("{escaped_expression}")'
79
+
80
+ return f"[equation]({expression})"
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from notionary.markdown.markdown_node import MarkdownNode
6
+
7
+
8
+ class EquationMarkdownBlockParams(BaseModel):
9
+ expression: str
10
+
11
+
12
+ class EquationMarkdownNode(MarkdownNode):
13
+ """
14
+ Programmatic interface for creating Markdown equation blocks.
15
+ Example:
16
+ [equation](E = mc^2)
17
+ [equation]("f(x) = \\sin(x) + \\cos(x)")
18
+ """
19
+
20
+ def __init__(self, expression: str):
21
+ self.expression = expression
22
+
23
+ @classmethod
24
+ def from_params(cls, params: EquationMarkdownBlockParams) -> EquationMarkdownNode:
25
+ return cls(expression=params.expression)
26
+
27
+ def to_markdown(self) -> str:
28
+ expr = self.expression.strip()
29
+ if not expr:
30
+ return "[equation]()"
31
+
32
+ if ("\n" in expr) or (")" in expr) or ('"' in expr):
33
+ escaped = expr.replace("\\", "\\\\").replace('"', r"\"")
34
+ return f'[equation]("{escaped}")'
35
+
36
+ return f"[equation]({expr})"
@@ -0,0 +1,11 @@
1
+ from pydantic import BaseModel
2
+ from typing_extensions import Literal
3
+
4
+
5
+ class EquationBlock(BaseModel):
6
+ expression: str
7
+
8
+
9
+ class CreateEquationBlock(BaseModel):
10
+ type: Literal["equation"] = "equation"
11
+ equation: EquationBlock
@@ -0,0 +1,25 @@
1
+ from notionary.blocks.file.file_element import FileElement
2
+ from notionary.blocks.file.file_element_markdown_node import (
3
+ FileMarkdownNode,
4
+ FileMarkdownNodeParams,
5
+ )
6
+ from notionary.blocks.file.file_element_models import (
7
+ CreateFileBlock,
8
+ ExternalFile,
9
+ FileBlock,
10
+ FileType,
11
+ FileUploadFile,
12
+ NotionHostedFile,
13
+ )
14
+
15
+ __all__ = [
16
+ "FileElement",
17
+ "FileType",
18
+ "ExternalFile",
19
+ "NotionHostedFile",
20
+ "FileUploadFile",
21
+ "FileBlock",
22
+ "CreateFileBlock",
23
+ "FileMarkdownNode",
24
+ "FileMarkdownNodeParams",
25
+ ]
@@ -0,0 +1,93 @@
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.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
+
21
+
22
+ class FileElement(BaseBlockElement):
23
+ """
24
+ Handles conversion between Markdown file embeds and Notion file blocks.
25
+
26
+ Markdown file syntax:
27
+ - [file](https://example.com/document.pdf "Caption")
28
+ - [file](https://example.com/document.pdf)
29
+
30
+ Supports external file URLs with optional captions.
31
+ """
32
+
33
+ PATTERN = re.compile(
34
+ r"^\[file\]\(" # prefix
35
+ r'(https?://[^\s\)"]+)' # URL
36
+ r'(?:\s+"([^"]*)")?' # optional caption
37
+ r"\)$"
38
+ )
39
+
40
+ @classmethod
41
+ def match_notion(cls, block: Block) -> bool:
42
+ # Notion file block covers files
43
+ return block.type == BlockType.FILE and block.file
44
+
45
+ @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:
50
+ return None
51
+
52
+ url, caption_text = match.group(1), match.group(2) or ""
53
+
54
+ # Build FileBlock using FileType enum
55
+ file_block = FileBlock(
56
+ type=FileType.EXTERNAL, external=ExternalFile(url=url), caption=[]
57
+ )
58
+ if caption_text.strip():
59
+ rt = RichTextObject.from_plain_text(caption_text)
60
+ file_block.caption = [rt]
61
+
62
+ empty_para = ParagraphBlock(rich_text=[])
63
+
64
+ return [
65
+ CreateFileBlock(file=file_block),
66
+ CreateParagraphBlock(paragraph=empty_para),
67
+ ]
68
+
69
+ @classmethod
70
+ def notion_to_markdown(cls, block: Block) -> Optional[str]:
71
+ if block.type != BlockType.FILE or not block.file:
72
+ return None
73
+
74
+ fb: FileBlock = block.file
75
+
76
+ # Determine URL (only external and file types are valid for Markdown)
77
+ if fb.type == FileType.EXTERNAL and fb.external:
78
+ url = fb.external.url
79
+ elif fb.type == FileType.FILE and fb.file:
80
+ url = fb.file.url
81
+ elif fb.type == FileType.FILE_UPLOAD:
82
+ # Uploaded file has no stable URL for Markdown
83
+ return None
84
+ else:
85
+ return None
86
+
87
+ if not fb.caption:
88
+ return f"[file]({url})"
89
+
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})"
@@ -0,0 +1,35 @@
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
+
9
+
10
+ class FileMarkdownNodeParams(BaseModel):
11
+ url: str
12
+ caption: Optional[str] = None
13
+
14
+
15
+ class FileMarkdownNode(MarkdownNode):
16
+ """
17
+ Programmatic interface for creating Notion-style Markdown file embeds.
18
+ Example: [file](https://example.com/file.pdf "My Caption")
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
+ """
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})"
@@ -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
@@ -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
  ]