notionary 0.2.22__py3-none-any.whl → 0.2.24__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/__init__.py +1 -1
- notionary/blocks/__init__.py +3 -1
- notionary/blocks/audio/__init__.py +0 -2
- notionary/blocks/audio/audio_element.py +92 -49
- notionary/blocks/audio/audio_markdown_node.py +4 -17
- notionary/blocks/bookmark/__init__.py +0 -2
- notionary/blocks/bookmark/bookmark_markdown_node.py +5 -21
- notionary/blocks/breadcrumbs/__init__.py +0 -2
- notionary/blocks/breadcrumbs/breadcrumb_markdown_node.py +2 -21
- notionary/blocks/bulleted_list/__init__.py +0 -2
- notionary/blocks/bulleted_list/bulleted_list_markdown_node.py +3 -17
- notionary/blocks/bulleted_list/bulleted_list_models.py +0 -1
- notionary/blocks/callout/__init__.py +0 -2
- notionary/blocks/callout/callout_markdown_node.py +4 -18
- notionary/blocks/callout/callout_models.py +3 -4
- notionary/blocks/child_database/child_database_element.py +2 -4
- notionary/blocks/code/code_markdown_node.py +5 -19
- notionary/blocks/column/__init__.py +0 -4
- notionary/blocks/column/column_list_markdown_node.py +3 -19
- notionary/blocks/column/column_markdown_node.py +4 -21
- notionary/blocks/divider/__init__.py +0 -2
- notionary/blocks/divider/divider_markdown_node.py +2 -16
- notionary/blocks/embed/__init__.py +0 -2
- notionary/blocks/embed/embed_markdown_node.py +4 -17
- notionary/blocks/equation/__init__.py +0 -1
- notionary/blocks/equation/equation_element_markdown_node.py +3 -15
- notionary/blocks/file/__init__.py +0 -2
- notionary/blocks/file/file_element.py +67 -46
- notionary/blocks/file/file_element_markdown_node.py +4 -17
- notionary/blocks/heading/__init__.py +0 -2
- notionary/blocks/heading/heading_markdown_node.py +5 -19
- notionary/blocks/heading/heading_models.py +3 -3
- notionary/blocks/image_block/__init__.py +0 -2
- notionary/blocks/image_block/image_element.py +66 -25
- notionary/blocks/image_block/image_markdown_node.py +5 -20
- notionary/{markdown → blocks/markdown}/markdown_builder.py +29 -233
- notionary/blocks/markdown/markdown_node.py +25 -0
- notionary/blocks/mixins/file_upload/__init__.py +3 -0
- notionary/blocks/mixins/file_upload/file_upload_mixin.py +320 -0
- notionary/blocks/numbered_list/__init__.py +0 -1
- notionary/blocks/numbered_list/numbered_list_markdown_node.py +3 -17
- notionary/blocks/numbered_list/numbered_list_models.py +3 -3
- notionary/blocks/paragraph/__init__.py +0 -2
- notionary/blocks/paragraph/paragraph_markdown_node.py +3 -13
- notionary/blocks/pdf/__init__.py +0 -2
- notionary/blocks/pdf/pdf_element.py +81 -32
- notionary/blocks/pdf/pdf_markdown_node.py +5 -18
- notionary/blocks/quote/__init__.py +0 -2
- notionary/blocks/quote/quote_markdown_node.py +3 -13
- notionary/blocks/registry/__init__.py +1 -2
- notionary/blocks/registry/block_registry.py +116 -61
- notionary/blocks/rich_text/text_inline_formatter.py +1 -1
- notionary/blocks/table/__init__.py +0 -2
- notionary/blocks/table/table_markdown_node.py +17 -16
- notionary/blocks/table_of_contents/__init__.py +0 -2
- notionary/blocks/table_of_contents/table_of_contents_element.py +27 -15
- notionary/blocks/table_of_contents/table_of_contents_markdown_node.py +3 -17
- notionary/blocks/table_of_contents/table_of_contents_models.py +2 -2
- notionary/blocks/todo/__init__.py +0 -2
- notionary/blocks/todo/todo_markdown_node.py +9 -20
- notionary/blocks/todo/todo_models.py +2 -3
- notionary/blocks/toggle/__init__.py +0 -2
- notionary/blocks/toggle/toggle_markdown_node.py +5 -19
- notionary/blocks/toggleable_heading/__init__.py +0 -2
- notionary/blocks/toggleable_heading/toggleable_heading_markdown_node.py +6 -23
- notionary/blocks/video/__init__.py +0 -2
- notionary/blocks/video/video_element.py +110 -34
- notionary/blocks/video/video_markdown_node.py +4 -15
- notionary/comments/__init__.py +26 -0
- notionary/comments/client.py +211 -0
- notionary/comments/models.py +129 -0
- notionary/file_upload/client.py +3 -2
- notionary/file_upload/models.py +10 -1
- notionary/file_upload/notion_file_upload.py +5 -5
- notionary/page/client.py +1 -6
- notionary/page/markdown_whitespace_processor.py +129 -0
- notionary/page/notion_page.py +87 -48
- notionary/page/page_content_deleting_service.py +1 -1
- notionary/page/page_content_writer.py +32 -129
- notionary/page/page_context.py +0 -6
- notionary/page/reader/handler/column_list_renderer.py +2 -2
- notionary/page/reader/handler/column_renderer.py +2 -2
- notionary/page/reader/handler/line_renderer.py +2 -2
- notionary/page/reader/handler/toggle_renderer.py +2 -2
- notionary/page/reader/handler/toggleable_heading_renderer.py +2 -2
- notionary/page/writer/handler/toggle_handler.py +8 -4
- notionary/page/writer/handler/toggleable_heading_handler.py +3 -2
- notionary/page/writer/markdown_to_notion_converter.py +74 -30
- notionary/schemas/__init__.py +3 -0
- notionary/schemas/base.py +73 -0
- notionary/shared/__init__.py +3 -0
- notionary/{blocks/rich_text → shared}/name_to_id_resolver.py +0 -2
- {notionary-0.2.22.dist-info → notionary-0.2.24.dist-info}/METADATA +15 -2
- {notionary-0.2.22.dist-info → notionary-0.2.24.dist-info}/RECORD +97 -95
- notionary/blocks/guards.py +0 -22
- notionary/blocks/registry/block_registry_builder.py +0 -264
- notionary/markdown/makdown_document_model.py +0 -0
- notionary/markdown/markdown_document_model.py +0 -228
- notionary/markdown/markdown_node.py +0 -30
- notionary/models/notion_database_response.py +0 -0
- notionary/page/writer/markdown_to_notion_formatting_post_processor.py +0 -73
- notionary/page/writer/markdown_to_notion_post_processor.py +0 -0
- /notionary/{markdown/___init__.py → blocks/markdown/markdown_document_model.py} +0 -0
- {notionary-0.2.22.dist-info → notionary-0.2.24.dist-info}/LICENSE +0 -0
- {notionary-0.2.22.dist-info → notionary-0.2.24.dist-info}/WHEEL +0 -0
@@ -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/file_upload/client.py
CHANGED
@@ -11,6 +11,7 @@ from notionary.file_upload.models import (
|
|
11
11
|
FileUploadCreateRequest,
|
12
12
|
FileUploadListResponse,
|
13
13
|
FileUploadResponse,
|
14
|
+
UploadMode,
|
14
15
|
)
|
15
16
|
|
16
17
|
|
@@ -25,7 +26,7 @@ class NotionFileUploadClient(BaseNotionClient):
|
|
25
26
|
filename: str,
|
26
27
|
content_type: Optional[str] = None,
|
27
28
|
content_length: Optional[int] = None,
|
28
|
-
mode:
|
29
|
+
mode: UploadMode = UploadMode.SINGLE_PART,
|
29
30
|
) -> Optional[FileUploadResponse]:
|
30
31
|
"""
|
31
32
|
Create a new file upload.
|
@@ -34,7 +35,7 @@ class NotionFileUploadClient(BaseNotionClient):
|
|
34
35
|
filename: Name of the file (max 900 bytes)
|
35
36
|
content_type: MIME type of the file
|
36
37
|
content_length: Size of the file in bytes
|
37
|
-
mode: Upload mode (
|
38
|
+
mode: Upload mode (UploadMode.SINGLE_PART or UploadMode.MULTI_PART)
|
38
39
|
|
39
40
|
Returns:
|
40
41
|
FileUploadResponse or None if failed
|
notionary/file_upload/models.py
CHANGED
@@ -1,8 +1,17 @@
|
|
1
|
+
from enum import Enum
|
2
|
+
|
1
3
|
from typing import Literal, Optional
|
2
4
|
|
3
5
|
from pydantic import BaseModel
|
4
6
|
|
5
7
|
|
8
|
+
class UploadMode(str, Enum):
|
9
|
+
"""Enum for file upload modes."""
|
10
|
+
|
11
|
+
SINGLE_PART = "single_part"
|
12
|
+
MULTI_PART = "multi_part"
|
13
|
+
|
14
|
+
|
6
15
|
class FileUploadResponse(BaseModel):
|
7
16
|
"""
|
8
17
|
Represents a Notion file upload object as returned by the File Upload API.
|
@@ -44,7 +53,7 @@ class FileUploadCreateRequest(BaseModel):
|
|
44
53
|
filename: str
|
45
54
|
content_type: Optional[str] = None
|
46
55
|
content_length: Optional[int] = None
|
47
|
-
mode:
|
56
|
+
mode: UploadMode = UploadMode.SINGLE_PART
|
48
57
|
|
49
58
|
def model_dump(self, **kwargs):
|
50
59
|
"""Override to exclude None values"""
|
@@ -5,7 +5,7 @@ from io import BytesIO
|
|
5
5
|
from pathlib import Path
|
6
6
|
from typing import Optional
|
7
7
|
|
8
|
-
from notionary.file_upload.models import FileUploadResponse
|
8
|
+
from notionary.file_upload.models import FileUploadResponse, UploadMode
|
9
9
|
from notionary.util import LoggingMixin
|
10
10
|
|
11
11
|
|
@@ -196,7 +196,7 @@ class NotionFileUpload(LoggingMixin):
|
|
196
196
|
filename=filename,
|
197
197
|
content_type=content_type,
|
198
198
|
content_length=file_size,
|
199
|
-
mode=
|
199
|
+
mode=UploadMode.SINGLE_PART,
|
200
200
|
)
|
201
201
|
|
202
202
|
if not file_upload:
|
@@ -228,7 +228,7 @@ class NotionFileUpload(LoggingMixin):
|
|
228
228
|
filename=filename,
|
229
229
|
content_type=content_type,
|
230
230
|
content_length=file_size,
|
231
|
-
mode=
|
231
|
+
mode=UploadMode.MULTI_PART,
|
232
232
|
)
|
233
233
|
|
234
234
|
if not file_upload:
|
@@ -269,7 +269,7 @@ class NotionFileUpload(LoggingMixin):
|
|
269
269
|
filename=filename,
|
270
270
|
content_type=content_type,
|
271
271
|
content_length=file_size,
|
272
|
-
mode=
|
272
|
+
mode=UploadMode.SINGLE_PART,
|
273
273
|
)
|
274
274
|
|
275
275
|
if not file_upload:
|
@@ -299,7 +299,7 @@ class NotionFileUpload(LoggingMixin):
|
|
299
299
|
filename=filename,
|
300
300
|
content_type=content_type,
|
301
301
|
content_length=file_size,
|
302
|
-
mode=
|
302
|
+
mode=UploadMode.MULTI_PART,
|
303
303
|
)
|
304
304
|
|
305
305
|
if not file_upload:
|
notionary/page/client.py
CHANGED
@@ -42,18 +42,13 @@ class NotionPageClient(BaseNotionClient):
|
|
42
42
|
)
|
43
43
|
|
44
44
|
properties: dict[str, Any] = {
|
45
|
-
"title": {
|
46
|
-
"title": [
|
47
|
-
{"type": "text", "text": {"content": title}}
|
48
|
-
]
|
49
|
-
}
|
45
|
+
"title": {"title": [{"type": "text", "text": {"content": title}}]}
|
50
46
|
}
|
51
47
|
|
52
48
|
payload = {"parent": parent, "properties": properties}
|
53
49
|
response = await self.post("pages", payload)
|
54
50
|
return NotionPageResponse.model_validate(response)
|
55
51
|
|
56
|
-
|
57
52
|
async def patch_page(
|
58
53
|
self, page_id: str, data: Optional[dict[str, Any]] = None
|
59
54
|
) -> NotionPageResponse:
|
@@ -0,0 +1,129 @@
|
|
1
|
+
"""
|
2
|
+
Markdown whitespace processing utilities.
|
3
|
+
|
4
|
+
Handles normalization of markdown text while preserving code blocks and their indentation.
|
5
|
+
"""
|
6
|
+
|
7
|
+
from typing import Tuple
|
8
|
+
|
9
|
+
|
10
|
+
class MarkdownWhitespaceProcessor:
|
11
|
+
"""
|
12
|
+
Processes markdown text to normalize whitespace while preserving code block formatting.
|
13
|
+
|
14
|
+
This processor handles:
|
15
|
+
- Removing leading whitespace from regular lines
|
16
|
+
- Preserving code block structure and indentation
|
17
|
+
- Normalizing code block markers
|
18
|
+
"""
|
19
|
+
|
20
|
+
@staticmethod
|
21
|
+
def process_markdown_whitespace(markdown_text: str) -> str:
|
22
|
+
"""Process markdown text to normalize whitespace while preserving code blocks."""
|
23
|
+
lines = markdown_text.split("\n")
|
24
|
+
if not lines:
|
25
|
+
return ""
|
26
|
+
|
27
|
+
return MarkdownWhitespaceProcessor._process_whitespace_lines(lines)
|
28
|
+
|
29
|
+
@staticmethod
|
30
|
+
def _process_whitespace_lines(lines: list[str]) -> str:
|
31
|
+
"""Process all lines and return the processed markdown."""
|
32
|
+
processed_lines = []
|
33
|
+
in_code_block = False
|
34
|
+
current_code_block = []
|
35
|
+
|
36
|
+
for line in lines:
|
37
|
+
processed_lines, in_code_block, current_code_block = (
|
38
|
+
MarkdownWhitespaceProcessor._process_single_line(
|
39
|
+
line, processed_lines, in_code_block, current_code_block
|
40
|
+
)
|
41
|
+
)
|
42
|
+
|
43
|
+
return "\n".join(processed_lines)
|
44
|
+
|
45
|
+
@staticmethod
|
46
|
+
def _process_single_line(
|
47
|
+
line: str,
|
48
|
+
processed_lines: list[str],
|
49
|
+
in_code_block: bool,
|
50
|
+
current_code_block: list[str],
|
51
|
+
) -> Tuple[list[str], bool, list[str]]:
|
52
|
+
"""Process a single line and return updated state."""
|
53
|
+
if MarkdownWhitespaceProcessor._is_code_block_marker(line):
|
54
|
+
return MarkdownWhitespaceProcessor._handle_code_block_marker(
|
55
|
+
line, processed_lines, in_code_block, current_code_block
|
56
|
+
)
|
57
|
+
if in_code_block:
|
58
|
+
current_code_block.append(line)
|
59
|
+
return processed_lines, in_code_block, current_code_block
|
60
|
+
else:
|
61
|
+
processed_lines.append(line.lstrip())
|
62
|
+
return processed_lines, in_code_block, current_code_block
|
63
|
+
|
64
|
+
@staticmethod
|
65
|
+
def _handle_code_block_marker(
|
66
|
+
line: str,
|
67
|
+
processed_lines: list[str],
|
68
|
+
in_code_block: bool,
|
69
|
+
current_code_block: list[str],
|
70
|
+
) -> Tuple[list[str], bool, list[str]]:
|
71
|
+
"""Handle code block start/end markers."""
|
72
|
+
if not in_code_block:
|
73
|
+
return MarkdownWhitespaceProcessor._start_code_block(line, processed_lines)
|
74
|
+
else:
|
75
|
+
return MarkdownWhitespaceProcessor._end_code_block(
|
76
|
+
processed_lines, current_code_block
|
77
|
+
)
|
78
|
+
|
79
|
+
@staticmethod
|
80
|
+
def _start_code_block(
|
81
|
+
line: str, processed_lines: list[str]
|
82
|
+
) -> Tuple[list[str], bool, list[str]]:
|
83
|
+
"""Start a new code block."""
|
84
|
+
processed_lines.append(
|
85
|
+
MarkdownWhitespaceProcessor._normalize_code_block_start(line)
|
86
|
+
)
|
87
|
+
return processed_lines, True, []
|
88
|
+
|
89
|
+
@staticmethod
|
90
|
+
def _end_code_block(
|
91
|
+
processed_lines: list[str], current_code_block: list[str]
|
92
|
+
) -> Tuple[list[str], bool, list[str]]:
|
93
|
+
"""End the current code block."""
|
94
|
+
processed_lines.extend(
|
95
|
+
MarkdownWhitespaceProcessor._normalize_code_block_content(
|
96
|
+
current_code_block
|
97
|
+
)
|
98
|
+
)
|
99
|
+
processed_lines.append("```")
|
100
|
+
return processed_lines, False, []
|
101
|
+
|
102
|
+
@staticmethod
|
103
|
+
def _is_code_block_marker(line: str) -> bool:
|
104
|
+
"""Check if line is a code block marker."""
|
105
|
+
return line.lstrip().startswith("```")
|
106
|
+
|
107
|
+
@staticmethod
|
108
|
+
def _normalize_code_block_start(line: str) -> str:
|
109
|
+
"""Normalize code block opening marker."""
|
110
|
+
language = line.lstrip().replace("```", "", 1).strip()
|
111
|
+
return "```" + language
|
112
|
+
|
113
|
+
@staticmethod
|
114
|
+
def _normalize_code_block_content(code_lines: list[str]) -> list[str]:
|
115
|
+
"""Normalize code block indentation."""
|
116
|
+
if not code_lines:
|
117
|
+
return []
|
118
|
+
|
119
|
+
# Find minimum indentation from non-empty lines
|
120
|
+
non_empty_lines = [line for line in code_lines if line.strip()]
|
121
|
+
if not non_empty_lines:
|
122
|
+
return [""] * len(code_lines)
|
123
|
+
|
124
|
+
min_indent = min(len(line) - len(line.lstrip()) for line in non_empty_lines)
|
125
|
+
if min_indent == 0:
|
126
|
+
return code_lines
|
127
|
+
|
128
|
+
# Remove common indentation
|
129
|
+
return ["" if not line.strip() else line[min_indent:] for line in code_lines]
|