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,35 +5,28 @@ 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, BlockType
9
- from notionary.blocks.paragraph.paragraph_models import (
10
- CreateParagraphBlock,
11
- ParagraphBlock,
12
- )
13
11
  from notionary.blocks.pdf.pdf_models import CreatePdfBlock
14
- from notionary.blocks.rich_text.rich_text_models import RichTextObject
15
- from notionary.blocks.rich_text.text_inline_formatter import TextInlineFormatter
16
12
 
17
13
 
18
- class PdfElement(BaseBlockElement):
14
+ class PdfElement(BaseBlockElement, CaptionMixin):
19
15
  """
20
16
  Handles conversion between Markdown PDF embeds and Notion PDF blocks.
21
17
 
22
18
  Markdown PDF syntax:
23
- - [pdf](https://example.com/document.pdf "Caption") # External URL
24
- - [pdf](notion://file_id_here "Caption") # Notion hosted file
25
- - [pdf](upload://upload_id_here "Caption") # File upload
26
- - [pdf](https://example.com/document.pdf) # Without caption
19
+ - [pdf](https://example.com/document.pdf) - External URL
20
+ - [pdf](https://example.com/document.pdf)(caption:Annual Report 2024) - URL with caption
21
+ - (caption:User Manual)[pdf](https://example.com/manual.pdf) - caption before URL
22
+ - [pdf](notion://file_id_here)(caption:Notion hosted file) - Notion hosted file
23
+ - [pdf](upload://upload_id_here)(caption:File upload) - File upload
27
24
 
28
25
  Supports all three PDF types: external, notion-hosted, and file uploads.
29
26
  """
30
27
 
31
- PATTERN = re.compile(
32
- r"^\[pdf\]\(" # prefix
33
- r'((?:https?://|notion://|upload://)[^\s\)"]+)' # URL with protocol
34
- r'(?:\s+"([^"]*)")?' # optional caption
35
- r"\)$"
36
- )
28
+ # Flexible pattern that can handle caption in any position
29
+ PDF_PATTERN = re.compile(r"\[pdf\]\(((?:https?://|notion://|upload://)[^\s\"]+)\)")
37
30
 
38
31
  @classmethod
39
32
  def match_notion(cls, block: Block) -> bool:
@@ -41,51 +34,64 @@ class PdfElement(BaseBlockElement):
41
34
  return block.type == BlockType.PDF and block.pdf
42
35
 
43
36
  @classmethod
44
- def markdown_to_notion(cls, text: str) -> BlockCreateResult:
45
- """Convert markdown PDF link to Notion FileBlock (used for PDF) followed by an empty paragraph."""
46
- match = cls.PATTERN.match(text.strip())
47
- if not match:
37
+ async def markdown_to_notion(cls, text: str) -> BlockCreateResult:
38
+ """Convert markdown PDF link to Notion FileBlock (used for PDF)."""
39
+ # Use our own regex to find the PDF URL
40
+ pdf_match = cls.PDF_PATTERN.search(text.strip())
41
+ if not pdf_match:
48
42
  return None
49
43
 
50
- url, caption_text = match.group(1), match.group(2) or ""
44
+ url = pdf_match.group(1)
45
+
46
+ # Use mixin to extract caption (if present anywhere in text)
47
+ caption_text = cls.extract_caption(text.strip())
48
+ caption_rich_text = cls.build_caption_rich_text(caption_text or "")
51
49
 
52
50
  # Build FileBlock using FileType enum (reused for PDF)
53
51
  pdf_block = FileBlock(
54
- type=FileType.EXTERNAL, external=ExternalFile(url=url), caption=[]
52
+ type=FileType.EXTERNAL,
53
+ external=ExternalFile(url=url),
54
+ caption=caption_rich_text,
55
55
  )
56
- if caption_text.strip():
57
- rt = RichTextObject.from_plain_text(caption_text)
58
- pdf_block.caption = [rt]
59
-
60
- empty_para = ParagraphBlock(rich_text=[])
61
56
 
