notionary 0.2.21__py3-none-any.whl → 0.2.22__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 (96) 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 +61 -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/name_to_id_resolver.py +205 -0
  42. notionary/blocks/rich_text/rich_text_models.py +62 -29
  43. notionary/blocks/rich_text/text_inline_formatter.py +432 -101
  44. notionary/blocks/syntax_prompt_builder.py +137 -0
  45. notionary/blocks/table/table_element.py +110 -9
  46. notionary/blocks/table_of_contents/table_of_contents_element.py +19 -2
  47. notionary/blocks/todo/todo_element.py +21 -4
  48. notionary/blocks/toggle/toggle_element.py +19 -3
  49. notionary/blocks/toggle/toggle_markdown_node.py +1 -1
  50. notionary/blocks/toggleable_heading/toggleable_heading_element.py +19 -4
  51. notionary/blocks/types.py +69 -0
  52. notionary/blocks/video/video_element.py +44 -39
  53. notionary/blocks/video/video_markdown_node.py +10 -5
  54. notionary/database/client.py +23 -0
  55. notionary/file_upload/models.py +2 -2
  56. notionary/markdown/markdown_builder.py +34 -27
  57. notionary/page/client.py +26 -6
  58. notionary/page/notion_page.py +37 -6
  59. notionary/page/page_content_deleting_service.py +117 -0
  60. notionary/page/page_content_writer.py +89 -113
  61. notionary/page/page_context.py +65 -0
  62. notionary/page/reader/handler/__init__.py +2 -0
  63. notionary/page/reader/handler/base_block_renderer.py +4 -4
  64. notionary/page/reader/handler/block_rendering_context.py +5 -0
  65. notionary/page/reader/handler/line_renderer.py +16 -3
  66. notionary/page/reader/handler/numbered_list_renderer.py +85 -0
  67. notionary/page/reader/page_content_retriever.py +17 -5
  68. notionary/page/writer/handler/__init__.py +2 -0
  69. notionary/page/writer/handler/code_handler.py +12 -40
  70. notionary/page/writer/handler/column_handler.py +12 -12
  71. notionary/page/writer/handler/column_list_handler.py +13 -13
  72. notionary/page/writer/handler/equation_handler.py +74 -0
  73. notionary/page/writer/handler/line_handler.py +4 -4
  74. notionary/page/writer/handler/regular_line_handler.py +31 -37
  75. notionary/page/writer/handler/table_handler.py +8 -72
  76. notionary/page/writer/handler/toggle_handler.py +14 -12
  77. notionary/page/writer/handler/toggleable_heading_handler.py +22 -16
  78. notionary/page/writer/markdown_to_notion_converter.py +28 -9
  79. notionary/page/writer/markdown_to_notion_converter_context.py +30 -0
  80. notionary/page/writer/markdown_to_notion_formatting_post_processor.py +73 -0
  81. notionary/page/writer/markdown_to_notion_post_processor.py +0 -0
  82. notionary/page/writer/markdown_to_notion_text_length_post_processor.py +0 -0
  83. notionary/page/writer/notion_text_length_processor.py +150 -0
  84. notionary/telemetry/service.py +0 -1
  85. notionary/user/notion_user_manager.py +22 -95
  86. notionary/util/concurrency_limiter.py +0 -0
  87. notionary/workspace.py +4 -4
  88. notionary-0.2.22.dist-info/METADATA +237 -0
  89. {notionary-0.2.21.dist-info → notionary-0.2.22.dist-info}/RECORD +92 -77
  90. notionary/page/markdown_whitespace_processor.py +0 -80
  91. notionary/page/notion_text_length_utils.py +0 -119
  92. notionary/user/notion_user_provider.py +0 -1
  93. notionary-0.2.21.dist-info/METADATA +0 -229
  94. /notionary/page/reader/handler/{context.py → equation_renderer.py} +0 -0
  95. {notionary-0.2.21.dist-info → notionary-0.2.22.dist-info}/LICENSE +0 -0
  96. {notionary-0.2.21.dist-info → notionary-0.2.22.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)
@@ -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
 
notionary/page/client.py CHANGED
@@ -20,20 +20,40 @@ class NotionPageClient(BaseNotionClient):
20
20
 
21
21
  async def create_page(
22
22
  self,
23
+ *,
23
24
  parent_database_id: Optional[str] = None,
24
- properties: Optional[dict[str, Any]] = None,
25
+ parent_page_id: Optional[str] = None,
26
+ title: str,
25
27
  ) -> NotionPageResponse:
