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
notionary/page/client.py
CHANGED
@@ -20,18 +20,33 @@ class NotionPageClient(BaseNotionClient):
|
|
20
20
|
|
21
21
|
async def create_page(
|
22
22
|
self,
|
23
|
+
*,
|
23
24
|
parent_database_id: Optional[str] = None,
|
24
|
-
|
25
|
+
parent_page_id: Optional[str] = None,
|
26
|
+
title: str,
|
25
27
|
) -> NotionPageResponse:
|
26
28
|
"""
|
27
|
-
Creates a new page in a
|
29
|
+
Creates a new page either in a database or as a child of another page.
|
30
|
+
Exactly one of parent_database_id or parent_page_id must be provided.
|
31
|
+
Only 'title' is supported here (no icon/cover/children).
|
28
32
|
"""
|
29
|
-
|
30
|
-
|
31
|
-
"
|
33
|
+
# Exakt einen Parent zulassen
|
34
|
+
if (parent_database_id is None) == (parent_page_id is None):
|
35
|
+
raise ValueError("Specify exactly one parent: database OR page")
|
36
|
+
|
37
|
+
# Parent bauen
|
38
|
+
parent = (
|
39
|
+
{"database_id": parent_database_id}
|
40
|
+
if parent_database_id
|
41
|
+
else {"page_id": parent_page_id}
|
42
|
+
)
|
43
|
+
|
44
|
+
properties: dict[str, Any] = {
|
45
|
+
"title": {"title": [{"type": "text", "text": {"content": title}}]}
|
32
46
|
}
|
33
47
|
|
34
|
-
|
48
|
+
payload = {"parent": parent, "properties": properties}
|
49
|
+
response = await self.post("pages", payload)
|
35
50
|
return NotionPageResponse.model_validate(response)
|
36
51
|
|
37
52
|
async def patch_page(
|
notionary/page/notion_page.py
CHANGED
@@ -5,12 +5,16 @@ import random
|
|
5
5
|
from typing import TYPE_CHECKING, Any, Callable, Optional, Union
|
6
6
|
|
7
7
|
from notionary.blocks.client import NotionBlockClient
|
8
|
+
from notionary.comments import CommentClient, Comment
|
9
|
+
from notionary.blocks.syntax_prompt_builder import SyntaxPromptBuilder
|
8
10
|
from notionary.blocks.models import DatabaseParent
|
9
11
|
from notionary.blocks.registry.block_registry import BlockRegistry
|
10
12
|
from notionary.blocks.registry.block_registry_builder import BlockRegistryBuilder
|
13
|
+
from notionary.database.client import NotionDatabaseClient
|
11
14
|
from notionary.markdown.markdown_builder import MarkdownBuilder
|
12
15
|
from notionary.page.client import NotionPageClient
|
13
16
|
from notionary.page.models import NotionPageResponse
|
17
|
+
from notionary.page.page_content_deleting_service import PageContentDeletingService
|
14
18
|
from notionary.page.page_content_writer import PageContentWriter
|
15
19
|
from notionary.page.property_formatter import NotionPropertyFormatter
|
16
20
|
from notionary.page.reader.page_content_retriever import PageContentRetriever
|
@@ -54,6 +58,7 @@ class NotionPage(LoggingMixin):
|
|
54
58
|
|
55
59
|
self._client = NotionPageClient(token=token)
|
56
60
|
self._block_client = NotionBlockClient(token=token)
|
61
|
+
self._comment_client = CommentClient(token=token)
|
57
62
|
self._page_data = None
|
58
63
|
|
59
64
|
self.block_element_registry = BlockRegistry.create_registry()
|
@@ -63,6 +68,11 @@ class NotionPage(LoggingMixin):
|
|
63
68
|
block_registry=self.block_element_registry,
|
64
69
|
)
|
65
70
|
|
71
|
+
self._page_content_deleting_service = PageContentDeletingService(
|
72
|
+
page_id=self._page_id,
|
73
|
+
block_registry=self.block_element_registry,
|
74
|
+
)
|
75
|
+
|
66
76
|
self._page_content_retriever = PageContentRetriever(
|
67
77
|
block_registry=self.block_element_registry,
|
68
78
|
)
|
@@ -202,6 +212,45 @@ class NotionPage(LoggingMixin):
|
|
202
212
|
"""
|
203
213
|
return self.block_element_registry.builder
|
204
214
|
|
215
|
+
def get_prompt_information(self) -> str:
|
216
|
+
markdown_syntax_builder = SyntaxPromptBuilder()
|
217
|
+
return markdown_syntax_builder.build_concise_reference()
|
218
|
+
|
219
|
+
async def get_comments(self) -> list[Comment]:
|
220
|
+
return await self._comment_client.list_all_comments_for_page(page_id=self._page_id)
|
221
|
+
|
222
|
+
async def post_comment(
|
223
|
+
self,
|
224
|
+
content: str,
|
225
|
+
*,
|
226
|
+
discussion_id: Optional[str] = None,
|
227
|
+
rich_text: Optional[list[dict[str, Any]]] = None,
|
228
|
+
) -> Optional[Comment]:
|
229
|
+
"""
|
230
|
+
Post a comment on this page.
|
231
|
+
|
232
|
+
Args:
|
233
|
+
content: The plain text content of the comment
|
234
|
+
discussion_id: Optional discussion ID to reply to an existing discussion
|
235
|
+
rich_text: Optional rich text formatting for the comment content
|
236
|
+
|
237
|
+
Returns:
|
238
|
+
Comment: The created comment object, or None if creation failed
|
239
|
+
"""
|
240
|
+
try:
|
241
|
+
# Use the comment client to create the comment
|
242
|
+
comment = await self._comment_client.create_comment(
|
243
|
+
page_id=self._page_id,
|
244
|
+
content=content,
|
245
|
+
discussion_id=discussion_id,
|
246
|
+
rich_text=rich_text,
|
247
|
+
)
|
248
|
+
self.logger.info(f"Successfully posted comment on page '{self._title}'")
|
249
|
+
return comment
|
250
|
+
except Exception as e:
|
251
|
+
self.logger.error(f"Failed to post comment on page '{self._title}': {str(e)}")
|
252
|
+
return None
|
253
|
+
|
205
254
|
async def set_title(self, title: str) -> str:
|
206
255
|
"""
|
207
256
|
Set the title of the page.
|
@@ -264,7 +313,7 @@ class NotionPage(LoggingMixin):
|
|
264
313
|
Returns:
|
265
314
|
bool: True if successful, False otherwise
|
266
315
|
"""
|
267
|
-
clear_result = await self.
|
316
|
+
clear_result = await self._page_content_deleting_service.clear_page_content()
|
268
317
|
if not clear_result:
|
269
318
|
self.logger.error("Failed to clear page content before replacement")
|
270
319
|
|
@@ -279,7 +328,7 @@ class NotionPage(LoggingMixin):
|
|
279
328
|
"""
|
280
329
|
Clear all content from the page.
|
281
330
|
"""
|
282
|
-
return await self.
|
331
|
+
return await self._page_content_deleting_service.clear_page_content()
|
283
332
|
|
284
333
|
async def get_text_content(self) -> str:
|
285
334
|
"""
|
@@ -310,6 +359,32 @@ class NotionPage(LoggingMixin):
|
|
310
359
|
self.logger.error(f"Error updating page emoji: {str(e)}")
|
311
360
|
return None
|
312
361
|
|
362
|
+
async def create_child_database(self, title: str) -> NotionDatabase:
|
363
|
+
from notionary import NotionDatabase
|
364
|
+
|
365
|
+
database_client = NotionDatabaseClient(token=self._client.token)
|
366
|
+
|
367
|
+
create_database_response = await database_client.create_database(
|
368
|
+
title=title,
|
369
|
+
parent_page_id=self._page_id,
|
370
|
+
)
|
371
|
+
|
372
|
+
return await NotionDatabase.from_database_id(
|
373
|
+
id=create_database_response.id, token=self._client.token
|
374
|
+
)
|
375
|
+
|
376
|
+
async def create_child_page(self, title: str) -> NotionPage:
|
377
|
+
from notionary import NotionPage
|
378
|
+
|
379
|
+
child_page_response = await self._client.create_page(
|
380
|
+
parent_page_id=self._page_id,
|
381
|
+
title=title,
|
382
|
+
)
|
383
|
+
|
384
|
+
return await NotionPage.from_page_id(
|
385
|
+
page_id=child_page_response.id, token=self._client.token
|
386
|
+
)
|
387
|
+
|
313
388
|
async def set_external_icon(self, url: str) -> Optional[str]:
|
314
389
|
"""
|
315
390
|
Sets the page icon to an external image.
|
@@ -0,0 +1,117 @@
|
|
1
|
+
from typing import Optional
|
2
|
+
|
3
|
+
from notionary.blocks.client import NotionBlockClient
|
4
|
+
from notionary.blocks.models import Block
|
5
|
+
from notionary.blocks.registry.block_registry import BlockRegistry
|
6
|
+
from notionary.page.reader.page_content_retriever import PageContentRetriever
|
7
|
+
from notionary.util import LoggingMixin
|
8
|
+
|
9
|
+
|
10
|
+
class PageContentDeletingService(LoggingMixin):
|
11
|
+
"""Service responsible for deleting page content and blocks."""
|
12
|
+
|
13
|
+
def __init__(self, page_id: str, block_registry: BlockRegistry):
|
14
|
+
self.page_id = page_id
|
15
|
+
self.block_registry = block_registry
|
16
|
+
self._block_client = NotionBlockClient()
|
17
|
+
self._content_retriever = PageContentRetriever(block_registry=block_registry)
|
18
|
+
|
19
|
+
async def clear_page_content(self) -> Optional[str]:
|
20
|
+
"""Clear all content of the page and return deleted content as markdown."""
|
21
|
+
try:
|
22
|
+
children_response = await self._block_client.get_block_children(
|
23
|
+
block_id=self.page_id
|
24
|
+
)
|
25
|
+
|
26
|
+
if not children_response or not children_response.results:
|
27
|
+
return None
|
28
|
+
|
29
|
+
# Use PageContentRetriever for sophisticated markdown conversion
|
30
|
+
deleted_content = self._content_retriever._convert_blocks_to_markdown(
|
31
|
+
children_response.results, indent_level=0
|
32
|
+
)
|
33
|
+
|
34
|
+
# Delete blocks
|
35
|
+
success = True
|
36
|
+
for block in children_response.results:
|
37
|
+
block_success = await self._delete_block_with_children(block)
|
38
|
+
if not block_success:
|
39
|
+
success = False
|
40
|
+
|
41
|
+
if not success:
|
42
|
+
self.logger.warning("Some blocks could not be deleted")
|
43
|
+
|
44
|
+
return deleted_content if deleted_content else None
|
45
|
+
|
46
|
+
except Exception:
|
47
|
+
self.logger.error("Error clearing page content", exc_info=True)
|
48
|
+
return None
|
49
|
+
|
50
|
+
async def _delete_block_with_children(self, block: Block) -> bool:
|
51
|
+
"""Delete a block and all its children recursively."""
|
52
|
+
if not block.id:
|
53
|
+
self.logger.error("Block has no valid ID")
|
54
|
+
return False
|
55
|
+
|
56
|
+
self.logger.debug("Deleting block: %s (type: %s)", block.id, block.type)
|
57
|
+
|
58
|
+
try:
|
59
|
+
if block.has_children and not await self._delete_block_children(block):
|
60
|
+
return False
|
61
|
+
|
62
|
+
return await self._delete_single_block(block)
|
63
|
+
|
64
|
+
except Exception as e:
|
65
|
+
self.logger.error("Failed to delete block %s: %s", block.id, str(e))
|
66
|
+
return False
|
67
|
+
|
68
|
+
async def _delete_block_children(self, block: Block) -> bool:
|
69
|
+
"""Delete all children of a block."""
|
70
|
+
self.logger.debug("Block %s has children, deleting children first", block.id)
|
71
|
+
|
72
|
+
try:
|
73
|
+
children_blocks = await self._block_client.get_all_block_children(block.id)
|
74
|
+
|
75
|
+
if not children_blocks:
|
76
|
+
self.logger.debug("No children found for block: %s", block.id)
|
77
|
+
return True
|
78
|
+
|
79
|
+
self.logger.debug(
|
80
|
+
"Found %d children to delete for block: %s",
|
81
|
+
len(children_blocks),
|
82
|
+
block.id,
|
83
|
+
)
|
84
|
+
|
85
|
+
# Delete all children recursively
|
86
|
+
for child_block in children_blocks:
|
87
|
+
if not await self._delete_block_with_children(child_block):
|
88
|
+
self.logger.error(
|
89
|
+
"Failed to delete child block: %s", child_block.id
|
90
|
+
)
|
91
|
+
return False
|
92
|
+
|
93
|
+
self.logger.debug(
|
94
|
+
"Successfully deleted all children of block: %s", block.id
|
95
|
+
)
|
96
|
+
return True
|
97
|
+
|
98
|
+
except Exception as e:
|
99
|
+
self.logger.error(
|
100
|
+
"Failed to delete children of block %s: %s", block.id, str(e)
|
101
|
+
)
|
102
|
+
return False
|
103
|
+
|
104
|
+
async def _delete_single_block(self, block: Block) -> bool:
|
105
|
+
"""Delete a single block."""
|
106
|
+
deleted_block: Optional[Block] = await self._block_client.delete_block(block.id)
|
107
|
+
|
108
|
+
if deleted_block is None:
|
109
|
+
self.logger.error("Failed to delete block: %s", block.id)
|
110
|
+
return False
|
111
|
+
|
112
|
+
if deleted_block.archived or deleted_block.in_trash:
|
113
|
+
self.logger.debug("Successfully deleted/archived block: %s", block.id)
|
114
|
+
return True
|
115
|
+
else:
|
116
|
+
self.logger.warning("Block %s was not properly archived/deleted", block.id)
|
117
|
+
return False
|
@@ -2,12 +2,9 @@ from typing import Callable, Optional, Union
|
|
2
2
|
|
3
3
|
from notionary.blocks.client import NotionBlockClient
|
4
4
|
from notionary.blocks.divider import DividerElement
|
5
|
-
from notionary.blocks.models import Block
|
6
5
|
from notionary.blocks.registry.block_registry import BlockRegistry
|
7
6
|
from notionary.blocks.table_of_contents import TableOfContentsElement
|
8
7
|
from notionary.markdown.markdown_builder import MarkdownBuilder
|
9
|
-
from notionary.page.markdown_whitespace_processor import MarkdownWhitespaceProcessor
|
10
|
-
from notionary.page.reader.page_content_retriever import PageContentRetriever
|
11
8
|
from notionary.page.writer.markdown_to_notion_converter import MarkdownToNotionConverter
|
12
9
|
from notionary.util import LoggingMixin
|
13
10
|
|
@@ -22,8 +19,6 @@ class PageContentWriter(LoggingMixin):
|
|
22
19
|
block_registry=block_registry
|
23
20
|
)
|
24
21
|
|
25
|
-
self._content_retriever = PageContentRetriever(block_registry=block_registry)
|
26
|
-
|
27
22
|
async def append_markdown(
|
28
23
|
self,
|
29
24
|
content: Union[str, Callable[[MarkdownBuilder], MarkdownBuilder]],
|
@@ -33,14 +28,6 @@ class PageContentWriter(LoggingMixin):
|
|
33
28
|
) -> Optional[str]:
|
34
29
|
"""
|
35
30
|
Append markdown content to a Notion page using either text or builder callback.
|
36
|
-
|
37
|
-
Args:
|
38
|
-
content: Either raw markdown text OR a callback function that receives a MarkdownBuilder
|
39
|
-
append_divider: Whether to append a divider
|
40
|
-
prepend_table_of_contents: Whether to prepend table of contents
|
41
|
-
|
42
|
-
Returns:
|
43
|
-
str: The processed markdown content that was appended (None if failed)
|
44
31
|
"""
|
45
32
|
|
46
33
|
if isinstance(content, str):
|
@@ -66,7 +53,9 @@ class PageContentWriter(LoggingMixin):
|
|
66
53
|
processed_markdown = self._process_markdown_whitespace(final_markdown)
|
67
54
|
|
68
55
|
try:
|
69
|
-
blocks = self._markdown_to_notion_converter.convert(
|
56
|
+
blocks = await self._markdown_to_notion_converter.convert(
|
57
|
+
processed_markdown
|
58
|
+
)
|
70
59
|
|
71
60
|
result = await self._block_client.append_block_children(
|
72
61
|
block_id=self.page_id, children=blocks
|
@@ -83,114 +72,101 @@ class PageContentWriter(LoggingMixin):
|
|
83
72
|
self.logger.error("Error appending markdown: %s", str(e), exc_info=True)
|
84
73
|
return None
|
85
74
|
|
86
|
-
|
87
|
-
"""
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
)
|
92
|
-
|
93
|
-
if not children_response or not children_response.results:
|
94
|
-
return None
|
95
|
-
|
96
|
-
# Use PageContentRetriever for sophisticated markdown conversion
|
97
|
-
deleted_content = self._content_retriever._convert_blocks_to_markdown(
|
98
|
-
children_response.results, indent_level=0
|
99
|
-
)
|
100
|
-
|
101
|
-
# Delete blocks
|
102
|
-
success = True
|
103
|
-
for block in children_response.results:
|
104
|
-
block_success = await self._delete_block_with_children(block)
|
105
|
-
if not block_success:
|
106
|
-
success = False
|
107
|
-
|
108
|
-
if not success:
|
109
|
-
self.logger.warning("Some blocks could not be deleted")
|
110
|
-
|
111
|
-
return deleted_content if deleted_content else None
|
112
|
-
|
113
|
-
except Exception:
|
114
|
-
self.logger.error("Error clearing page content", exc_info=True)
|
115
|
-
return None
|
116
|
-
|
117
|
-
async def _delete_block_with_children(self, block: Block) -> bool:
|
118
|
-
"""Delete a block and all its children recursively."""
|
119
|
-
if not block.id:
|
120
|
-
self.logger.error("Block has no valid ID")
|
121
|
-
return False
|
122
|
-
|
123
|
-
self.logger.debug("Deleting block: %s (type: %s)", block.id, block.type)
|
124
|
-
|
125
|
-
try:
|
126
|
-
if block.has_children and not await self._delete_block_children(block):
|
127
|
-
return False
|
128
|
-
|
129
|
-
return await self._delete_single_block(block)
|
130
|
-
|
131
|
-
except Exception as e:
|
132
|
-
self.logger.error("Failed to delete block %s: %s", block.id, str(e))
|
133
|
-
return False
|
134
|
-
|
135
|
-
async def _delete_block_children(self, block: Block) -> bool:
|
136
|
-
"""Delete all children of a block."""
|
137
|
-
self.logger.debug("Block %s has children, deleting children first", block.id)
|
75
|
+
def _process_markdown_whitespace(self, markdown_text: str) -> str:
|
76
|
+
"""Process markdown text to normalize whitespace while preserving code blocks."""
|
77
|
+
lines = markdown_text.split("\n")
|
78
|
+
if not lines:
|
79
|
+
return ""
|
138
80
|
|
139
|
-
|
140
|
-
children_blocks = await self._block_client.get_all_block_children(block.id)
|
81
|
+
return self._process_whitespace_lines(lines)
|
141
82
|
|
142
|
-
|
143
|
-
|
144
|
-
|
83
|
+
def _process_whitespace_lines(self, lines: list[str]) -> str:
|
84
|
+
"""Process all lines and return the processed markdown."""
|
85
|
+
processed_lines = []
|
86
|
+
in_code_block = False
|
87
|
+
current_code_block = []
|
145
88
|
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
89
|
+
for line in lines:
|
90
|
+
processed_lines, in_code_block, current_code_block = (
|
91
|
+
self._process_single_line(
|
92
|
+
line, processed_lines, in_code_block, current_code_block
|
93
|
+
)
|
150
94
|
)
|
151
95
|
|
152
|
-
|
153
|
-
for child_block in children_blocks:
|
154
|
-
if not await self._delete_block_with_children(child_block):
|
155
|
-
self.logger.error(
|
156
|
-
"Failed to delete child block: %s", child_block.id
|
157
|
-
)
|
158
|
-
return False
|
159
|
-
|
160
|
-
self.logger.debug(
|
161
|
-
"Successfully deleted all children of block: %s", block.id
|
162
|
-
)
|
163
|
-
return True
|
96
|
+
return "\n".join(processed_lines)
|
164
97
|
|
165
|
-
|
166
|
-
|
167
|
-
|
98
|
+
def _process_single_line(
|
99
|
+
self,
|
100
|
+
line: str,
|
101
|
+
processed_lines: list[str],
|
102
|
+
in_code_block: bool,
|
103
|
+
current_code_block: list[str],
|
104
|
+
) -> tuple[list[str], bool, list[str]]:
|
105
|
+
"""Process a single line and return updated state."""
|
106
|
+
if self._is_code_block_marker(line):
|
107
|
+
return self._handle_code_block_marker(
|
108
|
+
line, processed_lines, in_code_block, current_code_block
|
168
109
|
)
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
"""Delete a single block."""
|
173
|
-
deleted_block: Optional[Block] = await self._block_client.delete_block(block.id)
|
174
|
-
|
175
|
-
if deleted_block is None:
|
176
|
-
self.logger.error("Failed to delete block: %s", block.id)
|
177
|
-
return False
|
178
|
-
|
179
|
-
if deleted_block.archived or deleted_block.in_trash:
|
180
|
-
self.logger.debug("Successfully deleted/archived block: %s", block.id)
|
181
|
-
return True
|
110
|
+
if in_code_block:
|
111
|
+
current_code_block.append(line)
|
112
|
+
return processed_lines, in_code_block, current_code_block
|
182
113
|
else:
|
183
|
-
|
184
|
-
return
|
114
|
+
processed_lines.append(line.lstrip())
|
115
|
+
return processed_lines, in_code_block, current_code_block
|
185
116
|
|
186
|
-
def
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
117
|
+
def _handle_code_block_marker(
|
118
|
+
self,
|
119
|
+
line: str,
|
120
|
+
processed_lines: list[str],
|
121
|
+
in_code_block: bool,
|
122
|
+
current_code_block: list[str],
|
123
|
+
) -> tuple[list[str], bool, list[str]]:
|
124
|
+
"""Handle code block start/end markers."""
|
125
|
+
if not in_code_block:
|
126
|
+
return self._start_code_block(line, processed_lines)
|
127
|
+
else:
|
128
|
+
return self._end_code_block(processed_lines, current_code_block)
|
129
|
+
|
130
|
+
def _start_code_block(
|
131
|
+
self, line: str, processed_lines: list[str]
|
132
|
+
) -> tuple[list[str], bool, list[str]]:
|
133
|
+
"""Start a new code block."""
|
134
|
+
processed_lines.append(self._normalize_code_block_start(line))
|
135
|
+
return processed_lines, True, []
|
136
|
+
|
137
|
+
def _end_code_block(
|
138
|
+
self, processed_lines: list[str], current_code_block: list[str]
|
139
|
+
) -> tuple[list[str], bool, list[str]]:
|
140
|
+
"""End the current code block."""
|
141
|
+
processed_lines.extend(self._normalize_code_block_content(current_code_block))
|
142
|
+
processed_lines.append("```")
|
143
|
+
return processed_lines, False, []
|
144
|
+
|
145
|
+
def _is_code_block_marker(self, line: str) -> bool:
|
146
|
+
"""Check if line is a code block marker."""
|
147
|
+
return line.lstrip().startswith("```")
|
148
|
+
|
149
|
+
def _normalize_code_block_start(self, line: str) -> str:
|
150
|
+
"""Normalize code block opening marker."""
|
151
|
+
language = line.lstrip().replace("```", "", 1).strip()
|
152
|
+
return "```" + language
|
153
|
+
|
154
|
+
def _normalize_code_block_content(self, code_lines: list[str]) -> list[str]:
|
155
|
+
"""Normalize code block indentation."""
|
156
|
+
if not code_lines:
|
157
|
+
return []
|
158
|
+
|
159
|
+
# Find minimum indentation from non-empty lines
|
160
|
+
non_empty_lines = [line for line in code_lines if line.strip()]
|
161
|
+
if not non_empty_lines:
|
162
|
+
return [""] * len(code_lines)
|
163
|
+
|
164
|
+
min_indent = min(len(line) - len(line.lstrip()) for line in non_empty_lines)
|
165
|
+
if min_indent == 0:
|
166
|
+
return code_lines
|
167
|
+
|
168
|
+
# Remove common indentation
|
169
|
+
return ["" if not line.strip() else line[min_indent:] for line in code_lines]
|
194
170
|
|
195
171
|
def _ensure_table_of_contents_exists_in_registry(self) -> None:
|
196
172
|
"""Ensure TableOfContents is registered in the block registry."""
|
@@ -0,0 +1,64 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
from typing import TYPE_CHECKING, Optional
|
4
|
+
from dataclasses import dataclass
|
5
|
+
from contextvars import ContextVar
|
6
|
+
|
7
|
+
if TYPE_CHECKING:
|
8
|
+
from notionary.database.client import NotionDatabaseClient
|
9
|
+
from notionary.file_upload import NotionFileUploadClient
|
10
|
+
|
11
|
+
|
12
|
+
@dataclass(frozen=True)
|
13
|
+
class PageContextProvider:
|
14
|
+
"""Context object that provides dependencies for block conversion operations."""
|
15
|
+
|
16
|
+
page_id: str
|
17
|
+
database_client: NotionDatabaseClient
|
18
|
+
file_upload_client: NotionFileUploadClient
|
19
|
+
|
20
|
+
|
21
|
+
# Context variable
|
22
|
+
_page_context: ContextVar[Optional[PageContextProvider]] = ContextVar(
|
23
|
+
"page_context", default=None
|
24
|
+
)
|
25
|
+
|
26
|
+
|
27
|
+
def get_page_context() -> PageContextProvider:
|
28
|
+
"""Get current page context or raise if not available."""
|
29
|
+
context = _page_context.get()
|
30
|
+
if context is None:
|
31
|
+
raise RuntimeError(
|
32
|
+
"No page context available. Use 'async with page_context(...)'"
|
33
|
+
)
|
34
|
+
return context
|
35
|
+
|
36
|
+
|
37
|
+
def get_page_context_optional() -> Optional[PageContextProvider]:
|
38
|
+
"""Get current page context or None if not available."""
|
39
|
+
return _page_context.get()
|
40
|
+
|
41
|
+
|
42
|
+
class page_context:
|
43
|
+
"""Async-only context manager for page operations."""
|
44
|
+
|
45
|
+
def __init__(self, provider: PageContextProvider):
|
46
|
+
self.provider = provider
|
47
|
+
self._token = None
|
48
|
+
|
49
|
+
def _set_context(self) -> PageContextProvider:
|
50
|
+
"""Helper to set context and return provider."""
|
51
|
+
self._token = _page_context.set(self.provider)
|
52
|
+
return self.provider
|
53
|
+
|
54
|
+
def _reset_context(self) -> None:
|
55
|
+
"""Helper to reset context."""
|
56
|
+
if self._token is not None:
|
57
|
+
_page_context.reset(self._token)
|
58
|
+
|
59
|
+
async def __aenter__(self) -> PageContextProvider:
|
60
|
+
return self._set_context()
|
61
|
+
|
62
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
63
|
+
self._reset_context()
|
64
|
+
return False
|
@@ -3,6 +3,7 @@ from .block_rendering_context import BlockRenderingContext
|
|
3
3
|
from .column_list_renderer import ColumnListRenderer
|
4
4
|
from .column_renderer import ColumnRenderer
|
5
5
|
from .line_renderer import LineRenderer
|
6
|
+
from .numbered_list_renderer import NumberedListRenderer
|
6
7
|
from .toggle_renderer import ToggleRenderer
|
7
8
|
from .toggleable_heading_renderer import ToggleableHeadingRenderer
|
8
9
|
|
@@ -12,6 +13,7 @@ __all__ = [
|
|
12
13
|
"ColumnListRenderer",
|
13
14
|
"ColumnRenderer",
|
14
15
|
"LineRenderer",
|
16
|
+
"NumberedListRenderer",
|
15
17
|
"ToggleRenderer",
|
16
18
|
"ToggleableHeadingRenderer",
|
17
19
|
]
|
@@ -17,12 +17,12 @@ class BlockHandler(ABC):
|
|
17
17
|
self._next_handler = handler
|
18
18
|
return handler
|
19
19
|
|
20
|
-
def handle(self, context: BlockRenderingContext) -> None:
|
20
|
+
async def handle(self, context: BlockRenderingContext) -> None:
|
21
21
|
"""Handle the block or pass to next handler."""
|
22
22
|
if self._can_handle(context):
|
23
|
-
self._process(context)
|
23
|
+
await self._process(context)
|
24
24
|
elif self._next_handler:
|
25
|
-
self._next_handler.handle(context)
|
25
|
+
await self._next_handler.handle(context)
|
26
26
|
|
27
27
|
@abstractmethod
|
28
28
|
def _can_handle(self, context: BlockRenderingContext) -> bool:
|
@@ -30,7 +30,7 @@ class BlockHandler(ABC):
|
|
30
30
|
pass
|
31
31
|
|
32
32
|
@abstractmethod
|
33
|
-
def _process(self, context: BlockRenderingContext) -> None:
|
33
|
+
async def _process(self, context: BlockRenderingContext) -> None:
|
34
34
|
"""Process the block and update context."""
|
35
35
|
pass
|
36
36
|
|
@@ -16,6 +16,11 @@ class BlockRenderingContext:
|
|
16
16
|
block_registry: BlockRegistry
|
17
17
|
convert_children_callback: Optional[Callable[[list[Block], int], str]] = None
|
18
18
|
|
19
|
+
# For batch processing
|
20
|
+
all_blocks: Optional[list[Block]] = None
|
21
|
+
current_block_index: Optional[int] = None
|
22
|
+
blocks_consumed: int = 0
|
23
|
+
|
19
24
|
# Result
|
20
25
|
markdown_result: Optional[str] = None
|
21
26
|
children_result: Optional[str] = None
|