62
- return [
63
- CreatePdfBlock(pdf=pdf_block),
64
- CreateParagraphBlock(paragraph=empty_para),
65
- ]
57
+ return CreatePdfBlock(pdf=pdf_block)
66
58
 
67
59
  @classmethod
68
- def notion_to_markdown(cls, block: Block) -> Optional[str]:
60
+ async def notion_to_markdown(cls, block: Block) -> Optional[str]:
69
61
  if block.type != BlockType.PDF or not block.pdf:
70
62
  return None
71
63
 
72
64
  pb: FileBlock = block.pdf
73
65
 
74
- # Determine URL (only external and file types are valid for Markdown)
75
66
  if pb.type == FileType.EXTERNAL and pb.external:
76
67
  url = pb.external.url
77
68
  elif pb.type == FileType.FILE and pb.file:
78
69
  url = pb.file.url
79
70
  elif pb.type == FileType.FILE_UPLOAD:
80
- # Uploaded PDF has no stable URL for Markdown
81
71
  return None
82
72
  else:
83
73
  return None
84
74
 
85
- if not pb.caption:
86
- return f"[pdf]({url})"
75
+ result = f"[pdf]({url})"
76
+
77
+ # Add caption if present
78
+ caption_markdown = await cls.format_caption_for_markdown(pb.caption or [])
79
+ if caption_markdown:
80
+ result += caption_markdown
87
81
 
88
- caption_md = TextInlineFormatter.extract_text_with_formatting(pb.caption)
89
- if caption_md:
90
- return f'[pdf]({url} "{caption_md}")'
91
- return f"[pdf]({url})"
82
+ return result
83
+
84
+ @classmethod
85
+ def get_system_prompt_information(cls) -> Optional[BlockElementMarkdownInformation]:
86
+ """Get system prompt information for PDF blocks."""
87
+ return BlockElementMarkdownInformation(
88
+ block_type=cls.__name__,
89
+ description="PDF blocks embed and display PDF documents from external URLs with optional captions",
90
+ syntax_examples=[
91
+ "[pdf](https://example.com/document.pdf)",
92
+ "[pdf](https://example.com/report.pdf)(caption:Annual Report 2024)",
93
+ "(caption:User Manual)[pdf](https://example.com/manual.pdf)",
94
+ "[pdf](https://example.com/guide.pdf)(caption:**Important** documentation)",
95
+ ],
96
+ usage_guidelines="Use for embedding PDF documents that can be viewed inline. Supports external URLs and Notion-hosted files. Caption supports rich text formatting and should describe the PDF content.",
97
+ )
@@ -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 PdfMarkdownNodeParams(BaseModel):
@@ -12,10 +13,9 @@ class PdfMarkdownNodeParams(BaseModel):
12
13
  caption: Optional[str] = None
13
14
 
14
15
 
15
- class PdfMarkdownNode(MarkdownNode):
16
+ class PdfMarkdownNode(MarkdownNode, CaptionMarkdownNodeMixin):
16
17
  """
17
18
  Programmatic interface for creating Notion-style Markdown PDF embeds.
18
- Example: [pdf](https://example.com/document.pdf "My Caption")
19
19
  """
20
20
 
21
21
  def __init__(self, url: str, caption: Optional[str] = None):
@@ -27,9 +27,11 @@ class PdfMarkdownNode(MarkdownNode):
27
27
  return cls(url=params.url, caption=params.caption)
28
28
 
29
29
  def to_markdown(self) -> str:
30
+ """Return the Markdown representation.
31
+
32
+ Examples:
33
+ - [pdf](https://example.com/document.pdf)
34
+ - [pdf](https://example.com/document.pdf)(caption:Critical safety information)
30
35
  """
