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
@@ -5,34 +5,27 @@ from typing import Optional
5
5
 
6
6
  from notionary.blocks.base_block_element import BaseBlockElement
7
7
  from notionary.blocks.file.file_element_models import ExternalFile, FileBlock, FileType
8
+ from notionary.blocks.mixins.captions import CaptionMixin
9
+ from notionary.blocks.syntax_prompt_builder import BlockElementMarkdownInformation
8
10
  from notionary.blocks.models import Block, BlockCreateResult
9
- from notionary.blocks.paragraph.paragraph_models import (
10
- CreateParagraphBlock,
11
- ParagraphBlock,
12
- )
13
- from notionary.blocks.rich_text.rich_text_models import RichTextObject
14
- from notionary.blocks.rich_text.text_inline_formatter import TextInlineFormatter
15
11
  from notionary.blocks.types import BlockType
16
12
  from notionary.blocks.video.video_element_models import CreateVideoBlock
17
13
 
18
14
 
19
- class VideoElement(BaseBlockElement):
15
+ class VideoElement(BaseBlockElement, CaptionMixin):
20
16
  """
21
17
  Handles conversion between Markdown video embeds and Notion video blocks.
22
18
 
23
19
  Markdown video syntax:
24
20
  - [video](https://example.com/video.mp4) - URL only
25
- - [video](https://example.com/video.mp4 "Caption") - URL + caption
21
+ - [video](https://example.com/video.mp4)(caption:Demo Video) - URL with caption
22
+ - (caption:Tutorial video)[video](https://youtube.com/watch?v=abc123) - caption before URL
26
23
 
27
24
  Supports YouTube, Vimeo, and direct file URLs.
28
25
  """
29
26
 
30
- PATTERN = re.compile(
31
- r"^\[video\]\(" # prefix
32
- r"(https?://[^\s\"]+)" # URL
33
- r"(?:\s+\"([^\"]+)\")?" # optional caption
34
- r"\)$"
35
- )
27
+ # Flexible pattern that can handle caption in any position
28
+ VIDEO_PATTERN = re.compile(r"\[video\]\((https?://[^\s\"]+)\)")
36
29
 
