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.
- notionary/blocks/_bootstrap.py +9 -1
- notionary/blocks/audio/audio_element.py +53 -28
- notionary/blocks/audio/audio_markdown_node.py +10 -4
- notionary/blocks/base_block_element.py +15 -3
- notionary/blocks/bookmark/bookmark_element.py +39 -36
- notionary/blocks/bookmark/bookmark_markdown_node.py +16 -17
- notionary/blocks/breadcrumbs/breadcrumb_element.py +2 -2
- notionary/blocks/bulleted_list/bulleted_list_element.py +21 -4
- notionary/blocks/callout/callout_element.py +20 -4
- notionary/blocks/child_database/__init__.py +11 -4
- notionary/blocks/child_database/child_database_element.py +59 -0
- notionary/blocks/child_database/child_database_models.py +7 -14
- notionary/blocks/child_page/child_page_element.py +94 -0
- notionary/blocks/client.py +0 -1
- notionary/blocks/code/code_element.py +51 -2
- notionary/blocks/code/code_markdown_node.py +52 -1
- notionary/blocks/column/column_element.py +9 -3
- notionary/blocks/column/column_list_element.py +18 -3
- notionary/blocks/divider/divider_element.py +3 -11
- notionary/blocks/embed/embed_element.py +27 -6
- notionary/blocks/equation/equation_element.py +94 -41
- notionary/blocks/equation/equation_element_markdown_node.py +8 -9
- notionary/blocks/file/file_element.py +56 -37
- notionary/blocks/file/file_element_markdown_node.py +9 -7
- notionary/blocks/guards.py +22 -0
- notionary/blocks/heading/heading_element.py +23 -4
- notionary/blocks/image_block/image_element.py +43 -38
- notionary/blocks/image_block/image_markdown_node.py +10 -5
- notionary/blocks/mixins/captions/__init__.py +4 -0
- notionary/blocks/mixins/captions/caption_markdown_node_mixin.py +31 -0
- notionary/blocks/mixins/captions/caption_mixin.py +92 -0
- notionary/blocks/models.py +3 -1
- notionary/blocks/numbered_list/numbered_list_element.py +21 -4
- notionary/blocks/paragraph/paragraph_element.py +21 -5
- notionary/blocks/pdf/pdf_element.py +47 -41
- notionary/blocks/pdf/pdf_markdown_node.py +9 -7
- notionary/blocks/quote/quote_element.py +26 -9
- notionary/blocks/quote/quote_markdown_node.py +2 -2
- notionary/blocks/registry/block_registry.py +1 -46
- notionary/blocks/registry/block_registry_builder.py +8 -0
- notionary/blocks/rich_text/rich_text_models.py +62 -29
- notionary/blocks/rich_text/text_inline_formatter.py +432 -101
- notionary/blocks/syntax_prompt_builder.py +137 -0
- notionary/blocks/table/table_element.py +110 -9
- notionary/blocks/table_of_contents/table_of_contents_element.py +19 -2
- notionary/blocks/todo/todo_element.py +21 -4
- notionary/blocks/toggle/toggle_element.py +19 -3
- notionary/blocks/toggle/toggle_markdown_node.py +1 -1
- notionary/blocks/toggleable_heading/toggleable_heading_element.py +19 -4
- notionary/blocks/types.py +69 -0
- notionary/blocks/video/video_element.py +44 -39
- notionary/blocks/video/video_markdown_node.py +10 -5
- notionary/comments/__init__.py +26 -0
- notionary/comments/client.py +211 -0
- notionary/comments/models.py +129 -0
- notionary/database/client.py +23 -0
- notionary/file_upload/models.py +2 -2
- notionary/markdown/markdown_builder.py +34 -27
- notionary/page/client.py +21 -6
- notionary/page/notion_page.py +77 -2
- notionary/page/page_content_deleting_service.py +117 -0
- notionary/page/page_content_writer.py +89 -113
- notionary/page/page_context.py +64 -0
- notionary/page/reader/handler/__init__.py +2 -0
- notionary/page/reader/handler/base_block_renderer.py +4 -4
- notionary/page/reader/handler/block_rendering_context.py +5 -0
- notionary/page/reader/handler/line_renderer.py +16 -3
- notionary/page/reader/handler/numbered_list_renderer.py +85 -0
- notionary/page/reader/page_content_retriever.py +17 -5
- notionary/page/writer/handler/__init__.py +2 -0
- notionary/page/writer/handler/code_handler.py +12 -40
- notionary/page/writer/handler/column_handler.py +12 -12
- notionary/page/writer/handler/column_list_handler.py +13 -13
- notionary/page/writer/handler/equation_handler.py +74 -0
- notionary/page/writer/handler/line_handler.py +4 -4
- notionary/page/writer/handler/regular_line_handler.py +31 -37
- notionary/page/writer/handler/table_handler.py +8 -72
- notionary/page/writer/handler/toggle_handler.py +14 -12
- notionary/page/writer/handler/toggleable_heading_handler.py +22 -16
- notionary/page/writer/markdown_to_notion_converter.py +28 -9
- notionary/page/writer/markdown_to_notion_converter_context.py +30 -0
- notionary/page/writer/markdown_to_notion_formatting_post_processor.py +73 -0
- notionary/page/writer/markdown_to_notion_post_processor.py +0 -0
- notionary/page/writer/markdown_to_notion_text_length_post_processor.py +0 -0
- notionary/page/writer/notion_text_length_processor.py +150 -0
- notionary/shared/__init__.py +5 -0
- notionary/shared/name_to_id_resolver.py +203 -0
- notionary/telemetry/service.py +0 -1
- notionary/user/notion_user_manager.py +22 -95
- notionary/util/concurrency_limiter.py +0 -0
- notionary/workspace.py +4 -4
- notionary-0.2.23.dist-info/METADATA +235 -0
- {notionary-0.2.21.dist-info → notionary-0.2.23.dist-info}/RECORD +96 -77
- notionary/page/markdown_whitespace_processor.py +0 -80
- notionary/page/notion_text_length_utils.py +0 -119
- notionary/user/notion_user_provider.py +0 -1
- notionary-0.2.21.dist-info/METADATA +0 -229
- /notionary/page/reader/handler/{context.py → equation_renderer.py} +0 -0
- {notionary-0.2.21.dist-info → notionary-0.2.23.dist-info}/LICENSE +0 -0
- {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
|
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
|
-
|
31
|
-
|
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
|
49
|
-
|
50
|
-
|
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
|
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,
|
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
|
-
|
89
|
-
captions = fo.caption or []
|
90
|
-
if not captions:
|
91
|
-
return f"[video]({url})"
|
80
|
+
result = f"[video]({url})"
|
92
81
|
|
93
|
-
|
94
|
-
|
95
|
-
|
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
|
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
|
-
|
31
|
-
|
32
|
-
|
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
|
notionary/database/client.py
CHANGED
@@ -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.
|
notionary/file_upload/models.py
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
from typing import
|
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:
|
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
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
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,
|
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,
|
374
|
+
BookmarkMarkdownNode(url=url, title=title, caption=caption)
|
368
375
|
)
|
369
376
|
return self
|
370
377
|
|