31
- Convert to markdown as [pdf](url "caption") or [pdf](url) if caption is empty.
32
- """
33
- if self.caption:
34
- return f'[pdf]({self.url} "{self.caption}")'
35
- return f"[pdf]({self.url})"
36
+ base_markdown = f"[pdf]({self.url})"
37
+ return self.append_caption_to_markdown(base_markdown, self.caption)
@@ -4,6 +4,7 @@ import re
4
4
  from typing import Optional
5
5
 
6
6
  from notionary.blocks.base_block_element import BaseBlockElement
7
+ from notionary.blocks.syntax_prompt_builder import BlockElementMarkdownInformation
7
8
  from notionary.blocks.models import Block, BlockCreateResult, BlockType
8
9
  from notionary.blocks.quote.quote_models import CreateQuoteBlock, QuoteBlock
9
10
  from notionary.blocks.rich_text.text_inline_formatter import TextInlineFormatter
@@ -15,19 +16,19 @@ class QuoteElement(BaseBlockElement):
15
16
  Handles conversion between Markdown quotes and Notion quote blocks.
16
17
 
17
18
  Markdown quote syntax:
18
- - [quote](Simple quote text)
19
+ - > Simple quote text
19
20
 
20
21
  Only single-line quotes without author metadata.
21
22
  """
22
23
 
23
- PATTERN = re.compile(r"^\[quote\]\(([^\n\r]+)\)$")
24
+ PATTERN = re.compile(r"^>\s*(.+)$")
24
25
 
25
26
  @classmethod
26
27
  def match_notion(cls, block: Block) -> bool:
27
28
  return block.type == BlockType.QUOTE and block.quote
28
29
 
29
30
  @classmethod
30
- def markdown_to_notion(cls, text: str) -> BlockCreateResult:
31
+ async def markdown_to_notion(cls, text: str) -> BlockCreateResult:
31
32
  """Convert markdown quote to Notion QuoteBlock."""
32
33
  match = cls.PATTERN.match(text.strip())
33
34
  if not match:
@@ -37,22 +38,38 @@ class QuoteElement(BaseBlockElement):
37
38
  if not content:
38
39
  return None
39
40
 
40
- # Parse inline formatting into rich text objects
41
- rich_text = TextInlineFormatter.parse_inline_formatting(content)
41
+ # Reject multiline quotes
42
+ if "\n" in content or "\r" in content:
43
+ return None
44
+
45
+ rich_text = await TextInlineFormatter.parse_inline_formatting(content)
42
46
 
43
- # Return a typed QuoteBlock
44
47
  quote_content = QuoteBlock(rich_text=rich_text, color=BlockColor.DEFAULT)
45
48
  return CreateQuoteBlock(quote=quote_content)
46
49
 
47
50
  @classmethod
48
- def notion_to_markdown(cls, block: Block) -> Optional[str]:
51
+ async def notion_to_markdown(cls, block: Block) -> Optional[str]:
49
52
  if block.type != BlockType.QUOTE or not block.quote:
50
53
  return None
51
54
 
52
55
  rich = block.quote.rich_text
53
- text = TextInlineFormatter.extract_text_with_formatting(rich)
56
+ text = await TextInlineFormatter.extract_text_with_formatting(rich)
54
57
 
55
58
  if not text.strip():
56
59
  return None
57
60
 
58
- return f"[quote]({text.strip()})"
61
+ return f"> {text.strip()}"
62
+
63
+ @classmethod
64
+ def get_system_prompt_information(cls) -> Optional[BlockElementMarkdownInformation]:
65
+ """Get system prompt information for quote blocks."""
66
+ return BlockElementMarkdownInformation(
67
+ block_type=cls.__name__,
68
+ description="Quote blocks display highlighted quotations or emphasized text",
69
+ syntax_examples=[
70
+ "> This is an important quote",
71
+ "> The only way to do great work is to love what you do",
72
+ "> Innovation distinguishes between a leader and a follower",
73
+ ],
74
+ usage_guidelines="Use for quotations, important statements, or text that should be visually emphasized. Content should be meaningful and stand out from regular paragraphs.",
75
+ )
@@ -12,7 +12,7 @@ class QuoteMarkdownBlockParams(BaseModel):
12
12
  class QuoteMarkdownNode(MarkdownNode):