37
30
  YOUTUBE_PATTERNS = [
38
31
  re.compile(r"(?:https?://)?(?:www\.)?youtube\.com/watch\?v=([\w-]{11})"),
@@ -44,34 +37,33 @@ class VideoElement(BaseBlockElement):
44
37
  return block.type == BlockType.VIDEO and block.video
45
38
 
46
39
  @classmethod
47
- def markdown_to_notion(cls, text: str) -> BlockCreateResult:
48
- """Convert markdown video syntax to a Notion VideoBlock plus an empty paragraph."""
49
- match = cls.PATTERN.match(text.strip())
50
- if not match:
40
+ async def markdown_to_notion(cls, text: str) -> BlockCreateResult:
41
+ """Convert markdown video syntax to a Notion VideoBlock."""
42
+ # Use our own regex to find the video URL
43
+ video_match = cls.VIDEO_PATTERN.search(text.strip())
44
+ if not video_match:
51
45
  return None
52
46
 
53
- url, caption_text = match.group(1), match.group(2) or ""
47
+ url = video_match.group(1)
54
48
 
55
49
  vid_id = cls._get_youtube_id(url)
56
50
  if vid_id:
57
51
  url = f"https://www.youtube.com/watch?v={vid_id}"
58
52
 
53
+ # Use mixin to extract caption (if present anywhere in text)
54
+ caption_text = cls.extract_caption(text.strip())
55
+ caption_rich_text = cls.build_caption_rich_text(caption_text or "")
56
+
59
57
  video_block = FileBlock(
60
- type=FileType.EXTERNAL, external=ExternalFile(url=url), caption=[]
58
+ type=FileType.EXTERNAL,
59
+ external=ExternalFile(url=url),
60
+ caption=caption_rich_text,
61
61
  )
62
- if caption_text.strip():
63
- rt = RichTextObject.from_plain_text(caption_text.strip())
64
- video_block.caption = [rt]
65
-
66
- empty_para = ParagraphBlock(rich_text=[])
67
62
 
68
- return [
69
- CreateVideoBlock(video=video_block),
70
- CreateParagraphBlock(paragraph=empty_para),
71
- ]
63
+ return CreateVideoBlock(video=video_block)
72
64
 
73
65
  @classmethod
74
- def notion_to_markdown(cls, block: Block) -> Optional[str]:
66
+ async def notion_to_markdown(cls, block: Block) -> Optional[str]:
75
67
  if block.type != BlockType.VIDEO or not block.video:
76
68
  return None
77
69
 
@@ -85,17 +77,14 @@ class VideoElement(BaseBlockElement):
85
77
  else:
86
78
  return None # (file_upload o.ä. hier nicht supported)
87
79
 
88
- # Captions
89
- captions = fo.caption or []
90
- if not captions:
91
- return f"[video]({url})"
80
+ result = f"[video]({url})"
92
81
 
93
- caption_text = "".join(
94
- (rt.plain_text or TextInlineFormatter.extract_text_with_formatting([rt]))
95
- for rt in captions
96
- )
82
+ # Add caption if present
83
+ caption_markdown = await cls.format_caption_for_markdown(fo.caption or [])
84
+ if caption_markdown:
85
+ result += caption_markdown
97
86
 
98
- return f'[video]({url} "{caption_text}")'
87
+ return result
99
88
 
100
89
  @classmethod
101
90
  def _get_youtube_id(cls, url: str) -> Optional[str]:
@@ -104,3 +93,19 @@ class VideoElement(BaseBlockElement):
104
93
  if m:
105
94
  return m.group(1)
106
95
  return None
96
+
97
+ @classmethod
98
+ def get_system_prompt_information(cls) -> Optional[BlockElementMarkdownInformation]:
99
+ """Get system prompt information for video blocks."""
100
+ return BlockElementMarkdownInformation(
101
+ block_type=cls.__name__,
102
+ description="Video blocks embed videos from external URLs like YouTube, Vimeo, or direct video files",
103
+ syntax_examples=[
104
+ "[video](https://youtube.com/watch?v=abc123)",
105
+ "[video](https://vimeo.com/123456789)",
106
+ "[video](https://example.com/video.mp4)(caption:Demo Video)",
107
+ "(caption:Tutorial)[video](https://youtu.be/abc123)",
108
+ "[video](https://youtube.com/watch?v=xyz)(caption:**Important** tutorial)",
109
+ ],
110
+ usage_guidelines="Use for embedding videos from supported platforms or direct video file URLs. Supports YouTube, Vimeo, and direct video files. Caption supports rich text formatting and describes the video content.",
111
+ )
@@ -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 VideoMarkdownBlockParams(BaseModel):
@@ -12,10 +13,9 @@ class VideoMarkdownBlockParams(BaseModel):
12
13
  caption: Optional[str] = None
13
14
 
14
15
 
15
- class VideoMarkdownNode(MarkdownNode):
16
+ class VideoMarkdownNode(MarkdownNode, CaptionMarkdownNodeMixin):
16
17
  """
17
18
  Programmatic interface for creating Notion-style video blocks.
18
- Example: [video](https://example.com/video.mp4 "Optional caption")
19
19
  """
20
20
 
21
21
  def __init__(self, url: str, caption: Optional[str] = None):
@@ -27,6 +27,11 @@ class VideoMarkdownNode(MarkdownNode):
27
27
  return cls(url=params.url, caption=params.caption)
28
28
 
29
29
  def to_markdown(self) -> str:
30
- if self.caption:
31
- return f'[video]({self.url} "{self.caption}")'
32
- return f"[video]({self.url})"
30
+ """Return the Markdown representation.
31
+
32
+ Examples:
33
+ - [video](https://example.com/movie.mp4)
34
+ - [video](https://www.youtube.com/watch?v=dQw4w9WgXcQ)(caption:Music Video)
35
+ """
36
+ base_markdown = f"[video]({self.url})"
37
+ return self.append_caption_to_markdown(base_markdown, self.caption)
@@ -0,0 +1,26 @@
1
+ from .client import CommentClient
2
+ from .models import (
3
+ Comment,
4
+ CommentAttachment,
5
+ CommentAttachmentExternal,
6
+ CommentAttachmentFile,
7
+ CommentDisplayName,
8
+ CommentListResponse,
9
+ CommentParent,
10
+ FileWithExpiry,
11
+ UserRef,
12
+ )
13
+
14
+
15
+ __all__ = [
16
+ "CommentClient",
17
+ "Comment",
18
+ "CommentAttachment",
19
+ "CommentAttachmentExternal",
20
+ "CommentAttachmentFile",
21
+ "CommentDisplayName",
22
+ "CommentListResponse",
23
+ "CommentParent",
24
+ "FileWithExpiry",
25
+ "UserRef",
26
+ ]
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, AsyncGenerator, Optional
4
+
5
+ from notionary.base_notion_client import BaseNotionClient
6
+ from notionary.blocks.rich_text.rich_text_models import RichTextObject
7
+ from notionary.comments.models import Comment, CommentListResponse
8
+
9
+
10
+ class CommentClient(BaseNotionClient):
11
+ """
12
+ Client for Notion comment operations.
13
+ Uses Pydantic models for typed responses.
14
+
15
+ Notes / API constraints:
16
+ - Listing returns only *unresolved* comments. Resolved comments are not returned.
17
+ - You can create:
18
+ 1) a top-level comment on a page
19
+ 2) a reply in an existing discussion (requires discussion_id)
20
+ You cannot start a brand-new inline thread via API.
21
+ - Read/Insert comment capabilities must be enabled for the integration.
22
+ """
23
+
24
+ async def retrieve_comment(self, comment_id: str) -> Comment:
25
+ """
26
+ Retrieve a single Comment object by its ID.
27
+
28
+ Requires the integration to have "Read comment" capability enabled.
29
+ Raises 403 (restricted_resource) without it.
30
+ """
31
+ resp = await self.get(f"comments/{comment_id}")
32
+ if resp is None:
33
+ raise RuntimeError("Failed to retrieve comment.")
34
+ return Comment.model_validate(resp)
35
+
36
+ async def list_all_comments_for_page(
37
+ self, *, page_id: str, page_size: int = 100
38
+ ) -> list[Comment]:
39
+ """Returns all unresolved comments for a page (handles pagination)."""
40
+ results: list[Comment] = []
41
+ cursor: str | None = None
42
+ while True:
43
+ page = await self.list_comments(
44
+ block_id=page_id, start_cursor=cursor, page_size=page_size
45
+ )
46
+ results.extend(page.results)
47
+ if not page.has_more:
48
+ break
49
+ cursor = page.next_cursor
50
+ return results
51
+
52
+ async def list_comments(
53
+ self,
54
+ *,
55
+ block_id: str,
56
+ start_cursor: Optional[str] = None,
57
+ page_size: Optional[int] = None,
58
+ ) -> CommentListResponse:
59
+ """
60
+ List unresolved comments for a page or block.
61
+
62
+ Args:
63
+ block_id: Page ID or block ID to list comments for.
64
+ start_cursor: Pagination cursor.
65
+ page_size: Max items per page (<= 100).
66
+
67
+ Returns:
68
+ CommentListResponse with results, next_cursor, has_more, etc.
69
+ """
70
+ params: dict[str, str | int] = {"block_id": block_id}
71
+ if start_cursor:
72
+ params["start_cursor"] = start_cursor
73
+ if page_size:
74
+ params["page_size"] = page_size
75
+
76
+ resp = await self.get("comments", params=params)
77
+ if resp is None:
78
+ raise RuntimeError("Failed to list comments.")
79
+ return CommentListResponse.model_validate(resp)
80
+
81
+ async def iter_comments(
82
+ self,
83
+ *,
84
+ block_id: str,
85
+ page_size: int = 100,
86
+ ) -> AsyncGenerator[Comment, None]:
87
+ """
88
+ Async generator over all unresolved comments for a given page/block.
89
+ Handles pagination under the hood.
90
+ """
91
+ cursor: Optional[str] = None
92
+ while True:
93
+ page = await self.list_comments(
94
+ block_id=block_id, start_cursor=cursor, page_size=page_size
95
+ )
96
+ for item in page.results:
97
+ yield item
98
+ if not page.has_more:
99
+ break
100
+ cursor = page.next_cursor
101
+
102
+ async def create_comment_on_page(
103
+ self,
104
+ *,
105
+ page_id: str,
106
+ text: str,
107
+ display_name: Optional[dict] = None,
108
+ attachments: Optional[list[dict]] = None,
109
+ ) -> Comment:
110
+ """
111
+ Create a top-level comment on a page.
112
+
113
+ Args:
114
+ page_id: Target page ID.
115
+ text: Plain text content for the comment (rich_text will be constructed).
116
+ display_name: Optional "Comment Display Name" object to override author label.
117
+ attachments: Optional list of "Comment Attachment" objects (max 3).
118
+
119
+ Returns:
120
+ The created Comment object.
121
+ """
122
+ body: dict = {
123
+ "parent": {"page_id": page_id},
124
+ "rich_text": [{"type": "text", "text": {"content": text}}],
125
+ }
126
+ if display_name:
127
+ body["display_name"] = display_name
128
+ if attachments:
129
+ body["attachments"] = attachments
130
+
131
+ resp = await self.post("comments", data=body)
132
+ if resp is None:
133
+ raise RuntimeError("Failed to create page comment.")
134
+ return Comment.model_validate(resp)
135
+
136
+ async def create_comment(
137
+ self,
138
+ *,
139
+ page_id: Optional[str] = None,
140
+ discussion_id: Optional[str] = None,
141
+ content: Optional[str] = None,
142
+ rich_text: Optional[list[RichTextObject]] = None,
143
+ display_name: Optional[dict[str, Any]] = None,
144
+ attachments: Optional[list[dict[str, Any]]] = None,
145
+ ) -> Comment:
146
+ """
147
+ Create a comment on a page OR reply to an existing discussion.
148
+
149
+ Rules:
150
+ - Exactly one of page_id or discussion_id must be provided.
151
+ - Provide either rich_text OR content (plain text). If both given, rich_text wins.
152
+ - Up to 3 attachments allowed by Notion.
153
+ """
154
+ # validate parent
155
+ if (page_id is None) == (discussion_id is None):
156
+ raise ValueError("Specify exactly one parent: page_id OR discussion_id")
157
+
158
+ # build rich_text if only content is provided
159
+ rt = rich_text if rich_text else None
160
+ if rt is None:
161
+ if not content:
162
+ raise ValueError("Provide either 'rich_text' or 'content'.")
163
+ rt = [{"type": "text", "text": {"content": content}}]
164
+
165
+ body: dict[str, Any] = {"rich_text": rt}
166
+ if page_id:
167
+ body["parent"] = {"page_id": page_id}
168
+ else:
169
+ body["discussion_id"] = discussion_id
170
+
171
+ if display_name:
172
+ body["display_name"] = display_name
173
+ if attachments:
174
+ body["attachments"] = attachments
175
+
176
+ resp = await self.post("comments", data=body)
177
+ if resp is None:
178
+ raise RuntimeError("Failed to create comment.")
179
+ return Comment.model_validate(resp)
180
+
181
+ # ---------- Convenience wrappers ----------
182
+
183
+ async def create_comment_on_page(
184
+ self,
185
+ *,
186
+ page_id: str,
187
+ text: str,
188
+ display_name: Optional[dict] = None,
189
+ attachments: Optional[list[dict]] = None,
190
+ ) -> Comment:
191
+ return await self.create_comment(
192
+ page_id=page_id,
193
+ content=text,
194
+ display_name=display_name,
195
+ attachments=attachments,
196
+ )
197
+
198
+ async def reply_to_discussion(
199
+ self,
200
+ *,
201
+ discussion_id: str,
202
+ text: str,
203
+ display_name: Optional[dict] = None,
204
+ attachments: Optional[list[dict]] = None,
205
+ ) -> Comment:
206
+ return await self.create_comment(
207
+ discussion_id=discussion_id,
208
+ content=text,
209
+ display_name=display_name,
210
+ attachments=attachments,
211
+ )
@@ -0,0 +1,129 @@
1
+ # notionary/comments/models.py
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from typing import Literal, Optional, Union
6
+
7
+ from pydantic import BaseModel, Field, ConfigDict
8
+
9
+ from notionary.blocks.rich_text import RichTextObject
10
+
11
+
12
+ class UserRef(BaseModel):
13
+ """Minimal Notion user reference."""
14
+
15
+ model_config = ConfigDict(extra="ignore")
16
+ object: Literal["user"] = "user"
17
+ id: str
18
+
19
+
20
+ class CommentParent(BaseModel):
21
+ """
22
+ Parent of a comment. Can be page_id or block_id.
23
+ Notion responds with the active one; the other remains None.
24
+ """
25
+
26
+ model_config = ConfigDict(extra="ignore")
27
+ type: Literal["page_id", "block_id"]
28
+ page_id: Optional[str] = None
29
+ block_id: Optional[str] = None
30
+
31
+
32
+ class FileWithExpiry(BaseModel):
33
+ """File object with temporary URL (common Notion pattern)."""
34
+
35
+ model_config = ConfigDict(extra="ignore")
36
+ url: str
37
+ expiry_time: Optional[datetime] = None
38
+
39
+
40
+ class CommentAttachmentFile(BaseModel):
41
+ """Attachment stored by Notion with expiring download URL."""
42
+
43
+ model_config = ConfigDict(extra="ignore")
44
+ type: Literal["file"] = "file"
45
+ name: Optional[str] = None
46
+ file: FileWithExpiry
47
+
48
+
49
+ class CommentAttachmentExternal(BaseModel):
50
+ """External attachment referenced by URL."""
51
+
52
+ model_config = ConfigDict(extra="ignore")
53
+ type: Literal["external"] = "external"
54
+ name: Optional[str] = None
55
+ external: dict # {"url": "..."} – kept generic
56
+
57
+
58
+ CommentAttachment = Union[CommentAttachmentFile, CommentAttachmentExternal]
59
+
60
+
61
+ # ---------------------------
62
+ # Display name override (optional)
63
+ # ---------------------------
64
+
65
+
66
+ class CommentDisplayName(BaseModel):
67
+ """
68
+ Optional display name override for comments created by an integration.
69
+ Example: {"type": "integration", "resolved_name": "int"}.
70
+ """
71
+
72
+ model_config = ConfigDict(extra="ignore")
73
+ type: str
74
+ resolved_name: Optional[str] = None
75
+
76
+
77
+ # ---------------------------
78
+ # Core Comment object
79
+ # ---------------------------
80
+
81
+
82
+ class Comment(BaseModel):
83
+ """
84
+ Notion Comment object as returned by:
85
+ - GET /v1/comments/{comment_id} (retrieve)
86
+ - GET /v1/comments?block_id=... (list -> in results[])
87
+ - POST /v1/comments (create)
88
+ """
89
+
90
+ model_config = ConfigDict(extra="ignore")
91
+
92
+ object: Literal["comment"] = "comment"
93
+ id: str
94
+
95
+ parent: CommentParent
96
+ discussion_id: str
97
+
98
+ created_time: datetime
99
+ last_edited_time: datetime
100
+
101
+ created_by: UserRef
102
+
103
+ rich_text: list[RichTextObject] = Field(default_factory=list)
104
+
105
+ # Optional fields that may appear depending on capabilities/payload
106
+ display_name: Optional[CommentDisplayName] = None
107
+ attachments: Optional[list[CommentAttachment]] = None
108
+
109
+
110
+ # ---------------------------
111
+ # List envelope (for list-comments)
112
+ # ---------------------------
113
+
114
+
115
+ class CommentListResponse(BaseModel):
116
+ """
117
+ Envelope for GET /v1/comments?block_id=...
118
+ """
119
+
120
+ model_config = ConfigDict(extra="ignore")
121
+
122
+ object: Literal["list"] = "list"
123
+ results: list[Comment] = Field(default_factory=list)
124
+ next_cursor: Optional[str] = None
125
+ has_more: bool = False
126
+
127
+ # Notion includes these two fields on the list envelope.
128
+ type: Optional[Literal["comment"]] = None
129
+ comment: Optional[dict] = None
@@ -1,5 +1,7 @@
1
1
  from typing import Any, Dict, Optional
2
2
 
3
+ from urllib3.util import response
4
+
3
5
  from notionary.base_notion_client import BaseNotionClient
4
6
  from notionary.database.models import (
5
7
  NotionDatabaseResponse,
@@ -18,6 +20,27 @@ class NotionDatabaseClient(BaseNotionClient):
18
20
  def __init__(self, token: Optional[str] = None, timeout: int = 30):
19
21
  super().__init__(token, timeout)
20
22
 
23
+ async def create_database(
24
+ self,
25
+ title: str,
26
+ parent_page_id: Optional[str],
27
+ properties: Optional[Dict[str, Any]] = None,
28
+ ) -> NotionDatabaseResponse:
29
+ """
30
+ Creates a new database as child of the specified page.
31
+ """
32
+ if properties is None:
33
+ properties = {"Name": {"title": {}}}
34
+
35
+ database_data = {
36
+ "parent": {"page_id": parent_page_id},
37
+ "title": [{"type": "text", "text": {"content": title}}],
38
+ "properties": properties,
39
+ }
40
+
41
+ response = await self.post("databases", database_data)
42
+ return NotionDatabaseResponse.model_validate(response)
43
+
21
44
  async def get_database(self, database_id: str) -> NotionDatabaseResponse:
22
45
  """
23
46
  Gets metadata for a Notion database by its ID.
@@ -1,4 +1,4 @@
1
- from typing import List, Literal, Optional
1
+ from typing import Literal, Optional
2
2
 
3
3
  from pydantic import BaseModel
4
4
 
@@ -28,7 +28,7 @@ class FileUploadListResponse(BaseModel):
28
28
  """
29
29
 
30
30
  object: Literal["list"]
31
- results: List[FileUploadResponse]
31
+ results: list[FileUploadResponse]
32
32
  next_cursor: Optional[str] = None
33
33
  has_more: bool
34
34
  type: Literal["file_upload"]
@@ -51,6 +51,7 @@ from notionary.blocks.toggleable_heading import (
51
51
  ToggleableHeadingMarkdownBlockParams,
52
52
  ToggleableHeadingMarkdownNode,
53
53
  )
54
+ from notionary.blocks.types import BlockType, MarkdownBlockType
54
55
  from notionary.blocks.video import VideoMarkdownBlockParams, VideoMarkdownNode
55
56
  from notionary.markdown.markdown_document_model import (
56
57
  MarkdownBlock,
@@ -67,32 +68,38 @@ class MarkdownBuilder:
67
68
  def __init__(self) -> None:
68
69
  self.children: list[MarkdownNode] = []
69
70
 
70
- # Explicit mapping instead of dynamic getattr - type-safe and clear
71
71
  self._block_processors: dict[str, Callable[[Any], None]] = {
72
- "heading": self._add_heading,
73
- "paragraph": self._add_paragraph,
74
- "quote": self._add_quote,
75
- "bulleted_list": self._add_bulleted_list,
76
- "numbered_list": self._add_numbered_list,
77
- "todo": self._add_todo,
78
- "callout": self._add_callout,
79
- "code": self._add_code,
80
- "image": self._add_image,
81
- "video": self._add_video,
82
- "audio": self._add_audio,
83
- "file": self._add_file,
84
- "pdf": self._add_pdf,
85
- "bookmark": self._add_bookmark,
86
- "embed": self._add_embed,
87
- "table": self._add_table,
88
- "divider": self._add_divider,
89
- "equation": self._add_equation,
90
- "table_of_contents": self._add_table_of_contents,
91
- "toggle": self._add_toggle,
92
- "toggleable_heading": self._add_toggleable_heading,
93
- "columns": self._add_columns,
94
- "breadcrumb": self._add_breadcrumb,
95
- "space": self._add_space,
72
+ MarkdownBlockType.HEADING_1: self._add_heading,
73
+ MarkdownBlockType.HEADING_2: self._add_heading,
74
+ MarkdownBlockType.HEADING_3: self._add_heading,
75
+ MarkdownBlockType.PARAGRAPH: self._add_paragraph,
76
+ MarkdownBlockType.QUOTE: self._add_quote,
77
+ MarkdownBlockType.BULLETED_LIST_ITEM: self._add_bulleted_list,
78
+ MarkdownBlockType.NUMBERED_LIST_ITEM: self._add_numbered_list,
79
+ MarkdownBlockType.TO_DO: self._add_todo,
80
+ MarkdownBlockType.CALLOUT: self._add_callout,
81
+ MarkdownBlockType.CODE: self._add_code,
82
+ MarkdownBlockType.IMAGE: self._add_image,
83
+ MarkdownBlockType.VIDEO: self._add_video,
84
+ MarkdownBlockType.AUDIO: self._add_audio,
85
+ MarkdownBlockType.FILE: self._add_file,
86
+ MarkdownBlockType.PDF: self._add_pdf,
87
+ MarkdownBlockType.BOOKMARK: self._add_bookmark,
88
+ MarkdownBlockType.EMBED: self._add_embed,
89
+ MarkdownBlockType.TABLE: self._add_table,
90
+ MarkdownBlockType.DIVIDER: self._add_divider,
91
+ MarkdownBlockType.EQUATION: self._add_equation,
92
+ MarkdownBlockType.TABLE_OF_CONTENTS: self._add_table_of_contents,
93
+ MarkdownBlockType.TOGGLE: self._add_toggle,
94
+ MarkdownBlockType.COLUMN_LIST: self._add_columns,
95
+ MarkdownBlockType.BREADCRUMB: self._add_breadcrumb,
96
+ MarkdownBlockType.HEADING: self._add_heading,
97
+ MarkdownBlockType.BULLETED_LIST: self._add_bulleted_list,
98
+ MarkdownBlockType.NUMBERED_LIST: self._add_numbered_list,
99
+ MarkdownBlockType.TODO: self._add_todo,
100
+ MarkdownBlockType.TOGGLEABLE_HEADING: self._add_toggleable_heading,
101
+ MarkdownBlockType.COLUMNS: self._add_columns,
102
+ MarkdownBlockType.SPACE: self._add_space,
96
103
  }
97
104
 
98
105
  @classmethod
@@ -353,7 +360,7 @@ class MarkdownBuilder:
353
360
  return self
354
361
 
355
362
  def bookmark(
356
- self, url: str, title: Optional[str] = None, description: Optional[str] = None
363
+ self, url: str, title: Optional[str] = None, caption: Optional[str] = None
357
364
  ) -> Self:
358
365
  """
359
366
  Add a bookmark.
@@ -364,7 +371,7 @@ class MarkdownBuilder:
364
371
  description: Optional bookmark description text
365
372
  """
366
373
  self.children.append(
367
- BookmarkMarkdownNode(url=url, title=title, description=description)
374
+ BookmarkMarkdownNode(url=url, title=title, caption=caption)
368
375
  )
369
376
  return self
370
377