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.
- 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 +61 -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/name_to_id_resolver.py +205 -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/database/client.py +23 -0
- notionary/file_upload/models.py +2 -2
- notionary/markdown/markdown_builder.py +34 -27
- notionary/page/client.py +26 -6
- notionary/page/notion_page.py +37 -6
- notionary/page/page_content_deleting_service.py +117 -0
- notionary/page/page_content_writer.py +89 -113
- notionary/page/page_context.py +65 -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/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.22.dist-info/METADATA +237 -0
- {notionary-0.2.21.dist-info → notionary-0.2.22.dist-info}/RECORD +92 -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.22.dist-info}/LICENSE +0 -0
- {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
|
24
|
-
- [pdf](
|
25
|
-
- [pdf](
|
26
|
-
- [pdf](
|
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
|
-
|
32
|
-
|
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)
|
46
|
-
|
47
|
-
|
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
|
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,
|
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
|
-
|
86
|
-
|
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
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
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
|
-
|
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
|
-
-
|
19
|
+
- > Simple quote text
|
19
20
|
|
20
21
|
Only single-line quotes without author metadata.
|
21
22
|
"""
|
22
23
|
|
23
|
-
PATTERN = re.compile(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
|
-
#
|
41
|
-
|
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"
|
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:
|
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"
|
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
|