13
13
  """
14
14
  Programmatic interface for creating Notion-style quote blocks.
15
- Example: [quote](This is a quote)
15
+ Example: > This is a quote
16
16
  """
17
17
 
18
18
  def __init__(self, text: str):
@@ -23,4 +23,4 @@ class QuoteMarkdownNode(MarkdownNode):
23
23
  return cls(text=params.text)
24
24
 
25
25
  def to_markdown(self) -> str:
26
- return f"[quote]({self.text})"
26
+ return f"> {self.text}"
@@ -3,11 +3,8 @@ from __future__ import annotations
3
3
  from typing import Optional, Type
4
4
 
5
5
  from notionary.blocks.base_block_element import BaseBlockElement
6
- from notionary.blocks.models import Block, BlockCreateResult
7
6
  from notionary.blocks.registry.block_registry_builder import BlockRegistryBuilder
8
7
  from notionary.telemetry import (
9
- MarkdownToNotionConversionEvent,
10
- NotionToMarkdownConversionEvent,
11
8
  ProductTelemetry,
12
9
  )
13
10
 
@@ -61,6 +58,7 @@ class BlockRegistry:
61
58
  .with_equation()
62
59
  .with_table_of_contents()
63
60
  .with_breadcrumbs()
61
+ .with_child_database()
64
62
  .with_paragraphs() # position here is important - its a fallback!
65
63
  )
66
64
 
@@ -92,49 +90,6 @@ class BlockRegistry:
92
90
  """
93
91
  return element_class.__name__ in self._builder._elements
94
92
 
95
- def find_markdown_handler(self, text: str) -> Optional[Type[BaseBlockElement]]:
96
- """Find an element that can handle the given markdown text."""
97
- for element in self._builder._elements.values():
98
- if element.match_markdown(text):
99
- return element
100
- return None
101
-
102
- def markdown_to_notion(self, text: str) -> "BlockCreateResult":
103
- """Convert markdown to Notion block using registered elements."""
104
- handler = self.find_markdown_handler(text)
105
-
106
- if handler:
107
- self.telemetry.capture(
108
- MarkdownToNotionConversionEvent(
109
- handler_element_name=handler.__name__,
110
- )
111
- )
112
-
113
- return handler.markdown_to_notion(text)
114
- return None
115
-
116
- def notion_to_markdown(self, block: "Block") -> Optional[str]:
117
- """Convert Notion block to markdown using registered elements."""
118
- handler = self._find_notion_handler(block)
119
-
120
- if not handler:
121
- return None
122
-
123
- self.telemetry.capture(
124
- NotionToMarkdownConversionEvent(
125
- handler_element_name=handler.__name__,
126
- )
127
- )
128
-
129
- return handler.notion_to_markdown(block)
130
-
131
93
  def get_elements(self) -> list[Type[BaseBlockElement]]:
132
94
  """Get all registered elements."""
133
95
  return list(self._builder._elements.values())
134
-
135
- def _find_notion_handler(self, block: Block) -> Optional[Type[BaseBlockElement]]:
136
- """Find an element that can handle the given Notion block."""
137
- for element in self._builder._elements.values():
138
- if element.match_notion(block):
139
- return element
140
- return None
@@ -9,6 +9,7 @@ from notionary.blocks.bookmark import BookmarkElement
9
9
  from notionary.blocks.breadcrumbs import BreadcrumbElement
10
10
  from notionary.blocks.bulleted_list import BulletedListElement
11
11
  from notionary.blocks.callout import CalloutElement
12
+ from notionary.blocks.child_database import ChildDatabaseElement
12
13
  from notionary.blocks.code import CodeElement
13
14
  from notionary.blocks.column import ColumnElement, ColumnListElement
14
15
  from notionary.blocks.divider import DividerElement
@@ -70,6 +71,7 @@ class BlockRegistryBuilder:
70
71
  .with_equation()
71
72
  .with_table_of_contents()
72
73
  .with_breadcrumbs()
74
+ .with_child_database()
73
75
  ).build()
74
76
 
75
77
  def remove_element(self, element_class: Type[BaseBlockElement]) -> Self:
@@ -151,6 +153,9 @@ class BlockRegistryBuilder:
151
153
  def with_breadcrumbs(self) -> Self:
152
154
  return self._add_element(BreadcrumbElement)
153
155
 
156
+ def with_child_database(self) -> Self:
157
+ return self._add_element(ChildDatabaseElement)
158
+
154
159
  def without_headings(self) -> Self:
155
160
  return self.remove_element(HeadingElement)
156
161
 
@@ -213,6 +218,9 @@ class BlockRegistryBuilder:
213
218
  def without_breadcrumbs(self) -> Self:
214
219
  return self.remove_element(BreadcrumbElement)
215
220
 
221
+ def without_child_database(self) -> Self:
222
+ return self.remove_element(ChildDatabaseElement)
223
+
216
224
  def build(self) -> BlockRegistry:
217
225
  """