26
28
  """
27
- Creates a new page in a Notion database or as a child page.
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
- page_data = {
30
- "parent": {"database_id": parent_database_id} if parent_database_id else {},
31
- "properties": properties or {},
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": {
46
+ "title": [
47
+ {"type": "text", "text": {"content": title}}
48
+ ]
49
+ }
32
50
  }
33
51
 
34
- response = await self.post("pages", page_data)
52
+ payload = {"parent": parent, "properties": properties}
53
+ response = await self.post("pages", payload)
35
54
  return NotionPageResponse.model_validate(response)
36
55
 
56
+
37
57
  async def patch_page(
38
58
  self, page_id: str, data: Optional[dict[str, Any]] = None
39
59
  ) -> NotionPageResponse:
@@ -5,12 +5,15 @@ 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.blocks.syntax_prompt_builder import SyntaxPromptBuilder
8
9
  from notionary.blocks.models import DatabaseParent
9
10
  from notionary.blocks.registry.block_registry import BlockRegistry
10
11
  from notionary.blocks.registry.block_registry_builder import BlockRegistryBuilder
12
+ from notionary.database.client import NotionDatabaseClient
11
13
  from notionary.markdown.markdown_builder import MarkdownBuilder
12
14
  from notionary.page.client import NotionPageClient
13
15
  from notionary.page.models import NotionPageResponse
16
+ from notionary.page.page_content_deleting_service import PageContentDeletingService
14
17
  from notionary.page.page_content_writer import PageContentWriter
15
18
  from notionary.page.property_formatter import NotionPropertyFormatter
16
19
  from notionary.page.reader.page_content_retriever import PageContentRetriever
@@ -21,7 +24,6 @@ from notionary.util.fuzzy import find_best_match
21
24
  if TYPE_CHECKING:
22
25
  from notionary import NotionDatabase
23
26
 
24
-
25
27
  class NotionPage(LoggingMixin):
26
28
  """
27
29
  Managing content and metadata of a Notion page.
@@ -55,7 +57,7 @@ class NotionPage(LoggingMixin):
55
57
  self._client = NotionPageClient(token=token)
56
58
  self._block_client = NotionBlockClient(token=token)
57
59
  self._page_data = None
58
-
60
+
59
61
  self.block_element_registry = BlockRegistry.create_registry()
60
62
 
61
63
  self._page_content_writer = PageContentWriter(
@@ -63,6 +65,11 @@ class NotionPage(LoggingMixin):
63
65
  block_registry=self.block_element_registry,
64
66
  )
65
67
 
68
+ self._page_content_deleting_service = PageContentDeletingService(
69
+ page_id=self._page_id,
70
+ block_registry=self.block_element_registry,
71
+ )
72
+
66
73
  self._page_content_retriever = PageContentRetriever(
67
74
  block_registry=self.block_element_registry,
68
75
  )
@@ -202,6 +209,10 @@ class NotionPage(LoggingMixin):
202
209
  """
203
210
  return self.block_element_registry.builder
204
211
 
212
+ def get_prompt_information(self) -> str:
213
+ markdown_syntax_builder = SyntaxPromptBuilder()
214
+ return markdown_syntax_builder.build_concise_reference()
215
+
205
216
  async def set_title(self, title: str) -> str:
206
217
  """
207
218
  Set the title of the page.
@@ -264,7 +275,7 @@ class NotionPage(LoggingMixin):
264
275
  Returns:
265
276
  bool: True if successful, False otherwise
266
277
  """
267
- clear_result = await self._page_content_writer.clear_page_content()
278
+ clear_result = await self._page_content_deleting_service.clear_page_content()
268
279
  if not clear_result:
269
280
  self.logger.error("Failed to clear page content before replacement")
270
281
 
@@ -279,7 +290,7 @@ class NotionPage(LoggingMixin):
279
290
  """
280
291
  Clear all content from the page.
281
292
  """
282
- return await self._page_content_writer.clear_page_content()
293
+ return await self._page_content_deleting_service.clear_page_content()
283
294
 
284
295
  async def get_text_content(self) -> str:
285
296
  """
@@ -309,7 +320,27 @@ class NotionPage(LoggingMixin):
309
320
 
310
321
  self.logger.error(f"Error updating page emoji: {str(e)}")
311
322
  return None
312
-
323
+
324
+ async def create_child_database(self, title: str) -> NotionDatabase:
325
+ from notionary import NotionDatabase
326
+ database_client = NotionDatabaseClient(token=self._client.token)
327
+
328
+ create_database_response = await database_client.create_database(
329
+ title=title,
330
+ parent_page_id=self._page_id,
331
+ )
332
+
333
+ return await NotionDatabase.from_database_id(id=create_database_response.id, token=self._client.token)
334
+
335
+ async def create_child_page(self, title: str) -> NotionPage:
336
+ from notionary import NotionPage
337
+ child_page_response = await self._client.create_page(
338
+ parent_page_id=self._page_id,
339
+ title=title,
340
+ )
341
+
342
+ return await NotionPage.from_page_id(page_id=child_page_response.id, token=self._client.token)
343
+
313
344
  async def set_external_icon(self, url: str) -> Optional[str]:
314
345
  """
315
346
  Sets the page icon to an external image.
@@ -605,4 +636,4 @@ class NotionPage(LoggingMixin):
605
636
  """Extract parent database ID from page response."""
606
637
  parent = page_response.parent
607
638
  if isinstance(parent, DatabaseParent):
608
- return parent.database_id
639
+ return parent.database_id
@@ -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