218
226
  Build and return the configured BlockRegistry instance.
@@ -0,0 +1,205 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from contextlib import contextmanager
5
+ from typing import Optional
6
+
7
+ from notionary.user.notion_user_manager import NotionUserManager
8
+ from notionary.util import format_uuid
9
+ from notionary.util.fuzzy import find_best_match
10
+
11
+
12
+ class NameIdResolver:
13
+ """
14
+ Bidirectional resolver for Notion page and database names and IDs.
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ *,
20
+ token: Optional[str] = None,
21
+ search_limit: int = 10,
22
+ ):
23
+ """
24
+ Initialize the resolver with a Notion workspace.
25
+ """
26
+ from notionary import NotionWorkspace
27
+
28
+ self.workspace = NotionWorkspace(token=token)
29
+ self.notion_user_manager = NotionUserManager(token=token)
30
+ self.search_limit = search_limit
31
+
32
+ async def resolve_page_id(self, name: str) -> Optional[str]:
33
+ """
34
+ Convert a page name to its Notion page ID.
35
+ Specifically searches only pages, not databases.
36
+ """
37
+ if not name:
38
+ return None
39
+
40
+ cleaned_name = name.strip()
41
+
42
+ # Return if already a valid Notion ID
43
+ formatted_uuid = format_uuid(cleaned_name)
44
+ if formatted_uuid:
45
+ return formatted_uuid
46
+
47
+ # Search for page by name
48
+ return await self._resolve_page_id(cleaned_name)
49
+
50
+ async def resolve_database_id(self, name: str) -> Optional[str]:
51
+ """
52
+ Convert a database name to its Notion database ID.
53
+ Specifically searches only databases, not pages.
54
+ """
55
+ if not name:
56
+ return None
57
+
58
+ cleaned_name = name.strip()
59
+
60
+ formatted_uuid = format_uuid(cleaned_name)
61
+ if formatted_uuid:
62
+ return formatted_uuid
63
+
64
+ return await self._resolve_database_id(cleaned_name)
65
+
66
+ async def resolve_page_name(self, page_id: str) -> Optional[str]:
67
+ """
68
+ Convert a Notion page ID to its human-readable title.
69
+ """
70
+ if not page_id:
71
+ return None
72
+
73
+ formatted_id = format_uuid(page_id)
74
+ if not formatted_id:
75
+ return None
76
+
77
+ try:
78
+ from notionary import NotionPage
79
+
80
+ page = await NotionPage.from_page_id(formatted_id)
81
+ return page.title if page else None
82
+ except Exception:
83
+ return None
84
+
85
+ async def resolve_database_name(self, database_id: str) -> Optional[str]:
86
+ """
87
+ Convert a Notion database ID to its human-readable title.
88
+ """
89
+ if not database_id:
90
+ return None
91
+
92
+ # Validate and format UUID
93
+ formatted_id = format_uuid(database_id)
94
+ if not formatted_id:
95
+ return None
96
+
97
+ try:
98
+ from notionary.database import NotionDatabase
99
+
100
+ database = await NotionDatabase.from_database_id(formatted_id)
101
+ return database.title if database else None
102
+ except Exception:
103
+ return None
104
+
105
+ async def resolve_user_id(self, name: str) -> Optional[str]:
106
+ """
107
+ Convert a user name to its Notion user ID.
108
+ Specifically searches only users.
109
+ """
110
+ if not name:
111
+ return None
112
+
113
+ cleaned_name = name.strip()
114
+
115
+ # Return if already a valid Notion ID
116
+ formatted_uuid = format_uuid(cleaned_name)
117
+ if formatted_uuid:
118
+ return formatted_uuid
119
+
120
+ # Search for user by name
121
+ return await self._resolve_user_id(cleaned_name)
122
+
123
+ async def resolve_user_name(self, user_id: str) -> Optional[str]:
124
+ """
125
+ Convert a Notion user ID to its human-readable name.
126
+
127
+ Args:
128
+ user_id: Notion user ID to resolve
129
+
130
+ Returns:
131
+ User name if found, None if not found or inaccessible
132
+ """
133
+ if not user_id:
134
+ return None
135
+
136
+ # Validate and format UUID
137
+ formatted_id = format_uuid(user_id)
138
+ if not formatted_id:
139
+ return None
140
+
141
+ try:
142
+ user = await self.notion_user_manager.get_user_by_id(formatted_id)
143
+ return user.name if user else None
144
+ except Exception:
145
+ return None
146
+
147
+ async def _resolve_user_id(self, name: str) -> Optional[str]:
148
+ """Search for users matching the name."""
149
+ try:
150
+ users = await self.notion_user_manager.find_users_by_name(name)
151
+
152
+ if not users:
153
+ return None
154
+
155
+ # Use fuzzy matching to find best match
156
+ best_match = find_best_match(
157
+ query=name,
158
+ items=users,
159
+ text_extractor=lambda user: user.name or "",
160
+ )
161
+
162
+ return best_match.item.id if best_match else None
163
+ except Exception:
164
+ return None
165
+
166
+ async def _resolve_page_id(self, name: str) -> Optional[str]:
167
+ """Search for pages matching the name."""
168
+ search_results = await self.workspace.search_pages(
169
+ query=name, limit=self.search_limit
170
+ )
171
+
172
+ return self._find_best_fuzzy_match(query=name, candidate_objects=search_results)
173
+
174
+ async def _resolve_database_id(self, name: str) -> Optional[str]:
175
+ """Search for databases matching the name."""
176
+ search_results = await self.workspace.search_databases(
177
+ query=name, limit=self.search_limit
178
+ )
179
+
180
+ return self._find_best_fuzzy_match(query=name, candidate_objects=search_results)
181
+
182
+ def _find_best_fuzzy_match(
183
+ self, query: str, candidate_objects: list
184
+ ) -> Optional[str]:
185
+ """
186
+ Find the best fuzzy match among candidate objects using existing fuzzy matching logic.
187
+
188
+ Args:
189
+ query: The search query to match against
190
+ candidate_objects: Objects (pages or databases) with .id and .title attributes
191
+
192
+ Returns:
193
+ ID of best match, or None if no match meets threshold
194
+ """
195
+ if not candidate_objects:
196
+ return None
197
+
198
+ # Use existing fuzzy matching logic
199
+ best_match = find_best_match(
200
+ query=query,
201
+ items=candidate_objects,
202
+ text_extractor=lambda obj: obj.title,
203
+ )
204
+
205
+ return best_match.item.id if best_match else None